扣了款订单却没建成功:事务与 ACID

线上事故复盘:扣款成功、订单却没创建。你意识到「两条写入要么都成、要么都不成」需要事务。

学 45 min
练 60 min
盘 15 min
共 120 分钟

学 · 45 min

  1. 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 版答案和通用版答案的差异是加分位。

  2. 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)才能继续执行后续语句--不回滚就接着写会一路报错。

  3. 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 面试里翻车。

  4. 04长事务的危害:阻塞 vacuum、表膨胀、锁等待

    场景周五下午全站变慢,查出来是一个开了两小时没提交的报表查询。

    长事务三宗罪:① 它的快照让 vacuum 不敢清理之后产生的死元组 -> 表膨胀;② 事务 ID 有回卷风险,老事务拖住全局;③ 持有的锁一直不放,别人排队。排查入口:pg_stat_activityxact_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

  1. 手写一个下单事务(建订单 + 扣库存),中途故意报错验证全部回滚
    参考答案

    第二条 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';   -- 库存回来了:扣减和新订单一起消失
  2. 用 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 也不影响演示
  3. 开一个 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);
  4. 验证未提交的修改在另一个会话中不可见
    参考答案

    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';   -- 还原
  5. 写下长事务的三个具体危害
    参考答案

    ① 拖住 vacuum:老事务的快照让它之后产生的死元组不敢清理,表持续膨胀;② 锁不放:持有的行锁 / 表锁让后续写入排队,锁等待时间 = 事务长度;③ 事务 ID 回卷风险:最老的事务卡住全局,txid 无法推进,PG 被迫紧急处理。排查入口都是 pg_stat_activity 按 xact_start 排序看最老的几个。

过关标准 能说出 ACID 每个字母分别由数据库的什么机制保证。