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);分布式里还要靠复制。这里有一个非常常见的错觉:提交成功不等于数据已经到了多数派。
| 字母 | 单机谁保证 | 分布式里变成什么 | 最常见的破法 |
|---|---|---|---|
| A | undo/rollback + WAL | 2PC 的原子提交,或 Saga 的补偿 | 协调者崩溃、补偿失败、部分提交 |
| C | 约束、触发器 + 应用检查 | 跨服务不变量、对账、补偿 | 只在一个库检查,另一个库不知道 |
| I | 锁 / MVCC | 没有全局隔离,中间状态会外泄 | 把「本地事务」当成「全局事务」 |
| D | WAL fsync + 副本 | quorum 复制或异步复制 | 主库宕机,异步备库还没收到 |
6.1.1 分布式里的 A:不是一个布尔值
单机事务的原子性是二值的:提交了就是全做,回滚了就是全不做。跨服务之后,「全做」需要所有参与者一起提交,「全不做」需要所有参与者一起回滚;而参与者可能在不同机器、不同数据库、不同可用区,任何一方都可能在关键时刻失联。
工程上有三种做法,它们对 A 的保证强度依次递减:
- 2PC(两阶段提交):协调者先问所有参与者「能不能提交」,全部同意后再统一提交。它提供原子提交,但参与者在投票后要一直持有锁,直到收到最终决定;协调者崩溃时,参与者会卡在 in-doubt 状态。2PC 的细节和失败窗口在 6.6。
- TCC(Try-Confirm-Cancel):每个服务提供「预留 / 确认 / 取消」三个动作,Try 先冻结资源,Confirm 真正扣减,Cancel 释放。它把原子性换成了「业务层的预留 + 补偿」,需要处理空回滚、悬挂和幂等。
- 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 不是数据库的一个开关,而是一条链。链上任何一段是异步的,链的末端就可能丢数据。异步复制的主库宕机、备库提升,是生产上「已提交数据丢失」最常见的原因之一。
事务边界(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 锁住所有在岗医生),或者把不变量变成数据库能检查的约束。
变体 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 |
这张表有三个容易踩的坑:
- PostgreSQL 的
READ UNCOMMITTED不是「真的允许脏读」,它被静默提升为READ COMMITTED。想复现脏读,在 PG 上做不到。 - PostgreSQL 的
REPEATABLE READ是快照隔离(SI),不是标准意义上的锁式 RR。它没有幻读,但允许写偏斜;很多团队以为「RR 就安全了」,其实只是换了一类异常。 - 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 的思路是:写不覆盖旧值,而是产生一个新版本;读按自己的快照选择看哪个版本。这样读不阻塞写、写不阻塞读,代价是旧版本要保留、要回收。
图里有一条 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_id | m_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 是否可见;可见才继续往回找,不可见则这个版本仍然有效。
快照隔离(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_ID | xmin、xmax、cid |
| 快照结构 | read view:m_ids/min/max/creator | snapshot:xmin/xmax/xip |
| 旧版本回收 | purge 线程清理 undo | VACUUM/autovacuum 清理死元组 |
| 回收受阻的症状 | History list length 增长、undo 表空间膨胀 | 表/索引膨胀、n_dead_tup 高、vacuum 滞后 |
| 更新方式 | 原地更新聚簇索引,旧值进 undo | 写新元组,旧元组留堆,索引可能新增条目 |
| 二级索引 | 存主键,可见性可能要回表 | 存 TID,指向具体元组;HOT 可减少索引更新 |
| 锁 | 行锁、间隙锁、next-key lock(RR) | 行锁;无间隙锁;SSI 用谓词锁 |
| 序列化冲突 | 靠锁/死锁检测;无 SSI | SSI,返回 40001 |
| 长事务的主要代价 | undo 膨胀、purge 滞后 | 表膨胀、vacuum 滞后、xid 年龄 |
| DDL | 8.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 length | pg_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 TABLE | DDL 等待长事务,后续查询排队 |
| 谓词锁(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 哪些错误值得重试
不是所有数据库错误都能重试。把「唯一键冲突」重试一百次,只会得到一百次冲突;把「语法错误」重试,只会浪费连接。大致分三类:可重试、不可重试、结果未知。
| 错误 | PostgreSQL | MySQL | 能重试吗 | 动作 |
|---|---|---|---|---|
| 序列化失败 | 40001 | —(无 SSI) | 能 | 退避后重试整个事务 |
| 死锁 | 40P01 | 1213 | 能 | 退避后重试整个事务 |
| 锁等待超时 | 55P03(lock_timeout) | 1205 | 能,但要先查原因 | 缩短事务/调整顺序,再重试 |
| 唯一键冲突 | 23505 | 1062 | 不能(除非是幂等插入) | 当作「已存在」处理 |
| 语法/类型错误 | 42601/22P02 | 1064 | 不能 | 修代码 |
| 连接断开 | 08006 | 2013 | 能,但结果未知 | 重连 + 幂等键,按 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()))
这段代码有四个刻意的选择:
- 只捕获
psycopg.Error并检查sqlstate,不靠错误消息文本匹配;消息会随版本/语言变化。 - 重试整个
conn.transaction()块,而不是块里的某条语句;这样每次重试都是一个干净的事务。 - 退避带抖动,否则一批同时死锁的请求会在同一时刻再次相撞。
- 最多 5 次,并且没有无限循环;重试耗尽后把错误抛给上层,由上层决定降级还是返回用户。
如果连接本身断了(08006),上面的 with psycopg.connect(...) 不会自动重连;生产代码需要在外层重建连接,并且用幂等键保证「断线前的提交可能已经生效」不会变成重复副作用。这一点在 6.8 和 6.11 会展开。
6.5.5 减少死锁的四条规则
- 统一加锁顺序:所有事务按主键/账户 ID 排序后依次加锁;不要一个按 A→B、一个按 B→A。
- 缩短事务:把网络调用、文件 IO、用户交互挪到事务外;事务里只留必要的读写。
- 一次锁够:能一条
UPDATE ... WHERE完成的,不要先SELECT再UPDATE;能批量更新的,不要逐行循环。 - 设置超时:
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;能用本地事务 + 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 的保证是「最终一致」,不是「原子」。它有四个必须承认的事实:
- 中间状态对外可见:T1 创建了订单、T2 还没扣款时,别的请求能看到这个订单。要用业务状态(
PENDING/RESERVED)把它标出来,让其他操作知道「还没完成」。 - 补偿不是回滚:退款不是「撤销扣款」,而是一笔新的正向交易;它可能失败、可能被风控拦截、可能产生手续费。补偿动作必须幂等,并且要能重试。
- 没有隔离:另一个事务可能读到中间状态并据此做决定。工程上用「语义锁」(semantic lock)来处理:在业务层标记「这个实体正在事务中」,其他事务要么等待,要么拒绝。
- 补偿也可能失败:
C3失败时,需要有重试、死信、人工介入。Saga 的尽头往往是一张对账工单,这不是耻辱,而是设计的一部分。
6.6.5 怎么选
| 方案 | 原子性 | 隔离性 | 延迟 | 复杂度 | 适用 |
|---|---|---|---|---|---|
| 2PC/XA | 强原子提交 | 参与者本地隔离,无全局快照 | 高(2 RTT + fsync) | 中 | 跨库短事务、同构数据库、能接受阻塞 |
| TCC | 业务级原子 | 靠预留实现 | 中 | 高(三个接口 + 控制表) | 支付、库存、座位等资源预留 |
| Saga | 最终一致 | 无,需要语义锁 | 低 | 中高(补偿链 + 对账) | 长流程、跨多个服务、能接受中间状态 |
| Outbox | 本地原子 + 最终一致 | 无 | 低 | 低 | 事件通知、异步解耦(6.7) |
选型时先回答三个问题:
- 这个操作能不能接受「中间状态被看到」? 不能,就别用 Saga;考虑 2PC 或把数据放到一个库里。
- 参与方能不能长时间持锁? 不能,就别用 2PC;考虑 TCC 或 Saga。
- 补偿失败时,业务能不能人工介入? 不能,就别把 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 就不会重复」:outbox 只保证「业务数据和消息一起提交」,不保证「消息只被消费一次」;重复仍然会发生,去重必须在消费端做。
- 「outbox 可以随便删」:删除已发布记录前要确认所有消费者都已处理;保留窗口太短,重放/补数就没有依据。
- 「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 成功 | 已提交 | 扣款已生效 |
| t3 | 1 s 超时,重试同一请求 | 网关/连接已断,响应没回去 | — | 客户端认为失败 |
| t4 | — | 收到重试,视为新请求 | BEGIN;再扣 A;再加 B;再插一条 | — |
| t5 | — | COMMIT 成功 | 两条 transfers | A 少了 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 发消息,消息重发会让下游再执行一次。
所以完整的修复通常是三层:
| 层 | 机制 | 挡住什么 | 挡不住什么 |
|---|---|---|---|
| 接入层 | 客户端幂等键 + 服务端去重表 | 同一意图的重复请求 | 客户端换键、用户重复点击 |
| 存储层 | 唯一约束 + 同事务写业务/去重记录 | 同一键的重复写入 | 外部调用、跨库效果 |
| 业务层 | 状态机 + 对账 + 补偿 | 中间状态、遗漏、错账 | 需要人工介入的极端情况 |
某支付服务在高峰期出现「用户投诉扣了两次」。排查发现:客户端超时 1 s,SDK 自动重试一次;服务端 transfers 表没有 request_id,每次请求都插入新行;两次请求相隔 1.2 s,都成功提交。数据库日志显示两条 transfers,金额相同,时间相差 1.2 s。
修复分三步:
- 客户端在用户点击时生成
request_id,所有自动/手动重试复用它; - 服务端建唯一索引
transfers(request_id),先插入、再扣款,冲突时返回原记录; - 对历史数据按
(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_age | OLTP < 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 | 偶尔有 | 频繁出现 | 加锁顺序/热点行问题 |
- 先找最老的事务:
xact_age/trx_started排序,看它在等什么、持有什么。 - 再找锁等待的根:
pg_blocking_pids/data_locks,顺着链找到 blocker;被阻塞者只是受害者。 - 最后看趋势:回滚率、死锁数、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;跨大洋的写延迟是物理距离决定的,不是软件能优化的。
- 只读副本落后:快照读不阻塞,但读到的可能是旧时间点;需要「读最新」时,仍要选足够新的时间戳。
五条可以带走的教训:
- 原子提交和一致性是两件事:2PC 解决「一起提交」,TrueTime 解决「时间顺序」,缺一不可。
- 不确定性可以被等待消除:commit-wait 的本质是用延迟换确定性;这个交易在很多系统里都成立。
- 协调者必须高可用:2PC 的单点不是理论问题,是值班时真会遇到的阻塞。
- 只读路径可以绕过 2PC:快照读是 Spanner 性能的关键;写路径的代价不能套到读上。
- 不是所有系统都能复制这套方案:它依赖 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;
这个结果是候选集,不是判决书:同一秒内的同额流水可能真是两笔。确认重复后用冲正流水抵消,不要物理删除,否则审计链和「余额 = 流水之和」都会断。
退款服务收到一条「退款 100 元」的消息,执行 INSERT INTO ledger (account_id, amount) VALUES ('A', 100),提交成功,然后给消息队列回 ack。ack 在网络上丢了,消息队列重投;服务再次插入,又成功。结果 A 账户多了 200 元。
修复:
- 消息带
event_id,消费端在同一个事务里写processed_events和ledger; ledger.request_id唯一,重放时ON CONFLICT返回原记录;- 对账任务按
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 本章陷阱清单
- 把超时当失败直接重试:超时只说明本地不再等待,服务端可能已提交;必须带幂等键进入 unknown,而不是换新键重发。
- 在数据库事务里调用外部服务:回滚不会撤销已发出的扣款/发券/短信;外部调用要移到事务外,或用 outbox/TCC。
- 以为 RR 就没有写偏斜:快照隔离只保证读一致,不保证跨行不变量;值班、预算、库存都可能被两个「各自合法」的事务破坏。
- 在同一个事务里重试单条 SQL:PostgreSQL 语句失败后事务已 aborted(25P02),必须先回滚再重开。
- 重试所有数据库错误:唯一键冲突、语法错误、类型错误重试一万次也不会成功,只会浪费连接和放大过载。
- 用自增 ID 当幂等键:自增 ID 是服务端生成的,每次重试都是新值;键必须由客户端在首次发送前生成。
- 唯一约束建了,但代码在冲突后继续扣款:
ON CONFLICT DO NOTHING不返回行时,应用必须返回原结果,而不是继续执行副作用。 - 把 2PC 当银弹:2PC 只保证原子提交,不保证隔离、不保证高可用;协调者崩溃时参与者会阻塞在 in-doubt。
- Saga 补偿不幂等:补偿动作会被重试,退款/解冻重复执行就是二次事故;补偿必须带幂等键。
- TCC 不处理空回滚和悬挂:Cancel 先到、Try 后到都会留下永远不释放的预留资源;事务控制表要记录
cancel并拒绝迟到的 Try。 - outbox 发完就删:保留窗口短于消费端的最大重投递时间,重放/补数就没有依据;删除前确认消费位点。
- 只看
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 |
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.11 | 6.9 | 交叉引用 | 明确不重复 TCP/IP、socket、epoll |
| 本书自编 + 数据库教材/论文 | Gray 2PC、Garcia-Molina Saga、Spanner OSDI 2012 | 6.3、6.6、6.10 | 完整 | 只引用定义与公开事实,不复制论文/文档长段 |
| PostgreSQL / MySQL 官方文档 | MVCC、锁、pg_stat_*、InnoDB 锁 | 6.3、6.4、6.9 | 参考 | 命令与字段以文档为准,本章只讲读法 |
这一章没有对应的母本章节:谢希仁和张彦飞都不讲事务隔离、MVCC 和 2PC。本书自编的部分集中在「机制 → 失败窗口 → 观测命令」这条链上;定义和协议事实来自数据库教材与论文,代码和事故场景是按工程实践重写的,不复制原文。
下一站:事务解决的是「一次操作要么全做、要么全不做」,但仍有很多事需要「先记下来、稍后处理」。进入 7 队列、流与事件驱动,看至少一次投递、offset 提交与消费者组如何接住本章留下的 outbox 和重复消息。