Keyboard shortcuts

Press ← or → to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

6 事务:ACID、隔离级别、MVCC

本章回答

一条 BEGIN ... COMMIT 到底承诺了什么?为什么同一个账户在两次读之间会变?为什么「事务回滚了」不等于「副作用没发生」?跨库转账用 2PC、TCC 还是 Saga,各自把什么风险留给了你?

读完你会

能用异常表判断一个隔离级别够不够;能读懂 MVCC 的版本链、read view 与可见性;能在 PostgreSQL 与 InnoDB 里定位长事务、锁等待和死锁;能写出带唯一约束和幂等键的重试;能画出 2PC 与 Saga 的失败窗口,并说清它们各自不保证什么。

第 5 章的共识解决的是「多个副本对同一条日志的顺序达成一致」。但用户不关心日志,用户关心的是「我这次转账,要么扣款和入账都生效,要么都不生效」。这是另一个问题:一组读写如何组成一个不可分割的边界。共识保证副本之间不打架,事务保证一组操作对外表现为一个整体;两者经常一起出现,但不是同一件事。

先看一个每天都在发生的场景。账户 A 向账户 B 转 100 元,服务端执行两条 UPDATE 和一条 INSERT,然后提交。数据库说提交成功,客户端却因为网关超时没收到响应。客户端重试,服务端又执行了一遍。数据库的每个事务都是原子的:第一次扣了 100,第二次又扣了 100,每次都不违反任何约束。用户看到一次成功,账上少了 200。

这个事故里没有哪个事务「写错」。错的是事务边界画在了数据库里,而逻辑请求的边界在数据库外。数据库只能保证「这一个事务全做或全不做」,它不知道「这是同一个用户意图的第二次尝试」。本章要做的,就是把「事务」这个词拆成三层:单机数据库的 ACID、并发事务之间的隔离、跨服务/跨库的提交协议。三层各有各的保证,也各有各的裂缝。

路线是这样:6.1 先把 ACID 四个字母在单机和分布式里的真实含义拆开;6.2 用五类异常说明隔离级别到底在防什么;6.3 讲 MVCC 的版本链、快照和可见性;6.4 对比 InnoDB 与 PostgreSQL 的实现差异;6.5 讲锁、死锁和「什么错误才值得重试」;6.6 讲 2PC、TCC、Saga 的失败窗口;6.7 讲 Outbox 与幂等消费。后半程是四件必须单独成节的事:重试如何把一次转账做成两次(6.8)、用什么命令观测长事务和锁(6.9)、Spanner 如何用 TrueTime 把 2PC 做成全球外部一致(6.10),以及非幂等 INSERT 被重放时会发生什么(6.11)。

一句话先放在这里:事务不是数据库送的免费午餐,而是一份边界声明。边界画错,ACID 也救不了你。

6.1 ACID 在单机与分布式中的含义

先看一条具体的转账语句。它看起来平平无奇,但四个字母都能在这几行里找到落点,也能找到裂缝。

BEGIN;
UPDATE accounts
   SET balance = balance - 100
 WHERE id = 'A' AND balance >= 100;
UPDATE accounts
   SET balance = balance + 100
 WHERE id = 'B';
INSERT INTO transfers (id, from_acct, to_acct, amount)
VALUES ('t-20261019-001', 'A', 'B', 100);
COMMIT;

A(原子性) 说的是:这四条语句要么全部生效,要么全部不生效。第二条 UPDATE 如果因为 B 账户不存在而报错,数据库会把第一条的扣款回滚掉。实现上靠 undo/rollback 段和 WAL:先记「要改成什么」,再改内存页,提交时把日志刷盘;崩溃后用日志重放或回滚。

但原子性有一个容易被忽略的前提:应用程序得让数据库知道「这个事务失败了」。如果第一条 UPDATE 因为 balance >= 100 不满足而影响了 0 行,数据库不会报错,事务会继续执行并提交。余额不足时,扣款语句「什么都没做」,入账语句却加了 100。原子性没有失效,它是被「0 行更新不是错误」这个 SQL 语义绕过去了。应用必须检查受影响行数,或者把条件写成会抛错的约束。

C(一致性) 是最容易被误解的一个字母。教科书说「事务把数据库从一个一致状态带到另一个一致状态」,但「一致」不是数据库自己能定义的。数据库能检查的是它认识的约束:主键、外键、唯一索引、CHECK、NOT NULL。至于「所有账户余额之和不变」「库存不能超卖」「一个用户只能有一个生效的订阅」,数据库不知道,也不会替你检查。

上面那条 SQL 里,balance >= 100 是一个应用层不变量;它只保护了 A 账户不透支,没有保护「转账前后总额不变」。如果第二条 UPDATE 写错了账户,或者金额单位从元变成分,数据库都会欣然提交。C 是应用的不变量,不是数据库的赠品。在分布式系统里,这条不变量往往跨多个库、多个服务,更不可能由某一个数据库的约束来保证。

I(隔离) 管的是并发事务之间「谁能看到谁」。两个人同时转账,会不会读到对方的中间状态?会不会两个人都基于同一个旧余额做决定?这是 6.2 和 6.3 的主题。

D(持久性) 说的是:COMMIT 返回之后,即使进程崩溃、机器断电,已提交的数据也不应该丢。单机上靠 WAL 刷盘(PostgreSQL 的 fsync、MySQL 的 redo log);分布式里还要靠复制。这里有一个非常常见的错觉:提交成功不等于数据已经到了多数派。

字母单机谁保证分布式里变成什么最常见的破法
Aundo/rollback + WAL2PC 的原子提交,或 Saga 的补偿协调者崩溃、补偿失败、部分提交
C约束、触发器 + 应用检查跨服务不变量、对账、补偿只在一个库检查,另一个库不知道
I锁 / MVCC没有全局隔离,中间状态会外泄把「本地事务」当成「全局事务」
DWAL fsync + 副本quorum 复制或异步复制主库宕机,异步备库还没收到

6.1.1 分布式里的 A:不是一个布尔值

单机事务的原子性是二值的:提交了就是全做,回滚了就是全不做。跨服务之后,「全做」需要所有参与者一起提交,「全不做」需要所有参与者一起回滚;而参与者可能在不同机器、不同数据库、不同可用区,任何一方都可能在关键时刻失联。

工程上有三种做法,它们对 A 的保证强度依次递减:

  1. 2PC(两阶段提交):协调者先问所有参与者「能不能提交」,全部同意后再统一提交。它提供原子提交,但参与者在投票后要一直持有锁,直到收到最终决定;协调者崩溃时,参与者会卡在 in-doubt 状态。2PC 的细节和失败窗口在 6.6。
  2. TCC(Try-Confirm-Cancel):每个服务提供「预留 / 确认 / 取消」三个动作,Try 先冻结资源,Confirm 真正扣减,Cancel 释放。它把原子性换成了「业务层的预留 + 补偿」,需要处理空回滚、悬挂和幂等。
  3. Saga:每个服务做本地事务,失败时按相反顺序执行补偿。它没有原子性,只有「最终补偿」;中间状态对外可见,补偿也可能失败。

所以「分布式事务」不是一个东西,而是一个谱:左边是强原子、长锁、低可用;右边是最终一致、短锁、要补偿和对账。选型的第一问不是「哪个最强」,而是「这个业务能不能接受中间状态被看到,能不能接受补偿失败后人工介入」。

6.1.2 分布式里的 D:提交、刷盘、复制是三件事

很多人把「事务提交成功」理解为「数据已经安全」。在单机上,这大致成立(前提是 fsync 真的落了盘);在分布式里,至少要区分三个位置:

  • 本机页缓存:数据在内存里,崩溃可能丢;
  • 本机 WAL/redo 落盘:单机崩溃可恢复,但机器整机损毁可能丢;
  • 多数派副本落盘:才能扛住单机/单 AZ 故障。

PostgreSQL 的 synchronous_commit 控制提交时等什么:

取值提交返回前保证崩溃后可能丢什么
off只写本地 WAL 缓冲,不等待刷盘最近若干事务(通常几百毫秒)
local本地 WAL 刷盘本机不丢,但备库可能没有
on(默认)本地 WAL 刷盘;有同步备库时等其刷盘无同步备库/异步复制时可能丢
remote_write备库收到并写入其 WAL 缓冲备库崩溃可能丢
remote_apply备库已应用,读可见延迟最高,但不丢已提交

MySQL 对应的是 innodb_flush_log_at_trx_commit 和 sync_binlog:前者控制 redo log 刷盘,后者控制 binlog 刷盘。两个都设为 1 才是「提交即落盘」;只设一个,崩溃恢复后可能出现「数据在、binlog 不在」或反过来,影响复制和 PITR。

这张表说明:D 不是数据库的一个开关,而是一条链。链上任何一段是异步的,链的末端就可能丢数据。异步复制的主库宕机、备库提升,是生产上「已提交数据丢失」最常见的原因之一。

定义 6.1.1 · 事务边界与逻辑请求边界

事务边界(transaction boundary)是数据库原子性生效的范围:从 BEGIN 到 COMMIT/ROLLBACK 之间的一组语句。逻辑请求边界(request boundary)是用户意图生效的范围:一次点击、一个支付 intent、一条转账指令。

单机时代两者经常重合,所以人们容易混用。跨服务之后,一个逻辑请求可能包含多个本地事务、多次 RPC、多条消息;数据库只看得见事务边界,看不见逻辑请求边界。重试、幂等、去重、对账,全都建立在「逻辑请求有身份」这个前提上;没有身份,数据库的原子性只能保证「每一次尝试内部不撕裂」,保证不了「同一意图只生效一次」。

提交成功之后,数据可能还在路上

COMMIT 返回只说明数据库接受了这个事务。它可能还在备库的复制队列里,可能还在消息队列的缓冲区里,可能还没被下游服务消费。「已提交」是本地事实,「已传播」是分布式事实;两者之间有一段窗口,所有「提交后立刻发消息」的双写问题都长在这段窗口里(6.7)。

6.1.3 C 在分布式里:从约束变成对账

单机的不变量可以写成约束,让数据库替你在每次写入时检查。跨服务之后,不变量往往长这样:

  • 「订单表里的每一笔订单,都必须有一条对应的库存扣减记录」;
  • 「账户余额 = 所有流水之和」;
  • 「一个支付 intent 最多对应一条成功的扣款」。

这些不变量没有一个能由单个数据库的 CHECK 表达。工程上的做法是把它们拆成三类机制:

机制保证代价例子
本地约束强、即时只能管一个库唯一索引防重复扣款
分布式协议强、有阻塞复杂度、延迟、可用性2PC 跨库转账
对账/补偿最终、可人工有时间窗口,可能错账日终流水对账、Saga 补偿

成熟系统通常三者都用:本地唯一约束挡住大部分重复,分布式协议处理必须原子的跨库操作,对账兜住协议失败和人为错误。

6.2 隔离级别与异常:从脏读到写偏斜

隔离级别不是「数据库的一个档位」,而是「一组异常,你允许哪些发生」。要理解它,先得认识五类异常。它们不是教科书里的名词,每一种都能对应到一条真实的业务 bug。

6.2.1 五类异常

异常一句话定义最小 schedule业务后果
脏读(dirty read)读到了另一个事务未提交的写T1 写 x=1;T2 读 x=1;T1 回滚基于「从未存在过」的数据做决定
不可重复读(non-repeatable read)同一事务内两次读同一行,值不同T1 读 x=0;T2 写 x=1 并提交;T1 再读 x=1同一份报表前后矛盾
幻读(phantom)同一事务内两次按同一条件查,行集不同T1 查 WHERE city='SH' 得 3 行;T2 插入一行并提交;T1 再查得 4 行统计/限额判断失效
丢失更新(lost update)两个事务读同一值、各自写回,后写覆盖先写T1 读 100,T2 读 100;T1 写 110;T2 写 120一次更新凭空消失
写偏斜(write skew)两个事务读重叠的集合、写不相交的行,各自都合法,合起来违反不变量两个医生都看到「2 人在岗」,各自把自己改成下班约束被绕过,且没有写冲突

前三个是「读」的异常,后两个是「写」的异常。丢失更新和写偏斜的区别值得单独记:丢失更新是两个事务写同一行,写偏斜是两个事务写不同的行、但读取的集合有重叠。前者可以被行锁或「第一次更新者获胜」挡住,后者不会产生任何行级写冲突,所以快照隔离也挡不住。

6.2.2 写偏斜:没有写冲突,也能破坏约束

假设医院有一条规则:任何时候至少有一名医生在岗。当前有两名医生 Alice 和 Bob 在岗。两人同时想下班,各自执行:

-- 会话 1(Alice)
BEGIN ISOLATION LEVEL REPEATABLE READ;
SELECT count(*) FROM doctors WHERE on_call;          -- 2
-- 会话 2(Bob),在会话 1 的 SELECT 之后开始
BEGIN ISOLATION LEVEL REPEATABLE READ;
SELECT count(*) FROM doctors WHERE on_call;          -- 2
UPDATE doctors SET on_call = false WHERE name = 'Bob';
COMMIT;
-- 回到会话 1
UPDATE doctors SET on_call = false WHERE name = 'Alice';
COMMIT;

(上面是 PostgreSQL 语法;MySQL 要在 BEGIN 之前执行 SET TRANSACTION ISOLATION LEVEL REPEATABLE READ。)

两个事务读到的都是 2,都认为「还有一个人在」,于是都把自己改成下班。它们写的是不同的行,没有写写冲突;在快照隔离(PostgreSQL 的 REPEATABLE READ、InnoDB 的 REPEATABLE READ 一致读)下,两个都能提交。提交之后 count(*) = 0,规则被破坏。

这不是数据库的 bug,而是快照隔离的语义:它保证「你看到的是一个一致的快照」,不保证「你的写和别人的写合起来像一个接一个执行」。要阻止写偏斜,需要可串行化,或者手工加锁(例如先 SELECT ... FOR UPDATE 锁住所有在岗医生),或者把不变量变成数据库能检查的约束。

例 6.2.1 · 写偏斜的两个变体

变体 A:值班规则。 两个事务各读 count(on_call)=2,各写自己 on_call=false,提交后 0 人在岗。修复:SELECT ... FOR UPDATE 锁住相关行,或使用 SERIALIZABLE 并重试 40001。

变体 B:优惠券预算。 活动预算 100 元,两个请求各读到「已用 60,剩 40」,各发一张 30 元券,各自插入自己的发券记录。两条记录不冲突,提交后已用 120,超预算。修复:把预算扣减收敛到一行(UPDATE budget SET used = used + 30 WHERE used + 30 <= total),或对预算行加锁。

两个变体的共同点:不变量约束的是「集合」,而写操作落在「单行」。只要检查和使用不是原子的,中间就有窗口。

6.2.3 隔离级别:标准说的和数据库做的

SQL 标准定义了四个级别,用三个「读异常」来区分。但标准只规定「不允许什么」,没规定「怎么实现」;同一个级别在不同数据库上的行为可能不同。

级别脏读不可重复读幻读PostgreSQL 实际InnoDB 实际
Read Uncommitted可能可能可能等同 Read Committed名义上允许;很少使用
Read Committed否可能可能默认级别;每条语句一个新快照默认级别;每条语句一个新 read view
Repeatable Read否否标准说可能快照隔离:无幻读,但允许写偏斜首次读建 read view、事务内复用;锁定读用 next-key lock 防幻读
Serializable否否否SSI:冲突时返回 40001隐式加共享锁;可能死锁,不是 SSI

这张表有三个容易踩的坑:

  1. PostgreSQL 的 READ UNCOMMITTED 不是「真的允许脏读」,它被静默提升为 READ COMMITTED。想复现脏读,在 PG 上做不到。
  2. PostgreSQL 的 REPEATABLE READ 是快照隔离(SI),不是标准意义上的锁式 RR。它没有幻读,但允许写偏斜;很多团队以为「RR 就安全了」,其实只是换了一类异常。
  3. InnoDB 的 REPEATABLE READ 对「一致读」和「锁定读」是两套语义。普通 SELECT 读快照,SELECT ... FOR UPDATE 读最新版本并加 next-key lock。同一个事务里混用两者,容易出现「读到的和锁到的不是同一版」。
一句话

隔离级别管的是「你能看到什么」,不是「你的业务规则对不对」。写偏斜就是业务规则在「看到」和「写入」之间被别人钻了空子。

6.2.4 丢失更新:绝对值写入与增量写入

丢失更新经常被和写偏斜混为一谈。看一个最小例子:

T1: SELECT balance FROM accounts WHERE id='A';   -- 100
T2: SELECT balance FROM accounts WHERE id='A';   -- 100
T1: UPDATE accounts SET balance = 110 WHERE id='A';  -- 应用算好的绝对值
T2: UPDATE accounts SET balance = 120 WHERE id='A';  -- 覆盖,T1 的 +10 消失

在 PostgreSQL 的 REPEATABLE READ 下,T2 的 UPDATE 会发现这行已经被 T1 改过,返回 40001 could not serialize access due to concurrent update,应用必须重试。在 InnoDB 的 REPEATABLE READ 下,T2 的 UPDATE 会等待 T1 提交,然后基于最新版本执行;如果 SQL 写的是 SET balance = 120(绝对值),T1 的更新仍然会被覆盖;如果写的是 SET balance = balance + 20(增量),则不会丢。

所以「数据库的隔离级别能防丢失更新」这句话只在特定写法下成立。最稳的写法是让数据库做增量,或者用版本号/CAS:

UPDATE accounts
   SET balance = balance - 100, version = version + 1
 WHERE id = 'A' AND version = 7 AND balance >= 100;
-- 受影响行数为 0:要么版本变了,要么余额不足;应用据此决定重试还是拒绝

这条语句把「检查」和「写入」合并成一个原子操作,不依赖隔离级别,也不依赖读到的旧值。它是第 4 章 CAS 思想在事务里的落地。

6.3 MVCC:版本、快照、可见性

如果没有 MVCC,读和写会互相阻塞:读要加共享锁,写要加排他锁,一个长查询就能把整张表的写入卡住。MVCC 的思路是:写不覆盖旧值,而是产生一个新版本;读按自己的快照选择看哪个版本。这样读不阻塞写、写不阻塞读,代价是旧版本要保留、要回收。

MVCC:读快照,写新版本 x 的版本链 0→1→2;事务 T1 的快照看到 1,T2 的快照看到 2,旧版本由 vacuum 回收。 MVCC:读快照,写新版本 v0 x=0 T0 v1 x=1 T1 写 v2 x=2 T2 写 T1 快照 可见 v1,不可见 v2 T2 快照 可见 v2 旧版本保留到没有快照需要它;长事务 = vacuum 无法回收。 写偏斜:两个事务各读一部分、各写另一部分,快照隔离下仍可能破坏不变量。
读图:快照隔离下,读不阻塞写、写不阻塞读;代价是旧版本要保留,长事务会拖住回收。图中 T1 在 T2 提交后仍然读到 1,这不是 bug,而是快照的语义。

图里有一条 x 的版本链:v0(x=0) → v1(x=1) → v2(x=2)。T1 在 T2 提交之后仍然读到 x=1,因为 T1 的快照是在 T2 提交之前拿的。这不是 bug,是快照隔离的承诺:一个事务看到的世界,在它开始(或第一次读)的那一刻被冻结了。

6.3.1 三个词:版本、快照、可见性

  • 版本(version):某一行的某一个历史状态,带有「谁创建的」「谁删除/替换了它」的元数据。
  • 快照(snapshot / read view):一个事务在某一时刻看到的「事务 ID 边界」,通常包括「哪些事务还活跃」「下一个未分配的事务 ID」。
  • 可见性(visibility):给定一个版本和一个快照,判断这个版本对当前事务是否可见。可见性规则是 MVCC 的核心,也是不同数据库实现差异最大的地方。

三者合起来回答一个问题:「我现在读这一行,应该读哪个版本?」 答案不是「最新的」,而是「我的快照允许我看到的最新版本」。

6.3.2 PostgreSQL:元组头的 xmin/xmax

PostgreSQL 把版本信息放在每个堆元组的头部:

  • xmin:创建这个元组的事务 ID;
  • xmax:删除(或锁定)这个元组的事务 ID;0 表示没被删除;
  • cid:命令 ID,用于同一事务内的语句顺序。

快照由三部分组成:

xmin  = 快照时刻仍活跃的最小事务 ID(更早的事务一定已结束)
xmax  = 快照时刻下一个尚未分配的事务 ID(更大的事务一定还没开始)
xip   = 快照时刻仍在进行中的事务 ID 列表

在 psql 里可以直接看:

SELECT pg_current_snapshot();
-- 100:105:101,102

100:105:101,102 的意思是:小于 100 的事务都已结束,大于等于 105 的事务都还没开始,101 和 102 两个事务在快照时刻仍然活跃。一个元组的 xmin 如果落在 xip 里,或者 >= xmax,对当前快照就不可见。

可见性判断可以写成下面这段伪代码(省略了命令 ID、子事务、冻结、行锁的 LOCK_ONLY 等细节):

def visible(xmin, xmax, snap, self_xid, committed):
    if xmax == self_xid:  # 自己删掉的版本,对自己不可见
        return False
    # 自己写的东西,自己一定看得见
    if xmin == self_xid:
        return True
    # 插入者没提交,或者快照建立时它还没开始/还在活跃
    if not committed(xmin) or xmin >= snap.xmax or xmin in snap.xip:
        return False
    # 没有被删除
    if xmax == 0:
        return True
    # 删除者已提交,且对快照可见,才算真的被删了
    deleted = committed(xmax) and xmax < snap.xmax and xmax not in snap.xip
    return not deleted

注意最后一行:xmax 有值不代表元组已经不可见。如果删除它的事务在快照之后才提交,或者当时还在活跃,那么这个删除对当前快照「还没发生」,元组仍然可见。「已删除」是一个相对于快照的事实,不是一个全局事实。

6.3.3 InnoDB:undo log 里的旧版本

InnoDB 的行里没有把每个版本都摊在表上,而是只保留最新版本,旧版本放在 undo log 里,通过行头的 DB_ROLL_PTR 串成一条版本链:

聚簇索引行(最新)
  DB_TRX_ID = 900
  DB_ROLL_PTR ──▶ undo record(x=2, trx=900, roll_ptr=...)
                     └──▶ undo record(x=1, trx=800, roll_ptr=...)
                              └──▶ undo record(x=0, trx=700)

一致读(普通 SELECT)拿一个 read view,从最新版本沿 ROLL_PTR 往回走,直到找到第一个「对 read view 可见」的版本。read view 的四个字段是:

字段含义
m_ids创建 read view 时活跃的读写事务 ID 集合
min_trx_idm_ids 中的最小值
max_trx_id创建 read view 时下一个要分配的事务 ID
creator_trx_id创建这个 read view 的事务 ID

判断规则比 PostgreSQL 更直接:

trx_id == creator_trx_id        → 可见(自己的修改)
trx_id <  min_trx_id            → 可见(在 read view 之前已提交)
trx_id >= max_trx_id            → 不可见(read view 之后才开始)
trx_id in m_ids                 → 不可见(read view 时还活跃)
否则                             → 可见

再叠加「删除标记」:版本带 delete_flag 时,还要看删除它的事务对 read view 是否可见;可见才继续往回找,不可见则这个版本仍然有效。

定义 6.3.1 · 快照隔离

快照隔离(snapshot isolation, SI)保证:一个事务的所有读都来自同一个一致快照;事务提交时,如果它写过的行被其他已提交事务改过,则产生写写冲突(first-committer-wins),后提交者回滚。

SI 阻止了脏读、不可重复读、幻读和同一行的丢失更新,但不阻止写偏斜。PostgreSQL 的 REPEATABLE READ 和 InnoDB 的 REPEATABLE READ 一致读都提供 SI 级别的语义;要排除写偏斜,需要可串行化(PostgreSQL 的 SSI)或显式锁。

6.3.4 长事务为什么危险

MVCC 的旧版本不是免费的。只要还有一个快照需要某个旧版本,数据库就不能回收它。于是一个「忘了提交」的事务会带来连锁反应:

  • PostgreSQL:VACUUM 无法清理比最老快照更旧的死元组,表会膨胀;索引也会膨胀;查询变慢;极端情况下事务 ID 回卷(wraparound)风险上升。
  • InnoDB:purge 线程无法清理 undo,History list length 持续增长;回滚段变大;磁盘占用上升;崩溃恢复变慢。

这两种症状在 6.9 都有对应的观测命令。这里先记住一个可执行的规则:OLTP 事务应该以毫秒到秒计,不要以分钟计。批量任务要分批提交,报表要用只读事务/快照导出,不要让一个 BEGIN 跨越用户思考时间。

6.3.5 一个可复现的快照实验

开两个 psql 会话,按下面的顺序执行,可以直接看到「T1 在 T2 提交后仍然读到旧值」:

-- 会话 1
BEGIN ISOLATION LEVEL REPEATABLE READ;
SELECT balance FROM accounts WHERE id = 'A';   -- 100

-- 会话 2
BEGIN;
UPDATE accounts SET balance = 50 WHERE id = 'A';
COMMIT;

-- 会话 1,再次查询
SELECT balance FROM accounts WHERE id = 'A';   -- 仍然是 100
COMMIT;
快照不是备份

MVCC 的快照只保证「这个事务看到一致的时间点」,不保证「数据不会丢」。备份是另一套机制(物理备份、PITR、逻辑导出)。把只读事务当成备份、或者在长事务里做导出,既拖住回收,又不能提供可恢复性。

6.4 InnoDB 与 PostgreSQL 的实现差异

两个数据库都支持 MVCC,都用「快照 + 版本」实现读不阻塞写,但存放版本的位置、回收方式、锁的粒度完全不同。理解差异不是为了站队,而是为了在出问题时知道该看什么指标。

维度InnoDB(MySQL)PostgreSQL
版本存放undo log,行内 DB_ROLL_PTR 串链堆表里保留新旧元组,索引指向元组
行头字段DB_TRX_ID、DB_ROLL_PTR、DB_ROW_IDxmin、xmax、cid
快照结构read view:m_ids/min/max/creatorsnapshot:xmin/xmax/xip
旧版本回收purge 线程清理 undoVACUUM/autovacuum 清理死元组
回收受阻的症状History list length 增长、undo 表空间膨胀表/索引膨胀、n_dead_tup 高、vacuum 滞后
更新方式原地更新聚簇索引,旧值进 undo写新元组,旧元组留堆,索引可能新增条目
二级索引存主键,可见性可能要回表存 TID,指向具体元组;HOT 可减少索引更新
锁行锁、间隙锁、next-key lock(RR)行锁;无间隙锁;SSI 用谓词锁
序列化冲突靠锁/死锁检测;无 SSISSI,返回 40001
长事务的主要代价undo 膨胀、purge 滞后表膨胀、vacuum 滞后、xid 年龄
DDL8.0 原子 DDL,但仍有在线 DDL 代价事务性 DDL,可回滚

6.4.1 更新为什么在两边长得不一样

InnoDB 的聚簇索引就是表:主键决定行的物理位置。更新一行时,它尽量在原地改,把旧值写进 undo,回滚指针指向旧版本。这样主键查询很快,但二级索引里存的是主键值,二级索引本身不携带事务 ID;当二级索引扫描命中一行时,可能需要回到聚簇索引做可见性判断。覆盖索引能减少回表,但不一定完全消除这种检查。

PostgreSQL 的堆表不按主键排序,更新一行会写一个新元组,旧元组留在原地,索引条目指向具体的元组位置(TID)。这带来两个后果:

  • 更新频繁的表会积累死元组,必须靠 VACUUM 回收;回收跟不上就膨胀。
  • 如果更新没有改索引列,并且新元组能放在同一页的空闲空间里,PG 可以做 HOT 更新,不新增索引条目;否则每个索引都要新增一条指向新元组的记录。

这解释了一个常见现象:同样的更新量,MySQL 的表大小可能很稳定,PostgreSQL 的表却在涨。不是 PG「更差」,而是它的版本存放策略把回收的责任明确交给了 vacuum;只要 autovacuum 跟得上,空间会复用,表大小会趋于稳定。跟不上的时候,VACUUM FULL 能立刻收缩,但会持锁重写整表,生产上要谨慎。

6.4.2 回收:purge 与 vacuum 的对照

问题InnoDB 看什么PostgreSQL 看什么
旧版本积压SHOW ENGINE INNODB STATUS 的 History list lengthpg_stat_user_tables.n_dead_tup、last_autovacuum
谁挡住了回收最老的 read view(长事务/长查询)pg_stat_activity.backend_xmin、pg_stat_activity.xact_start
回收进度purge 线程、undo 表空间大小pg_stat_progress_vacuum、autovacuum 日志
极端风险undo 表空间占满磁盘事务 ID 回卷、表膨胀到磁盘告警

一个可执行的检查顺序是:先找最老的事务/查询,再看回收指标,最后才考虑调参数。大多数「回收不动」不是参数问题,而是一个忘了提交的连接或一个跑了几小时的查询。

backend_xmin 越小,说明这个连接持有的快照越老,阻挡的回收范围越大。一个 idle in transaction 状态、xact_age 几小时的连接,通常就是膨胀的元凶。

6.5 锁、死锁与重试

MVCC 让读不阻塞写,但写和写之间仍然要排队。两个事务改同一行,数据库必须用锁保证「后一个不会基于过期的版本做决定」。锁用得好是并发控制,用不好就是死锁和超时;而死锁之后怎么重试,决定了系统是「短暂抖动」还是「雪崩」。

6.5.1 锁的层次

锁作用范围什么时候用典型症状
行锁(record lock)单行UPDATE、DELETE、SELECT ... FOR UPDATE热点行排队
间隙锁(gap lock)索引区间之间的空隙InnoDB RR 的锁定读,防幻读插入被阻塞,但看不到谁锁了行
next-key lock行 + 前面的间隙InnoDB RR 默认的锁定读范围更新/插入互相阻塞
表级意向锁(IS/IX)表配合行锁,标记「表里有行锁」不直接阻塞,但影响表锁
表锁/元数据锁整表DDL、LOCK TABLEDDL 等待长事务,后续查询排队
谓词锁(predicate lock)查询条件PostgreSQL SSI读写依赖冲突,返回 40001

PostgreSQL 的行锁有一个容易忽略的细节:普通 SELECT 不加锁,所以它不会阻塞 UPDATE;SELECT ... FOR UPDATE 才会。InnoDB 的普通 SELECT 也不加锁(一致读),但 SELECT ... FOR UPDATE 在 RR 下会加 next-key lock,锁住的范围可能比你想象的大。

「锁了一行」不等于「只影响一行」

InnoDB 的 next-key lock 锁的是索引记录及其前面的间隙。一个 UPDATE ... WHERE id BETWEEN 10 AND 20 可能锁住范围内不存在的键位,导致插入 15 被阻塞。PostgreSQL 没有间隙锁,但 SSI 会用谓词锁跟踪读写依赖,冲突时让事务回滚。看不到锁行,不代表没有锁;6.9 的 pg_locks 和 data_locks 是查这个的。

6.5.2 死锁:两个事务的加锁顺序相反

死锁的最小例子是两条转账语句以相反顺序更新两个账户:

-- 会话 1
BEGIN;
UPDATE accounts SET balance = balance - 10 WHERE id = 'A';
UPDATE accounts SET balance = balance + 10 WHERE id = 'B';
COMMIT;

-- 会话 2(几乎同时)
BEGIN;
UPDATE accounts SET balance = balance - 10 WHERE id = 'B';
UPDATE accounts SET balance = balance + 10 WHERE id = 'A';
COMMIT;

会话 1 持有 A 的行锁,等 B;会话 2 持有 B 的行锁,等 A。两边都不肯放手,形成环。数据库的处理方式不一样:

  • InnoDB 默认开启死锁检测(innodb_deadlock_detect=ON),发现环后立刻回滚「代价较小」的那个事务,客户端收到 ERROR 1213 (40001): Deadlock found when trying to get lock。
  • PostgreSQL 在 deadlock_timeout(默认 1 s)后检测等待图,发现环就中止其中一个事务,返回 40P01 deadlock_detected。

两者都会牺牲一个事务来打破环。死锁不是异常,而是并发系统的正常现象;应用必须能识别并重试,而不是把它当成 bug 报给用户。

6.5.3 哪些错误值得重试

不是所有数据库错误都能重试。把「唯一键冲突」重试一百次,只会得到一百次冲突;把「语法错误」重试,只会浪费连接。大致分三类:可重试、不可重试、结果未知。

错误PostgreSQLMySQL能重试吗动作
序列化失败40001—(无 SSI)能退避后重试整个事务
死锁40P011213能退避后重试整个事务
锁等待超时55P03(lock_timeout)1205能,但要先查原因缩短事务/调整顺序,再重试
唯一键冲突235051062不能(除非是幂等插入)当作「已存在」处理
语法/类型错误42601/22P021064不能修代码
连接断开080062013能,但结果未知重连 + 幂等键,按 unknown 处理
事务已中止25P02—不能在同事务内重试先 ROLLBACK,再重开事务

最容易犯的错是「在同一个事务里重试一条语句」。PostgreSQL 里,一条语句失败后整个事务进入 aborted 状态,后续语句全部返回 25P02,必须先 ROLLBACK。重试的单位是整个事务,不是单条 SQL。

6.5.4 一个可用的重试封装

下面这段代码只重试「可重试」的 SQLSTATE,并且重试的是整个事务:

import random
import time

import psycopg

RETRYABLE = {"40001", "40P01", "55P03"}


def run_tx(dsn: str, work, attempts: int = 5):
    """work(conn) 在一个事务里执行;失败时整体回滚并重试。"""
    with psycopg.connect(dsn) as conn:
        for i in range(attempts):
            try:
                with conn.transaction():
                    return work(conn)
            except psycopg.Error as exc:
                if exc.sqlstate not in RETRYABLE or i == attempts - 1:
                    raise
                # 指数退避 + 抖动:避免所有客户端同时重试
                delay = min(0.05 * (2 ** i), 1.0)
                time.sleep(delay * (0.5 + random.random()))

这段代码有四个刻意的选择:

  1. 只捕获 psycopg.Error 并检查 sqlstate,不靠错误消息文本匹配;消息会随版本/语言变化。
  2. 重试整个 conn.transaction() 块,而不是块里的某条语句;这样每次重试都是一个干净的事务。
  3. 退避带抖动,否则一批同时死锁的请求会在同一时刻再次相撞。
  4. 最多 5 次,并且没有无限循环;重试耗尽后把错误抛给上层,由上层决定降级还是返回用户。

如果连接本身断了(08006),上面的 with psycopg.connect(...) 不会自动重连;生产代码需要在外层重建连接,并且用幂等键保证「断线前的提交可能已经生效」不会变成重复副作用。这一点在 6.8 和 6.11 会展开。

6.5.5 减少死锁的四条规则

  1. 统一加锁顺序:所有事务按主键/账户 ID 排序后依次加锁;不要一个按 A→B、一个按 B→A。
  2. 缩短事务:把网络调用、文件 IO、用户交互挪到事务外;事务里只留必要的读写。
  3. 一次锁够:能一条 UPDATE ... WHERE 完成的,不要先 SELECT 再 UPDATE;能批量更新的,不要逐行循环。
  4. 设置超时:SET lock_timeout = '2s'、SET statement_timeout = '5s',让慢事务快速失败,而不是无限等待拖垮连接池。

6.6 分布式事务:2PC、TCC、Saga

单库事务的边界是 BEGIN ... COMMIT,跨库之后没有这样一个语句能包住两个数据库。你要么引入一个协调者,让所有参与者按同一个决定提交或回滚(2PC);要么把「原子性」降级成「预留 + 确认/取消」(TCC);要么承认中间状态存在,用补偿把系统拉回一致(Saga)。三条路都有失败窗口,区别在于窗口里谁被阻塞、谁能自救。

6.6.1 2PC:两个阶段,一个决定

协调者 C                    参与者 A                参与者 B
   │ ① prepare ─────────────▶│                        │
   │ ◀───────────── vote yes │                        │
   │ ① prepare ──────────────────────────────────────▶│
   │ ◀────────────────────────────────────── vote yes │
   │
   │ ② 把 commit 决定写入日志(必须先落盘)
   │ ② commit ──────────────▶│                        │
   │ ② commit ───────────────────────────────────────▶│
   │ ◀─────────────── ack    │                        │
   │ ◀─────────────────────────────────────── ack    │

第一阶段(prepare/vote):协调者问每个参与者「这个事务能不能提交」。参与者在本地执行事务的所有写,把 undo/redo 和 prepared 状态写入日志,锁住相关资源,然后投票 yes 或 no。投 yes 意味着「我能提交,但我现在不能单独决定」。

第二阶段(commit/abort):如果所有参与者都投 yes,协调者把「commit」决定写入自己的日志(必须先落盘),再给所有参与者发 commit;只要有一个投 no,就发 abort。参与者按决定执行,释放锁,回 ack。

6.6.2 2PC 的失败窗口

失败时刻谁崩了参与者状态能否自救处理
prepare 发出前协调者未收到 prepare能,安全回滚超时后 abort
参与者投 yes 前参与者未写 prepared能,本地回滚协调者收到 no 或超时
投 yes 后、收到决定前协调者in-doubt,持锁不能等协调者恢复;协调者要高可用
commit 只送达一部分网络/协调者一部分提交,一部分 in-doubt不能协调者重试;参与者不能自行决定
参与者投 yes 后崩溃参与者恢复后读 prepared 日志不能单独决定恢复后向协调者询问
网络分区双方各自等待不能超时策略 + 人工介入

最危险的是第四行:一部分参与者已经提交,另一部分还在等。如果等待的参与者「等不及了」,自己决定回滚(启发式回滚),系统就出现「A 库扣了钱、B 库没入账」的分裂。XA 规范允许这种启发式决定,但工程上应该把它当成事故:宁可阻塞,也不要让参与者单方面推翻协调者的决定。

2PC 的代价也很具体:

  • 延迟:至少两轮网络往返,加上协调者和参与者的日志 fsync;跨可用区时,每次 fsync 都是毫秒级。
  • 锁持有时间长:从 prepare 到 commit,参与者一直持有行锁;并发一高,锁等待和死锁都会上升。
  • 协调者是单点:协调者崩溃时,所有 in-doubt 事务都卡住;生产上协调者本身要用 Paxos/Raft 复制。
  • 吞吐有上限:每个事务都要两次持久化和多轮 RPC,不适合高并发短事务。
2PC 不是银弹

2PC 保证的是「原子提交」,不是「隔离」,也不是「高可用」。它不能让跨库的读看到一致快照,不能在协调者失联时让参与者继续前进,也不能消除网络分区。把 2PC 加到微服务上,只是把「部分提交」换成了「阻塞 + 部分提交」,复杂度还上升了。能用一个库解决的事,不要用 2PC;能用本地事务 + Outbox 解决的事,也不要上 2PC。

6.6.3 TCC:把原子性换成预留

TCC 把一次业务操作拆成三个动作:

阶段做什么失败后必须满足
Try检查并预留资源(冻结金额、锁座位)释放预留幂等;预留可查询
Confirm确认使用预留(真正扣减)重试直到成功幂等;不会失败(业务上已确保)
Cancel释放预留(解冻、退座)重试直到成功幂等;能处理「没 Try 过」

TCC 有两个经典问题,名字听起来玄,本质都是「消息/请求乱序 + 重试」:

  • 空回滚(empty rollback):Cancel 先于 Try 到达。参与者没有对应的 Try 记录,不能报错(否则协调者会一直重试),而要记录「这个分支已取消」,返回成功。等 Try 后来到达时,看到「已取消」就拒绝执行,避免资源被预留后永远不释放。
  • 悬挂(hanging):Try 在 Cancel 之后到达。如果没有上面的「已取消」记录,Try 会成功预留资源,但 Confirm 永远不会来,资源就挂住了。解决方法是 Try 先查事务控制表:状态是 cancelled 就拒绝。

TCC 还需要一张事务控制表(tx_id、branch_id、status),记录全局事务和每个分支的状态。Try 插入 try,Confirm 更新为 confirm,Cancel 更新为 cancel;所有更新都用「状态机 + 条件更新」保证幂等和顺序。TCC 的隔离性来自「预留」:冻结的金额还在账户里,但可用余额已经减少,其他事务看到的是「可用余额」,不会把同一笔钱花两次。

6.6.4 Saga:没有原子性,只有补偿

Saga 把长事务拆成一串本地事务 T1, T2, ..., Tn,每个 Ti 都有一个补偿动作 Ci。如果 Tk 失败,就按相反顺序执行 Ck-1, ..., C1:

正向:  T1 ──▶ T2 ──▶ T3 ──▶ T4
                              │
                              └─ T4 失败
                                     │
补偿:  C1 ◀── C2 ◀── C3 ◀──────────┘
        (执行顺序:C3 → C2 → C1)

Saga 的保证是「最终一致」,不是「原子」。它有四个必须承认的事实:

  1. 中间状态对外可见:T1 创建了订单、T2 还没扣款时,别的请求能看到这个订单。要用业务状态(PENDING/RESERVED)把它标出来,让其他操作知道「还没完成」。
  2. 补偿不是回滚:退款不是「撤销扣款」,而是一笔新的正向交易;它可能失败、可能被风控拦截、可能产生手续费。补偿动作必须幂等,并且要能重试。
  3. 没有隔离:另一个事务可能读到中间状态并据此做决定。工程上用「语义锁」(semantic lock)来处理:在业务层标记「这个实体正在事务中」,其他事务要么等待,要么拒绝。
  4. 补偿也可能失败:C3 失败时,需要有重试、死信、人工介入。Saga 的尽头往往是一张对账工单,这不是耻辱,而是设计的一部分。

6.6.5 怎么选

方案原子性隔离性延迟复杂度适用
2PC/XA强原子提交参与者本地隔离,无全局快照高(2 RTT + fsync)中跨库短事务、同构数据库、能接受阻塞
TCC业务级原子靠预留实现中高(三个接口 + 控制表)支付、库存、座位等资源预留
Saga最终一致无,需要语义锁低中高(补偿链 + 对账)长流程、跨多个服务、能接受中间状态
Outbox本地原子 + 最终一致无低低事件通知、异步解耦(6.7)

选型时先回答三个问题:

  1. 这个操作能不能接受「中间状态被看到」? 不能,就别用 Saga;考虑 2PC 或把数据放到一个库里。
  2. 参与方能不能长时间持锁? 不能,就别用 2PC;考虑 TCC 或 Saga。
  3. 补偿失败时,业务能不能人工介入? 不能,就别把 Saga 当成「自动回滚」;要么把不变量收敛到单库,要么设计更强的预留。

6.7 Outbox 与幂等消费

很多团队第一次遇到「双写」问题时,会写这样的代码:

BEGIN;
INSERT INTO orders (...) VALUES (...);
COMMIT;
publish("order.created", order_id);   -- 进程在这里崩溃,消息永远发不出去

数据库提交了,消息没发,下游永远不知道有这个订单。把顺序反过来,又会出现另一种错误:

publish("order.created", order_id);   -- 消息先发出去
BEGIN;
INSERT INTO orders (...) VALUES (...);
ROLLBACK;                              -- 插入失败,下游看到了一个不存在的订单

问题的本质是:数据库事务和消息发送是两个系统,没有一个本地事务能同时包住它们。2PC 可以包,但代价高;更常用的做法是 Outbox:把「要发的消息」写进业务库的同一张表、同一个事务,然后由另一个进程把 outbox 里的记录搬到消息队列。

6.7.1 Outbox 表

CREATE TABLE outbox (
  id           BIGSERIAL   PRIMARY KEY,
  aggregate_id TEXT        NOT NULL,
  event_type   TEXT        NOT NULL,
  payload      JSONB       NOT NULL,
  created_at   TIMESTAMPTZ NOT NULL DEFAULT now(),
  published_at TIMESTAMPTZ
);

CREATE INDEX outbox_pending ON outbox (id) WHERE published_at IS NULL;

业务事务变成:

BEGIN;
INSERT INTO orders (id, user_id, amount) VALUES ('o-1', 'u-1', 100);
INSERT INTO outbox (aggregate_id, event_type, payload)
VALUES ('o-1', 'order.created', '{"order_id":"o-1","amount":100}');
COMMIT;

两条插入在同一个本地事务里:要么都在,要么都不在。不会出现「订单在、消息不在」;也不会出现「消息在、订单不在」。Outbox 把分布式双写问题降级成了本地事务问题,这是它唯一但足够重要的价值。

6.7.2 Relay:至少一次地搬

一个最小的 relay 循环:

def relay_once(conn, publish, batch: int = 100) -> int:
    with conn.transaction():
        rows = conn.execute(
            "SELECT id, aggregate_id, event_type, payload "
            "FROM outbox WHERE published_at IS NULL "
            "ORDER BY id LIMIT %s FOR UPDATE SKIP LOCKED",
            (batch,),
        ).fetchall()
        for row in rows:
            publish(row)  # 可能成功、可能超时;下游必须能去重
            conn.execute(
                "UPDATE outbox SET published_at = now() WHERE id = %s",
                (row["id"],),
            )
        return len(rows)

这段代码有几个取舍:

  • FOR UPDATE SKIP LOCKED 让多个 relay 实例可以并行处理,而不会互相阻塞;每个实例拿到不同的行。
  • ORDER BY id 保证同一批内的顺序;跨实例的全局顺序不保证,所以消息的消费端要能处理乱序,或者按 aggregate_id 分区。
  • publish 在事务里,如果 publish 成功但事务提交失败,消息会重发;如果 publish 超时但实际成功,也会重发。这就是至少一次,下游必须按事件 ID 去重。
  • 生产上通常会把「claim 一批、提交、再 publish、再标记」拆开,避免网络调用把数据库事务拉长;代价是状态机多一个 locked_until 字段。

6.7.3 消费端:去重表与业务写入在同一个事务里

至少一次投递意味着消费端一定会遇到重复。去重的关键不是「记住所有见过的 ID」,而是「把去重记录和业务效果放在同一个事务里」:

CREATE TABLE processed_events (
  consumer     TEXT        NOT NULL,
  event_id     UUID        NOT NULL,
  processed_at TIMESTAMPTZ NOT NULL DEFAULT now(),
  PRIMARY KEY (consumer, event_id)
);
def handle_event(conn, event) -> None:
    with conn.transaction():
        inserted = conn.execute(
            "INSERT INTO processed_events (consumer, event_id) "
            "VALUES (%s, %s) ON CONFLICT DO NOTHING RETURNING event_id",
            ("billing", event["id"]),
        ).fetchone()
        if inserted is None:
            return  # 已经处理过,直接跳过
        conn.execute(
            "UPDATE accounts SET balance = balance + %s WHERE id = %s",
            (event["amount"], event["account_id"]),
        )

如果「插入去重记录」成功、「更新余额」失败,整个事务回滚,事件会被重新投递;如果两者都成功,事务提交,重复投递会被 ON CONFLICT 挡住。这里没有「先查再插」的竞态,因为唯一约束和业务写入在同一个事务里。

去重表会一直增长,需要按时间分区或定期清理。清理的保留窗口要大于「消息的最长重投递时间」,否则一个延迟很久的重投会被当成新事件。

6.7.4 Outbox、CDC 与本地消息表

方案原子性延迟侵入性适用
双写(先库后 MQ)无低低能容忍丢消息的场景
Outbox 轮询本地事务原子轮询间隔中(多一张表)大多数业务系统
CDC(Debezium 等)本地事务原子低(读日志)低(不改业务代码)已有 binlog/WAL,追求低延迟
本地消息表 + 事务消息本地事务原子低高(依赖 MQ 特性)特定 MQ 生态

CDC 不是「没有重复」:它读的是数据库日志,日志里的每一次变更都会被投递一次;如果消费端提交 offset 失败,或者 relay 重启,仍然会重放。CDC 只是把「写 outbox 表」换成了「读数据库日志」,至少一次的语义没有变。

Outbox 的三个常见误解
  1. 「有了 outbox 就不会重复」:outbox 只保证「业务数据和消息一起提交」,不保证「消息只被消费一次」;重复仍然会发生,去重必须在消费端做。
  2. 「outbox 可以随便删」:删除已发布记录前要确认所有消费者都已处理;保留窗口太短,重放/补数就没有依据。
  3. 「CDC 不需要业务改造,所以没有顺序问题」:CDC 按日志顺序投递,但消费者多实例、分区重平衡都会打乱处理顺序;顺序保证要落到分区键上。

6.8 失败模式:重试把一次转账做成两次

把前面几节拼起来,就得到本章最重要的一个失败模式。服务端代码如下,看起来没有任何问题:

def transfer(conn, from_acct: str, to_acct: str, amount: int) -> None:
    with conn.transaction():
        conn.execute(
            "UPDATE accounts SET balance = balance - %s WHERE id = %s",
            (amount, from_acct),
        )
        conn.execute(
            "UPDATE accounts SET balance = balance + %s WHERE id = %s",
            (amount, to_acct),
        )
        conn.execute(
            "INSERT INTO transfers (from_acct, to_acct, amount) VALUES (%s, %s, %s)",
            (from_acct, to_acct, amount),
        )

事务是原子的,两条 UPDATE 和一条 INSERT 要么全做、要么全不做。数据库层面没有任何约束被违反。但客户端调用的是 POST /transfer,请求里没有幂等键;SDK 在 1 秒超时后自动重试一次。

时间客户端服务端数据库结果
t0发送 POST /transfer,无幂等键收到请求——
t1—BEGIN;扣 A;加 B;插 transfers行锁、写 WAL—
t2—COMMIT 成功已提交扣款已生效
t31 s 超时,重试同一请求网关/连接已断,响应没回去—客户端认为失败
t4—收到重试,视为新请求BEGIN;再扣 A;再加 B;再插一条—
t5—COMMIT 成功两条 transfersA 少了 200,B 多了 200
t6收到 200,显示成功——用户只看到一次成功

这个事故的根因不在数据库,也不在重试策略本身,而在两个边界的错位:

  • 数据库的事务边界是 BEGIN ... COMMIT,它保证「这一次尝试」内部不撕裂;
  • 用户意图的边界是「A 向 B 转 100 元」,它应该只生效一次;
  • 重试产生了一次新的尝试,服务端为它开了一个新事务;数据库没有任何信息能判断「这和刚才那笔是同一个意图」。

事务是幂等的敌人吗?不是。事务根本不知道「意图」存在。 它只看见语句。没有幂等键,服务端就无法把第二次尝试归并到第一次;两次尝试各自都是合法事务,合起来就是重复副作用。

6.8.1 两种更隐蔽的变体

上面是最直观的「响应丢失 + 客户端重试」。还有两种变体,往往更难发现:

变体一:服务端内部重试死锁事务。 6.5 的重试封装会把整个事务再执行一遍。如果事务里只有数据库操作,重试是安全的;但如果事务里还调用了外部服务(扣款网关、发券、发短信),回滚不会撤销外部副作用:

with conn.transaction():
    conn.execute("UPDATE accounts SET balance = balance - 100 WHERE id = 'A'")
    gateway.charge(customer_id, 100)   # 外部副作用:事务回滚不会退款
    conn.execute("INSERT INTO transfers ...")

第一次执行到 gateway.charge 后发生死锁,事务回滚,重试再次 charge。数据库里可能只有一条 transfer,网关却扣了两次。事务边界只能包住数据库,包不住网络调用。

变体二:提交成功后抛异常。 服务端 COMMIT 成功,然后在序列化响应、写日志、发消息时抛异常;上层捕获异常后重试。数据库已经提交,重试会再执行一次。这类 bug 在「提交后还有一段代码」的实现里很常见。

6.8.2 修复:让「意图」在数据库里留下痕迹

修复的核心不是「别重试」,而是「让重试可以被识别」。最小方案是客户端生成请求 ID,服务端在同一个事务里先插入这个 ID,用唯一约束做闸门:

CREATE TABLE transfers (
  id          BIGSERIAL   PRIMARY KEY,
  request_id  UUID        NOT NULL UNIQUE,
  from_acct   TEXT        NOT NULL,
  to_acct     TEXT        NOT NULL,
  amount      NUMERIC     NOT NULL,
  created_at  TIMESTAMPTZ NOT NULL DEFAULT now()
);

BEGIN;
INSERT INTO transfers (request_id, from_acct, to_acct, amount)
VALUES ('6f1c2a9e-8d1b-4f3a-9c2e-2b7d5a0c1e44', 'A', 'B', 100)
ON CONFLICT (request_id) DO NOTHING
RETURNING id;
-- 如果没有返回行,说明这个 request_id 已经处理过:
-- 查出原记录,直接返回原结果,不再扣款
-- 只有上面返回了行(首次请求)才执行下面两条:
UPDATE accounts SET balance = balance - 100 WHERE id = 'A';
UPDATE accounts SET balance = balance + 100 WHERE id = 'B';
COMMIT;

注意这里的顺序:先插入幂等记录,再做副作用。如果反过来(先扣款再插入),第二次重试会在扣款之后才撞上唯一约束,扣款已经发生,事务虽然会回滚,但如果中间还有外部调用就来不及了。

如果 INSERT ... ON CONFLICT DO NOTHING 没有返回行,应用必须查原记录并返回原结果,而不是继续执行后面的 UPDATE。这一步经常被漏掉:约束挡住了重复插入,代码却继续扣款,等于白做。

6.8.3 唯一约束挡不住的部分

唯一约束能挡住「同一张表插两条」,但挡不住下面这些:

  • 外部副作用:扣款网关、发券、发短信不在数据库里,唯一约束管不到;
  • 跨表/跨服务的效果:A 库的 transfers 唯一了,B 库的 ledger 可能还有一条;
  • 异步链路:事务提交后通过 outbox 发消息,消息重发会让下游再执行一次。

所以完整的修复通常是三层:

层机制挡住什么挡不住什么
接入层客户端幂等键 + 服务端去重表同一意图的重复请求客户端换键、用户重复点击
存储层唯一约束 + 同事务写业务/去重记录同一键的重复写入外部调用、跨库效果
业务层状态机 + 对账 + 补偿中间状态、遗漏、错账需要人工介入的极端情况
例 6.8.1 · 一次转账,两次扣款

某支付服务在高峰期出现「用户投诉扣了两次」。排查发现:客户端超时 1 s,SDK 自动重试一次;服务端 transfers 表没有 request_id,每次请求都插入新行;两次请求相隔 1.2 s,都成功提交。数据库日志显示两条 transfers,金额相同,时间相差 1.2 s。

修复分三步:

  1. 客户端在用户点击时生成 request_id,所有自动/手动重试复用它;
  2. 服务端建唯一索引 transfers(request_id),先插入、再扣款,冲突时返回原记录;
  3. 对历史数据按 (from_acct, to_acct, amount, 时间窗) 做一次对账,把确认重复的流水冲正。

第三步不能省。只改代码不修历史,用户看到的账单仍然是错的;而且对账过程本身会暴露「哪些重复是重试、哪些是真实的两笔转账」,不能一律删。

幂等键的粒度

request_id 应该代表「一次用户意图」,而不是「一次 HTTP 请求」。如果用户在页面上点两次「转账」,这是两个意图,应该有两个 key;如果 SDK 自动重试,应该是同一个 key。把 key 生成在「用户点击」而不是「HTTP 客户端发送」这一层,才能同时覆盖自动重试和手动重试。

6.9 观测:pg_stat_activity、pg_locks 与 InnoDB status

事务出问题时,第一现场通常不是错误日志,而是「现在谁在等谁」。PostgreSQL 的 pg_stat_activity 和 pg_locks、MySQL 的 information_schema.innodb_trx 和 SHOW ENGINE INNODB STATUS,能回答三个问题:有没有长事务?谁在等锁?回滚和死锁是不是在变多?

6.9.1 一条命令看长事务与锁等待

psql "$PGDSN" -x -c "
SELECT pid, usename, state,
       now() - xact_start AS xact_age,
       now() - query_start AS query_age,
       wait_event_type, wait_event,
       pg_blocking_pids(pid) AS blockers,
       left(query, 60) AS query
FROM pg_stat_activity
WHERE datname = current_database()
ORDER BY xact_age DESC NULLS LAST
LIMIT 8;"

一次本机测试的输出(节选):

-[ RECORD 1 ]--+--------------------------------------------
pid            | 48213
usename        | app
state          | idle in transaction
xact_age       | 00:12:41.238
query_age      | 00:12:41.236
wait_event_type| Client
wait_event     | ClientRead
blockers       | {}
query          | UPDATE accounts SET balance = balance - 100 ...

-[ RECORD 2 ]--+--------------------------------------------
pid            | 48290
usename        | app
state          | active
xact_age       | 00:01:07.512
query_age      | 00:01:07.510
wait_event_type| Lock
wait_event     | transactionid
blockers       | {48213}
query          | UPDATE accounts SET balance = balance + 100 ...

怎么读:

字段/组合正常异常先做什么
state = idle in transaction短暂出现(毫秒级)持续数秒到数分钟应用忘了 COMMIT/ROLLBACK,或连接池归还逻辑有 bug
xact_ageOLTP < 1 s> 5 s 就值得看,> 1 min 基本是事故找这个事务在等什么、做了什么
wait_event_type = Lock偶尔短暂持续 > 1 s看 blockers,找链的根
blockers 非空空有 PID被阻塞者不是根因,根因在 blocker
query_age 接近 xact_age正常长查询持锁考虑 statement_timeout 或拆批

最关键的一条:不要从被阻塞的 PID 开始优化,要顺着 blockers 找到根。一条 UPDATE 等了 60 秒,可能只是因为另一个连接 12 分钟前开了事务、再也没提交。

6.9.2 锁等待与回滚率

找到根之后,用 pg_locks 确认锁的类型和对象:

psql "$PGDSN" -c "
SELECT l.pid, l.locktype, l.mode, l.granted, c.relname
FROM pg_locks l
LEFT JOIN pg_class c ON c.oid = l.relation
WHERE NOT l.granted
   OR l.pid IN (SELECT pid FROM pg_stat_activity
                WHERE cardinality(pg_blocking_pids(pid)) > 0)
ORDER BY l.pid;"

再看回滚率和死锁。这两个指标是累计值,单次采样没有意义,要看趋势或增量:

psql "$PGDSN" -c "
SELECT xact_commit, xact_rollback, deadlocks,
       round(100.0 * xact_rollback
             / nullif(xact_commit + xact_rollback, 0), 2) AS rollback_pct
FROM pg_stat_database
WHERE datname = current_database();"
 xact_commit | xact_rollback | deadlocks | rollback_pct
-------------+---------------+-----------+--------------
     1842301 |         41207 |        17 |         2.19

读法:

  • rollback_pct 在 OLTP 系统里通常应低于 1%。2.19% 说明每 50 个事务就有一个回滚,可能是业务校验失败、唯一键冲突,也可能是序列化失败重试。
  • deadlocks 是累计值。17 次本身不可怕,可怕的是单位时间内持续增长;把采样时间点记下来,算增量。
  • 回滚率高不一定是坏事:如果都是「重复请求撞唯一键」,说明幂等闸门在工作;如果都是 40001/40P01,说明并发冲突或加锁顺序有问题。

6.9.3 InnoDB:History list length 与活跃事务

MySQL 侧看两张东西:information_schema.innodb_trx 里的活跃事务,和 SHOW ENGINE INNODB STATUS 里的 History list length。

mysql -N -e "SELECT trx_id, trx_state,
  TIMESTAMPDIFF(SECOND, trx_started, NOW()) AS age_s,
  trx_rows_locked, trx_rows_modified,
  LEFT(trx_query, 60) AS query
FROM information_schema.innodb_trx
ORDER BY trx_started LIMIT 5;"

mysql -e "SHOW ENGINE INNODB STATUS\G" | sed -n '/TRANSACTIONS/,/FILE I\/O/p'
4218842  RUNNING  128  3  1  UPDATE accounts SET balance = balance - 100 ...

------------
TRANSACTIONS
------------
Trx id counter 4218850
Purge done for trx's n:o < 4218831 undo n:o < 0 state: running
History list length 1823411
---TRANSACTION 4218842, ACTIVE 128 sec
3 lock struct(s), heap size 1136, 2 row lock(s), undo log entries 1

读法:

指标正常异常含义
age_s(事务年龄)秒级> 60 s长事务,可能持锁、拖住 purge
trx_rows_locked个位数成百上千范围更新/缺索引,锁范围过大
History list length随写入量波动,稳定或缓慢增长持续快速增长,或 > 100 万purge 跟不上,undo 膨胀
ACTIVE n sec短长与 innodb_trx 对照,找同一个事务
LATEST DETECTED DEADLOCK偶尔有频繁出现加锁顺序/热点行问题
事务观测的三步
  1. 先找最老的事务:xact_age / trx_started 排序,看它在等什么、持有什么。
  2. 再找锁等待的根:pg_blocking_pids / data_locks,顺着链找到 blocker;被阻塞者只是受害者。
  3. 最后看趋势:回滚率、死锁数、History list length 都要看增量;单点采样会被业务周期骗。

6.10 真实案例:Spanner 的 TrueTime 与 2PC

2012 年,Google 在 OSDI 发表了 Spanner 的论文,描述一个全球分布、外部一致(externally consistent)的数据库。它经常被引用成「全球强一致可以做到」,但论文真正有意思的地方不是「强一致」,而是它如何把「时间不确定」变成「等一会儿」,以及 2PC 在这个架构里到底承担了什么。

6.10.1 TrueTime:把现在表示成一个区间

普通机器上的 now() 返回一个时间点,但这个点可能有几毫秒到几秒的误差。Spanner 的 TrueTime API 不返回点,而返回一个区间:

TT.now() = [earliest, latest]
ε = latest - earliest

TrueTime 由 GPS 接收器和原子钟支撑,Google 的数据中心里 ε 通常在 7 ms 以内(论文给出的典型值)。TT.after(t) 和 TT.before(t) 不是「大概」,而是「在 ε 的假设下,确定 t 已经过去/还没到」。

拿到区间之后,Spanner 给读写事务分配提交时间戳 s = TT.now().latest,然后等到 TT.after(s) 为真才真正提交。这段等待叫 commit-wait,长度大约是 2ε:

TT.now() = [e, l],  ε = l - e
             e          l
真实时间 ────┼──────────┼────────▶
             │          │
             │    选 s = l
             │          │
             │◀─────────┼────▶ 等待 TT.after(s),约 2ε
                        │
                     commit @ s

为什么这样能保证外部一致性?因为任何在 s 之后开始的事务,拿到的时间戳一定大于 s;任何在 s 之前提交的事务,时间戳一定小于 s。commit-wait 把「时钟可能不准」换成了「等足够久,久到不确定性已经过去」。代价是每次写多等约 2ε;ε 变大,写延迟就变大。

6.10.2 2PC 在 Spanner 里的位置

Spanner 的数据按 tablet 分片,每个分片是一个 Paxos 复制组。跨分片的事务用 2PC:

层机制作用代价
副本一致每个分片内 Paxos单分片内多数派提交、选主一轮 quorum RTT
跨分片原子2PC多个 Paxos 组一起提交/回滚两阶段、协调者、阻塞窗口
时间TrueTime + commit-wait给事务一个全局可比较的时间戳约 2ε 的等待
只读事务快照时间戳不加锁,从足够新的副本读读到的是某个过去的时间点

这里有两级协议:组内 Paxos,组间 2PC。2PC 的协调者本身也要高可用,否则它一崩,所有 in-doubt 的跨组事务都卡住;Spanner 把协调者状态也复制起来。只读事务不参与 2PC,它们在一个选定的时间戳上读快照,因此不需要锁,也不会阻塞写。

这个组合说明:2PC 不是「分布式事务的银弹」,它只是原子提交的一层。没有 Paxos,协调者和参与者都可能丢状态;没有 TrueTime,跨地域的事务无法给出外部一致的时间顺序;有了这两者,还要付出 commit-wait 和跨地域 RTT 的代价。

6.10.3 失败链与教训

Spanner 不是「不会坏」,它的设计把失败从「数据不一致」转移到了「延迟上升/不可用」:

  • ε 变大:GPS 信号受干扰、时钟漂移,TrueTime 的不确定性上升;commit-wait 变长,写延迟上升,上层超时和重试增多。
  • 协调者故障:跨组 2PC 的 in-doubt 事务要等协调者恢复;协调者用 Paxos 复制来缩短这个窗口,但不能消除。
  • 跨地域网络抖动:组内 Paxos 和组间 2PC 都要 RTT;跨大洋的写延迟是物理距离决定的,不是软件能优化的。
  • 只读副本落后:快照读不阻塞,但读到的可能是旧时间点;需要「读最新」时,仍要选足够新的时间戳。

五条可以带走的教训:

  1. 原子提交和一致性是两件事:2PC 解决「一起提交」,TrueTime 解决「时间顺序」,缺一不可。
  2. 不确定性可以被等待消除:commit-wait 的本质是用延迟换确定性;这个交易在很多系统里都成立。
  3. 协调者必须高可用:2PC 的单点不是理论问题,是值班时真会遇到的阻塞。
  4. 只读路径可以绕过 2PC:快照读是 Spanner 性能的关键;写路径的代价不能套到读上。
  5. 不是所有系统都能复制这套方案:它依赖 GPS/原子钟、受控的数据中心网络、专门的运维;普通云上多租户应用很难假设 ε 有界。
事实边界

本节只引用 Spanner 2012 年 OSDI 论文的公开内容:TrueTime 的区间语义、ε 的典型量级、commit-wait、Paxos 分片与跨组 2PC。论文里的数字是当时 Google 数据中心的测量值,不是对所有环境的承诺;具体的当前实现细节以 Google 的公开论文/文档为准。链接见 A.3 参考文献 与 Spanner 论文。

6.11 重试导致重复副作用:非幂等 INSERT 的重放

6.8 讲的是「转账服务没有幂等键」。这一节把镜头再拉近一点:即使事务本身没问题,一条非幂等的 INSERT 被重放,也会产生两条记录。它的可怕之处在于「没有任何错误」:两次插入都成功,数据库不报错,只有对账时才发现多了。

6.11.1 一条没有唯一键的流水表

CREATE TABLE ledger (
  id         BIGSERIAL   PRIMARY KEY,
  account_id TEXT        NOT NULL,
  amount     NUMERIC     NOT NULL,
  created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);

-- 每次重试都执行一次;id 是新生成的,数据库没有任何理由拒绝
INSERT INTO ledger (account_id, amount) VALUES ('A', -100);

重放可能来自四个地方:

重放来源怎么发生表现闸门
客户端重试超时后重发同一请求两条金额相同的流水客户端 request_id + 唯一约束
服务端重试未回滚干净部分写入已提交、或外部写入已发生两条流水,时间接近重试前确保整个事务回滚
Outbox/CDC 重投relay 重启、offset 提交失败消费端重复执行processed_events 去重表
人工补数运维重跑脚本批量重复脚本幂等 + dry-run

它们的共同点是:重放的是「动作」,不是「结果」。如果动作是 INSERT,重放就多一行;如果动作是 UPDATE balance = 100(绝对值),重放可能无害;如果动作是 UPDATE balance = balance - 100(增量),重放就多扣一次。

6.11.2 修复:唯一键 + 原子 upsert

给流水表加一个客户端生成的 request_id,并让它唯一:

CREATE TABLE ledger (
  id          BIGSERIAL   PRIMARY KEY,
  request_id  UUID        NOT NULL UNIQUE,
  account_id  TEXT        NOT NULL,
  amount      NUMERIC     NOT NULL,
  created_at  TIMESTAMPTZ NOT NULL DEFAULT now()
);

写入用「原子 upsert」,保证无论重放多少次,都返回同一行的 id:

INSERT INTO ledger (request_id, account_id, amount)
VALUES ('6f1c2a9e-8d1b-4f3a-9c2e-2b7d5a0c1e44', 'A', -100)
ON CONFLICT (request_id)
DO UPDATE SET request_id = EXCLUDED.request_id
RETURNING id;

为什么用 DO UPDATE 而不是 DO NOTHING?因为 DO NOTHING 在冲突时不返回行,应用还得再查一次;而「插入」和「查询」之间可能有并发窗口。DO UPDATE SET request_id = EXCLUDED.request_id 是一个空更新,但它会锁住冲突行并 RETURNING 原 id,让「插入或取回」变成一次原子操作。代价是会多产生一个行版本(PostgreSQL)或一次 undo(InnoDB),对流水表通常可以接受;如果写入量极大,可以用 DO NOTHING + 重查,但要处理好「查不到」的竞态。

对应的 Python:

def append_ledger(conn, request_id: str, account_id: str, amount: int) -> int:
    row = conn.execute(
        "INSERT INTO ledger (request_id, account_id, amount) "
        "VALUES (%s, %s, %s) "
        "ON CONFLICT (request_id) DO UPDATE "
        "SET request_id = EXCLUDED.request_id "
        "RETURNING id",
        (request_id, account_id, amount),
    ).fetchone()
    return row["id"]

这段代码只解决「同一 request_id 不重复」。它不解决:

  • 客户端换了 request_id(用户点了两次,或 SDK 每次重试都生成新键);
  • 副作用发生在另一张表/另一个服务(6.8 的三层修复);
  • 消费端重复处理同一条消息(需要 6.7 的 processed_events)。

6.11.3 对账:发现已经发生的重复

唯一约束只能防未来,不能修历史。上线修复之后,通常要跑一次对账,找出已经重复的流水:

SELECT account_id, amount,
       date_trunc('second', created_at) AS second,
       count(*) AS n
FROM ledger
GROUP BY 1, 2, 3
HAVING count(*) > 1
ORDER BY n DESC
LIMIT 50;

这个结果是候选集,不是判决书:同一秒内的同额流水可能真是两笔。确认重复后用冲正流水抵消,不要物理删除,否则审计链和「余额 = 流水之和」都会断。

例 6.11.1 · 同一笔退款,两条流水

退款服务收到一条「退款 100 元」的消息,执行 INSERT INTO ledger (account_id, amount) VALUES ('A', 100),提交成功,然后给消息队列回 ack。ack 在网络上丢了,消息队列重投;服务再次插入,又成功。结果 A 账户多了 200 元。

修复:

  1. 消息带 event_id,消费端在同一个事务里写 processed_events 和 ledger;
  2. ledger.request_id 唯一,重放时 ON CONFLICT 返回原记录;
  3. 对账任务按 event_id 比对消息和流水,发现「有流水、无消息」或「有消息、无流水」的缺口。

注意第 1 步和第 2 步不是二选一:processed_events 挡住消费端重复,ledger.request_id 挡住写入端重复;两层都做,才能覆盖「消费端重启」和「上游重发」两种不同的重放。

6.12 事务边界设计清单

把前面所有讨论压缩成一张表。每设计一个写路径,从上到下过一遍;答不上来的那一条,就是下一次事故的入口。

#检查项为什么合格的样子
1事务里有没有 RPC/HTTP/文件 IO?网络调用会拉长事务、持锁,且回滚撤不回副作用事务里只有数据库读写
2每个写操作有幂等键吗?谁生成?没有身份就无法识别重试客户端在「用户意图」层生成,重试复用
3唯一约束建在哪张表、哪些字段?约束是最后一道闸门与业务写入同库同事务,先插入再副作用
4键冲突时返回什么?返回错会让客户端误判原结果 + 200,或 409(同键不同参)
5隔离级别够不够?有没有写偏斜?跨行不变量不会被 RR 自动保护用 Serializable + 重试,或把不变量收敛到一行
6加锁顺序统一吗?相反顺序是死锁的头号来源按主键/账户 ID 排序后加锁
7重试只针对可重试错误吗?重试约束冲突/语法错误没有意义40001、40P01、1213、1205,其余不重试
8重试前重新读状态了吗?带着旧读结果重试会重复冲突新事务 + 重新查询 + 重新判断
9外部副作用幂等/可补偿吗?数据库回滚不会退款幂等键、TCC 预留、或 Saga 补偿
10是否用 outbox 替代双写?双写没有原子性业务行 + outbox 行同事务
11消费端去重与业务写入同事务吗?分开写会有「处理了但没记」的窗口processed_events + 业务更新同一事务
12长事务/锁等待有超时和告警吗?长事务拖住 vacuum/purge,锁等待拖垮连接池statement_timeout、lock_timeout、面板告警
13对账覆盖哪些不变量?协议和对账是最后一道防线余额=流水之和、订单=库存扣减、支付=记账
14补偿失败后的人工路径是什么?Saga/TCC 都会有补偿失败工单、告警、可重放的修复脚本

这张表里最容易被跳过的是第 1 项和第 9 项。前者决定事务会不会变成「长事务 + 锁等待」,后者决定重试会不会变成「重复扣款」。先画边界,再写代码;边界画错,后面每一条都在给错误打补丁。

6.13 本章陷阱清单

12 条高频错误
  1. 把超时当失败直接重试:超时只说明本地不再等待,服务端可能已提交;必须带幂等键进入 unknown,而不是换新键重发。
  2. 在数据库事务里调用外部服务:回滚不会撤销已发出的扣款/发券/短信;外部调用要移到事务外,或用 outbox/TCC。
  3. 以为 RR 就没有写偏斜:快照隔离只保证读一致,不保证跨行不变量;值班、预算、库存都可能被两个「各自合法」的事务破坏。
  4. 在同一个事务里重试单条 SQL:PostgreSQL 语句失败后事务已 aborted(25P02),必须先回滚再重开。
  5. 重试所有数据库错误:唯一键冲突、语法错误、类型错误重试一万次也不会成功,只会浪费连接和放大过载。
  6. 用自增 ID 当幂等键:自增 ID 是服务端生成的,每次重试都是新值;键必须由客户端在首次发送前生成。
  7. 唯一约束建了,但代码在冲突后继续扣款:ON CONFLICT DO NOTHING 不返回行时,应用必须返回原结果,而不是继续执行副作用。
  8. 把 2PC 当银弹:2PC 只保证原子提交,不保证隔离、不保证高可用;协调者崩溃时参与者会阻塞在 in-doubt。
  9. Saga 补偿不幂等:补偿动作会被重试,退款/解冻重复执行就是二次事故;补偿必须带幂等键。
  10. TCC 不处理空回滚和悬挂:Cancel 先到、Try 后到都会留下永远不释放的预留资源;事务控制表要记录 cancel 并拒绝迟到的 Try。
  11. outbox 发完就删:保留窗口短于消费端的最大重投递时间,重放/补数就没有依据;删除前确认消费位点。
  12. 只看 COMMIT 返回就认为数据安全:异步备库、synchronous_commit=off、innodb_flush_log_at_trx_commit!=1 都可能让已提交事务在崩溃或切换后丢失/无法恢复。

6.14 本章出口检查

检查项达标线没达标去哪
解释 ACID 的 C能说出「C 是应用不变量,不是数据库赠品」并举一个跨服务例子6.1
说出五类异常能区分丢失更新与写偏斜,并各举一例6.2
画出 MVCC 版本链能标出 xmin/xmax 或 DB_TRX_ID/ROLL_PTR,并说出可见性规则6.3
对比 InnoDB 与 PostgreSQL能说出旧版本存放位置、回收机制、长事务症状的差异6.4
处理死锁能识别 40001/40P01/1213,只重试整个事务,带退避与抖动6.5
画出 2PC 失败窗口能指出协调者投 yes 后崩溃时参与者为什么不能自救6.6
解释 TCC 的空回滚与悬挂能说出事务控制表如何同时解决两者6.6
设计 Saga 补偿能说出补偿不幂等、补偿失败、中间状态可见三个问题6.6
用 outbox 替代双写能写出业务行 + outbox 同事务,并说明消费端如何去重6.7
用命令定位长事务能从 pg_stat_activity 的 xact_age/blockers 找到根6.9
修复非幂等 INSERT能给出唯一键 + 原子 upsert + 对账三步6.11
过一遍事务边界清单14 项里至少能答出 12 项6.12
即时自测 · 6

Q1 客户端 1 s 超时后重试了一次,服务端两次都提交成功,账上扣了两次。修复的第一步应该是什么?

Q2 两个事务都读到「有 2 名医生在岗」,各自把一名医生改成下班,都提交成功。这是什么异常?哪个隔离级别能自动阻止?

Q3 2PC 中协调者在所有参与者投 yes 之后、发出 commit 之前崩溃。参与者此时能自己决定回滚吗?为什么?

Q4 COMMIT 已经返回,为什么备库提升后仍可能丢数据?列出两个具体原因。

Q5 Outbox 保证了「业务数据和消息一起提交」,为什么消费端仍然要去重?

Q6 pg_stat_activity 里看到一个连接 state=idle in transaction、xact_age=12 分钟、backend_xmin 很老。你会先做什么?它可能造成哪两个连锁问题?

答案

A1 先让「意图」有身份:客户端在用户点击时生成 request_id,所有重试复用它;服务端在 transfers(request_id) 上建唯一约束,先插入幂等记录再扣款,冲突时返回原结果。然后才是对历史重复流水做对账冲正。

A2 写偏斜。两个事务写的是不同的行,没有写写冲突,所以快照隔离(PostgreSQL/InnoDB 的 RR)允许它们都提交;只有可串行化(PostgreSQL 的 SSI)或显式加锁能自动阻止。

A3 不能。参与者投 yes 后已经把 prepared 状态落盘并持有锁,它不知道其他参与者是否也投了 yes;单方面回滚可能和协调者的 commit 决定冲突,造成部分提交。正确做法是等协调者恢复后按日志重发决定;这也是 2PC 需要高可用协调者的原因。

A4 原因一:synchronous_commit=off,提交返回时 WAL 还没刷盘,主机断电可能丢失最近事务(local 会等本地刷盘,但不等备库);原因二:异步复制,主库提交后还没传到备库就宕机,备库提升后缺少最后一段 WAL。innodb_flush_log_at_trx_commit!=1 和 sync_binlog!=1 也会造成类似窗口。

A5 Outbox 只把「写业务数据」和「写消息记录」变成一个本地事务,relay 仍然是至少一次投递:发布成功但标记失败、relay 重启、消费端 offset 提交失败都会重发。去重必须放在消费端,用 (consumer, event_id) 唯一约束和业务写入同事务完成。

A6 先看它在等什么、持有什么:wait_event、pg_blocking_pids、以及它持有的 backend_xmin 挡住了哪些回收;确认是应用忘了提交还是连接池归还 bug,必要时 pg_terminate_backend 止损。两个连锁问题:VACUUM 无法回收旧版本导致表/索引膨胀;它持有的行锁让后续写排队,连接池被耗尽。

母本对应

母本章节本书落点程度说明
谢希仁《计算机网络》第 8 版第 5/6 章6.1、6.6背景只借用「分层与端到端」的视角;事务协议不在母本范围
张彦飞《深入理解 Linux 网络》第 4–7 章6.9背景连接/超时只引用,不展开内核路径
《Python 网络编程》并发与客户端工程6.5、6.7精选借用工程风格;重试、outbox 代码为本书重写
《Linux 系统与内核》第 4 篇4.1–4.116.9交叉引用明确不重复 TCP/IP、socket、epoll
本书自编 + 数据库教材/论文Gray 2PC、Garcia-Molina Saga、Spanner OSDI 20126.3、6.6、6.10完整只引用定义与公开事实,不复制论文/文档长段
PostgreSQL / MySQL 官方文档MVCC、锁、pg_stat_*、InnoDB 锁6.3、6.4、6.9参考命令与字段以文档为准,本章只讲读法

这一章没有对应的母本章节:谢希仁和张彦飞都不讲事务隔离、MVCC 和 2PC。本书自编的部分集中在「机制 → 失败窗口 → 观测命令」这条链上;定义和协议事实来自数据库教材与论文,代码和事故场景是按工程实践重写的,不复制原文。

下一站:事务解决的是「一次操作要么全做、要么全不做」,但仍有很多事需要「先记下来、稍后处理」。进入 7 队列、流与事件驱动,看至少一次投递、offset 提交与消费者组如何接住本章留下的 outbox 和重复消息。