15 事务与隔离级别:ACID、隔离级别、MVCC

开篇提问

先看一个经典场景,你来找问题:

账户 A 有 1000 元,账户 B 有 0 元。现在要转账:从 A 扣 100,给 B 加 100。代码这么写:

UPDATE accounts SET balance = balance - 100 WHERE id = 'A';
UPDATE accounts SET balance = balance + 100 WHERE id = 'B';

现在请问:如果第一条执行成功后数据库崩溃了,会发生什么?

答案是:A 少了 100,B 没加——100 元凭空消失了。

这个问题看起来很简单(用事务嘛),但我想让你往深里想:

“事务”到底保证了什么?为什么它能解决上面的问题?而且——如果把两个事务并发执行,各自都是正确的,为什么结果还可能是错的?

第二问是本章最难的部分。因为单个事务正确 ≠ 并发执行正确。而”并发下会出什么错、你能容忍哪些错”,就是隔离级别要回答的问题。


子主题一:ACID —— 事务的四个承诺

事务这个词被用滥了,我们先精确定义它。一个”真正的事务”要满足 ACID:

A - Atomicity(原子性):事务里的操作要么全做,要么全不做。

  • 开篇那个例子:两条 UPDATE 要么都成功,要么都不生效
  • 实现机制:Undo Log(回滚日志)——记录修改前的值,出错时用它回滚
  • 注意:原子性不是”并发”的概念,它只关心”部分失败”的情况

C - Consistency(一致性):事务执行前后,数据库都处于”合法状态”。

  • 比如”A + B 的总额不变”、“余额不能为负”
  • 关键点:一致性主要是”应用层”的责任,数据库只是提供了保证它的手段(原子性、隔离性、约束)。很多人误解为”数据库自动保证业务一致”——不是的,你得自己定义约束。

I - Isolation(隔离性):并发的多个事务之间互相不干扰。

  • 这是本章的重头戏,后面详细展开
  • 现实是:完全的隔离代价太高,所以有了”隔离级别”这回事

D - Durability(持久性):事务提交后,即使数据库崩溃,数据也不丢。

  • 实现机制:Redo Log / WAL(预写日志)——先把变更写进日志(顺序写,快),再慢慢应用到数据文件
  • 这就是为什么数据库”提交成功”后断电也不丢数据

一个关键的工程洞察:ACID 里真正”难”的是 I(隔离性)。 因为 A、C、D 都有相对标准的实现(undo log、约束、WAL),而隔离性要在”正确性”和”并发性能”之间做艰难的权衡——这个权衡的产物,就是隔离级别。


子主题二:并发事务会出什么问题——四种经典异常

如果两个事务完全串行执行,就没有问题。但串行意味着没有并发,性能极差。于是我们允许并发,然后就得面对”并发会出什么错”。

有四种经典异常,按”严重程度”从重到轻:

异常一:脏读(Dirty Read)—— 读到了别人未提交的数据。

  • 事务 T1 把余额从 1000 改成 900(未提交)
  • 事务 T2 读到 900
  • T1 回滚了(余额回到 1000)
  • T2 读到的 900 是从未真正存在过的数据
  • 危害:严重——基于不存在的数据做了决策

异常二:不可重复读(Non-repeatable Read)—— 同一事务内两次读同一行,结果不同。

  • T1 读余额 = 1000
  • T2 改成 900 并提交
  • T1 再读 = 900
  • T1 在同一个事务里,两次读到不一样的值
  • 危害:中等——破坏了”事务内视图一致性”,可能导致逻辑错误

异常三:幻读(Phantom Read)—— 同一事务内两次查询,返回的行数不同。

  • T1 查询”余额 > 500 的账户”,返回 10 条
  • T2 插入一个新账户(余额 800)并提交
  • T1 再查,返回 11 条
  • 多出来的那行像个”幻影”
  • 和不可重复读的区别:不可重复读是”同一行的值变了”,幻读是”行数变了”(新增/删除)

异常四:更新丢失(Lost Update)—— 两个事务的更新互相覆盖。

  • T1 读余额 = 1000,打算加 100
  • T2 读余额 = 1000,打算加 200
  • T1 写入 1100
  • T2 写入 1200(覆盖了 T1 的修改)
  • 最终 1200,但正确应该是 1300
  • 危害:严重,而且在 Read Committed 隔离级别下就会发生,非常常见

更新丢失是最容易被忽视、也最常在生产上造成资损的一个。因为很多数据库默认隔离级别是 Read Committed,而它不防这个。


子主题三:四种隔离级别——在”正确性”和”性能”之间划档

SQL 标准定义了四种隔离级别,本质是”允许哪些异常发生”:

隔离级别脏读不可重复读幻读更新丢失
读未提交 Read Uncommitted会会会会
读已提交 Read Committed不会会会会
可重复读 Repeatable Read不会不会会不会*
串行化 Serializable不会不会不会不会

(*可重复读下是否会更新丢失,取决于具体实现;MySQL InnoDB 的可重复读配合 MVCC + 行锁,在某些写法下仍可能丢失更新,需要用 SELECT ... FOR UPDATE 或乐观锁来防。)

逐级理解:

读未提交(Read Uncommitted):几乎不加锁,能读到别人未提交的数据。几乎没有实用价值,除了某些对性能极端苛求、且能容忍脏读的统计场景。

读已提交(Read Committed):只能读到已提交的数据。这是 Oracle、PostgreSQL、SQL Server 的默认级别。

  • 实现:通常靠 MVCC,每个语句看到一个”最新的已提交快照”
  • 注意:同一事务内,不同语句可能看到不同的快照(所以才叫”读已提交”而非”可重复读”)

可重复读(Repeatable Read):同一事务内,多次读同一行结果相同。这是 MySQL InnoDB 的默认级别。

  • 实现:事务开始时建立一个快照,整个事务都用这个快照
  • 注意:它防的是”不可重复读”,但对幻读的防护依赖于实现(InnoDB 用 Next-Key Lock 在一定程度上防了幻读)

串行化(Serializable):完全串行,没有任何并发异常。

  • 实现:严格两阶段锁(读加共享锁、写加排他锁),或者可串行化快照隔离(SSI)
  • 代价:并发性能极差,锁等待、死锁频发

核心权衡一目了然:隔离级别越低,并发越高,但可能的异常越多。


子主题四:MVCC —— 用”多版本”实现”读不加锁”

如果隔离靠”加锁”,那读会阻塞写、写会阻塞读,并发度极低。MVCC(多版本并发控制) 是解决这个问题的最重要发明。

MVCC 的核心思想:

每个数据行保留多个历史版本。读操作读”某个时间点的快照”,写操作创建新版本。读写互不阻塞。

具体机制(以 InnoDB 为例,概念上其他数据库类似):

  • 每行有隐藏字段:创建版本号(trx_id)、删除版本号
  • 事务开始时分配一个唯一递增的事务 ID
  • 读的时候,只看到”事务 ID ≤ 当前事务 ID 且已提交”的版本
  • 写的时候,创建新版本,旧版本保留(供其他事务的旧快照读)
  • 旧版本在没有事务需要时,由后台 purge 线程清理

MVCC 带来的巨大好处:

  • 读不加锁 → 读不会被写阻塞
  • 写不加锁(对读而言) → 写不会被读阻塞
  • 这就是所谓的”读不阻塞写,写不阻塞读”——这是现代数据库高并发的基石

“快照读”和”当前读”的重要区分(MySQL 语境):

  • 快照读:普通的 SELECT,读快照,不加锁
  • 当前读:SELECT ... FOR UPDATE、UPDATE、DELETE,读最新版本并加锁
  • 这个区分极其重要:在可重复读下,UPDATE 用的是”当前读”——它会看到其他事务已提交的最新值。这就能解释那个经典的”诡异”现象:你在事务里 SELECT 看到余额 1000,但 UPDATE balance = balance - 100 却基于 900 来算(因为另一个事务已经改成 900 了)。

MVCC 的代价:

  • 存储开销(多版本要存)
  • 清理开销(purge)
  • 长事务会导致版本链很长,性能下降(所以要避免长事务)

子主题五:分布式事务——当事务跨越多个节点

单机事务讲完了,但现代系统是分布式的。当一次操作要跨多个数据库/服务时,事务怎么办?

这就是分布式事务问题,它比单机事务难得多,因为:

  • 没有共享的锁管理器
  • 没有共享的事务 ID
  • 网络可能分区

方案一:两阶段提交(2PC)。(第 12 章讲过)

  • 准备阶段 + 提交阶段
  • 问题:协调者单点、阻塞、性能差

方案二:TCC(Try-Confirm-Cancel)。

  • 应用层实现的补偿型事务
  • Try:预留资源(比如下单时冻结库存)
  • Confirm:确认(真正扣减)
  • Cancel:取消(释放预留)
  • 优点:比 2PC 灵活,锁粒度小
  • 缺点:对业务侵入极大——每个操作都要写三个接口

方案三:Saga。

  • 把一个长事务拆成多个本地事务,每个都有对应的补偿操作
  • 正常流程:T1 → T2 → T3
  • 失败流程:T1 → T2 → 失败 → C2(补偿 T2)→ C1(补偿 T1)
  • 优点:不需要锁、适合长事务
  • 缺点:没有隔离性(中间状态对外可见)、补偿逻辑复杂

方案四:本地消息表 / 事务消息。

  • 把”业务操作”和”发消息”放在同一个本地事务里
  • 消息消费方保证幂等
  • 最终一致性(不是强一致)
  • 这是互联网公司最常用的方案——因为它简单、可靠、性能好

最重要的实践建议:

能用”最终一致 + 幂等”解决,就不要用分布式事务。

分布式事务的代价(性能、复杂度、可用性)往往超出预期。很多团队一开始追求”强一致的分布式事务”,最后都退化成了”本地事务 + 消息 + 幂等 + 对账”。


子主题六:把四种异常放到时序表里逐帧解剖

上一节我们给了四种异常的定义。但定义是”背”的,背下来你还是不知道它什么时候会咬你。

真正理解异常的方式只有一种:把它画成时间线,一格一格往下推。

先回答那个根本问题:为什么两个”各自都正确”的事务,交错执行后结果就错了?

因为每个事务的逻辑都依赖于一个隐含前提——“我读到的世界,在我写完之前不会变”。交错执行把这个前提撕碎了:你的判断依据(读到的值)和你的写入对象(数据库里的当前值)之间,隔着一整个别人。

生活化一点:你打开冰箱,看到还有一瓶牛奶,于是你决定不用买了。你老婆在你转身的时候把那瓶牛奶拿出来喝了。你的购物清单是对的,牛奶没了——但错的是”决策依据”和”决策执行”之间那段空档。

数据库里的所有并发异常,本质都是这个空档。


异常一:脏读(Dirty Read)

先看是什么:读到了别人未提交的数据——而那个人可能回滚,于是你读到的是一个”从未真正存在过”的值。

时刻事务 T1(转账)事务 T2(提现审批)数据库真实值
t1BEGIN; UPDATE accounts SET balance=900 WHERE id='A';900(未提交)
t2BEGIN; SELECT balance ... WHERE id='A'; → 900900(未提交)
t3判断”余额 ≥ 500,允许提现 800”,写入提现单900(未提交)
t4ROLLBACK;1000(回滚了)
t5COMMIT;1000

结果:T2 基于一个从未存在过的 900,批准了一笔 800 的提现。

反问一句:T2 已经提交了,谁去撤回那张提现单?

答案是没人。数据库不会帮你撤回——在数据库看来 T2 是个干净的事务,它读的时候那个值”确实是 900”。脏读的恐怖之处就在这里:错误在提交之后才暴露,而且没有任何机制能追溯。

危害等级:最高。因为它污染的是”决策”,而决策会变成不可逆的外部动作(打款、发货、发券)。


异常二:不可重复读(Non-repeatable Read)

定义:同一事务内,两次读同一行,值不一样(因为别人改了并提交)。

时刻事务 T1(出对账报表)事务 T2(转账)id=‘A’ 的 balance
t1BEGIN; SELECT balance → 10001000
t2BEGIN; UPDATE ... SET balance=900; COMMIT;900
t3SELECT balance → 900900
t4用 1000 和 900 算差额 → 报表对不上账900

你可能会说:这有什么大不了的?我重新查一次不就好了?

问题在于——不是所有逻辑都能”重新查一次”。看这个更狠的例子:

T1: SELECT balance FROM accounts WHERE id='A';        → 1000
T1: SELECT SUM(amount) FROM流水 WHERE account='A';     → 300
    (此时 T1 认为:1000 和流水 300 应该匹配)
T2: UPDATE accounts SET balance = 1200 WHERE id='A'; COMMIT;   (又入账 200)
T1: (继续用旧的 1000 和 300 做校验)→ 恒等式被破坏

不可重复读破坏的不是”值”,而是”事务内视图的稳定性”这个前提。 一旦这个前提没了,你写的所有”先读 A,再读 B,然后基于 A、B 做判断”的代码,都建立在流沙上。

和脏读的区别:脏读读的是未提交数据(可能回滚),不可重复读读的是已提交数据(真实存在,只是前后不一致)。 后者温和得多,但在 Read Committed(Oracle、PG、SQL Server 的默认级别)下天天发生。


异常三:幻读(Phantom Read)

定义:同一事务内,两次同样的查询,返回的行数变了(别人插入或删除了)。

时刻事务 T1(批量打标)事务 T2(新订单)满足条件的行
t1BEGIN; SELECT id FROM orders WHERE status='PAID' AND amount>1000; → 3 行{101, 102, 103}
t2INSERT INTO orders ... amount=2000, status='PAID'; COMMIT;{101,102,103,104}
t3SELECT id FROM orders WHERE status='PAID' AND amount>1000; → 4 行{101,102,103,104}
t4给 3 个 id 打标(用 t1 的结果)→ 104 号漏处理

多出来的那行像个幽灵,你第一次看它在墙里,第二次看它站你面前。

但这里有个更深的点,很多人没想透:

幻读的麻烦不是”我多看到一行”,而是——我要锁住的是”满足条件的集合”,但我只能锁”当时存在的那几行”。新行是凭空冒出来的,我根本没有地方可以加锁。

把这句话读三遍。它是理解后面间隙锁的钥匙。

你 SELECT ... WHERE amount > 1000 FOR UPDATE,数据库给当时那 3 行加了行锁。然后别人插了一条 amount = 2000——这条新记录从来没被任何锁覆盖过,因为锁只能加在”已存在的行”上。所以防止幻读必须发明一种”锁住不存在的数据”的机制,这才有了 Gap Lock。

和不可重复读的区别一句话:不可重复读是”同一行的值变了”(UPDATE 造成),幻读是”行数变了”(INSERT/DELETE 造成)。


异常四:更新丢失(Lost Update)——最阴险的那个

先澄清一个绝大多数人搞错的认知。

看这两段代码:

-- 写法 A:让数据库自己算
UPDATE sku_stock SET stock = stock - 1 WHERE id = 1;
 
-- 写法 B:读进应用,内存里算,写回绝对值
v = SELECT stock FROM sku_stock WHERE id = 1;   -- 读到 10
UPDATE sku_stock SET stock = 9 WHERE id = 1;    -- 写回 9(应用算出来的)

写法 A 在任何隔离级别下都不会丢更新。 为什么?因为 UPDATE 是当前读——它读的是最新已提交版本,并且对那一行加排他锁,两个事务会被串行化。两个事务各减 1,结果是 8,完全正确。

写法 B 会丢更新,而且从 Read Committed 到 Repeatable Read 都会。 看时序:

时刻事务 T1(卖出一件)事务 T2(卖出一件)stock 真实值
t1SELECT stock → 10SELECT stock → 1010
t2应用计算 10-1=9应用计算 10-1=910
t3UPDATE stock = 99
t4UPDATE stock = 99(覆盖了 T1)

卖了 2 件,只扣了 1 件。超卖。

注意关键:T2 的 UPDATE 本身成功了、加锁了、没有冲突——因为两次写的是”同一行的同一个新值”,串行执行下来结果也是 9。 锁解决不了这个问题,因为问题出在”读”和”写”之间隔了应用代码,锁只保护了写那一瞬间。

所以更新丢失的准确定位是:读-改-写(read-modify-write)循环在并发下的覆盖问题。 只要你的代码是”读值 → 内存算 → 写回绝对值”,它就是颗定时炸弹。

顺带澄清:第一类 vs 第二类更新丢失

  • 第一类丢失更新(回滚覆盖):T1 改成 900(未提交),T2 改成 800(提交),T1 回滚 → 把 T2 的 800 覆盖回去了。这个只要有行锁就不可能发生(T1 持 X 锁时 T2 根本改不了),所以现代数据库都不讨论它。
  • 第二类丢失更新(提交覆盖):就是上面那个例子。两个事务都提交,后者覆盖前者。这才是真实世界里天天发生的那个。

怎么修?三板斧

-- 方案 1:让数据库算(最推荐,锁时间最短,一行搞定)
UPDATE sku_stock SET stock = stock - 1 WHERE id = 1 AND stock >= 1;
-- 检查 affected_rows:=1 扣成功,=0 库存不足。绝不超卖。
 
-- 方案 2:悲观锁,把读也升级成当前读
BEGIN;
SELECT stock FROM sku_stock WHERE id = 1 FOR UPDATE;   -- 当前读 + X 锁
UPDATE sku_stock SET stock = 9 WHERE id = 1;
COMMIT;
 
-- 方案 3:乐观锁(CAS),适合冲突少的场景
UPDATE sku_stock SET stock = 9 WHERE id = 1 AND stock = 10;
-- affected_rows = 0 就说明被别人改了,重试

反问:为什么更新丢失最容易被忽视?

因为它不像脏读那样”看起来危险”。脏读你一听就害怕,会主动躲。更新丢失呢?代码能跑、测试能过、单用户完全正确、压测时因为并发不够也测不出来。等到大促那天,同一件商品被秒了 1000 次,库存只扣了 300 次——你在监控上看到的是”订单量暴涨”,等你发现超卖,货已经发不出去了。


四种异常横向对照(背这张表)

异常一句话谁干的破坏的东西在哪级会发生怎么修
脏读读到未提交的数据别人未提交的写决策依据(可能不存在)RURC 及以上 / MVCC
不可重复读同一行两次读值不同别人已提交的 UPDATE事务内视图稳定性RU, RCRR(事务级快照)
幻读两次查询行数不同别人已提交的 INSERT/DELETE”锁住集合”的语义RU, RC, RR*Next-Key Lock / Serializable
更新丢失后写覆盖先写两个读-改-写循环交错数据正确性(资损)RU, RC, RR 也会原子更新 / FOR UPDATE / 乐观锁

(*InnoDB 的 RR 对”加锁读”路径防住了幻读,对”快照读”路径没防——这个微妙之处正是下一节的主题。)


子主题七:InnoDB 可重复读的边界——快照读 vs 当前读

先抛个真实现象,你猜是 bug 还是特性。

MySQL 默认是 Repeatable Read。官方文档和 SQL 标准都说 RR 会有幻读。但你亲自试了一下:

-- 事务 A
BEGIN;
SELECT COUNT(*) FROM t WHERE id > 100;   -- 3
-- (此时事务 B 插入一条 id=200 并 COMMIT)
SELECT COUNT(*) FROM t WHERE id > 100;   -- 还是 3!

“咦?幻读没发生啊?MySQL 是不是已经达到 Serializable 了?”

你是不是想说:既然我怎么查都是 3 行,那幻读就不存在呗?

这就引出了一个极其重要的区分。


快照读 vs 当前读:两条完全不同的读路径

InnoDB 里,“读”根本不是一回事,而是两套机制:

快照读(Consistent Read / Snapshot Read)

  • 触发:普通的 SELECT(不加 FOR UPDATE / LOCK IN SHARE MODE)
  • 走哪条路:MVCC,读 ReadView 对应的历史版本
  • 加不加锁:不加任何锁
  • 看到什么:事务开始时(RR)那一份冻结的快照

当前读(Current Read / Locking Read)

  • 触发:SELECT ... FOR UPDATE、SELECT ... LOCK IN SHARE MODE(8.0 写作 FOR SHARE)、以及所有的 UPDATE / DELETE
  • 走哪条路:读最新版本(最新的已提交数据)
  • 加不加锁:加锁(X 锁或 S 锁,RR 下通常是 Next-Key Lock)
  • 看到什么:数据库此刻的真实状态,不看你的快照

狠一点的比喻:

你闭上眼睛说”我没看见鬼”,不等于房间里没鬼。等你睁开眼(当前读),鬼不但还在,而且会咬你。

RR 下快照读看不到新插入的行,那不叫”防住了幻读”,那叫”你根本没去看”。


经典诡异现象之一:明明看到 1000,改完变成 800

这个例子必须亲手推一遍,它是区分”会用 MySQL”和”懂 MySQL”的分水岭。

表 accounts,id=‘A’ 的余额是 1000。

时刻事务 T1事务 T2数据库真实值
t1BEGIN;1000
t2SELECT balance WHERE id='A'; → 1000(生成 ReadView)1000
t3BEGIN; UPDATE accounts SET balance=900 WHERE id='A'; COMMIT;900
t4SELECT balance WHERE id='A'; → 1000900
t5UPDATE accounts SET balance = balance - 100 WHERE id='A';800
t6SELECT balance WHERE id='A'; → 800800

t4 明明读到 1000,t5 执行”减 100”,t6 却读到 800。

1000 - 100 = 800??这是 MySQL 算错了吗?

没有算错。是 T1 的”看”和”改”用了两套机制。

  • t4 的 SELECT 是快照读,看到的是自己的 ReadView:1000。这个行为完全符合 RR 的定义。
  • t5 的 UPDATE 是当前读,它读到的是最新已提交版本:900,然后在 900 的基础上减 100,得到 800。

为什么数据库要这么设计?

停下来想一下:如果 UPDATE 允许基于”你看到的旧版本”来更新,会发生什么?

T1 看到 1000,算出 900,写回 900
T2 已经把 1000 改成 900 并提交了
→ T1 的写入把 T2 的修改"覆盖回"900 —— 看起来值一样,但如果 T2 改的是别的值呢?

那就是第一类丢失更新(回滚/旧值覆盖新值),是绝对不能接受的。

所以数据库做了个残酷的取舍:宁可让你”看到的值”和”改到的值”不一致,也绝不允许丢更新。

记住这条铁律:

UPDATE 的语义是”在最新已提交版本上做修改”,不是”在我看到的版本上做修改”。

那正确的代码怎么写?

推论:在 RR 下,只要你打算”先读后改”,那个”读”必须升级成当前读并加锁,否则你的判断依据和你的写入对象不是同一个世界。

-- 错误:读的是快照,改的是现状,中间有缝
BEGIN;
SELECT balance FROM accounts WHERE id='A';        -- 1000(快照)
IF balance >= 100 THEN
    UPDATE accounts SET balance = balance - 100 WHERE id='A';   -- 基于 900 改 → 800
END IF;
COMMIT;
 
-- 正确:把读也变成当前读 + 加 X 锁,锁住这一行直到事务结束
BEGIN;
SELECT balance FROM accounts WHERE id='A' FOR UPDATE;   -- 当前读,且别的事务改不了
IF balance >= 100 THEN
    UPDATE accounts SET balance = balance - 100 WHERE id='A';
END IF;
COMMIT;
 
-- 更好:压根不读,让数据库原子做
UPDATE accounts SET balance = balance - 100 WHERE id='A' AND balance >= 100;

经典诡异现象之二:幻读真的发生了(以”写”的形式)

现在我们来实锤 InnoDB 的 RR 确实有幻读。

-- 表 t 里只有 id = 10 和 id = 20 两行
 
-- 事务 T1
BEGIN;
SELECT * FROM t WHERE id > 100;        -- 空结果集(快照读)
-- 事务 T2: INSERT INTO t (id) VALUES (200); COMMIT;
SELECT * FROM t WHERE id > 100;        -- 还是空(快照读,符合 RR,没毛病)
UPDATE t SET c = 1 WHERE id > 100;     -- 【关键】1 row affected !!!!!!
SELECT * FROM t WHERE id > 100;        -- 现在看到 id=200 这一行了!!!

幽灵出现在你的事务里了。

发生了什么?

  1. 第 3 步的快照读没看到 id=200,因为它走 MVCC,被 ReadView 挡住了。
  2. 第 4 步的 UPDATE 是当前读,它扫描的是真实的最新数据,于是看见了 T2 提交的 id=200,并且修改了它。
  3. 修改之后,这一行的 DB_TRX_ID 变成了 T1 自己的事务 ID。按照 ReadView 可见性规则——“自己修改的行对自己可见”——于是第 5 步的快照读就能看见它了。

这就是幻读,而且是确凿的幻读。 它只是不走”读”这条路暴露,而是走”写”这条路暴露:你以为你在给 0 行打标,结果改了 1 行。

想想这个后果:你的业务代码先 SELECT 出一批 id,做业务判断,然后 UPDATE ... WHERE id IN (...) 或者 UPDATE ... WHERE 条件。两次扫描看到的集合不一样 → 有的行没处理,有的行被意外处理。而且这类 bug 只在并发下偶发,极难复现。


那 InnoDB 到底防住了什么?

精确的说法是这样的:

对”当前读”路径,InnoDB 用 Next-Key Lock 锁住索引范围,别的事务插不进来——所以在这条路上它确实防住了幻读。 对”快照读”路径,它压根没打算防——它防的是不可重复读。

所以 InnoDB 的 RR 是一个”混血”:比标准 RR 强(加锁读无幻读),比 Serializable 弱(下面会说)。这也是为什么很多人说”MySQL 的 RR 已经够用了”——只要你全程用加锁读,它确实很接近 Serializable。但只要你混用了快照读和写,缝隙就出现了。


最后一刀:RR 不等于 Serializable——写偏斜(Write Skew)

再问一个反问:如果我把所有读都改成 FOR UPDATE,所有写都加锁,是不是就没有并发异常了?

不是。看这个例子:

表 oncall(duty_date, doctor_id),业务规则:任何一天至少要有 1 名医生在岗。
2026-09-02 这天有 A、B 两名医生。

T1(A 请假):
  BEGIN;
  SELECT COUNT(*) FROM oncall WHERE duty_date='2026-09-02';   → 2
  -- 判断:2 > 1,我走了还剩 1 个,可以请假
  DELETE FROM oncall WHERE doctor_id='A' AND duty_date='2026-09-02';
  COMMIT;

T2(B 请假):
  BEGIN;
  SELECT COUNT(*) FROM oncall WHERE duty_date='2026-09-02';   → 2   (快照读,也看到 A 还在)
  -- 判断:2 > 1,我走了还剩 1 个,可以请假
  DELETE FROM oncall WHERE doctor_id='B' AND duty_date='2026-09-02';
  COMMIT;

结果:那天没人值班。业务规则被打破。

为什么加锁也挡不住? 因为两个事务改的是不同的行——A 删自己的行,B 删自己的行,行锁互不冲突,两个事务都能成功。冲突发生在”读集”(都读到了对方的记录)和”写集”(都要删)的交叉上,而行锁只能保护写集。

这叫写偏斜(Write Skew),是快照隔离(Snapshot Isolation)下最著名的漏洞。

正式的结论:MySQL InnoDB 的 Repeatable Read,实现的是快照隔离(SI),不是可串行化(Serializable)。

SQL 标准里 RR 的定义和 SI 的实现,长期是错位的——这是数据库界最著名的一处”标准与实现打架”。后来才有论文正式证明 “SI ≠ Serializable”,并提出了 可串行化快照隔离 SSI(PostgreSQL 的 SERIALIZABLE 就是 SSI,能挡住写偏斜;MySQL 的 SERIALIZABLE 则是简单粗暴地给所有普通 SELECT 加共享 next-key 锁,直接退化成串行)。

本节的核心一句话:

RR 的承诺是”你看到的世界不变”,不是”你看到的世界是对的”。它保证一致性视图,不保证你的业务不变式。


子主题八:MVCC 实现细节——undo 版本链、ReadView、purge

上一节我们一直在说”读快照”。现在把它拆开,看快照到底是怎么造出来的。

第一步:行里的三个隐藏列

InnoDB 的聚簇索引叶子节点上,每一行除了你定义的字段,还藏着三个你看不见的列:

隐藏列大小作用
DB_TRX_ID6 字节最后修改这一行的事务 ID
DB_ROLL_PTR7 字节回滚指针,指向 undo log 里这个行的上一个版本
DB_ROW_ID6 字节单调递增的行 ID;表没定义主键时,它充当聚簇索引

(顺带一个实践点:每张 InnoDB 表都必须有主键。没有主键时 InnoDB 会用 DB_ROW_ID 建一个全局共享的隐含聚簇索引——注意是全局共享的自增计数器,所有无主键表共用,这会带来真实的竞争;而且二级索引回表要绕一层,性能更差。)

第二步:undo log 的两种类型

  • insert undo log:INSERT 产生。只在自己回滚时才用得上——因为别人在你提交前看不到这行(脏读都不允许了,更别说看未提交的插入)。所以事务一提交就可以直接删。
  • update undo log:UPDATE / DELETE 产生。它是 MVCC 的命根子——别的老事务要靠它还原出旧版本。绝对不能在提交时删,得等没有事务需要它了才能 purge。

注意 undo 存的是什么:不是整行的拷贝,而是**“怎么把新版本变回旧版本”的增量逆操作**(update undo 里记的是被修改字段的前像)。所以”还原一个版本”不是读一份数据,而是沿着链一路执行逆操作。

第三步:版本链长什么样

假设 id=1 这行经历了:事务 10 插入(balance=0)→ 事务 20 改成 1000 → 事务 25 改成 900 → 事务 30 改成 800。

【聚簇索引里的当前记录】
┌─────────────────────────────────────────┐
│ id=1, balance=800                        │
│ DB_TRX_ID = 30                           │
│ DB_ROLL_PTR ──────────┐                  │
└───────────────────────┼─────────────────┘
                        ↓
        ┌───────────────────────────────┐
        │ undo U3: balance=900          │
        │ DB_TRX_ID = 25                │
        │ ROLL_PTR ───────┐             │
        └─────────────────┼─────────────┘
                          ↓
        ┌───────────────────────────────┐
        │ undo U2: balance=1000         │
        │ DB_TRX_ID = 20                │
        │ ROLL_PTR ───────┐             │
        └─────────────────┼─────────────┘
                          ↓
        ┌───────────────────────────────┐
        │ undo U1: balance=0 (INSERT)   │
        │ DB_TRX_ID = 10                │
        │ ROLL_PTR = NULL               │
        └───────────────────────────────┘

新版本在链头(就是数据页里那条物理记录),老版本在 undo 里,用 DB_ROLL_PTR 串成一条从新到旧的链。

“读一个旧版本”的完整动作是:从当前记录出发,拿它的 DB_TRX_ID 去问 ReadView”你看得见吗”,看不见就顺着 ROLL_PTR 走到上一个版本,再问,直到找到一个可见的版本,或者走到链尾(说明这行对你来说不存在)。

第四步:ReadView——可见性的全部规则

ReadView 是”事务的眼镜”。生成时它快照下系统的状态,包含四个东西:

字段含义
m_ids(trx_ids)生成 ReadView 那一刻,所有活跃(已开始但未提交)事务的 ID 列表
min_trx_id(up_limit_id / 低水位)m_ids 里最小的那个事务 ID
max_trx_id(low_limit_id / 高水位)系统即将分配给下一个事务的 ID(即当前已分配的最大 ID + 1)
creator_trx_id创建这个 ReadView 的事务自己的 ID

(吐槽一句:InnoDB 源码里 up_limit_id 是”小”、low_limit_id 是”大”,名字和直觉完全相反,无数人被这个命名坑过。所以请记住语义,别记名字。)

可见性判断,四条规则,按顺序走:

对版本链上某个版本的 DB_TRX_ID = X:

规则 1:X == creator_trx_id
        → 这是我自己改的 → 可见

规则 2:X < min_trx_id(低水位)
        → 改这行的事务在我生成 ReadView 前就已经提交了 → 可见

规则 3:X >= max_trx_id(高水位)
        → 改这行的事务是在我生成 ReadView 之后才开启的 → 不可见

规则 4:min_trx_id <= X < max_trx_id
        → 这个事务在我生成 ReadView 时"正在运行中",需要查名单:
           X ∈ m_ids → 它还没提交 → 不可见
           X ∉ m_ids → 它在我生成 ReadView 前已经提交了 → 可见

走一遍实例。 假设系统里:

  • 事务 28(我)执行第一条 SELECT,生成 ReadView
  • 此时活跃事务 m_ids = [25, 30](25 和 30 都还没提交)
  • 所以 min_trx_id = 25,max_trx_id = 35,creator_trx_id = 28

现在读 id=1 这行,版本链是 30 → 25 → 20 → 10:

步骤检查的版本X命中哪条规则结论
1当前记录 (balance=800)30规则 4:25 ≤ 30 < 35,且 30 ∈ m_ids不可见,往下走
2undo U3 (balance=900)25规则 4:25 ≤ 25 < 35,且 25 ∈ m_ids不可见,往下走
3undo U2 (balance=1000)20规则 2:20 < 25可见 ✓

最终读到 balance = 1000。 尽管此刻磁盘上这行的真实值是 800、且事务 30 正在改它。

再看规则 1 为什么必要:如果 T1 自己 UPDATE 了一行,这行的 DB_TRX_ID 变成 T1 的 ID,但 T1 还没提交,所以它一定在别的 ReadView 的 m_ids 里——唯独对自己的 ReadView,靠规则 1 放行。 这就是为什么”改完立刻查”能看到自己的改动。

第五步:RR 和 RC 的区别,其实只有一行代码

这是本节最值钱的一句话:

在 InnoDB 里,RR 和 RC 的 MVCC 实现完全一样,唯一的区别是”什么时候生成 ReadView”。

  • RR:事务中第一次快照读时生成一个 ReadView,之后整个事务都复用它。(用 START TRANSACTION WITH CONSISTENT SNAPSHOT 可以让它在 BEGIN 时就生成。)
  • RC:每一条语句执行前都重新生成一个 ReadView。

就这一个调用时机的差别(MySQL 源码里 MVCC::view_open 的调用点),决定了两种隔离级别的全部语义差异:

ReadView 生成时机后果
RR事务级(第一次读时)整个事务看到同一个时间点 → 可重复读
RC语句级(每条语句)每条语句看到最新已提交 → 会出现不可重复读

反问:那 RC 下的 ReadView 是不是更”省”? 反过来。RC 每条语句都要新建 ReadView,分配、填充 m_ids(要遍历活跃事务链表),开销更高。RR 是”一次生成,全程复用”。所以 RR 的读性能其实不比 RC 差,RC 的优势主要在锁(RC 基本没有间隙锁,死锁少、锁范围小)。

第六步:purge——谁在清理那些没人要的版本

版本不能无限堆积,得有清理机制。这就是 purge 线程。

核心判定逻辑:

  1. 系统维护一个”最老的活跃 ReadView”(在 MySQL 8.0 里由 purge_sys 跟踪,老版本叫 trx->no)
  2. 对某个 undo 版本,如果它的 DB_TRX_ID 小于最老活跃 ReadView 的低水位,说明没有任何事务(无论多老)还能看得见它 → 可以安全 purge
  3. purge 线程(数量由 innodb_purge_threads 控制,默认 4)后台慢慢清理

purge 还要干一件重要的事:真正的物理删除。

InnoDB 的 DELETE 是两阶段的:

  • 阶段 1(即时):只打一个”删除标记”(delete-mark),行还在 B+ 树里,只是对新的读不可见了
  • 阶段 2(purge):等所有可能还要看这行的 ReadView 都消失了,purge 线程才把这条记录真正从 B+ 树里摘掉,并回收页空间

为什么要分两步?因为别的事务的旧快照里,这行还必须存在。 这就是”标记删除 + 延迟 purge”,MVCC 的必然代价。

怎么看 purge 跟不跟得上?

SHOW ENGINE INNODB STATUS\G
-- 找到 TRANSACTIONS 段:
------------
TRANSACTIONS
------------
Trx id counter 1234567
Purge done for trx's n:o < 1234000 undo n:o < 0 state: running but idle
History list length 12          ← 关键指标

History list length(历史链表长度)= 还没被 purge 的 undo 页数量。

  • 健康状态:几百以内,能快速回落
  • 轻度告警:持续几千上万
  • 严重:几十万、上百万,且只涨不落 → 一定有一个长事务卡在那儿

关键因果: 一个跑了 2 小时的事务,它的 ReadView 是 2 小时前的,那么这 2 小时里产生的所有 undo 版本,一个都 purge 不掉。哪怕这些版本 99.99% 已经没人看了。一个长事务,能卡住整个实例的 undo 回收。

MySQL 8.0 引入了独立的 undo 表空间和 innodb_undo_log_truncate=ON(自动截断回收空间),但截断的前提依然是:要被截断的那个 undo 段里没有任何活跃事务在用。所以——长事务不解决,undo 文件涨到几十 GB 就是缩不回去。

第七步:业界三种 MVCC 实现路线

同一个思想,三条不同的工程路线:

数据库存老版本的地方特点
MySQL InnoDB独立 undo 表空间,增量逆操作,用回滚指针串链主记录保持最新,读旧版本要沿链回溯(版本多时慢)
PostgreSQL堆表(heap)内部,每次 UPDATE 写一条完整新行版本,用 t_xmin/t_xmax 标记可见区间主表里就堆着多版本,靠 VACUUM 清理;读旧版本快,但表会膨胀(bloat),需要 autovacuum 精心调优
SQL Server / Oracle独立版本存储(tempdb / undo tablespace)思路类似 InnoDB,Oracle 的 undo 表空间还有著名的 ORA-01555 snapshot too old 错误

同一个 trade-off 反复出现:版本放在”行内”(读得快、主数据膨胀)还是”行外”(主数据干净、读旧版本要走链)。 这就是我们在第 3 章、第 6 章反复见到的”局部性 vs 干净”的权衡,在事务这个场景里又出现了一次。



子主题九:锁的类型——从行锁到 Next-Key Lock,再到意向锁

先问一个根本问题:MVCC 这么好,为什么还需要锁?

因为 MVCC 只解决”读-写”冲突,一个”写-写”冲突都解决不了。

两个事务要改同一行,你能让它们各自改各自的快照吗?不能——那结果就是丢更新。这时候必须有一个人说”你等等”,或者”你失败”。锁就是干这个的。

记住这个分工:

  • 读-写冲突 → MVCC 解决(读走快照,不加锁,互不阻塞)
  • 写-写冲突 → 锁解决(后来者等待或失败)

1. 行锁(Record Lock):锁住索引上的一条记录

最重要的一个事实:InnoDB 的行锁是加在”索引记录”上的,不是加在”数据行”上的。

这句话的杀伤力在于:如果你的 SQL 没走索引,InnoDB 就不知道该锁哪条索引记录,只能把扫过的所有记录(以及间隙)都锁上——实际上等于锁表。

两种模式:

  • S 锁(共享锁 / 读锁):SELECT ... LOCK IN SHARE MODE(8.0 起推荐写 FOR SHARE)。多个事务可同时持有 S 锁。
  • X 锁(排他锁 / 写锁):UPDATE / DELETE / SELECT ... FOR UPDATE。只有一个人能持有。

兼容性:

已持有 \ 请求SX
S✓ 兼容✗ 冲突
X✗ 冲突✗ 冲突

2. 间隙锁(Gap Lock):锁住”不存在的东西”

回到子主题六那句关键的话:防止幻读,必须能锁住”还不存在的行”。

间隙锁就是干这个的:它锁住两条索引记录之间的空隙,禁止在这个空隙里插入任何东西。

举例:表里有 id = 10, 20, 30 三行。

-- 事务 A
BEGIN;
SELECT * FROM t WHERE id BETWEEN 10 AND 30 FOR UPDATE;
-- 在 RR 下,锁住了 (10,20) 和 (20,30) 这些间隙
-- 事务 B: INSERT INTO t VALUES (15);  → 阻塞!直到事务 A 提交

间隙锁锁的是一段开区间 (a, b),锁的内容是”这里不能插入新东西”。它不锁任何已存在的行。

一个反直觉但极其重要的性质:两个事务可以同时持有同一个间隙的 Gap Lock。

为什么?因为间隙锁的”目标”是一致的——都是为了禁止插入。事务 A 想禁止插入,事务 B 也想禁止插入,它俩目标相同,没有冲突。

真正的冲突发生在**“插入意图锁”**上:

3. 插入意图锁(Insert Intention Lock)

INSERT 语句在真正插入之前,要在目标间隙上申请一个插入意图锁。

它和 Gap Lock 互斥。

这就是死锁的高发地带:A、B 都持有 (10,20) 的 Gap Lock(互相兼容,都拿到了),然后 A 想插入 15(需要插入意图锁,被 B 的 Gap Lock 挡住),B 想插入 16(被 A 的 Gap Lock 挡住)→ 死锁。后面子主题十会详细推。

4. Next-Key Lock = 行锁 + 间隙锁

定义:Next-Key Lock 锁住的是”前一个开区间 + 当前这条记录”,即左开右闭区间 (prev, current]。

索引:  ... 10 .......... 20 .......... 30 ...
Next-Key(20) 覆盖范围:  (10 ───────── 20]
                         ↑间隙          ↑记录本身

这是 InnoDB 在 RR 下的默认加锁单位,也是它防幻读的唯一手段。

加锁规则(必须记住的两条)

规则 A:唯一索引的等值查询,命中记录 → Next-Key Lock 退化为行锁。

SELECT * FROM t WHERE id = 20 FOR UPDATE;   -- id 是主键,且存在 id=20
-- 只锁 id=20 这一行,不加间隙锁

为什么能退化?因为唯一索引保证了不可能再插入第二个 id=20 的记录——既然插不进第二个,就不会产生幻影,间隙锁是多余的。

规则 B:其他情况(范围查询、非唯一索引、等值未命中)→ 保持 Next-Key Lock,并且范围向右延伸到”第一个不满足条件的记录”。

SELECT * FROM t WHERE id BETWEEN 10 AND 20 FOR UPDATE;
-- 锁住 (prev,10] 和 (10,20]  ——注意 10 和 20 之间的间隙也锁了,插不进 15
 
SELECT * FROM t WHERE id = 15 FOR UPDATE;   -- 表里没有 id=15
-- 锁住 (10,20) 这个间隙锁 —— 防止别人插一个 15 进来
 
SELECT * FROM t WHERE id > 10 FOR UPDATE;   -- 范围查询
-- 锁住 (10, 20], (20, 30], (30, +∞]  —— 一直到正无穷

(最后那条正是”一条范围 UPDATE 把整张表写死”的成因。)

RC 下没有间隙锁

Read Committed 级别下,InnoDB 基本不加 Gap Lock(除了外键检查、duplicate key 检查等极少数内部场景)。

这是一个非常重要的实践结论:

RC 下锁粒度更细、并发更高、死锁更少。

所以很多互联网公司(阿里、美团等)把 MySQL 的默认隔离级别改成 RC,配合 binlog_format=ROW(RC 下用 statement 格式 binlog 会有主从不一致问题,这是历史原因,ROW 格式下没这个问题)。

反问:那 RR 还用不用? 用。如果你的业务需要”事务内多次读必须一致”(比如对账、跑批、生成快照报表),RR 的事务级快照是刚需。但高并发 OLTP 的核心链路,RC 往往是更务实的选择。


5. 意向锁(Intention Lock):表级与行级锁的协调器

先抛问题:

假设事务 A 已经给表 t 的第 100 行加了 X 锁。现在事务 B 想 LOCK TABLES t WRITE(加表级 X 锁)。数据库怎么快速判断”能不能加”?

最笨的办法:遍历表里所有行,看有没有行锁。 一亿行的表,扫一遍?不可能。

解法就是意向锁:任何事务在给某一行加锁之前,必须先在这张表上先加一个”表级意向锁”。

  • 要加行级 S 锁 → 先在表上加 IS(Intention Shared,意向共享)
  • 要加行级 X 锁 → 先在表上加 IX(Intention Exclusive,意向排他)

这样,“能不能加表锁”就变成了”看一眼表上有没有意向锁”,O(1) 判断。

生活化类比:

小区门口的公告牌。 装修队进楼前先在牌子上写”3 单元 502 正在施工”。物业要封整栋楼做消杀时,不用挨家挨户敲门,看一眼牌子就知道”有人在里面,不能封”。牌子本身不锁任何房间,只是一个”声明”。

完整兼容矩阵(表级锁)

已持有 \ 请求X(表)IXS(表)IS
X(表)✗ 冲突✗ 冲突✗ 冲突✗ 冲突
IX✗ 冲突✓ 兼容✗ 冲突✓ 兼容
S(表)✗ 冲突✗ 冲突✓ 兼容✓ 兼容
IS✗ 冲突✓ 兼容✓ 兼容✓ 兼容

读出三个关键结论:

  1. IX 和 IX 互相兼容。 这是精髓——两个事务都在表里改行,但它们改的是不同的行,凭什么互斥?意向锁只表达”我在这张表的某(些)行上有锁”,不指定是哪行,所以它只用来拦”整表操作”,不降低行级并发。
  2. IX 只和表级 S / X 冲突。 也就是说,意向锁的唯一作用是:阻止别人做整表操作(LOCK TABLES、DROP TABLE、某些 DDL)。
  3. 意向锁是 InnoDB 自动加的,用户无法手动请求。 你写不出 LOCK TABLE t IN INTENTION EXCLUSIVE MODE 这种语句。

一句话总结意向锁:

它是”自底向上的信号”——行级锁往表级打一个招呼,好让表级锁不用扫全表。

(顺便区分一个常被混淆的东西:MDL(Metadata Lock,元数据锁)。它是 MySQL Server 层的锁,跟 InnoDB 的事务锁完全不是一套体系。它管的是”表结构”,比如你 SELECT 一张表时会加 MDL 读锁,此时有人想 ALTER TABLE 就会被堵住,状态显示 Waiting for table metadata lock。长事务堵死 DDL,堵的就是 MDL,不是 InnoDB 行锁。)


6. “我的 SQL 到底加了什么锁”对照表(RR 下)

这张表值得截图保存:

SQL条件/索引加的锁
SELECT * FROM t WHERE id=10无 FOR UPDATE不加锁(快照读)
SELECT * FROM t WHERE id=10 FOR UPDATE主键等值,命中id=10 的 X 型行锁
SELECT * FROM t WHERE id=10 FOR UPDATE主键等值,未命中(prev,10) 的间隙锁
SELECT * FROM t WHERE id BETWEEN 10 AND 20 FOR UPDATE主键范围(prev,10]、(10,20] 两个 Next-Key Lock
SELECT * FROM t WHERE name='a' FOR UPDATE二级索引二级索引上的 Next-Key Lock + 回表给主键行加行锁
UPDATE t SET ... WHERE ...同上规则同 FOR UPDATE(UPDATE 本质是”先当前读加锁,再写”)
DELETE FROM t WHERE ...同上规则同 FOR UPDATE
INSERT INTO t ...—插入意图锁(若无冲突,插入后给新行加 X 行锁)
SELECT ... WHERE name LIKE 'a%' FOR UPDATEname 无索引全表所有行 + 所有间隙都上锁 ≈ 锁表

现在回答那个反问:UPDATE t SET c=1 WHERE name='张三' 会加多少锁?

取决于 name 有没有索引。

  • 有索引 → 锁住 name='张三' 命中的那几行 + 相邻间隙
  • 没索引 → 全表扫描 → 扫过的每一行都加 Next-Key Lock → 整个表在事务提交前不可写,插入也全被阻塞

这就是”一条 UPDATE 把整个库写挂”的经典事故现场。 而它的触发条件往往只是——某个字段忘了建索引,或者索引因为函数/隐式类型转换失效了(WHERE CAST(name AS CHAR)=...、WHERE name = 123 这种)。


子主题十:死锁——等待图、超时回滚,和”锁顺序”怎么保证

问:两个事务各自持有一把锁,又都在等对方的锁。数据库怎么”知道”这是死锁?

数据库又不长眼睛,它只能靠两招。


招式零:超时(兜底,但很笨)

innodb_lock_wait_timeout 默认 50 秒。等待超过这个时间,报:

ERROR 1205 (HY000): Lock wait timeout exceeded; try restarting transaction

问题在哪?

  1. 它分不清”死锁”和”慢”。 一个真死锁,数据库要等 50 秒才响应;一个刚好等了 51 秒的正常大事务,却被误杀。
  2. 阈值怎么定? 太短误杀,太长用户等死。

所以超时只能当兜底,真正的主力是下一招。


招式一:等待图(wait-for graph)检测 —— InnoDB 的真做法

算法非常直白:

  • 节点 = 事务
  • 有向边 A → B = “事务 A 正在等待事务 B 持有的锁”
  • 每次加锁请求失败、需要进入等待时,InnoDB 就新增一条边,然后从自己出发做一次深度优先搜索(DFS),看能不能走回自己。
  • 找到环 → 死锁成立
    T1 ──等待──> T2
     ↑            │
     └────等待────┘
       构成环 → 死锁

谁被回滚?

InnoDB 选一个受害者(victim),标准大致是:回滚代价更小的那个。MySQL 8.0 用 trx_weight 衡量,权重 ≈ 该事务已修改的行数 + 已持有的锁数量。谁改的少、锁的少,谁就牺牲。

被回滚的事务收到:

ERROR 1213 (40001): Deadlock found when trying to get lock; try restarting transaction

注意:是整个事务回滚,不是回滚其中一条语句。 而且——数据库帮你回滚了,但数据库不会帮你重试。

检测的代价:O(n²) 的噩梦

这是生产上最痛的一个点。

每次加锁失败都要做一次图遍历。想象一个热点行(比如某爆款商品的库存行),同一时刻有 1000 个事务在排队等这一把锁——那么每次有人加入等待,都可能触发一次包含上千节点的图搜索。

结果:CPU 被打满在死锁检测上,而不是处理业务。 这是非常著名的 MySQL 高并发坑。

解法:

  1. 改用 RC → 锁范围小、等待少
  2. 把并发改成串行:用 Redis 预扣/队列削峰,让同一时刻只有少量请求打到数据库行上
  3. 关掉检测,只靠超时:innodb_deadlock_detect=OFF + 把 innodb_lock_wait_timeout 调小(比如 5 秒)。这是阿里等公司在超高热点场景公开过的做法——宁可少量误杀,也不让 CPU 被检测耗死

怎么排查?

SHOW ENGINE INNODB STATUS\G
-- 找到 LATEST DETECTED DEADLOCK 段
------------------------
LATEST DETECTED DEADLOCK
------------------------
*** (1) TRANSACTION:
TRANSACTION 12345, ACTIVE 3 sec starting index read
UPDATE t SET c=1 WHERE id=2
*** (1) WAITING FOR THIS LOCK TO BE GRANTED:
RECORD LOCKS ... lock_mode X locks rec but not gap waiting
*** (2) TRANSACTION:
TRANSACTION 12346, ACTIVE 2 sec starting index read
UPDATE t SET c=1 WHERE id=1
*** (2) HOLDS THE LOCK(S):
RECORD LOCKS ... lock_mode X locks rec but not gap
*** WE ROLL BACK TRANSACTION (2)

读懂它的关键:

  • lock_mode X locks rec but not gap = 行锁(不是间隙锁)
  • lock_mode X locks gap before rec = 间隙锁
  • lock_mode X (没有限定)= Next-Key Lock
  • lock_mode X locks gap before rec insert intention = 插入意图锁(死锁高发区)
  • 它只保留最近一次死锁 —— 想看全部,要开 innodb_print_all_deadlocks=ON 写进 error log

招式二(真正的招式):在 SQL 层面保证锁顺序

数据库兜底是下策。上策是让死锁根本不发生。

三条硬规则:

规则 1:对多行加锁,永远按同一个顺序

经典死锁:

-- 事务 T1
UPDATE accounts SET balance=... WHERE id = 1;   -- 拿到 id=1 的锁
UPDATE accounts SET balance=... WHERE id = 2;   -- 等 id=2 的锁
 
-- 事务 T2(顺序相反)
UPDATE accounts SET balance=... WHERE id = 2;   -- 拿到 id=2 的锁
UPDATE accounts SET balance=... WHERE id = 1;   -- 等 id=1 的锁
-- → 成环,死锁

修复:固定顺序。

-- 坏:IN 的顺序不保证加锁顺序!
UPDATE accounts SET balance=? WHERE id IN (2, 1);
 
-- 好:显式排序,保证所有事务按同一顺序加锁
SELECT id FROM accounts WHERE id IN (1,2) ORDER BY id FOR UPDATE;
-- 然后逐条 UPDATE

注意那个坑:WHERE id IN (...) 里的顺序,不保证加锁顺序。 优化器可能按主键顺序扫,也可能按别的顺序。必须显式 ORDER BY。

更彻底的做法:在应用层把要更新的 id 列表排序后再拼 SQL。这样不依赖数据库行为。

规则 2:用原子操作消灭”检查-执行”竞态(TOCTOU)

// 坏:先查后插,并发下两个请求都查到"不存在",都去插
if (!userDao.exists(phone)) {
    userDao.insert(user);     // 可能主键冲突,或者(没唯一索引时)产生脏数据
}
 
// 好:一条 SQL 原子完成,让唯一索引去仲裁
INSERT INTO user (phone, ...) VALUES (?, ...)
ON DUPLICATE KEY UPDATE ...;

这条规则的通用形式是:把”判断 + 执行”压缩成一条原子语句,让数据库内部的锁来保证串行,而不是让应用的两条语句之间存在时间窗口。

规则 3:缩小锁范围 + 让数据库自己算

-- 坏:读-改-写,锁跨越两次 SQL 和一段应用逻辑
v = SELECT stock FROM sku WHERE id=1;
UPDATE sku SET stock = #{v-1} WHERE id=1;
 
-- 好:一条语句,当前读 + 行锁,锁持有时间最短,且天然正确
UPDATE sku SET stock = stock - 1 WHERE id=1 AND stock >= 1;
-- 检查 affected_rows:1 = 成功,0 = 库存不足。绝不会超卖

“让数据库自己算”这三个好处是叠加的:

  1. 不需要 SELECT FOR UPDATE,锁持有时间从”两次 SQL + 应用逻辑”缩短到”一条 SQL”
  2. 不存在读-改-写窗口,天然无丢失更新
  3. 条件 stock >= 1 写进 WHERE,库存检查与扣减是同一个原子动作

Gap Lock 特有的死锁(RC 下不会发生)

这个必须推一遍,它是理解间隙锁的最好例子:

表里有 id = 10 和 id = 20

时刻 1  T1: DELETE FROM t WHERE id=15;   -- 拿到 (10,20) 的 Gap Lock
时刻 2  T2: DELETE FROM t WHERE id=16;   -- 也拿到 (10,20) 的 Gap Lock(Gap Lock 之间兼容!)
时刻 3  T1: INSERT INTO t VALUES(15);    -- 需要插入意图锁 → 被 T2 的 Gap Lock 挡住 → 等
时刻 4  T2: INSERT INTO t VALUES(16);    -- 需要插入意图锁 → 被 T1 的 Gap Lock 挡住 → 等
→ 死锁

根因:“Gap Lock 互相兼容,但与插入意图锁互斥”这个设计。 两个事务目标一致地”禁止插入”,结果谁都别想插。

这个死锁在 RC 下不存在(RC 没有 Gap Lock)——这是选 RC 的一个很实在的理由。


死锁能彻底消灭吗?

不能。 这是理论结论:任何满足”持有部分资源时申请新资源 + 不剥夺已持有资源 + 循环等待可能”的系统,都可能死锁。

工程上只能做三件事:

手段具体做法
减少锁RC 级别、走索引、让数据库原子算、缩小事务粒度
统一顺序批量操作排序、按主键顺序更新、避免交叉更新多张表
能安全重试应用层捕获 1213 错误并幂等重试

第三条最常被忘。 没有幂等和重试的分布式代码,遇到 1213 就是资损——因为事务被回滚了,但你的业务流程可能已经往前走了一步。


子主题十一:分布式事务深入——用”下单扣库存”把四条路走一遍

先把场景钉死:

用户下单,需要:① 创建订单(订单库)② 扣减库存(库存库)③ 扣款(支付/账户库)。三个库,三个服务。

反问:这三个操作,能不能塞进一个数据库事务里?

不能。它们不在同一个实例,没有共享的锁管理器、没有共享的事务 ID、没有共享的时钟。

再反问:那有没有可能——根本不让它成为分布式事务?


路线 0(最重要,却最常被跳过):重新划边界,让事务不跨库

把”库存”从独立服务下沉成订单库里的一张表,或者把热点 SKU 的库存按同一个分片键(比如 user_id 或 order_id 的哈希)路由到订单所在的那个分片。

于是:下单 = 单库本地事务。问题消失。

这不是妥协,这是最高级的解法。 分布式事务最难的部分从来不是”怎么做对”,而是”能不能不做”。

这是架构设计的第一性原则:先问”能不能通过重新划分边界让问题消失”,再问”用什么技术解决问题”。 阿里、美团反复讲的”按业务聚合根拆分,让 90% 的事务落在单库内”,说的就是这件事。

代价你也得认: 库存下沉到订单库后,“按 SKU 查询全量库存”、“跨用户维度的库存盘点”就变难了。这是真实的取舍——用查询灵活性换事务简单性。


路线 1:2PC / XA —— 教科书方案,互联网的伤疤

流程

协调者(TM)                    参与者1(订单库)        参与者2(库存库)        参与者3(账户库)
   │
   ├──── PREPARE ──────────────>│
   │<──── YES (写redo+undo, 持锁) ┤
   ├──── PREPARE ───────────────────────────────>│
   │<──── YES (写redo+undo, 持锁) ─────────────────┤
   ├──── PREPARE ────────────────────────────────────────────────>│
   │<──── YES (写redo+undo, 持锁) ─────────────────────────────────┤
   │
   │  写 commit 日志(落盘)
   ├──── COMMIT ───────────────>│
   ├──── COMMIT ───────────────────────────────>│
   ├──── COMMIT ────────────────────────────────────────────────>│

四个致命问题

问题 1:锁持有时间 = 2 次网络往返 + 多次落盘。 从 PREPARE 到 COMMIT,参与者的行锁一直持有,不能释放(因为还没决定是提交还是回滚)。在热点 SKU 库存行上,这意味着 QPS 直接崩。

问题 2:协调者单点 → in-doubt(悬而未决)状态。 协调者写完 commit 日志后挂了,或者发 COMMIT 之前挂了:

  • 参与者已经 PREPARE,握着锁,不知道该提交还是回滚
  • 它不能自己决定——因为它不知道别人是 YES 还是 NO
  • 只能干等协调者恢复,锁一直占着 → 这就是”阻塞”,2PC 最致命的缺陷

问题 3:部分提交的不一致窗口。 协调者只把 COMMIT 发给了参与者 1 就挂了 → 1 提交了,2、3 悬着。虽然协调者恢复后能继续推进,但在恢复之前,数据是不一致的,而且是”对外可见的不一致”。

问题 4:性能。 每个事务 2 轮 RTT + 至少 3 次 fsync。单机事务 1ms,跨机房 2PC 可能 50ms+。

3PC 为什么没人用?

3PC 想用”预提交阶段 + 超时自动提交”来解阻塞:协调者失联时,参与者默认提交(因为能进到预提交阶段说明大家都 YES 了)。

但网络分区下这会出大问题: 如果实际决策是”回滚”(某个参与者 NO 了但消息丢了),超时提交就会把本该回滚的事务提交了——一致性比阻塞更糟。

所以 3PC 在工业界基本没有落地。阻塞问题真正的解法是下面 Spanner 那套:把 2PC 的状态用共识算法复制,让协调者不再单点。

适用场景

  • 参与者少(2~3 个)、TPS 低(百级)、能接受秒级停顿
  • 传统金融内部系统的跨库转账(这类场景 XA 用得很好)
  • 互联网高并发下单:几乎不用

路线 2:TCC —— 把两阶段锁搬到业务层

三阶段是什么

阶段动作库存的变化订单状态
Try检查并预留资源stock=10, frozen=0 → stock=10, frozen=1INIT
Confirm确认,真正扣减stock=9, frozen=0CONFIRMED
Cancel取消,释放预留stock=10, frozen=0CANCELLED

注意 Try 不是”扣”,是”冻结”。 可用库存 = stock - frozen。

TCC 的本质一句话:

把数据库层的两阶段锁,搬到业务层,用”预留/冻结”这个业务概念来当锁。

好处是锁的语义由你定义,粒度可以极细:不是锁住整个库存行,而是”冻结 1 件”。锁的范围、时长、冲突判定,全在你的掌控里。

代价是——每个业务动作要写三个接口 + 一个协调框架。

必须解决的四个坑(面试官最爱,也是真会翻车的地方)

坑 1:幂等。 网络重试会让 Confirm/Cancel 被调用多次。 → 用全局事务 ID 做去重表,INSERT 去重记录与业务操作在同一个本地事务里。

坑 2:空回滚(Cancel 先于 Try 到达)。 Try 请求超时了,全局事务超时触发 Cancel,但参与者压根没执行 Try。此时 Cancel 必须:

  • 不能报错(否则框架一直重试 Cancel,永远失败)
  • 必须记录”已空回滚”,否则后面迟到的 Try 真的冻结了库存,就再没人释放了
时间线:
  TC ──Try──✗ 超时丢失
  TC ──Cancel──> 参与者:我没 Try 过,但我必须"假装成功"并记录 rollback_flag=1
  (网络恢复)
  TC ──Try(迟到)──> 参与者:查到 rollback_flag=1 → 直接丢弃,不执行

坑 3:悬挂(迟到的 Try)。 就是上面时间线的最后一步。空回滚之后迟到的 Try 必须被丢弃,否则产生”冻结了但没人释放”的僵尸库存。

坑 4:Confirm / Cancel 必须最终成功。 它们失败后只能无限重试 + 告警人工介入——因为此时 Try 已经提交,资源已被预留,没有退路。所以生产上必须有:重试队列 + 最大重试次数 + 人工干预后台 + 定时对账。

适用场景

  • 强一致要求高,且必须跨多个资源做”预留—确认”的场景:支付、金融、票务(座位占用)、券+库存+积分的组合占用
  • 成本:一个业务动作三套代码 + 事务协调框架(Seata TCC、ByteTCC、Hmily)

路线 3:Saga —— 长事务的唯一现实解法

基本形态

正向:  T1 创建订单(待支付)  →  T2 扣库存  →  T3 扣款  →  T4 发货
反向:  C1 取消订单          ←  C2 回滚库存 ←  C3 退款   ←  (发货后无法反向,需退货流程)

两种恢复策略:

  • 向后恢复(backward recovery):走补偿,把已完成的步骤全部撤销
  • 向前恢复(forward recovery):不回滚,重试失败的那一步直到成功。对”不可逆”的步骤(比如已发货)优先用这个

Saga 的硬伤:没有隔离性

这是它和 2PC/TCC 最本质的差别:Saga 的每个 Ti 都是独立提交的本地事务,中间状态对外完全可见。

后果是三种异常:

异常例子
丢失更新用户 A 下单扣了库存(T2 提交),运营后台把库存改成 0,然后 A 的流程失败触发 C2 回滚库存 → 运营的修改被覆盖
脏读用户看到订单”已创建”但库存已扣、款未扣——一个从未”真正成立”的中间态
模糊读同一用户在流程不同阶段查询,看到不同的中间状态

三个经典对策(来自 Hector & Kenneth 1987 年那篇论文,四十多年后仍在用)

对策 1:语义锁(Semantic Lock)。 给正在被 Saga 操作的记录打标记:locked_by = 全局事务ID。其他操作看到标记就拒绝或排队。

UPDATE sku_stock SET locked_by = 'TX-12345' WHERE sku_id=1 AND locked_by IS NULL;

这是用业务字段自己实现锁——因为数据库锁跨不了服务,只能自己造。

对策 2:交换更新(Commutative Updates)。 把 SET stock = 9 改成 SET stock = stock - 1,补偿就是 SET stock = stock + 1。

为什么有用? 因为加法是可交换的——不管”扣减”和”回滚”谁先谁后,也不管中间插了多少别的操作,最终结果都正确。这就绕开了覆盖问题。

这是最优雅的一招,成本几乎为零,效果极好。 凡是能用增量表达的业务,都不要用绝对值赋值。

对策 3:版本化 / 悲观视图。 重试时按版本号判断,版本变了就重新走一遍流程。

适用场景

  • 长事务(跨分钟、小时、天):下单 → 支付 → 风控 → 发货 → 结算 → 开票
  • 这类流程本来就不可能长时间锁资源,Saga 是唯一现实的解法。
  • 还有一条:Saga 天然适合”流程编排”(配一个状态机/工作流引擎),所以它在业务中台、订单中心里非常普遍。

路线 4:本地消息表 / 事务消息 —— 互联网的绝对主力

本地消息表(eBay 2008 年提出,最经典的形态)

BEGIN;
  INSERT INTO orders (order_id, user_id, ...) VALUES (...);              -- 建订单
  INSERT INTO outbox (id, biz_key, topic, body, status)
       VALUES (..., 'ORDER_CREATED', 'stock.deduct', '{...}', 'NEW');    -- 同一事务写消息表
COMMIT;

然后:

  1. 独立的投递线程/任务扫描 outbox WHERE status='NEW',投递到 MQ
  2. 投递成功后 UPDATE outbox SET status='SENT'
  3. 库存服务消费消息,扣库存,必须幂等(用 order_id 做唯一键或去重表)
  4. 消费失败 → MQ 重试;重试多次仍失败 → 进死信队列 + 告警 + 人工对账

为什么这样是对的? 因为”建订单”和”记下’我要通知扣库存’这件事”在同一个本地事务里,要么都成功要么都失败。不存在”订单建了但没通知”或”通知了但订单没建”。

至于”通知了但库存服务还没处理”——那是时间问题,不是正确性问题。重试 + 幂等,最终一定会一致。

事务消息(以 RocketMQ 为例)

1. 生产者发送 Half 消息(半消息)到 MQ —— 对消费者不可见
2. MQ 回复"Half 消息已收到"
3. 生产者执行本地事务(建订单)
4. 本地事务成功 → 向 MQ 发 COMMIT;失败 → 发 ROLLBACK
5. 【关键】如果第 4 步的消息丢了,MQ 会定时回查生产者:
   → 调用 checkLocalTransaction(order_id) 查数据库,决定提交还是回滚
6. COMMIT 后,Half 消息对消费者可见

第 5 步的”回查”是事务消息的灵魂——它用一个”反向查询”把”本地事务结果”和”消息状态”重新对齐,弥补了第 4 步可能丢消息的漏洞。

(Kafka 的事务是另一个东西——它保证的是”生产多条消息 + 消费位移”的原子性,用于 Exactly-Once 流处理,而不是”数据库操作 + 发消息”的原子性。这个区别经常被搞混。)

本质一句话

本地消息表和事务消息是同一个思路:用”本地事务的原子性”换取”业务操作与消息发送”的原子性,然后用”重试 + 幂等”换取跨服务的最终一致。


四条路线决策表

方案一致性隔离性业务侵入锁持有时间典型吞吐适合场景
2PC / XA强一致有(持锁)低(DB 层实现)长(跨网络持锁)低(百级 QPS)传统金融跨库、参与者少
TCC强一致Try 阶段有预留极高(三接口 + 幂等 + 防悬挂)中(业务自定义)中高支付、票务、券+库存组合
Saga最终一致无中(每个步骤写补偿)无(不持锁)高长流程、长事务
本地消息表 / 事务消息最终一致无低无很高绝大多数互联网场景
(路线 0)重新划边界强一致有无短最高永远优先考虑

回到”下单扣库存”:我到底推荐什么?

分层给答案,这才是有经验的架构师会做的事:

第 1 层:扣库存这一动作本身 —— 不要用分布式事务。

它是热点单行操作。用一条 SQL 在本地事务里原子完成:

UPDATE sku_stock SET stock = stock - 1 WHERE sku_id = ? AND stock >= 1;
-- affected_rows: 1 = 成功, 0 = 库存不足

超卖问题在数据库层就解决了,不需要任何分布式事务。要抗更高并发,就在前面加 Redis 预扣 + 异步落库 + 对账。

第 2 层:建订单 → 通知积分/物流/优惠券/推荐 —— 本地消息表,最终一致。

这些都不是下单成功的必要条件,晚几秒甚至几分钟完全可接受。

第 3 层:支付 —— 幂等 + 对账,不追求分布式事务。

支付回调天然是异步的、可能重复的。用 pay_no 做幂等键,加上 T+1 对账和差错处理。

第 4 层:真正需要 TCC 的,只有”必须跨多个资源同时预留、同时确认”的场景。

比如”同时占用 优惠券 + 库存 + 座位”,三个资源任何一个失败都要全部释放——这时候 Try/Confirm/Cancel 的语义是刚需。

最后一句大实话:

多数团队踩的坑不是”分布式事务选错了方案”,而是”本可以不是分布式事务的场景,硬做成了分布式事务”。 先问边界,再问技术。


子主题十二:业界对照——Spanner 的外部一致性与 NewSQL 的重新设计

先抛问题:单机靠 MVCC + 锁就能拿到”可串行化”。到了分布式,为什么 Google 要专门发明”外部一致性”这个词?

因为分布式下,“事务提交的顺序”和”用户感知到的顺序”会脱节。


一个让人抓狂的现实场景

09:00:00.000  你在手机上发了一条评论(事务 T1 写入,返回"发布成功")
09:00:00.100  你把链接发给朋友
09:00:00.500  朋友刷新页面(事务 T2)—— 居然看不到那条评论!

你明明先写完了、对方后读,却看不到。这违反物理因果律。

单机上不可能出现这种事(同一个时钟、同一份数据)。但分布式下太常见了:T2 的读请求被路由到了一个还没追上 T1 的副本。

这就是”外部一致性”要解决的问题。


外部一致性(External Consistency)的精确定义

如果事务 T1 提交之后,事务 T2 才开始,那么 T2 一定看得到 T1 的写入。

拆开说,它 = 可线性化(linearizability) + 事务性:

  • 每个事务看起来是在某个瞬间原子发生的
  • 事务的时间戳顺序,必须和真实世界的墙上时钟顺序一致

单机上这是白送的。分布式下这是难题,因为——分布式系统里没有”现在几点”这个全局共识。

GPS 会漂移,原子钟会漂移,NTP 误差几十毫秒是常态。而事务定序需要的恰恰是”谁先谁后”。


Spanner 的答案:TrueTime

TrueTime 是什么

  • 每个数据中心部署 GPS 接收器 + 原子钟(两套互为备份:GPS 有天线故障和 GPS 欺骗的风险;原子钟会随时间漂移。两者互相校准,成本与可靠性平衡)
  • TT.now() 不返回一个时间点,而是返回一个区间 [earliest, latest],并保证真实时间一定落在这个区间内。
        真实时间一定在这里
              ↓ ↓
TT.now() = [ earliest ───────── latest ]
                    └─── ε ───┘

ε 的典型大小:1~7 毫秒(论文里给的是锯齿状曲线——因为时钟漂移随上次校准后的时间线性增长,每次校准后回落,所以是锯齿)。

这个设计的精髓在于:它把”时间的不确定性”显式化了。

以前的系统假装 now() 是个精确值,然后在出问题时崩溃。Spanner 说:“时间就是个区间,我把它摆到台面上,然后围绕这个区间设计算法。“


Spanner 靠 TrueTime 做的两件事

规则一:提交等待(Commit Wait)—— 保证外部一致性

读写事务的流程:

1. 各参与者 Paxos 复制完成,准备提交
2. 协调者选一个提交时间戳 s = TT.now().latest
3. 【关键】必须等到 TT.now().earliest > s,才允许提交返回、数据对外可见
4. 通知参与者提交

为什么这样就够了?直觉推一遍:

  1. 已知真实时间一定在 [earliest, latest] 里
  2. 所以 TT.now().latest ≥ 真实时间 —— 也就是说 s 不早于真实时间
  3. 等到 TT.now().earliest > s 时,由于 earliest ≤ 真实时间,所以 真实时间 > s
  4. 结论:当事务被允许返回成功的那一刻,时间戳 s 在真实世界里已经过去了
  5. 于是”我提交成功了”这个事件,真实发生在 s 之后 → 任何在它之后开始的事务,其时间戳必然大于 s,必然能看到它

代价:每个写事务额外等 ε(大约几个毫秒)。

这是分布式系统史上最著名的一笔交易:用几毫秒的延迟,换取全球范围的强一致。

规则二:单调性与副本可读时间戳

  • 每个 Paxos 组的 leader 维护租约(默认 10 秒)。租约期内 leader 可以本地应答读,不用走一轮 Paxos。
  • 每个副本维护一个 safe timestamp(= 已应用的最大事务时间戳)。
  • 读请求带时间戳,副本如果发现自己的数据不够新,ts > safe_timestamp,就等(或转发给 leader)。

Spanner 的写路径:2PC + Paxos,关键是”不阻塞”

1. 客户端:读(读-写事务内),对涉及的每个 Paxos 组拿【读锁】,读最新数据
2. 客户端:选一个 Paxos 组做【协调者 leader】,发起 2PC
3. 各参与者 leader:拿【写锁】,写 prepare 记录 —— 并通过 Paxos 复制   ★关键
4. 协调者:收到全部 prepared
   → 用 TrueTime 选 commit 时间戳 s
   → Commit Wait(等到 TT.now().earliest > s)
   → 通过 Paxos 写 commit 记录
   → 通知参与者
5. 参与者:写 commit 记录,释放锁

第 3 步的 ★ 是传统 2PC 与 Spanner 2PC 的本质差别:

prepare 状态被 Paxos 复制到了多数派。所以协调者(或参与者 leader)挂了,新 leader 一上任就能从复制日志里查到”这个事务处于 prepared 状态”,继续推进。

传统 2PC 的阻塞问题,就这样被复制状态机解掉了。 协调者不再是单点,因为”协调者的决定”本身就是被复制的。

(严格说:Spanner 仍然需要 coordinator 的 commit record 持久化,但它的 coordinator 也是一个 Paxos 组,所以是容错的。)

只读事务:完全不加锁

Spanner 支持”在指定时间戳读”的只读事务:

  • 因为 MVCC 里那个时间戳的快照是冻结的,永远不会变
  • 所以完全不需要加锁,也不会和写冲突
  • 想要”读最新”,就取 TT.now().latest 当读时间戳(要保证看到所有已提交事务,需要这个时间戳已过 commit wait)

这就是 MVCC 在分布式下的自然延伸——只不过”版本”的定序从单机的事务 ID 换成了全局时间戳。


另一条路线:Percolator / TiDB(不用 TrueTime,用 TSO)

Spanner 依赖硬件(GPS + 原子钟),不是谁都造得起。那没有专用硬件怎么办?

答案:用中心化的时间戳服务(TSO - Timestamp Oracle,TiDB 里是 PD 组件)。

  • 一个全局唯一的发号器,发单调递增的时间戳
  • 优点:避开了”时钟不准”的问题,普通服务器就行
  • 缺点:所有时间戳都要走一次中心 → 它成了新的单点(靠 Raft 复制保证高可用)和潜在的吞吐瓶颈(TiDB 用批量预取缓解:一次取一批时间戳,本地发完再取)

客户端驱动的 2PC(Percolator 模型)

给定要修改的多行,选一行做【primary】,其余是 secondary

1. 从 PD 取 start_ts
2. 对 primary 加锁(写一个 lock 列,值指向 primary 自己),写入数据(data@start_ts)
3. 对所有 secondary 同样加锁 + 写数据(锁里记录"primary 是谁")
4. 从 PD 取 commit_ts
5. 【提交点】写 primary 的 commit 记录(data@commit_ts)+ 删除 primary 的锁 —— 这一步是原子的
6. 异步推进所有 secondary 提交(可以慢慢来,失败了重试即可)
7. 清理锁

这个设计最漂亮的一点:

整个分布式事务的原子性,被压缩到了”primary 那一条 commit 记录”上。

任何时候你想知道”这个事务到底提交了没有”,就去查 primary 的锁:

primary 的状态判定该做什么
锁还在,没有 commit 记录未提交(且客户端可能已崩溃)回滚:清理所有 secondary 的锁和数据
锁没了,但有 commit 记录已提交前滚:帮所有 secondary 补上提交
锁没了,也没有 commit 记录不可能(第 5 步是原子的)—

“以 primary 为单一真相源”——这是分布式事务里最重要的一条工程技巧。

对应的代价:读路径上,如果读到一行挂着未提交的锁,就必须去查 primary 判定该事务状态。 所以 Percolator 模型下读可能被写阻塞,冲突多的场景性能差。TiDB 后来引入了悲观事务模型(提前上锁,类似传统 2PL)来缓解。

隔离级别:快照隔离(SI) —— 所以 Percolator/TiDB 同样有写偏斜问题,不是 Serializable。


三种”重新设计”的对照

SpannerTiDB / PercolatorCalvin / FaunaDB
定序方式TrueTime(GPS + 原子钟),时间区间TSO 中心发号全局日志定序
提交协议2PC + Paxos 复制(非阻塞)客户端 2PC,primary 为提交点无需 2PC(预定序 + 确定性执行)
隔离级别外部一致性(最强)快照隔离 SI严格可串行化
关键代价需要专用硬件;commit wait 几 msTSO 单点压力;读可能被写阻塞事务必须提前声明、逻辑确定性
一句话用硬件和等待换最强一致用中心发号器换简单用”不允许交互式事务”换零协调

Calvin 值得单独说一句,因为它思路最激进:

所有事务先进入一个全局日志排好序,然后每个节点按完全相同的顺序执行完全相同的一批事务。 因为顺序一致、逻辑确定,结果天然一致——不需要 2PC,不需要分布式锁,不需要共识(共识只用来给日志定序)。

代价极其明确:事务不能”边读边决定写什么”,你必须提前声明整个事务逻辑(或者把事务拆成”读阶段 + 写阶段”,写阶段在定序后执行)。这对编程模型是很大的限制。

FoundationDB 走的是另一个极端:把”确定性”做到极致,连随机数、时间戳都必须通过特殊 API 获取——因为这样才能做确定性模拟测试(用单线程模拟器重现整个分布式系统的任意执行路径)。它的并发控制是乐观的:先声明读集写集,提交时做冲突检测。


顺便补上单机的正规军解法:SSI

PostgreSQL 的 SERIALIZABLE 用的是 可串行化快照隔离(SSI, Serializable Snapshot Isolation),由 Michael Cahill 的博士论文提出:

  • 不加锁(还是 MVCC 快照读)
  • 额外记录事务之间的读写依赖边(rw-antidependency):T1 读了某个版本,T2 写了个新版本,T1 没看到 → 记一条 T1 → T2 的边
  • 如果检测到”危险结构”(两条连续的 rw 边,即 T1 →rw T2 →rw T3),就回滚其中一个(通常是 T3)
  • 结果:不牺牲读的并发,拿到真正的可串行化,写偏斜被正规解决

对比一下 MySQL 的 SERIALIZABLE:它是简单粗暴地给所有普通 SELECT 加共享 Next-Key Lock,直接把读也变成加锁读,退化成串行。能用,但性能差距巨大。 这是”工程实现的代差”。


一句话总结 NewSQL 的重新设计

单机的事务靠”锁管理器 + MVCC”两样东西。分布式把它们都拆开重做了:

  • 用共识算法(Paxos/Raft)复制”事务状态”,让 2PC 不阻塞;
  • 用全局时间戳(TrueTime 或 TSO)给所有版本定序,让 MVCC 跨节点成立;
  • 用 primary / commit-point 把”原子性”压缩到一条可查询的记录上。

你会发现:这三招,本质都是在解决”分布式下没有全局真相”这个唯一的根问题。


子主题十三:实操视角——长事务的危害与开发中的常见误用

前面都是原理,这一节全是”怎么别在生产上翻车”。


一、长事务到底坏在哪?四个层面

危害 1:锁持有时间长 → 并发雪崩

事务不提交,锁就不放。

一个典型的翻车代码:

@Transactional
public void handle(Order o) {
    orderDao.lockForUpdate(o.getId());     // SELECT ... FOR UPDATE,拿到行锁
    // ... 一堆业务逻辑,3 秒
    orderDao.update(o);
}

那一行被锁 3 秒,意味着同一行上的所有写请求要排 3 秒队。

类比:你在超市收银台结账,掏出手机开始刷视频,后面 20 个人陪你一起等。 你占的不是 CPU,是那把锁。而锁是不可伸缩的资源。

危害 2:undo log 膨胀且回收不掉(最容易被忽略的杀手)

这是长事务最阴险的危害,因为它不报错、不告警,只是让整个库慢慢变慢。

因果链:

有一个事务开了 2 小时没提交
        ↓
它的 ReadView 是 2 小时前的
        ↓
这 2 小时里产生的【所有】undo 版本,都不能被 purge
        (因为 purge 只能清理"比最老 ReadView 还老"的版本)
        ↓
History list length 涨到几十万、上百万
        ↓
undo 表空间涨到几十 GB,而且【缩不回去】
(MySQL 8.0 的 undo truncate 也要求该 undo 段无活跃事务)
        ↓
版本链越来越长 → 任何一次快照读都可能需要回溯上千个版本
        ↓
同一个 SQL 从 1ms 退化到 1 秒 → 整个实例被拖垮

经典事故现场: 凌晨跑一个全表 UPDATE 的大事务,跑了 2 小时。期间整个库的普通 SELECT 全部变慢 —— 不是因为锁(普通 SELECT 不加锁),而是因为每个读都要多走版本链。

这个危害的隐蔽性在于:它伤害的是”和这个长事务毫无关系的其他查询”。

危害 3:主从延迟的锯齿状尖峰

关键事实:binlog 是在事务【提交时】才写入并发送给从库的。

所以一个跑 1 小时的大事务:

  • 从库在这 1 小时内什么都看不到(主库数据已经变了,从库还是旧的)
  • 然后在提交的一瞬间,收到一个巨大的 binlog 事件
  • 从库回放时,即使用并行复制,同一个事务内部必须串行 → 堵住

表现特征(非常好认): 主库 QPS 完全正常,从库延迟曲线呈锯齿状尖峰,尖峰的时间点和大任务的执行时间完全对齐。

业务后果: 读写分离的业务”写完之后读不到”——用户下完单刷新列表,空的。

危害 4:连接与 MDL

  • 长事务占着连接 → 连接池被打满 → 新请求拿不到连接
  • 长事务持有 MDL 读锁 → ALTER TABLE 被阻塞(状态 Waiting for table metadata lock)→ 加个字段加不上去,而且会阻塞后续所有对该表的查询(MDL 是排队公平的,写锁在等,后面的读也得排)

二、怎么避免?可执行清单

1. 监控先行(没有监控,一切都是空谈)

SELECT
    trx_id,
    trx_started,
    TIMESTAMPDIFF(SECOND, trx_started, NOW()) AS duration_sec,
    trx_rows_modified,
    trx_mysql_thread_id,          -- 用于 KILL
    LEFT(trx_query, 100) AS sql_text
FROM information_schema.innodb_trx
WHERE TIMESTAMPDIFF(SECOND, trx_started, NOW()) > 10
ORDER BY trx_started;

配上定时巡检 + 告警。超过阈值(比如 60 秒)自动 KILL <trx_mysql_thread_id> —— 这一招救过很多库。

(注意:只有 trx_query 是非 NULL 时才看得到 SQL;如果事务处于 idle 状态,trx_query 是 NULL,需要关联 information_schema.processlist 找对应的连接。)

2. 设置上限

参数建议说明
innodb_lock_wait_timeout5~10 秒默认 50 太长,互联网场景必须调小
innodb_undo_log_truncateONMySQL 8.0 自动回收 undo 空间
innodb_purge_threads4(默认);purge 跟不上可加大大写入量场景
innodb_max_undo_log_size1GB(默认)超过后触发 truncate
应用侧@Transactional(timeout = 5)客户端侧兜底,最可靠
max_execution_time对 SELECT 生效MySQL 5.7+

3. 主动拆分大事务

批量处理按批提交(比如每 1000 行一个事务)。

但拆批有三个必须配套的东西:

  • 幂等:重试时不能重复处理
  • 断点续跑:记下处理到的位点(WHERE id > last_id)
  • 可观测:记录已处理数量,能随时知道进度

否则你就把”一个大事务”变成了”一半成功一半失败的烂摊子”。

4. 检查 autocommit(“事务莫名其妙很长”的头号原因)

SELECT @@autocommit;      -- 必须是 1

某些连接池、某些 ORM、尤其是交互式客户端(如 Navicat 的查询窗口)会把 autocommit 设成 0。然后业务代码执行了一条 UPDATE 就再没提交过——这个事务会一直开着,直到连接断开。


三、开发中常见的事务误用(每一条都是血泪)

误用 1:事务里做远程调用 —— 最经典,也最致命

@Transactional
public void pay(Order order) {
    orderDao.updatePaid(order);                                  // 拿锁
    httpClient.post("https://bank.com/transfer", ...);           // 网络调用:可能 3 秒、30 秒、永不返回
    smsClient.send("支付成功");                                    // 又一次网络调用
    orderDao.updateFinished(order);                              // 锁终于要放了
}

三重罪:

  1. 行锁从毫秒级延长到秒级 → 热点行雪崩
  2. 远程调用成功但本地事务回滚 → 钱扣了,订单显示未支付 → 资损
  3. 网络调用超时但服务端其实已执行 → 状态永久不一致

反问:为什么第 2 条是必然发生的,而不是小概率? 因为在”远程调用之后”的任何一步失败(比如 updateFinished 唯一键冲突、连接被 DBA kill、应用重启),都会让本地事务回滚——但远程的钱已经扣了,你无法撤回。

正确做法:事务里只做本地 DB 操作。

// 正解:事务只包住本地操作,远程调用放到事务提交之后
public void pay(Order order) {
    transactionTemplate.execute(status -> {
        orderDao.updatePaid(order);                    // 本地 DB
        outboxDao.insert(buildTransferMessage(order));  // 同一事务记下"待转账"
        return null;
    });
    // 事务已提交,下面这些即使失败也不会污染数据
    // 方式 A:Spring 的 afterCommit 回调
    // 方式 B:发消息,异步消费者去调用银行 + 发短信(失败可重试)
}

这个模式的正式名字叫”本地记录 + 异步驱动 + 幂等”,它是 TCC / 事务消息的手写版。如果你理解了子主题十一,你会发现它就是本地消息表。

误用 2:事务粒度过大

一个定时任务把 100 万行数据在一个事务里更新。后果见前面 undo 膨胀 + 主从延迟两节。

改成 1000 行一批,配合幂等 + 断点续跑。

误用 3:在事务里做与一致性无关的事

参数校验、构造大对象、写文件日志、算报表、调用本地缓存预热——全部挪到事务外。

事务的原则只有一个词:尽可能短。 只包住真正需要原子性的那几行 SQL。

误用 4:先查后写(TOCTOU 竞态)

if (!userDao.exists(phone)) {
    userDao.insert(user);     // 并发下:两个请求都查到"不存在",都去插
}
  • 有唯一索引 → 其中一个报 DuplicateKeyException(还算好,只是体验差)
  • 没唯一索引 → 两条脏数据插进去(灾难)

正解:靠数据库唯一约束兜底。

INSERT INTO user (phone, ...) VALUES (?, ...)
ON DUPLICATE KEY UPDATE name = VALUES(name);

或捕获 DuplicateKeyException 转成友好提示。

通用原则:凡是”判断 + 执行”跨了两条语句,就有并发窗口。要么压缩成一条原子语句,要么依赖数据库约束。

误用 5:以为加了 @Transactional 就一定生效

Spring 的事务是基于 AOP 代理的,有五个著名的失效场景:

场景结果
同类内部方法自调用(this.method())没走代理 → 事务完全不生效
方法不是 publicCGLIB 能代理,JDK 动态代理不行;某些配置下失效
异常被 try-catch 吞掉没抛出不回滚(最隐蔽的坑)
抛出受检异常(Exception)默认不回滚,需要 @Transactional(rollbackFor = Exception.class)
用了 REQUIRES_NEW 却以为是同一事务内层独立提交,外层回滚——内层改的数据回不去

第 3 条最坑:

@Transactional
public void pay(Order o) {
    try {
        orderDao.update(o);
        int i = 1/0;          // 抛异常
    } catch (Exception e) {
        log.error("出错了", e);   // 吞掉了!事务不回滚,数据已经改了
    }
}

误用 6:读写分离下”写后读不到”

主库写完立刻读从库 → 主从延迟还没追上 → 读不到刚写的数据。

正解:

  • 写后读强制走主库(很多中间件支持 hint 或”最近 N 秒内写过的请求走主库”)
  • 或用 GTID 等待:SELECT WAIT_FOR_EXECUTED_GTID_SET('...', 1) 保证从库已追上再读

误用 7:用错隔离级别,还不知道自己用的是哪个

上线前必跑这一句:

SELECT @@global.transaction_isolation, @@session.transaction_isolation;

(MySQL 8.0 的变量名是 transaction_isolation;5.7 及以前叫 tx_isolation。)

为什么重要? 因为很多团队以为自己在用 RR,实际被 DBA 改成了 RC(这很常见,理由见子主题九)。两种级别下的锁行为完全不同——你在 RR 下写的代码(依赖间隙锁防幻读、依赖 FOR UPDATE 的锁范围),在 RC 下可能完全失效。而这类问题只有在并发足够高时才暴露。


收尾反问:为什么我反复强调”事务要短”?

因为事务的成本不是 CPU,是”排他性占用的时间”。

你多占一秒,全世界所有想碰这行数据的人就多等一秒。

事务的本质是”我暂时不让别人碰”。这个特权用得越久,系统的并发能力就越差。

而所有事务问题的解法,最终都归结为两句话:

  1. 能不占用,就不占用(原子语句、乐观锁、缩小粒度、重新划边界)
  2. 必须占用时,占用时间最短、顺序统一、且可安全重试

演进脉络

  • 1970s:Jim Gray 等人奠定事务理论,ACID 概念确立;两阶段锁(2PL)提出。
  • 1980s-90s:隔离级别被 SQL 标准定义;MVCC 思想成熟(Postgres 从一开始就用 MVCC)。
  • 2000s:InnoDB 引入 MVCC,MySQL 成为互联网标配;分布式事务(XA、2PC)在企业级应用。
  • 2010s:互联网规模下,2PC 的代价难以承受,TCC/Saga/事务消息成为主流;NewSQL(Spanner、TiDB)尝试用共识算法 + 两阶段提交重新实现强一致的分布式事务。
  • 现代:Spanner 用 TrueTime(GPS + 原子钟)实现全球范围的外部一致性;各类数据库在”强一致”和”性能”之间提供更细粒度的可调选项。

主线:事务的演进,是”隔离性”这条线上不断寻找更好平衡点的过程——从”加锁串行”(正确但慢),到 MVCC(读写不互斥),到分布式场景下的”用最终一致 + 幂等换取可用性”。每一步都在回答同一个问题:为了并发性能,我们愿意容忍哪些不一致?


本章思考题

【思考题】

  1. ACID 分别保证了什么?为什么说”一致性主要是应用层的责任”?

  2. 脏读、不可重复读、幻读、更新丢失,这四种异常的区别是什么?哪一种最容易被忽视却最常造成损失?

  3. MVCC 是怎么做到”读不阻塞写、写不阻塞读”的?它的代价是什么?

  4. 分布式事务的几种方案(2PC、TCC、Saga、事务消息)各有什么优劣?实践上应该怎么选?

  5. 为什么 UPDATE t SET stock = stock - 1 WHERE id = 1 在 Read Committed 下也不会丢失更新,而”先 SELECT 读到应用里算出 9,再 UPDATE ... SET stock = 9”却会丢失更新?请用时序表说明。

  6. MySQL InnoDB 的默认隔离级别是可重复读。事务 A 里 SELECT balance 读到 1000,此时事务 B 把余额改成 900 并 COMMIT,事务 A 再读仍是 1000,但 A 执行 UPDATE ... SET balance = balance - 100 之后却变成了 800。请解释为什么会”看到 1000 却改出 800”,并给出正确的写法。

  7. 有人说”MySQL 的 Repeatable Read 已经防住了幻读,因为我插入一行后事务里再查还是 3 行”。这个说法错在哪?请设计一个能让幻读暴露出来的操作序列。

  8. 请完整描述 InnoDB MVCC 的三个组成部分:行上的隐藏列、undo log 版本链、ReadView 的可见性判断规则。并说明 RR 和 RC 在 MVCC 实现上的唯一区别是什么,purge 线程什么时候才能清理一个旧版本。

  9. 什么是行锁、间隙锁、Next-Key Lock 和插入意图锁?为什么唯一索引的等值查询命中记录时 Next-Key Lock 会退化成行锁?意向锁(IS/IX)解决什么问题,IX 与 IX 是互斥还是兼容,为什么?

  10. InnoDB 是怎么检测死锁的?超时和等待图检测各有什么问题?在 SQL 和应用层可以通过哪些手段避免死锁?请举例说明一种只在 Repeatable Read 下才会出现、在 Read Committed 下不会出现的死锁。

  11. 场景:用户下单需要①创建订单(订单库)②扣减库存(库存库)③扣款(账户库),三个服务三个库。请分别用 2PC、TCC、Saga、本地消息表四种方案走一遍这个流程,说明各自的关键步骤、会踩的坑(如 TCC 的空回滚与悬挂)以及你最终的选型建议。

  12. 为什么 InnoDB 的 Repeatable Read 不等于 Serializable?请解释”写偏斜(Write Skew)“并用具体例子说明为什么加锁也挡不住它。PostgreSQL 用什么机制在 MVCC 之上实现真正的可串行化?

  13. Spanner 的”外部一致性”指的是什么?为什么分布式下需要专门定义这个概念?TrueTime 的 TT.now() 为什么返回一个区间而不是一个时间点,“提交等待(Commit Wait)“如何用这个区间保证外部一致性?Spanner 的 2PC 为什么不像传统 2PC 那样阻塞?Percolator/TiDB 没有 TrueTime 是怎么给事务定序的,它把”分布式事务的原子性”压缩到了什么东西上?

  14. 长事务会造成哪些危害?请分别说明”undo log 膨胀导致全库变慢”和”主从延迟呈锯齿状尖峰”这两条因果链。另外列举至少五种开发中常见的事务误用及其正确做法。

【参考答案】

  1. A 原子性:事务内操作要么全做要么全不做(靠 undo log)。C 一致性:事务前后数据库都处于合法状态。I 隔离性:并发事务互不干扰。D 持久性:提交后崩溃也不丢(靠 WAL/redo log)。一致性主要是应用层责任,因为”什么算合法状态”是业务规则(如”余额不能为负”、“总额守恒”),数据库只提供保证它的手段(原子性、隔离性、约束),真正的业务约束要开发者自己定义和实现。

  2. 脏读:读到别人未提交的数据(可能回滚,数据从未存在)。不可重复读:同一事务内两次读同一行值不同(该行被别人改了)。幻读:同一事务内两次查询返回行数不同(别人插入/删除了)。更新丢失:两个事务的更新互相覆盖(如都基于 1000 加钱,结果只保留后写的那个)。最容易被忽视的是更新丢失——它在 Read Committed(很多数据库的默认级别)下就会发生,且不像脏读那样”看起来危险”,却常在生产造成资损,需要 SELECT FOR UPDATE 或乐观锁来防。

  3. MVCC 为每行保留多个历史版本(记录创建/删除的事务 ID),事务读时只看”事务 ID 小于自己且已提交”的版本快照,写时创建新版本而非覆盖。于是读操作读快照不加锁、写操作创建新版本不阻塞读——读写互不阻塞。代价:存储开销(多版本)、清理开销(purge 旧版本)、长事务导致版本链变长使性能下降(所以要避免长事务)。

  4. 2PC:两阶段,强一致但协调者单点、阻塞、性能差。TCC:Try-Confirm-Cancel 应用层补偿,锁粒度小但业务侵入极大(每个操作写三个接口)。Saga:拆成本地事务+补偿,适合长事务但无隔离性(中间状态可见)、补偿复杂。事务消息/本地消息表:业务操作与发消息同事务、消费方幂等,最终一致,简单可靠高性能。实践建议:优先用”本地事务 + 消息 + 幂等 + 对账”实现最终一致,只有确实必须强一致时才考虑分布式事务,因为后者的性能、复杂度和可用性代价往往超出预期。

  5. 因为 UPDATE 是当前读:它读最新已提交版本并对该行加排他锁,两个事务会被串行化,各减 1 得 8,不会丢。而”读-改-写”把判断依据和写入对象拆成了两步 SQL:T1、T2 都在 t1 读到 stock=10(RC 下每条语句各自建 ReadView,都看到最新值 10),各自在应用内存算出 9,t3 和 t4 分别写回 9——两次写的是同一个值,锁不冲突(锁只保护了”写”那一瞬间),于是卖了 2 件只扣 1 件,超卖。时序:t1 T1 读 10 / T2 读 10;t2 各算 10-1=9;t3 T1 写 9;t4 T2 写 9(覆盖)。注意这个丢失更新在 RR 下同样会发生(RR 也不保证读-改-写的原子性)。三板斧修法:①让数据库原子算 UPDATE ... SET stock = stock - 1 WHERE stock >= 1 并检查 affected_rows;②SELECT ... FOR UPDATE 把读升级为当前读并持锁到事务结束;③乐观锁 UPDATE ... SET stock = 9 WHERE stock = 10,affected_rows=0 就重试。核心原则:读-改-写循环在并发下必然有覆盖窗口,要么消除它(原子语句),要么锁住它,要么检测它。

  6. 原因:A 里的两次普通 SELECT 是快照读,走 MVCC,读的是事务开始时生成的 ReadView,所以一直是 1000;但 UPDATE 是当前读,它读的是最新已提交版本 900,在 900 基础上减 100 得 800。数据库必须这么做——如果允许基于旧版本更新,就会产生第一类丢失更新(旧值覆盖别人的已提交修改)。所以铁律是:UPDATE 的语义是”在最新已提交版本上做修改”,不是”在我看到的版本上做修改”。修改后该行的 DB_TRX_ID 变成 A 自己的事务 ID,按 ReadView 规则 1(自己的修改对自己可见),A 随后再查就看到 800 了。正确写法:①最好——UPDATE accounts SET balance = balance - 100 WHERE id='A' AND balance >= 100,一条语句原子完成,锁持有时间最短;②如果必须先读后改,那个”读”要用 SELECT ... FOR UPDATE,把读也升级成当前读并加 X 锁,让判断依据和写入对象是同一个世界。

  7. 错在把”快照读看不到”当成了”幻读不存在”。RR 下普通 SELECT 走 MVCC,全程用同一个 ReadView,别人的新提交对你不可见——那不是防住了幻读,那是”你没去看”。幻读会从”写”这条路暴露出来:表 t 只有 id=10、20;T1 BEGIN 后 SELECT * FROM t WHERE id > 100 返回空(快照读);T2 插入 id=200 并 COMMIT;T1 再 SELECT 仍是空;但 T1 执行 UPDATE t SET c = 1 WHERE id > 100 时,UPDATE 是当前读,会看见 T2 提交的 id=200 并修改它(1 row affected),修改后该行 DB_TRX_ID 变成 T1 自己的 ID,于是 T1 再 SELECT 就看到这一行了——幽灵出现在事务里。另外,即使全程用加锁读,RR 也挡不住写偏斜(如两个医生各自读到”还有 2 人在岗”就都请假,结果没人值班),因为两者改的是不同行,行锁互不冲突。结论:InnoDB 的 RR 实现的是快照隔离(SI)而非 Serializable;对”当前读”路径靠 Next-Key Lock 防住了幻读,对”快照读”路径压根没打算防。

  8. 三部分:①隐藏列——每行有 DB_TRX_ID(最后修改该行的事务 ID)、DB_ROLL_PTR(回滚指针,指向 undo 里上一版本)、DB_ROW_ID(无主键时的隐含聚簇索引)。②undo 版本链——insert undo 只在回滚时用,提交即可删;update undo 是 MVCC 命根子,存的是”把新版本变回旧版本”的增量逆操作,用 DB_ROLL_PTR 串成从新到旧的链,新版本在链头(数据页里的物理记录),老版本在 undo 里。读旧版本 = 从当前记录沿链回溯,边走边用 ReadView 判断可见性。③ReadView 四条规则(对版本 DB_TRX_ID = X):(1) X == creator_trx_id → 自己改的,可见;(2) X < min_trx_id(低水位)→ 在我建 ReadView 前已提交,可见;(3) X >= max_trx_id(高水位)→ 在我之后才开启,不可见;(4) 介于两者之间 → X 在活跃事务列表 m_ids 里则未提交不可见,否则已提交可见。RR 与 RC 的唯一区别是 ReadView 的生成时机:RR 在事务第一次快照读时生成一次、全程复用;RC 每条语句执行前重新生成。就这一个调用时机的差别决定了两种隔离级别的全部语义。purge 时机:当某个版本的 DB_TRX_ID 小于系统中最老的活跃 ReadView 的低水位时,说明没有任何事务还能看见它,purge 线程才能清理;DELETE 也因此分两阶段——先打删除标记,等所有需要它的 ReadView 消失后才真正从 B+ 树摘除。监控指标是 SHOW ENGINE INNODB STATUS 里的 History list length。

  9. 行锁 Record Lock 锁索引上的一条记录(S 共享 / X 排他),注意 InnoDB 行锁加在索引上,不走索引就退化成锁全表。间隙锁 Gap Lock 锁两条索引记录之间的开区间,禁止在该空隙插入,它锁的是”不存在的东西”,这正是防幻读的关键(幻读的麻烦是”要锁的是集合,但新行凭空冒出来无处加锁”)。插入意图锁 是 INSERT 前在目标间隙申请的锁,与 Gap Lock 互斥,是死锁高发区。Next-Key Lock = 行锁 + 间隙锁,左开右闭 (prev, current],是 InnoDB 在 RR 下的默认加锁单位。唯一索引等值查询命中记录时退化为行锁,因为唯一性已保证不可能再插入第二个相同键值,不会产生幻影,间隙锁多余;未命中则退化为该处的纯间隙锁。意向锁是表级锁(IS/IX),作用是让”加表锁”不必扫描全表去查有没有行锁——任何事务加行锁前先在表上打一个意向标记,表锁判断就变成 O(1)。IX 与 IX 兼容:因为两个事务锁的是不同行,意向锁只表达”本表某行有锁”,只用来拦整表操作,不降低行级并发;IX 只与表级 S/X 冲突。

  10. 超时:innodb_lock_wait_timeout 默认 50 秒,超时报 ERROR 1205。问题是分不清”死锁”和”慢”,阈值难定,只能兜底。等待图检测(InnoDB 主力):节点是事务,边 A→B 表示 A 在等 B 的锁;每次加锁失败进入等待时就新增边并做一次 DFS 找环;发现环就选权重(已改行数 + 已持锁数)更小的事务作受害者整体回滚,报 ERROR 1213,应用层必须重试。代价是每次等待都要遍历图,热点行上成千事务排队时 O(n²) 检测会打满 CPU——解法包括改 RC、用 Redis/队列把并发变串行、或关掉检测(innodb_deadlock_detect=OFF)并调小超时。排查看 SHOW ENGINE INNODB STATUS 的 LATEST DETECTED DEADLOCK 段。避免手段:①对多行加锁固定顺序,IN 不保证顺序,必须显式 ORDER BY 或在应用层排序;②用 INSERT ... ON DUPLICATE KEY UPDATE 等原子语句消灭”检查-执行”竞态(TOCTOU);③缩小锁范围,走索引、让数据库自己算,避免全表扫描导致锁整表。只在 RR 下出现的死锁:表有 id=10、20;T1 DELETE WHERE id=15 拿到 (10,20) 间隙锁,T2 DELETE WHERE id=16 也拿到同一个间隙锁(Gap Lock 之间兼容),然后 T1 INSERT 15 和 T2 INSERT 16 各自需要插入意图锁,被对方的 Gap Lock 挡住 → 死锁。RC 没有间隙锁,所以这个死锁不存在。

  11. 2PC/XA:协调者发 PREPARE,各参与者写 redo+undo 并持锁回复 YES,协调者写 commit 日志后发 COMMIT。坑:锁从 prepare 持有到 commit(跨网络 ×2 + 多次落盘),热点 SKU 上 QPS 崩;协调者挂掉则参与者进入 in-doubt 只能干等(阻塞);部分提交窗口数据不一致;3PC 的超时自动提交在网络分区下会把该回滚的提交,所以没人用。TCC:Try 冻结库存(stock=10,frozen=1)+ 订单置 INIT,Confirm 真扣(stock=9,frozen=0)置 CONFIRMED,Cancel 释放预留置 CANCELLED。坑:幂等(重试要按全局事务 ID 去重)、空回滚(Try 超时丢失但 Cancel 已到,必须”假装成功”并记 rollback_flag)、悬挂(空回滚后迟到的 Try 必须丢弃,否则产生僵尸冻结库存)、Confirm/Cancel 必须最终成功(重试 + 告警 + 人工 + 对账)。Saga:正向 建订单→扣库存→扣款,反向 退款→回滚库存→取消订单。坑:没有隔离性,中间态对外可见,会丢失更新(运营改库存被回滚覆盖)、脏读、模糊读;对策是语义锁(locked_by=事务ID)、交换更新(用 stock = stock - 1 而非 SET stock = 9,加法可交换)、版本化。本地消息表:同一本地事务 insert 订单 + insert outbox 消息,独立线程扫描投递 MQ,消费者幂等(order_id 去重),失败重试 + 死信 + 对账;RocketMQ 事务消息是同一思路的变体,靠 Half 消息 + 回查 checkLocalTransaction 对齐状态。选型建议:先问”能不能不做分布式事务”——把库存下沉到订单库、按同一分片键路由,让下单变单库本地事务,这是最高级解法。然后分层:①扣库存本身用 UPDATE sku_stock SET stock = stock - 1 WHERE sku_id=? AND stock >= 1 一条 SQL 原子完成,靠 affected_rows 判成败,不需要任何分布式事务;②建订单→通知积分/物流/券走本地消息表最终一致;③支付回调靠幂等 + 对账;④只有”必须跨多资源同时预留同时确认”(券+库存+座位)才上 TCC。

  12. InnoDB 的 RR 实现的是快照隔离 SI,不是可串行化。写偏斜:表 oncall(duty_date, doctor_id) 约束”每天至少 1 人在岗”,某天有 A、B 两人。T1(A 请假)SELECT COUNT(*) → 2,判断”我走还剩 1 人”就 DELETE 自己并提交;T2(B 请假)也读到 2(快照里 A 还在),同样判断可以请假并 DELETE 自己;两个事务改的是不同的行,行锁互不冲突,都成功提交 → 那天没人值班,业务不变式被打破。即使把读改成 FOR UPDATE 也挡不住,因为两个事务的写集不相交,冲突发生在”读集与写集交叉”上,行锁只保护写集。这就是 SI 的经典漏洞。PostgreSQL 用 SSI(可串行化快照隔离) 解决:仍用 MVCC 不加锁,但额外记录事务间的读写依赖边(rw-antidependency:T1 读了某版本、T2 写了新版本而 T1 没看到),一旦检测到”两条连续的 rw 边”这种危险结构就回滚其中一个事务,从而在不牺牲读并发的前提下获得真正的可串行化。对比 MySQL 的 SERIALIZABLE 是给所有普通 SELECT 加共享 Next-Key Lock,直接退化成串行——能用但性能差距巨大。(MySQL 的 RR 对”当前读”路径靠 Next-Key Lock 防住了幻读,这比标准 RR 强;但快照读路径不防,且有写偏斜,所以整体仍是 SI。)

  13. 外部一致性:若事务 T1 提交之后 T2 才开始,则 T2 一定看得到 T1 的写入——即提交时间戳顺序必须与真实世界的墙上时钟顺序一致。分布式下需要专门定义它,因为副本可能滞后:你提交成功后告诉朋友,朋友读到的却是从库还没追上的旧数据,违反物理因果律;单机不存在这个问题。根因是分布式没有全局”现在几点”。TrueTime 用 GPS + 原子钟互为备份,TT.now() 返回区间 [earliest, latest] 并保证真实时间落在其中(ε 典型 1~7ms 锯齿状),把时间不确定性显式化。提交等待:读写事务取提交时间戳 s = TT.now().latest,然后必须等到 TT.now().earliest > s 才允许返回并让数据对外可见;因为真实时间 ≥ earliest > s,说明 s 这一时刻在真实世界已经过去,于是”提交成功”这个事件真实发生在 s 之后,任何更晚开始的事务时间戳必然更大、必然能看到它。代价是每事务多等几毫秒。Spanner 的 2PC 不阻塞的原因:prepare 记录通过 Paxos 复制到多数派,协调者/参与者 leader 挂掉后新 leader 从复制日志就能查到事务处于 prepared 状态并继续推进,协调者不再是单点——2PC 的阻塞被复制状态机解掉了。**Percolator/TiDB 没有 TrueTime,改用中心化 TSO(TiDB 的 PD)**发单调递增时间戳,靠批量预取缓解吞吐瓶颈。它把分布式事务的原子性压缩到 primary 行那一条 commit 记录上:客户端从多行中选一行做 primary,先对所有行加锁写数据,然后”写 primary 的 commit 记录并释放其锁”这一步是原子的,即提交点;任何时刻怀疑事务状态就去查 primary——锁还在无 commit 记录则未提交(回滚清理),锁没了有 commit 记录则已提交(前滚补齐 secondary),锁没了无 commit 记录不可能出现。代价是读路径遇到未提交的锁要去查 primary,读可能被写阻塞;隔离级别仍是快照隔离,同样有写偏斜。

  14. 危害:①锁持有时间长 → 热点行排队雪崩;②undo log 膨胀——一个开了 2 小时的事务,它的 ReadView 是 2 小时前的,导致这 2 小时产生的所有 undo 版本都无法 purge,History list length 涨到几十万、undo 表空间涨到几十 GB 且缩不回去(truncate 也要求无活跃事务占用),版本链变长后任何快照读都要回溯上千个版本,同一个 SQL 从 1ms 退化到 1s,连和该长事务无关的其他查询一起变慢;③主从延迟——binlog 在提交时才写并发送给从库,1 小时的大事务让从库 1 小时内看不到任何变化,然后瞬间收到巨大 binlog 事件且事务内必须串行回放,延迟曲线呈与大任务对齐的锯齿状尖峰,业务表现为”写后读不到”;④长事务占连接打满池、持 MDL 读锁堵死 DDL(Waiting for table metadata lock)并连带阻塞后续所有查询。避免:用 information_schema.innodb_trx 定时巡检并按 trx_mysql_thread_id KILL;innodb_lock_wait_timeout 调到 5~10 秒、开启 innodb_undo_log_truncate、应用侧 @Transactional(timeout=5);大批量处理拆批(每批配幂等 + 断点续跑 + 进度可观测);检查 @@autocommit 是否为 1(这是”事务莫名很长”的头号原因,尤其交互式客户端和某些 ORM)。五种以上常见误用:①事务里做远程调用——锁从毫秒延到秒级、远程成功但本地回滚造成资损、调用超时但服务端已执行;正解是事务只做本地 DB,远程调用放提交后(afterCommit 或发消息异步驱动,即”本地记录 + 异步驱动 + 幂等”)。②事务粒度过大——改成分批。③在事务里做参数校验、构造大对象、写日志等无关事——全部挪出事务,原则是”尽可能短”。④先查后写(TOCTOU)——并发下都查到不存在都去插;正解是唯一约束兜底 + INSERT ... ON DUPLICATE KEY UPDATE 或捕获 DuplicateKeyException。⑤以为 @Transactional 包住就生效——同类自调用不走代理、非 public、异常被 try-catch 吞掉不回滚、受检异常需 rollbackFor=Exception.class、REQUIRES_NEW 内层已提交外层回滚回不去。⑥读写分离下写后读不到——写后读强制走主库或用 WAIT_FOR_EXECUTED_GTID_SET。⑦不知道自己实际用的隔离级别——上线前必查 @@global.transaction_isolation,RR 与 RC 的锁行为完全不同,代码假设可能全错。