扣了款订单却没建成功:事务与 ACID
线上事故复盘:扣款成功、订单却没创建。你意识到「两条写入要么都成、要么都不成」需要事务。
学 · 45 min
01A 由回滚日志、C 由约束、I 由锁与 MVCC、D 由 WAL 保证
场景扣款成功订单没建--两条写入必须同生共死,这就是事务存在的意义。
教科书答案:A(原子)靠回滚日志、C(一致)靠约束、I(隔离)靠锁 + MVCC、D(持久)靠 WAL 预写日志。PG 特殊点要能接得住追问:PG 没有 undo log,A 靠 MVCC--回滚时新写的行版本直接标记作废(死元组),vacuum 收尾。答「PG 用 undo 保证原子性」是把 MySQL 的答案串了台。
begin; update products set stock = stock - 1 where id = 1; insert into orders(user_id, status, total_amount, created_at) values ('...', 1, 99.00, now()); -- 中间任何一步失败: rollback; -- 库存和白下的单一起消失 commit; -- 或者一起落盘易错ACID 每个字母「由什么机制保证」是高频面试题;PG 版答案和通用版答案的差异是加分位。
02BEGIN / COMMIT / ROLLBACK / SAVEPOINT
场景事务里的「存档点」:一批操作里有一部分失败了,只想重做那一部分。
begin开事务、commit落盘、rollback全撤销;savepoint 名字在事务里打存档点,rollback to savepoint 名字只回滚到存档点、事务继续。适合「批量处理里单条失败跳过继续」的场景。begin; savepoint sp1; insert into payments(order_id, method, amount) values ('...', 'x', -1); -- 违反 check 报错,事务进入 aborted 状态 rollback to savepoint sp1; -- 只回滚这条,事务继续 insert into payments(order_id, method, amount) values ('...', 'alipay', 99); commit;易错语句报错后事务进入 aborted 状态,必须 rollback(或 to savepoint)才能继续执行后续语句--不回滚就接着写会一路报错。
03psql 的自动提交行为
场景你没写 begin,为什么每条语句也「像个事务」?
psql 里不显式写 begin,每条语句被自动包一层单语句事务、立即提交。反过来,显式事务里的 DDL 也能回滚--PG 的 DDL 是事务性的,建表、加列、建索引都能 rollback;MySQL 的 DDL 会隐式提交、不可回滚。
begin; create table t_test (id int); rollback; -- t_test 不存在了:DDL 也被回滚(PG 特性,MySQL 不行)易错「DDL 不能回滚」是 MySQL 的规矩,当通用结论背会在 PG 面试里翻车。
04长事务的危害:阻塞 vacuum、表膨胀、锁等待
场景周五下午全站变慢,查出来是一个开了两小时没提交的报表查询。
长事务三宗罪:① 它的快照让 vacuum 不敢清理之后产生的死元组 -> 表膨胀;② 事务 ID 有回卷风险,老事务拖住全局;③ 持有的锁一直不放,别人排队。排查入口:
pg_stat_activity看xact_start最老的几个。select pid, state, xact_start, now() - xact_start as 事务年龄, left(query, 60) as query from pg_stat_activity where xact_start is not null order by xact_start limit 5; -- select pg_terminate_backend(pid); -- 处决最老的那个易错「开了忘提交」的最大来源是应用连接池--不是有人真的在跑两小时,是一个连接借出去没还干净。
练 · 60 min
- 手写一个下单事务(建订单 + 扣库存),中途故意报错验证全部回滚
参考答案
第二条 insert 失败,但第一条 update 也一并回滚--原子性。「扣款成功订单没建」的解法就是:把同生共死的写入放进同一个事务。
begin; select stock as 原库存 from products where name = '商品1'; -- 记下这个数 update products set stock = stock - 1 where name = '商品1'; -- 扣库存 insert into orders(user_id, status, total_amount, created_at) values ((select id from users limit 1), 1, 99.00, now()) returning id; -- 建订单 -- 故意失败:非空约束 insert into orders(user_id, status, total_amount, created_at) values (null, 1, 1.00, now()); -- ERROR: null value in column "user_id" of relation "orders" -- violates not-null constraint -- 报错后事务进入 aborted 状态:再执行任何语句都报错,必须先 rollback rollback; select stock from products where name = '商品1'; -- 库存回来了:扣减和新订单一起消失 - 用 SAVEPOINT 实现部分回滚
参考答案
存档点的用途:批量处理里单条失败跳过继续,不用整批重来。注意报错后若不 rollback to savepoint,事务卡在 aborted 状态什么都做不了。
begin; savepoint sp1; insert into payments(order_id, method, amount, status, paid_at) values ((select id from orders where status = 2 limit 1), 1, -1.00, 1, now()); -- ERROR: check 约束拦下(前提:D30 已加 amount > 0) rollback to savepoint sp1; -- 只撤销这一条,事务继续 insert into payments(order_id, method, amount, status, paid_at) values ((select id from orders where status = 2 limit 1), 1, 99.00, 1, now()); commit; -- 想保持库干净,把最后的 commit 换成 rollback 也不影响演示 - 开一个 5 分钟不提交的事务,在另一窗口查
pg_stat_activity观察状态参考答案
idle in transaction 就是「开了没提交」的标志状态;它拖住 vacuum 和全局 xmin--「周五全站变慢」的元凶长这样。这就是 learn 长事务排查入口那条查询的实战版。
-- 窗口A:开一个不提交的事务(改一行才会真正持有锁和事务 id) begin; update products set stock = stock where name = '商品1'; -- 停在这别动(也可以改成 select pg_sleep(300);) -- 窗口B:观察 A 的状态 select pid, state, xact_start, now() - xact_start as 事务年龄, left(query, 60) as query from pg_stat_activity where xact_start is not null order by xact_start; -- 预期:窗口A 的 state = idle in transaction,事务年龄持续增长 -- 练习完回窗口A commit 或 rollback 释放;看不惯可以直接杀: -- select pg_terminate_backend(pid); - 验证未提交的修改在另一个会话中不可见
参考答案
B 读到的是已提交的旧版本--MVCC 的可见性规则。这也解释了 PG 为什么根本不存在脏读(D34 正式展开)。
-- 基线:把演示行固定成已知值 update products set stock = 100 where name = '商品1'; -- 窗口A begin; update products set stock = 999 where name = '商品1'; -- 停在这,别提交 -- 窗口B select stock from products where name = '商品1'; -- 100:不是 999 -- 回窗口A执行 commit; 窗口B 再查: select stock from products where name = '商品1'; -- 999:提交后才可见 update products set stock = 100 where name = '商品1'; -- 还原 - 写下长事务的三个具体危害
参考答案
① 拖住 vacuum:老事务的快照让它之后产生的死元组不敢清理,表持续膨胀;② 锁不放:持有的行锁 / 表锁让后续写入排队,锁等待时间 = 事务长度;③ 事务 ID 回卷风险:最老的事务卡住全局,txid 无法推进,PG 被迫紧急处理。排查入口都是 pg_stat_activity 按 xact_start 排序看最老的几个。