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(提现审批) | 数据库真实值 |
|---|---|---|---|
| t1 | BEGIN; UPDATE accounts SET balance=900 WHERE id='A'; | 900(未提交) | |
| t2 | BEGIN; SELECT balance ... WHERE id='A'; → 900 | 900(未提交) | |
| t3 | 判断”余额 ≥ 500,允许提现 800”,写入提现单 | 900(未提交) | |
| t4 | ROLLBACK; | 1000(回滚了) | |
| t5 | COMMIT; | 1000 |
结果:T2 基于一个从未存在过的 900,批准了一笔 800 的提现。
反问一句:T2 已经提交了,谁去撤回那张提现单?
答案是没人。数据库不会帮你撤回——在数据库看来 T2 是个干净的事务,它读的时候那个值”确实是 900”。脏读的恐怖之处就在这里:错误在提交之后才暴露,而且没有任何机制能追溯。
危害等级:最高。因为它污染的是”决策”,而决策会变成不可逆的外部动作(打款、发货、发券)。
异常二:不可重复读(Non-repeatable Read)
定义:同一事务内,两次读同一行,值不一样(因为别人改了并提交)。
| 时刻 | 事务 T1(出对账报表) | 事务 T2(转账) | id=‘A’ 的 balance |
|---|---|---|---|
| t1 | BEGIN; SELECT balance → 1000 | 1000 | |
| t2 | BEGIN; UPDATE ... SET balance=900; COMMIT; | 900 | |
| t3 | SELECT balance → 900 | 900 | |
| 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(新订单) | 满足条件的行 |
|---|---|---|---|
| t1 | BEGIN; SELECT id FROM orders WHERE status='PAID' AND amount>1000; → 3 行 | {101, 102, 103} | |
| t2 | INSERT INTO orders ... amount=2000, status='PAID'; COMMIT; | {101,102,103,104} | |
| t3 | SELECT 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 真实值 |
|---|---|---|---|
| t1 | SELECT stock → 10 | SELECT stock → 10 | 10 |
| t2 | 应用计算 10-1=9 | 应用计算 10-1=9 | 10 |
| t3 | UPDATE stock = 9 | 9 | |
| t4 | UPDATE stock = 9 | 9(覆盖了 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 次——你在监控上看到的是”订单量暴涨”,等你发现超卖,货已经发不出去了。
四种异常横向对照(背这张表)
| 异常 | 一句话 | 谁干的 | 破坏的东西 | 在哪级会发生 | 怎么修 |
|---|---|---|---|---|---|
| 脏读 | 读到未提交的数据 | 别人未提交的写 | 决策依据(可能不存在) | RU | RC 及以上 / MVCC |
| 不可重复读 | 同一行两次读值不同 | 别人已提交的 UPDATE | 事务内视图稳定性 | RU, RC | RR(事务级快照) |
| 幻读 | 两次查询行数不同 | 别人已提交的 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 | 数据库真实值 |
|---|---|---|---|
| t1 | BEGIN; | 1000 | |
| t2 | SELECT balance WHERE id='A'; → 1000(生成 ReadView) | 1000 | |
| t3 | BEGIN; UPDATE accounts SET balance=900 WHERE id='A'; COMMIT; | 900 | |
| t4 | SELECT balance WHERE id='A'; → 1000 | 900 | |
| t5 | UPDATE accounts SET balance = balance - 100 WHERE id='A'; | 800 | |
| t6 | SELECT balance WHERE id='A'; → 800 | 800 |
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 这一行了!!!幽灵出现在你的事务里了。
发生了什么?
- 第 3 步的快照读没看到 id=200,因为它走 MVCC,被 ReadView 挡住了。
- 第 4 步的
UPDATE是当前读,它扫描的是真实的最新数据,于是看见了 T2 提交的 id=200,并且修改了它。 - 修改之后,这一行的
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_ID | 6 字节 | 最后修改这一行的事务 ID |
DB_ROLL_PTR | 7 字节 | 回滚指针,指向 undo log 里这个行的上一个版本 |
DB_ROW_ID | 6 字节 | 单调递增的行 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 | 不可见,往下走 |
| 2 | undo U3 (balance=900) | 25 | 规则 4:25 ≤ 25 < 35,且 25 ∈ m_ids | 不可见,往下走 |
| 3 | undo 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 线程。
核心判定逻辑:
- 系统维护一个”最老的活跃 ReadView”(在 MySQL 8.0 里由
purge_sys跟踪,老版本叫trx->no) - 对某个 undo 版本,如果它的
DB_TRX_ID小于最老活跃 ReadView 的低水位,说明没有任何事务(无论多老)还能看得见它 → 可以安全 purge - 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。只有一个人能持有。
兼容性:
| 已持有 \ 请求 | S | X |
|---|---|---|
| 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(表) | IX | S(表) | IS |
|---|---|---|---|---|
| X(表) | ✗ 冲突 | ✗ 冲突 | ✗ 冲突 | ✗ 冲突 |
| IX | ✗ 冲突 | ✓ 兼容 | ✗ 冲突 | ✓ 兼容 |
| S(表) | ✗ 冲突 | ✗ 冲突 | ✓ 兼容 | ✓ 兼容 |
| IS | ✗ 冲突 | ✓ 兼容 | ✓ 兼容 | ✓ 兼容 |
读出三个关键结论:
- IX 和 IX 互相兼容。 这是精髓——两个事务都在表里改行,但它们改的是不同的行,凭什么互斥?意向锁只表达”我在这张表的某(些)行上有锁”,不指定是哪行,所以它只用来拦”整表操作”,不降低行级并发。
- IX 只和表级 S / X 冲突。 也就是说,意向锁的唯一作用是:阻止别人做整表操作(
LOCK TABLES、DROP TABLE、某些 DDL)。 - 意向锁是 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 UPDATE | name 无索引 | 全表所有行 + 所有间隙都上锁 ≈ 锁表 |
现在回答那个反问: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
问题在哪?
- 它分不清”死锁”和”慢”。 一个真死锁,数据库要等 50 秒才响应;一个刚好等了 51 秒的正常大事务,却被误杀。
- 阈值怎么定? 太短误杀,太长用户等死。
所以超时只能当兜底,真正的主力是下一招。
招式一:等待图(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 高并发坑。
解法:
- 改用 RC → 锁范围小、等待少
- 把并发改成串行:用 Redis 预扣/队列削峰,让同一时刻只有少量请求打到数据库行上
- 关掉检测,只靠超时:
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 Locklock_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 = 库存不足。绝不会超卖“让数据库自己算”这三个好处是叠加的:
- 不需要
SELECT FOR UPDATE,锁持有时间从”两次 SQL + 应用逻辑”缩短到”一条 SQL” - 不存在读-改-写窗口,天然无丢失更新
- 条件
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=1 | INIT |
| Confirm | 确认,真正扣减 | stock=9, frozen=0 | CONFIRMED |
| Cancel | 取消,释放预留 | stock=10, frozen=0 | CANCELLED |
注意 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;然后:
- 独立的投递线程/任务扫描
outbox WHERE status='NEW',投递到 MQ - 投递成功后
UPDATE outbox SET status='SENT' - 库存服务消费消息,扣库存,必须幂等(用
order_id做唯一键或去重表) - 消费失败 → 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. 通知参与者提交
为什么这样就够了?直觉推一遍:
- 已知真实时间一定在
[earliest, latest]里 - 所以
TT.now().latest≥ 真实时间 —— 也就是说s不早于真实时间 - 等到
TT.now().earliest > s时,由于earliest≤ 真实时间,所以 真实时间 > s - 结论:当事务被允许返回成功的那一刻,时间戳 s 在真实世界里已经过去了
- 于是”我提交成功了”这个事件,真实发生在 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。
三种”重新设计”的对照
| Spanner | TiDB / Percolator | Calvin / FaunaDB | |
|---|---|---|---|
| 定序方式 | TrueTime(GPS + 原子钟),时间区间 | TSO 中心发号 | 全局日志定序 |
| 提交协议 | 2PC + Paxos 复制(非阻塞) | 客户端 2PC,primary 为提交点 | 无需 2PC(预定序 + 确定性执行) |
| 隔离级别 | 外部一致性(最强) | 快照隔离 SI | 严格可串行化 |
| 关键代价 | 需要专用硬件;commit wait 几 ms | TSO 单点压力;读可能被写阻塞 | 事务必须提前声明、逻辑确定性 |
| 一句话 | 用硬件和等待换最强一致 | 用中心发号器换简单 | 用”不允许交互式事务”换零协调 |
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_timeout | 5~10 秒 | 默认 50 太长,互联网场景必须调小 |
innodb_undo_log_truncate | ON | MySQL 8.0 自动回收 undo 空间 |
innodb_purge_threads | 4(默认);purge 跟不上可加大 | 大写入量场景 |
innodb_max_undo_log_size | 1GB(默认) | 超过后触发 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); // 锁终于要放了
}三重罪:
- 行锁从毫秒级延长到秒级 → 热点行雪崩
- 远程调用成功但本地事务回滚 → 钱扣了,订单显示未支付 → 资损
- 网络调用超时但服务端其实已执行 → 状态永久不一致
反问:为什么第 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()) | 没走代理 → 事务完全不生效 |
方法不是 public | CGLIB 能代理,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,是”排他性占用的时间”。
你多占一秒,全世界所有想碰这行数据的人就多等一秒。
事务的本质是”我暂时不让别人碰”。这个特权用得越久,系统的并发能力就越差。
而所有事务问题的解法,最终都归结为两句话:
- 能不占用,就不占用(原子语句、乐观锁、缩小粒度、重新划边界)
- 必须占用时,占用时间最短、顺序统一、且可安全重试
演进脉络
- 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(读写不互斥),到分布式场景下的”用最终一致 + 幂等换取可用性”。每一步都在回答同一个问题:为了并发性能,我们愿意容忍哪些不一致?
本章思考题
【思考题】
-
ACID 分别保证了什么?为什么说”一致性主要是应用层的责任”?
-
脏读、不可重复读、幻读、更新丢失,这四种异常的区别是什么?哪一种最容易被忽视却最常造成损失?
-
MVCC 是怎么做到”读不阻塞写、写不阻塞读”的?它的代价是什么?
-
分布式事务的几种方案(2PC、TCC、Saga、事务消息)各有什么优劣?实践上应该怎么选?
-
为什么
UPDATE t SET stock = stock - 1 WHERE id = 1在 Read Committed 下也不会丢失更新,而”先 SELECT 读到应用里算出 9,再UPDATE ... SET stock = 9”却会丢失更新?请用时序表说明。 -
MySQL InnoDB 的默认隔离级别是可重复读。事务 A 里
SELECT balance读到 1000,此时事务 B 把余额改成 900 并 COMMIT,事务 A 再读仍是 1000,但 A 执行UPDATE ... SET balance = balance - 100之后却变成了 800。请解释为什么会”看到 1000 却改出 800”,并给出正确的写法。 -
有人说”MySQL 的 Repeatable Read 已经防住了幻读,因为我插入一行后事务里再查还是 3 行”。这个说法错在哪?请设计一个能让幻读暴露出来的操作序列。
-
请完整描述 InnoDB MVCC 的三个组成部分:行上的隐藏列、undo log 版本链、ReadView 的可见性判断规则。并说明 RR 和 RC 在 MVCC 实现上的唯一区别是什么,purge 线程什么时候才能清理一个旧版本。
-
什么是行锁、间隙锁、Next-Key Lock 和插入意图锁?为什么唯一索引的等值查询命中记录时 Next-Key Lock 会退化成行锁?意向锁(IS/IX)解决什么问题,IX 与 IX 是互斥还是兼容,为什么?
-
InnoDB 是怎么检测死锁的?超时和等待图检测各有什么问题?在 SQL 和应用层可以通过哪些手段避免死锁?请举例说明一种只在 Repeatable Read 下才会出现、在 Read Committed 下不会出现的死锁。
-
场景:用户下单需要①创建订单(订单库)②扣减库存(库存库)③扣款(账户库),三个服务三个库。请分别用 2PC、TCC、Saga、本地消息表四种方案走一遍这个流程,说明各自的关键步骤、会踩的坑(如 TCC 的空回滚与悬挂)以及你最终的选型建议。
-
为什么 InnoDB 的 Repeatable Read 不等于 Serializable?请解释”写偏斜(Write Skew)“并用具体例子说明为什么加锁也挡不住它。PostgreSQL 用什么机制在 MVCC 之上实现真正的可串行化?
-
Spanner 的”外部一致性”指的是什么?为什么分布式下需要专门定义这个概念?TrueTime 的
TT.now()为什么返回一个区间而不是一个时间点,“提交等待(Commit Wait)“如何用这个区间保证外部一致性?Spanner 的 2PC 为什么不像传统 2PC 那样阻塞?Percolator/TiDB 没有 TrueTime 是怎么给事务定序的,它把”分布式事务的原子性”压缩到了什么东西上? -
长事务会造成哪些危害?请分别说明”undo log 膨胀导致全库变慢”和”主从延迟呈锯齿状尖峰”这两条因果链。另外列举至少五种开发中常见的事务误用及其正确做法。
【参考答案】
-
A 原子性:事务内操作要么全做要么全不做(靠 undo log)。C 一致性:事务前后数据库都处于合法状态。I 隔离性:并发事务互不干扰。D 持久性:提交后崩溃也不丢(靠 WAL/redo log)。一致性主要是应用层责任,因为”什么算合法状态”是业务规则(如”余额不能为负”、“总额守恒”),数据库只提供保证它的手段(原子性、隔离性、约束),真正的业务约束要开发者自己定义和实现。
-
脏读:读到别人未提交的数据(可能回滚,数据从未存在)。不可重复读:同一事务内两次读同一行值不同(该行被别人改了)。幻读:同一事务内两次查询返回行数不同(别人插入/删除了)。更新丢失:两个事务的更新互相覆盖(如都基于 1000 加钱,结果只保留后写的那个)。最容易被忽视的是更新丢失——它在 Read Committed(很多数据库的默认级别)下就会发生,且不像脏读那样”看起来危险”,却常在生产造成资损,需要
SELECT FOR UPDATE或乐观锁来防。 -
MVCC 为每行保留多个历史版本(记录创建/删除的事务 ID),事务读时只看”事务 ID 小于自己且已提交”的版本快照,写时创建新版本而非覆盖。于是读操作读快照不加锁、写操作创建新版本不阻塞读——读写互不阻塞。代价:存储开销(多版本)、清理开销(purge 旧版本)、长事务导致版本链变长使性能下降(所以要避免长事务)。
-
2PC:两阶段,强一致但协调者单点、阻塞、性能差。TCC:Try-Confirm-Cancel 应用层补偿,锁粒度小但业务侵入极大(每个操作写三个接口)。Saga:拆成本地事务+补偿,适合长事务但无隔离性(中间状态可见)、补偿复杂。事务消息/本地消息表:业务操作与发消息同事务、消费方幂等,最终一致,简单可靠高性能。实践建议:优先用”本地事务 + 消息 + 幂等 + 对账”实现最终一致,只有确实必须强一致时才考虑分布式事务,因为后者的性能、复杂度和可用性代价往往超出预期。
-
因为
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 就重试。核心原则:读-改-写循环在并发下必然有覆盖窗口,要么消除它(原子语句),要么锁住它,要么检测它。 -
原因: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 锁,让判断依据和写入对象是同一个世界。 -
错在把”快照读看不到”当成了”幻读不存在”。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 防住了幻读,对”快照读”路径压根没打算防。 -
三部分:①隐藏列——每行有 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。 -
行锁 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 冲突。 -
超时:
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;T1DELETE WHERE id=15拿到 (10,20) 间隙锁,T2DELETE WHERE id=16也拿到同一个间隙锁(Gap Lock 之间兼容),然后 T1INSERT 15和 T2INSERT 16各自需要插入意图锁,被对方的 Gap Lock 挡住 → 死锁。RC 没有间隙锁,所以这个死锁不存在。 -
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。 -
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。) -
外部一致性:若事务 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,读可能被写阻塞;隔离级别仍是快照隔离,同样有写偏斜。 -
危害:①锁持有时间长 → 热点行排队雪崩;②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_idKILL;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 的锁行为完全不同,代码假设可能全错。