一、💡 锁是什么:为什么另一个事务要等
先理解锁在做什么
数据库锁,就是给正在操作的数据加上访问规则:谁能继续操作,谁需要等待。 本篇先从两个事务修改同一条记录讲起,再逐步认识锁的名字和范围。
本篇以 MySQL 8.4、InnoDB 为主。事务与提交、回滚见 05.MySQL 事务;后面讨论 MVCC 时再联系 06.MySQL 隔离级别、07.MySQL MVCC。
1.1 为什么需要锁:两个人不能同时随意改同一份余额
账户 id = 1 的余额原来是 1000,事务 A、B 都想扣 100。先只看修改过程:
| 顺序 | 事务 A:第一次扣款 | 事务 B:第二次扣款 |
|---|---|---|
| ① | 开启事务,执行扣款 UPDATE,获得修改这条记录需要的锁 | |
| ② | 把余额从 1000 扣成 900,暂不提交 | 开启事务,也执行扣款 UPDATE;锁冲突,等待 |
| ③ | 提交,释放锁 | 获得锁,基于当前 900 扣成 800 |
| ④ | 提交,释放锁,最终余额为 800 |
这里两次扣款都使用 SET balance = balance - 100,由数据库在当前余额上扣减。锁让两个冲突的修改按顺序执行;如果程序提前拿旧余额算好固定值再写回,仍可能覆盖结果,第六章保留了两种写法的对照。
1.2 谁加锁,什么时候释放
执行 UPDATE 时,InnoDB 会自动申请修改相关记录需要的锁,不需要应用额外执行一条“加锁命令”。
如果 UPDATE 位于显式事务中,相关记录锁通常持有到 COMMIT 或 ROLLBACK。SQL 执行完,不等于事务已经结束,也不等于锁已经释放。 如果使用自动提交,一条 UPDATE 本身就是一个事务,执行并提交后锁随即释放。
上表中,如果 A 在③回滚,余额恢复为 1000,B 获得锁后扣成 900。B 不会直接拿 A 尚未提交的扣款结果继续扣。
1.3 锁住后,别人是不是连看都不行
不一定。修改同一条记录需要协调,普通查询通常仍能读取可见的历史版本。 所以“锁住一行”不能理解成“所有人都完全不能碰它”。下一章先比较具体 SQL,第五章再解释背后的 MVCC 规则。
二、👀 哪些操作会互相挡住:SELECT、FOR UPDATE 与 UPDATE
2.1 普通 SELECT 与 FOR UPDATE:看库存和占库存
下面沿用原来的库存例子,只比较“查看”和“先保留修改资格”。
-- 商品详情页展示库存:只看,不承诺接下来由我修改。
SELECT stock FROM product WHERE id = 1001;
-- 下单流程准备修改库存:读取当前记录,并申请修改这条记录需要的锁。
SELECT stock FROM product WHERE id = 1001 FOR UPDATE;可以用一句话区分:
普通 SELECT = 我看一眼库存
SELECT FOR UPDATE = 这条库存我马上要改,冲突操作先排队锁定读只有放在事务中才有实际意义。如果开启自动提交、语句执行完立即提交,锁也会立即释放,无法保护后续多条语句。
2.2 UPDATE 为什么不用再写 FOR UPDATE
UPDATE 本身已经表示“现在就要修改”,InnoDB 会自动申请相应的锁。FOR UPDATE 则是让 SELECT 在读取时就先申请修改需要的锁,以保护同一事务中后续的判断和更新。
2.3 直接对照:A 做完操作还没提交,B 能不能继续
普通 SELECT 通常不挡住别人修改,也通常不需要等待别人修改结束;FOR UPDATE 和 UPDATE 对同一条记录会互相挡住。
下面以 InnoDB 的 RC/RR、两个独立事务、按主键访问同一条已存在的记录 id = 1001 为前提。A 已完成表格左侧的操作,但事务尚未结束;再看 B 执行表格上方的语句时是否需要等待:
| A 已执行的操作(尚未提交) | B 普通 SELECT | B SELECT … FOR UPDATE | B UPDATE |
|---|---|---|---|
| 普通 SELECT | 不被 A 阻塞 | 不被 A 阻塞 | 不被 A 阻塞 |
| SELECT … FOR UPDATE | 通常不等,读取可见版本 | 等 A 释放锁 | 等 A 释放锁 |
| UPDATE | 通常不等,读取可见版本 | 等 A 释放锁 | 等 A 释放锁 |
读表的方法:先看 A 做了什么,再往右找 B 想做什么。 例如,A 只是普通查询,B 可以直接修改;A 如果做了 FOR UPDATE,即使还没有真正修改库存,B 的 UPDATE 也要等待。
再用并行过程看一次:
| 顺序 | 事务 A | 事务 B |
|---|---|---|
| ① | 开启事务,执行 SELECT ... WHERE id = 1001 FOR UPDATE,暂不提交 | |
| ② | 继续持有锁 | 普通 SELECT,通常直接返回可见版本 |
| ③ | 继续持有锁 | UPDATE 同一条记录,开始等待 |
| ④ | 提交或回滚,释放锁 | 获得锁后,UPDATE 才能继续处理 |
所以,“锁住了”不等于“别人不能查询”,而是相关的冲突操作需要排队。 表格只比较这里三类操作对同一记录的影响;范围查询、插入以及其他类型的锁在后文展开。
三、🔒 给规则起名字:共享锁 S 与排他锁 X
前面“修改记录时协调冲突操作的锁”,在这里正式叫作排他锁(X)。除了它,还有允许多个事务共同持有的共享锁(S)。
假设风控人员要审核一笔订单,处理人员要修改这笔订单:
| 锁 | SQL | 业务含义 | 谁会被挡住 |
|---|---|---|---|
| 共享锁(S Lock) | FOR SHARE | 我正在审核,请暂时别改 | 冲突的排他锁 |
| 排他锁(X Lock) | FOR UPDATE 或写操作 | 这条记录我要修改 | 冲突的共享锁和排他锁 |
-- 多个审核者可以同时持有兼容的共享锁。
SELECT * FROM orders WHERE id = 5001 FOR SHARE;
-- 处理者准备修改订单,获取排他锁。
SELECT * FROM orders WHERE id = 5001 FOR UPDATE;大白话理解:共享锁像“大家可以同时看,但暂时不准改”;排他锁像“我正在改,冲突操作先等我完成”。
普通 SELECT 通常不申请这里的 S 锁。不能因为它“也是读取”,就把它和 FOR SHARE 当成同一种操作。
| 同一条索引记录上的锁 | B 申请共享锁 S | B 申请排他锁 X |
|---|---|---|
| A 已持有共享锁 S | 可以兼容 | 等待 |
| A 已持有排他锁 X | 等待 | 等待 |
这张表比较不同事务对同一索引记录申请的 S/X 锁;后面的纯间隙锁有自己的兼容规则。
3.1 共享锁:两个人可以一起读,但修改要等
下面以 InnoDB 的 RC/RR、两个独立连接、同一条已存在的订单 id = 5001 为前提。每行按顺序执行,A、B 都使用显式事务。
| 顺序 | 事务 A:审核订单 | 事务 B:先审核,再尝试修改 |
|---|---|---|
| ① | 开启事务,执行 SELECT * FROM orders WHERE id = 5001 FOR SHARE,获得 S 锁,暂不提交 | |
| ② | 继续持有 S 锁 | 开启事务,也执行同一条 SELECT ... FOR SHARE,立即返回,也获得 S 锁 |
| ③ | 继续持有 S 锁 | 执行 UPDATE orders SET status = 'PAID' WHERE id = 5001,需要 X 锁;因为 A 仍持有 S 锁,开始等待 |
| ④ | 提交,释放自己的 S 锁 | 获得修改需要的 X 锁,UPDATE 继续执行 |
| ⑤ | 提交,释放锁 |
②说明 S 与 S 可以共存;③说明 A 持有 S 时,B 想修改就要等。 B 自己已经持有 S 锁,也不代表可以越过 A 的 S 锁直接修改。
3.2 排他锁:我先占住,其他锁定读取或修改要等
这是另一个独立例子,上一例中的两个事务都已结束。A 这次使用 FOR UPDATE 获得 X 锁。
下表②、③是 B 的两种独立选择,不是在同一个连接里先等待②、再接着执行③。实际演练时选其中一种即可。
| 顺序 | 事务 A:准备处理订单 | 事务 B:也来访问这笔订单 |
|---|---|---|
| ① | 开启事务,执行 SELECT * FROM orders WHERE id = 5001 FOR UPDATE,获得 X 锁,暂不提交 | |
| ② 选择一 | 继续持有 X 锁 | 开启事务,执行同一条 SELECT ... FOR SHARE,申请 S 锁,等待 A |
| ③ 选择二 | 继续持有 X 锁 | 开启事务,执行同一条 SELECT ... FOR UPDATE 或 UPDATE,申请 X 锁,等待 A |
| ④ | 提交,释放 X 锁 | 所选操作获得相应的锁,继续执行 |
| ⑤ | 提交,释放锁 |
A 持有 X 锁时,B 申请 S 或 X 都要等。 但如果 B 只是执行普通 SELECT,在这里的 RC/RR 前提下,通常仍能读取可见版本;普通 SELECT 不等于 FOR SHARE。
四、📍 锁住哪里:索引记录、间隙与 next-key
第三章讲的是谁与谁冲突:S 能和 S 共存,X 会与其他事务在同一记录上的 S/X 冲突。这一章换一个问题:保护一条已有记录,和阻止别人插入新记录,是不是同一件事?
先记住这幅图。本章继续使用原来的订单金额例子,假设金额索引中只有三条记录:
已有记录: 100 300 500
两条记录之间: 空隙 空隙
↑
新金额 200 可以插在这里300 是已有的记录,200 是还没出现、但可能插进来的新记录。 这就是接下来三种锁要区分的对象。
4.1 记录锁:保护已经存在的那条记录
先只看金额 300 这条订单。假设 A 已经对它持有排他的记录锁:
| 顺序 | 事务 A | 事务 B |
|---|---|---|
| ① | 锁住金额 300 的已有记录,暂不提交 | |
| ② | 继续持锁 | 想修改这条 300 的订单,等待 |
| ③ | 提交,释放锁 | 修改才能继续 |
这就是记录锁(Record Lock):保护已有的索引记录。在这个例子中,它协调的是“谁能修改 300 这条记录”。
仅有这条记录上的锁,不会因为锁了 300,就自动禁止在 100 与 300 之间新增一条 200。 若没有其他锁或约束冲突,插入 200 不会被这一个记录锁挡住。
这里是在拆开理解锁的作用,不是说所有 FOR UPDATE 都只加记录锁;同一条 SQL 实际上还可能获得下面的间隙锁或 next-key 锁。
4.2 间隙锁:阻止在一段空隙中插入新记录
回到原来的业务:运营任务准备处理金额在 100 到 500 元之间的待支付订单:
SELECT *
FROM orders
WHERE amount BETWEEN 100 AND 500
AND status = 'PENDING'
FOR UPDATE;如果只保护现有的 100、300、500 三条记录,别人仍可能新增金额 200 的订单。A 再做一次相同的锁定范围查询,就可能多出一条。
所以除了已有记录,还需要考虑两条记录之间允许插入的位置。这段位置叫“间隙”。
下面单独看“100 与 300 之间”的间隙,假设 A 已经锁住这段间隙:
| 顺序 | 事务 A | 事务 B |
|---|---|---|
| ① | 锁住 100 与 300 之间的间隙,暂不提交 | |
| ② | 继续持锁 | 想插入金额 200 的新订单,等待 |
| ③ | 提交,释放锁 | 插入才能继续 |
这就是间隙锁(Gap Lock):主要阻止别人往这段间隙插入新记录。150、200、250 都落在这段间隙内,都会受到相应限制。
锁间隙,不等于锁住两端的已有记录。 单独这一段间隙锁不负责阻止别人修改已有的 300;如果修改被挡住,要看是否还有记录锁等冲突。若修改金额导致索引项移入被锁间隙,则还会涉及插入位置的限制。
4.3 next-key:已有记录和它前面的间隙一起保护
现在把前两节组合起来:既要保护金额 300 的已有记录,又要阻止在它前面插入 200。
100 ----------- 300 ----------- 500
前面的间隙 ↑
已有记录
next-key 保护:100 与 300 之间的间隙 + 300 这条记录这就是 next-key lock(临键锁):一条索引记录上的记录锁,加上这条记录前面的间隙锁。
假设 A 持有 300 对应的排他 next-key 锁,分别看 B 的两种选择:
| 顺序 | 事务 A | 事务 B |
|---|---|---|
| ① | 锁住 300 及它前面 100~300 的间隙,暂不提交 | |
| ② 选择一 | 继续持锁 | 想修改已有的 300,被记录锁挡住,等待 |
| ③ 选择二 | 继续持锁 | 想在间隙里插入 200,被间隙锁挡住,等待 |
| ④ | 提交,释放锁 | 所选操作才能继续 |
②、③是两种独立情况,分别演示,不是在同一个连接里执行了一个等待操作后,还能继续执行下一个。
| 名字 | 保护哪里 | 在本章例子中的作用 |
|---|---|---|
| 记录锁 | 已有的 300 这条记录 | 持有排他记录锁时,别人修改 300 要等 |
| 间隙锁 | 100 与 300 之间的空隙 | 别人插入 200 要等 |
| next-key 锁 | 300 以及它前面的空隙 | 同时保护已有记录和相关插入位置 |
4.3.1 间隙锁和 next-key 锁到底差在哪
区别就在于:是否把右边那条已有记录也一起锁住。 仍假设索引值只有 100、300、500:
| 对比 | 间隙锁 (100, 300) | next-key 锁 (100, 300] |
|---|---|---|
| 保护范围 | 100 和 300 之间的空隙 | 同一段空隙 加上 300 这条记录 |
B 插入 200 | 等待 | 等待 |
B 修改已有 300 的非索引字段 | 不被这个间隙锁挡住 | 若 A 持有排他 next-key 锁,B 要等待 |
是否包含已有的 100 | 不包含 | 不包含 |
这里用“修改非索引字段”作对照,是为了只观察对已有记录的保护;修改索引值还可能涉及新的插入位置。
间隙锁: 100 [中间不能插入] 300
next-key: 100 [中间不能插入 + 300 也被保护]next-key 锁 = 间隙锁 + 右端记录上的记录锁。 (100, 300) 两端都不包含;(100, 300] 右端包含 300,下面一节再用完整区间图说明。
这张表单独比较两种锁的作用。实际一条 SQL 可能同时持有多个锁,“不被这个间隙锁挡住”不代表一定不会被其他锁挡住。参见 MySQL 8.4:InnoDB Locking。
InnoDB 在 RR 下的范围锁定读、范围修改中可能使用 next-key 锁,限制其他事务向相关范围插入记录,从而处理常见幻读问题。这些锁由 InnoDB 根据 SQL 和执行过程决定,不是让你手动写三条不同的“加锁命令”。
上面的表格把锁拆开讲作用;真实加锁范围仍由隔离级别、索引结构、查询条件和执行计划共同决定。原来的金额范围 SQL 不保证只锁住图中的这一段。
4.4 看懂前面的图后,再看区间写法
前面的“100 与 300 之间的间隙 + 300”,写成数学区间就是 (100, 300]:左边不含 100,右边包含 300。
假设某个索引按顺序只有 100、300、500,把它们作为区间边界,可以这样理解:
(-∞, 100] (100, 300] (300, 500] (500, +∞)
↑
300 对应的 next-key 区间(100, 300] 的左端不包含 100,右端包含 300:记录锁保护 300,间隙锁阻止在 100 与 300 之间插入。最后一个区间借助 supremum 伪记录表示索引末尾之后的范围。
这张图解释区间含义,不表示每条查询都会把所有区间锁住。二级索引可以有重复值,实际索引项还需要结合主键区分,不能只凭金额推导完整锁范围。
Gap Lock 主要限制插入,不等于把两端现有记录都锁住。 不同事务的纯间隙锁可以兼容;不能把记录级 S/X 的冲突规则直接搬到纯间隙锁上。
4.5 为什么没索引会让锁冲突变严重
关键不只是“没索引,查得慢”,还在于:锁定查询、UPDATE、DELETE 在寻找目标时,可能把扫描到的更多记录或范围也锁住。最后只查出一条,不代表只锁过这一条。
下面用 InnoDB、RR、显式事务解释。先保留原来的两条查询作对照:
-- id 是主键,可以直接定位已有的 5001。
SELECT * FROM orders WHERE id = 5001 FOR UPDATE;
-- status 没有合适索引时,要扫描很多记录才能判断哪些是待支付订单。
SELECT * FROM orders WHERE status = 'PENDING' FOR UPDATE;4.5.1 先分清:WHERE 条件和查找路线是两回事
WHERE 表达的是“我要什么数据”;索引帮助数据库决定“沿哪条路线找到它”。
假设表里暂时只有这三条订单,id 是主键,status 没有索引:
| id | status | 含义 |
|---|---|---|
| 5001 | PENDING | 待支付 |
| 5002 | PAID | 已支付 |
| 5003 | PAID | 已支付 |
| 查找方式 | 数据库大致怎么找 |
|---|---|
WHERE id = 5001,使用主键 | 沿主键索引直接定位 5001 |
WHERE status = 'PENDING',没有合适索引 | 沿主键记录逐条检查:5001 是,5002 不是,5003 不是 |
没有 status 索引,不等于表里完全没有索引。 这里数据库仍然可以扫描主键索引,只是不能靠它直接定位 PENDING,所以要逐条检查。
4.5.2 为什么查找路线会影响别人能不能修改
先把这条 SQL 的执行过程展开。沿用上面的三条订单,前提是 InnoDB、RR、显式事务,status 没有可用索引,执行计划采用全表扫描:
START TRANSACTION;
SELECT * FROM orders WHERE status = 'PENDING' FOR UPDATE;
-- 查询结束后暂不提交,观察哪些锁仍然持有。第一步:确定查找路线。 主键索引按 id 排列,没有按 status 排列的索引可以帮助定位。数据库不知道哪条是 PENDING,必须沿聚簇索引逐条检查。这里“没索引”说的是 status,不是说主键也没有。
第二步:沿这条路线扫描,并在读取记录时申请锁,然后判断 status。 FOR UPDATE 是锁定读,不是先普通查询得到结果,再给结果补锁。下面把记录上的保护单独列出来;实际 RR 全表扫描还会涉及间隙。
| 扫描步骤 | 读取记录时做什么 | 判断 WHERE | 是否返回 | 判断后记录上的锁 |
|---|---|---|---|---|
| 扫描到 id = 5001 | 申请保护该索引记录的排他锁 | PENDING,符合 | 返回 | 保留 |
| 扫描到 id = 5002 | 同样申请锁 | PAID,不符合 | 不返回 | 在这里的 RR 场景下仍保留 |
| 扫描到 id = 5003 | 同样申请锁 | PAID,不符合 | 不返回 | 在这里的 RR 场景下仍保留 |
这是便于理解的执行过程,不是说底层每一步都严格拆成独立指令。要抓住的是:锁跟着实际扫描的索引范围走,WHERE 决定哪些记录进入结果;结果过滤掉一条记录,不代表自动释放它的锁。
第三步:扫描到末尾,查询返回,但事务还没结束。 此时只返回了 5001,却仍持有对 5001、5002、5003 的锁。RR 下,这次全表扫描通常使用 next-key 锁,还会保护记录之间的间隙以及末尾间隙,所以其他事务插入新订单也可能等待。这里是许多索引记录及间隙被锁住,不能理解成“行锁自动升级成了表锁”。
第四步:事务提交或回滚,释放这些锁。 查询返回并不会替显式事务提交。在此之前,其他事务要修改 5002,即使它一直是 PAID,也会因为 5002 已被 A 锁住而等待;普通 SELECT 通常仍能读取可见版本。
status 无可用索引
→ 为了找全 PENDING,必须逐条扫描所有订单
→ FOR UPDATE 在扫描过程中加锁
→ RR 下,不匹配的记录也保留锁
→ 返回一条记录,却锁住了所有扫描到的记录及相关间隙
→ COMMIT / ROLLBACK 后释放为什么不先判断完再只锁 PENDING? 这不是 SQL 所承诺的执行方式。InnoDB 的锁定读按实际访问的索引范围加锁;这里扫描的是整个主键索引,status 只是扫描后的过滤条件。不要把“业务上只处理待支付订单”直接当成“数据库只锁待支付订单”的保证。换成 RC 时,不匹配记录的锁可以在判断后释放,因此上面的持锁结果不能直接套用到 RC。
FOR UPDATE 不是先无锁查完、最后只锁返回结果。InnoDB 会在扫描过程中申请相应的锁;在这里的 RR 全表扫描场景下,不匹配条件的已有记录也可能被锁住并一直持有到事务结束。
| 顺序 | 事务 A:查询待支付订单并加锁 | 事务 B:修改另一条已支付订单 |
|---|---|---|
| ① | 开启 RR 事务,执行 SELECT * FROM orders WHERE status = 'PENDING' FOR UPDATE | |
| ② | status 无合适索引,全表扫描;返回结果只有 5001,但扫描中的 5002、5003 也被加锁,暂不提交 | |
| ③ | 继续持锁 | 开启事务,执行 UPDATE orders SET status = 'CLOSED' WHERE id = 5002,等待 |
| ④ | 提交,释放锁 | UPDATE 才能继续 |
| ⑤ | 提交,释放锁 |
B 会觉得:“你查的是待支付订单,我改的是已支付订单,为什么也要等?”
原因是:业务上操作的目标不同,但 A 的查找路线经过并锁住了 B 要修改的记录。 锁冲突看的是实际加锁范围,不只看最终返回的结果。
这里讲的是明确采用全表扫描的 RR 场景;RC 下不匹配记录的锁释放规则有所不同,不能把“扫描到”一概等同于“锁到事务结束”。
4.5.3 有合适索引后,为什么冲突通常会减少
如果 status 有合适索引,且执行计划实际使用它,数据库通常可以沿 status 索引定位待支付订单,不必逐条检查所有订单。
假设订单表有 100 万行,但待支付订单只有 20 行,原来的对比可以这样理解:
| 对比 | 没有合适的 status 索引 | 有合适索引,且实际使用 |
|---|---|---|
| 怎么找 20 条待支付订单 | 可能扫描大量主键记录 | 沿 status 索引定位相关记录 |
| 对锁的影响 | 扫描和加锁范围可能很大 | 扫描和加锁通常更集中 |
| 对其他事务的影响 | 更容易挡住原本不相关的修改 | 通常减少不必要的等待 |
有索引不代表只锁最终返回的 20 行:范围锁定仍可能涉及间隙,使用二级索引加排他锁时还会锁住相应的主键记录。这里先抓住因果关系:找到目标需要扫描得越广,可能牵涉的锁越多,冲突机会就越大。
InnoDB 的行锁实际加在索引记录上,所以线上出现锁等待时,除了事务本身,还要检查 04.MySQL 索引 和实际执行计划。普通 SELECT 在 RC/RR 下通常走一致性读,不要把本节的扫描加锁行为套到它身上。参见 MySQL 8.4:不同 SQL 的锁规则。
4.5.4 把几个疑问串起来:锁是一路累积的
下面只讨论 InnoDB、RR、显式事务,status 无可用索引,执行计划采用全表扫描:
SELECT * FROM orders WHERE status = 'PENDING' FOR UPDATE;- 为什么 PAID 也被锁? 没有 status 索引,只能沿主键逐条扫描。FOR UPDATE 在扫描过程中加锁,WHERE 只决定是否返回;RR 下,不匹配的记录也会保留锁。
- next-key 到底锁哪里? 锁“当前记录 + 它前面的间隙”。主键是 10、20、30 时,扫描到 20,锁的是
(10, 20]:包含 20 和前面的空隙,不包含 10 这条记录。 - 扫描到 20,之前 10 前面的锁会释放吗? 不会。锁一路累积,通常等事务提交或回滚才一起释放。
- 那大表岂不是全表都被锁住? 对,这种完整扫描会覆盖所有已有记录和间隙,可能挡住其他事务的修改、删除和插入;普通 SELECT 通常仍能通过 MVCC 读取。这是许多锁覆盖全表,不是行锁升级成表锁。
扫描到 10:持有 (负无穷, 10]
扫描到 20:再加 (10, 20],之前的锁保留
扫描到 30:再加 (20, 30],之前的锁保留
扫描到末尾:再锁 (30, 正无穷)
提交或回滚:释放这些锁记住:不是“只锁查出来的记录”,而是要看实际扫描范围和隔离级别。使用合适索引、缩小单次处理范围、尽快提交,才能减少这种冲突;有索引但命中数据很多,也可能锁住大量记录。
五、🔄 接回 MVCC:能读到旧值,为什么修改还要等
MVCC 决定普通查询能看到哪个版本;锁协调对当前数据的冲突操作。 以下余额例子均以同一个 RR 事务为前提。
5.1 用一次扣款串起来:普通查询不用等,修改为什么要等
账户 id = 1 的余额原来是 1000。事务 A、B 都要扣 100,B 先通过普通 SELECT 建立快照:
| 顺序 | 事务 A | 事务 B |
|---|---|---|
| ① | 尚未修改 | 普通 SELECT,建立 Read View,看到 1000 |
| ② | 将余额扣成 900,暂不提交,持有排他锁 | |
| ③ | 再次普通 SELECT,仍看到 1000,通常不用等 A | |
| ④ | 执行 UPDATE ... SET balance = balance - 100 WHERE id = 1,等待 A 的锁 | |
| ⑤ | 提交,释放锁 | 获得锁,基于当前 900 扣成 800 |
| ⑥ | 提交,最终余额为 800 |
③只需要读取快照中可见的历史版本,不必修改 A 正在操作的当前记录。④要真正扣款,就必须协调对当前记录的访问,不能与 A 同时修改,因此需要等待。
锁就是让冲突操作按顺序继续的机制。 如果 A 在⑤回滚,B 面对的余额恢复为 1000,扣完就是 900。这说明当前读并非直接使用别人未提交的数据:遇到冲突,要按锁规则等待对方提交或回滚。
5.2 把读取规则分清:快照读与当前读
RR 复用旧 Read View 的规则,针对的是普通一致性读,不是事务里的所有 SQL。
| 操作 | 常见 SQL | 使用什么数据 | 遇到冲突写锁时 |
|---|---|---|---|
| 快照读,也叫一致性读 | 普通 SELECT | Read View 中可见的版本,同时能看到本事务自己的修改 | 通常可读取旧版本,不等待同一行的写锁 |
| 共享锁定读 | SELECT ... FOR SHARE,旧写法 LOCK IN SHARE MODE | 当前记录,申请 S 锁 | 通常等待 |
| 排他锁定读 | SELECT ... FOR UPDATE | 当前记录,申请 X 锁 | 通常等待 |
| 修改、删除 | UPDATE、DELETE | 围绕当前记录判断条件并修改、删除 | 申请相应 X 锁,冲突时可能等待 |
后面三类常被概括为“当前读”。S/X 锁的兼容关系已经在第三章说明,这里把它们与读取版本联系起来。
普通一致性读在 RR 下通常复用第一次一致性读建立的快照,RC 下每次建立新快照。不能把这套结论直接套到 SERIALIZABLE,也不能仅凭语句里有 SELECT 就认定它是快照读。
5.2.1 锁定读不会刷新普通查询的 Read View
初始余额为 1000,A、B 使用两个独立连接。B 在同一个 RR 事务中完成下列操作,期间只查询、不修改这条记录。表格从上往下表示执行顺序:
| 顺序 | 事务 A:修改余额 | 事务 B:先普通查询,再锁定查询 |
|---|---|---|
| ① | 开启事务,普通 SELECT,看到 1000,建立 Read View | |
| ② | 开启事务,将余额从 1000 改成 900,暂不提交 | |
| ③ | 提交,释放锁;当前已提交余额为 900 | |
| ④ | 普通 SELECT,仍看到 1000,沿用旧 Read View | |
| ⑤ | SELECT ... FOR UPDATE,看到当前的 900,并锁住记录 | |
| ⑥ | 再普通 SELECT,仍看到 1000:⑤没有刷新 Read View | |
| ⑦ | 提交,释放⑤获得的锁 |
对照⑤和⑥:锁定查询读到 900,不代表后面的普通查询也会改读 900。
因此,同一个事务中的两种读取结果可能来自不同的读取视角,不能假设它们必然相同。参见 官方锁定读说明。
5.2.2 普通查询能看到本事务自己的修改
这是另一个独立例子,初始余额重新设为 1000。仍使用两个独立连接,B 在同一个 RR 事务中完成查询和修改:
| 顺序 | 事务 A:第一次扣款 | 事务 B:查询后也扣款 |
|---|---|---|
| ① | 开启事务,普通 SELECT,看到 1000,建立 Read View | |
| ② | 开启事务,将余额从 1000 扣成 900,暂不提交 | |
| ③ | 提交,释放锁;当前已提交余额为 900 | |
| ④ | 普通 SELECT,仍看到 1000,沿用旧 Read View | |
| ⑤ | 执行 UPDATE account SET balance = balance - 100 WHERE id = 1,基于当前 900 扣成 800 | |
| ⑥ | 再普通 SELECT,看到 800:本事务自己的修改对自己可见 | |
| ⑦ | 提交,释放锁,最终余额为 800 |
对照两节的⑥:只做锁定查询,普通查询仍读到 1000;自己修改了记录,普通查询就能看到自己写入的 800。
最后一步不是 UPDATE 刷新了 Read View,而是本事务自己的修改对自己可见。自己插入的记录能被后续普通查询看到,自己删除的记录不会再返回。
没有被自己修改的其他记录,仍可能按旧快照读取。因此一次查询可能混合“自己刚改的版本”和“其他行的历史版本”,不能理解成数据库某个时刻的完整照片。参见 官方一致性读说明。
5.3 RC 和 RR 不只在 Read View 上有区别
| 条件 | RR | RC |
|---|---|---|
| 使用完整唯一索引条件,命中已有记录 | 通常只锁命中的索引记录,不锁前方间隙 | 通常锁命中的索引记录 |
| 范围锁定读、范围更新 | 通常使用间隙锁或 next-key lock 保护扫描范围 | 通常锁索引记录,允许向相邻间隙插入 |
| 未找到目标记录 | 仍可能锁住目标所在间隙 | 一般不通过搜索间隙锁保护“不存在” |
| 外键检查、重复键检查 | 按相应规则加锁 | 仍可能使用间隙锁,属于例外 |
这里“完整唯一索引条件”指足以唯一定位的条件;联合唯一索引只使用部分列,不一定符合这个前提。RC 下,UPDATE 对不匹配记录的锁还可能在条件判断后释放,所以“扫描过”也不总等于“一直锁到事务结束”。
接回上一篇的幻读:RR 连续普通查询通过同一快照保持结果;RR 范围锁定操作则通过锁限制相关插入。 两条路径的机制不同,不能说“MVCC 会挡住 INSERT”,也不能说“有 RR,任何混合读写都不会出现结果变化”。参见 官方隔离级别说明。
5.4 其他语句:不要把普通 SELECT 的规则套到所有 SQL
锁定 SELECT、UPDATE、DELETE 已在本章前面的小节对比,下面补充插入和复合语句:
| 操作 | 为什么不能套用普通 SELECT 的旧快照规则 |
|---|---|
INSERT ... VALUES ... | 新增记录要检查当前唯一键、外键等约束;旧快照查不到,不代表现在插入一定成功 |
INSERT ... ON DUPLICATE KEY UPDATE、REPLACE | 根据当前唯一键冲突决定更新或替换,并加相应锁 |
INSERT INTO ... SELECT ... | RR 下源表读取通常使用共享 next-key 锁,目标表插入也要加锁;RC 下源表读取则使用一致性读 |
CREATE TABLE ... SELECT、部分 UPDATE ... (SELECT ...) 等 | 内部查询有专门规则,要结合语句形式和隔离级别判断,不能把每个查询块都当成独立普通 SELECT |
例如,B 在同一个 RR 事务中先普通查询,A 再插入新记录:
| 顺序 | 事务 A | 事务 B |
|---|---|---|
| ① | 普通 SELECT 查询 id = 2,查不到 | |
| ② | 插入 id = 2 并提交 | |
| ③ | 再普通 SELECT,仍查不到 |
此时分别考虑 B 的下一步:
- 如果 INSERT 相同主键,可能报重复键,因为唯一性检查不能忽略当前已存在的记录。
- 如果 UPDATE 或 DELETE 这条记录,可能影响成功,即使旧快照中的普通查询看不到它。
这里假设 B 前面只做普通查询,没有锁住目标所在的间隙;如果提前做了相应的范围锁定,A 的插入可能先被阻塞。间隙锁和 next-key lock 已在第四章解释。参见 官方不同 SQL 的锁规则。
5.5 带着这三个问题进入下一章
判断一条 SQL 的行为时,依次问:
- 用哪个版本? 普通一致性读按 Read View 判断可见性;锁定读和修改围绕当前记录处理。
- 有没有自己改过? 普通查询能看到本事务自己的修改,不能简单理解成“RR 永远返回旧值”。
- 需要和谁排队? 对当前数据申请的锁发生冲突,就可能等待;能读到历史版本不代表已经取得修改资格。
六、🛒 业务里怎么用:扣款、库存与乐观锁
6.1 写法也很重要:在数据库里扣款,还是拿旧值算完再写回
接回第五章的余额例子:B 普通查询得到 1000,A 随后扣成 900 并提交。B 接下来执行的两种 UPDATE,含义完全不同。
下面的 account 表代表包含 id、balance 字段的账户表;完整测试表和双连接实验见第八章。
-- 写法一:让数据库在当前记录的余额上扣 100。
-- 右侧 balance 是执行 UPDATE 时的字段值 900。
UPDATE account
SET balance = balance - 100
WHERE id = 1;
-- 结果:800。-- 写法二:程序拿旧余额 1000,提前算出 900,再传入固定值。
-- 数据库收到的要求只是“把余额设为 900”。
UPDATE account
SET balance = 900
WHERE id = 1;
-- 结果:900,没有保留两次扣款的效果。两种写法都会申请相应的锁,但锁只能协调写入次序,不能修正程序根据旧值算出的目标余额。 实际扣款还应结合 balance >= 100 等条件、影响行数和业务事务判断是否成功。
6.2 秒杀最后一件商品
商品库存只剩 1,请求 A 和请求 B 同时到达。下面这种写法有风险:
A 普通 SELECT → stock = 1
B 普通 SELECT → stock = 1
A 在程序里计算 1 - 1,写回 0
B 也在程序里计算 1 - 1,写回 0两个请求都可能告诉用户“购买成功”。问题不在于有没有读到历史版本,而在于两个写操作必须竞争同一份当前库存。
6.2.1 首选:一条原子条件更新
UPDATE product
SET stock = stock - 1
WHERE id = 1001
AND stock > 0;先补一个容易被教程跳过的前提:只要执行 UPDATE,就已经是在告诉 InnoDB“我要修改匹配的数据”。 修改共享数据时,InnoDB 必须防止另一个事务同时修改同一份数据,因此存储引擎会在执行过程中自动申请相应的排他锁,不需要再额外写 FOR UPDATE。
普通 SELECT
→ 只想查看
→ 在常用隔离级别下通常使用 MVCC,不主动加排他锁
SELECT ... FOR UPDATE
→ 虽然是 SELECT,但明确表示“查完准备修改”
→ 主动申请排他锁
UPDATE
→ 语句本身已经表示“现在就要修改”
→ InnoDB 自动申请排他锁这里的“自动”不是数据库无缘无故猜测,而是 UPDATE 语句本身已经表达了写意图。可以把排他锁理解成 InnoDB 执行写操作时自带的交通管制:当前事务修改期间,其他事务不能同时取得冲突的写锁。
如果这条 UPDATE 位于显式事务中,锁通常持有到 COMMIT 或 ROLLBACK;如果使用自动提交,这条语句本身就是一个事务,执行并提交后锁随即释放。
id 是主键时,这条 SQL 能通过唯一索引精确定位记录,通常锁住目标索引记录。锁并不总是只覆盖一行;如果条件没有合适索引或使用范围查询,InnoDB 可能扫描并锁定更多索引记录或范围。
这里说的“原子”,是指检查条件和修改库存都包含在同一条 UPDATE 中,由 InnoDB 围绕同一份当前数据完成:
判断 stock > 0
→ 对符合条件的当前记录执行 stock = stock - 1
→ 成功或失败后返回影响行数应用没有先把库存读到内存,再隔一段时间把计算结果写回,因此不存在“判断已经通过,但真正更新前被另一个请求抢先修改”的应用层空档。
假设库存只剩 1,请求 A 和请求 B 同时执行这条 SQL:
| 时间 | 请求 A | 请求 B |
|---|---|---|
| ① | 找到商品记录,获得排他锁 | 也要修改同一记录,只能等待 |
| ② | 在当前值 1 上判断 stock > 0,扣成 0 | 继续等待 |
| ③ | 提交并释放锁 | 被唤醒 |
| ④ | 基于最新值 0 重新判断条件,stock > 0 不成立 | |
| ⑤ | 影响行数为 1 | 影响行数为 0 |
因此不会出现两个请求都拿着旧值 1,各自在应用里算出 0,然后都报告扣减成功。这里仍然使用了锁;“原子更新”不是无锁,而是数据库把条件判断、当前值计算和写入放在同一条不可拆开的更新语句中协调执行。
执行后检查影响行数:
| 影响行数 | 业务含义 |
|---|---|
1 | 成功扣减库存 |
0 | 商品不存在或库存已经不足 |
InnoDB 会对实际修改的索引记录加排他锁。先拿到锁的请求完成修改;另一个请求等待后会基于最新库存重新判断条件,不会凭旧的 stock = 1 再扣一次。
原子到什么范围
这只能保证这一条库存更新语句自身的并发安全,不代表“扣库存、创建订单、写流水”等多条 SQL 自动成为一个整体。多条操作要么一起成功、要么一起失败,仍然需要放进同一个事务,并正确处理提交与回滚。
6.2.2 复杂判断:先锁定,再处理
如果购买前需要先读取库存、再完成多步判断,可以在一个短事务里锁住商品行。会员资格、限购次数若存放在其他记录中,还需要为那些条件单独设计并发保护;只锁商品行并不会自动锁住所有业务条件:
START TRANSACTION;
SELECT stock
FROM product
WHERE id = 1001
FOR UPDATE;
-- 在同一事务内完成必要判断和更新。
UPDATE product
SET stock = stock - 1
WHERE id = 1001
AND stock > 0;
COMMIT;请求 A 持有锁时,请求 B 的冲突锁定读或更新会等待。普通 SELECT 通常仍可借助 MVCC 读取合适快照,这说明“锁了一行”不等于“所有人都不能看”。
别把远程调用放在锁里面
获取
FOR UPDATE锁之后,不要再调用支付网关、发送短信或等待用户输入。远程调用越久,锁持有越久,后面的请求排队越严重。
6.3 悲观锁、乐观锁和原子更新怎么选
先记住三句话:
- 原子条件更新:有货就扣。
- 乐观锁:别人改过,我就先停下来重新看。
- 悲观锁:先锁住,别人等我处理完再操作。
这里的“原子条件更新”指不带版本检查的写法。下面用版本号实现的乐观锁,本身也是一条原子条件更新,只是多了版本检查。 它们不是完全独立的两种 SQL。
6.3.1 原子条件更新:现在有货,就能扣
假设库存有 10 件,你和另一个人都想买一件。
UPDATE product
SET stock = stock - 1
WHERE id = 1001
AND stock > 0;这条 SQL 只问:“现在还有货吗?” 判断和扣减在同一条 SQL 中完成,不需要先查询库存。
- 另一个人先买走一件并提交,库存从
10变成9。 - 轮到你执行更新,还有货,库存从
9变成8。 - 两个人都能购买成功。
你不在意别人改过库存,只在意轮到自己时还有货。 应用通过影响行数判断:1 表示扣减成功,0 表示商品不存在或库存不足。
“原子”可以简单理解为:判断有没有货和扣掉一件是一个整体,别人不能插到这两步中间抢走同一件货。 但它不代表连创建订单等其他操作也自动成为一个整体,多条操作仍需要事务。
6.3.2 乐观锁:有货,而且版本没变,才能扣
还是库存 10 件。这次你和另一个人都先查到了:stock = 10,version = 7。
-- 先普通查询,记住读到的版本号。
SELECT stock, version
FROM product
WHERE id = 1001;
-- 假设刚才读到 version = 7,更新时带上这个版本号。
UPDATE product
SET stock = stock - 1,
version = version + 1
WHERE id = 1001
AND stock > 0
AND version = 7;这条更新除了问“还有货吗”,还要问:“我刚才看到的数据,被别人改过吗?”
- 另一个人先买走一件并提交:库存从
10变成9,版本从7变成8。 - 你仍然带着旧的
version = 7去更新。 - 虽然还有
9件,但版本对不上,你这次更新影响行数为0,失败。 - 你需要重新读取数据、重新判断,再决定是否重试;不能一直拿旧版本
7重试。
与前一种写法相比,差别就是多检查了“版本有没有变”。 因此,单纯扣库存,一般用第一种就够了;如果业务要求根据之前读到的数据做判断,并在保存时检查数据是否变过,再考虑乐观锁。
这个方案要求相关修改都遵守版本号约定,修改时递增版本号。影响行数为 0 也可能是商品不存在或库存不足,不能一律认定为版本冲突。
6.3.3 悲观锁:先锁住,再查看和扣减
还是库存 10 件。这次你先锁住商品行,再查看库存和处理购买。
START TRANSACTION;
-- 查库存时就锁住商品行,锁保持到事务结束。
SELECT stock
FROM product
WHERE id = 1001
FOR UPDATE;
-- 应用确认有货后,在同一个事务里扣减。
UPDATE product
SET stock = stock - 1
WHERE id = 1001
AND stock > 0;
COMMIT;上面演示的是扣减成功后提交的路径;如果商品不存在、库存不足或后续业务失败,应用应结束事务,按业务需要回滚。
- 你先执行
SELECT ... FOR UPDATE,查到10件,同时锁住这条商品记录。 - 另一个人也想对这条记录执行
FOR UPDATE或更新,需要等待。 - 你扣掉一件,库存变成
9,提交事务,释放锁。 - 对方取得锁后,看到
9件,再扣成8并提交。
与乐观锁相比,悲观锁是先让对方等着;乐观锁是先各自读取,更新时发现版本变了,再让本次更新失败。 悲观锁锁住商品行后,普通快照查询通常仍然可以读取,不代表别人完全看不到这条数据。
6.3.4 放在一起比较
| 方案 | 一句话理解 | 适合场景 | 代价 |
|---|---|---|---|
| 不带版本检查的原子条件更新 | 有货就扣 | 规则能写进 WHERE 的库存、余额更新 | 复杂业务判断不一定能放进一条 SQL |
| 乐观锁(带版本检查的原子条件更新) | 有货,并且版本没变,才扣 | 根据读取结果处理业务,保存时需要检查数据是否变过,且允许重试 | 版本冲突后需要重新读取、判断或提示失败 |
| 悲观锁 | 先锁住,查完处理完再放开 | 必须先读取、再做多步处理,并需要阻止别人同时修改 | 别人需要等待,事务必须短 |
注意:三种写法执行 UPDATE 时,InnoDB 都会申请相应的锁。 乐观锁不是“数据库写入时不加锁”,它只是没有在前面的普通查询阶段提前锁住数据,而是在更新条件中检查版本。
实战优先级
能用一条条件
UPDATE安全完成时,通常先用原子更新;业务必须“先查再做多步判断”时考虑悲观锁;冲突较少且业务允许重试时考虑乐观锁。
七、⏳ 等待与死锁:为什么两个事务都不动了
7.1 转账为什么会死锁
这里用的是 InnoDB 的行级排他锁,也叫 X 锁。执行 UPDATE 修改记录时,数据库会自动申请相应的锁,不需要额外写 FOR UPDATE。
假设 account.id 是主键,账户 A(id = 1)和账户 B(id = 2)都存在,且余额充足。下面只演示加锁过程,实际转账还需要检查余额和每次更新是否成功。
事务 1:A 给 B 转 100 元。
START TRANSACTION;
-- ① A 扣 100,自动锁住 A 这条账户记录。
UPDATE account SET balance = balance - 100 WHERE id = 1;
-- ③ 想给 B 加 100;如果 B 已被事务 2 锁住,就需要等待。
UPDATE account SET balance = balance + 100 WHERE id = 2;
COMMIT; -- 提交后释放锁。事务 2:B 给 A 转 100 元,在另一个数据库连接中执行。
START TRANSACTION;
-- ② B 扣 100,自动锁住 B 这条账户记录。
UPDATE account SET balance = balance - 100 WHERE id = 2;
-- ④ 想给 A 加 100;但 A 已被事务 1 锁住,也需要等待。
UPDATE account SET balance = balance + 100 WHERE id = 1;
COMMIT; -- 提交后释放锁。关键:因为这两条 UPDATE 在同一个事务中,所以第一条执行完后,锁不会马上释放。这里修改账户取得的排他锁,会一直持有到整个事务提交(COMMIT)或回滚(ROLLBACK)后才释放。
也就是说:事务还没结束,就算已经改完 A,也仍然拿着 A 的锁,再去申请 B 的锁。 如果另一个事务正好拿着 B 的锁、又想申请 A 的锁,就会互相等待,形成死锁。
如果两个连接按注释里的 ① → ② → ③ → ④ 顺序交错执行,就会发生下面的情况;不是把事务 1 全部执行完,再执行事务 2:
| 时间 | 事务 1:A 转给 B | 事务 2:B 转给 A |
|---|---|---|
| ① | 锁住账户 A | 锁住账户 B |
| ② | 等待账户 B | 等待账户 A |
| ③ | 无法继续 | 无法继续 |
事务 1 持有 A,等待 B
事务 2 持有 B,等待 A
→ 形成环形等待
→ 死锁InnoDB 检测到死锁后,通常会回滚其中一个事务,让另一个继续。死锁不是数据库“卡死了”,而是数据库主动牺牲一个事务来拆掉等待环。
怎么避免上面这种死锁?让所有转账都按同一个顺序锁账户。
假设 A 的 ID 是 1,B 的 ID 是 2。原来的顺序是:
- A 转给 B:先修改 A,锁住 A;再修改 B,申请 B 的锁。
- B 转给 A:先修改 B,锁住 B;再修改 A,申请 A 的锁。
这就可能各自拿着一个账户的锁,互相等待。
现在规定:不管谁给谁转钱,都先锁 ID 小的 A,再锁 ID 大的 B。锁的顺序和转账方向没有关系。
例如,B 给 A 转 100 元,可以先把两个账户按顺序锁住,再扣款、加款:
START TRANSACTION;
-- A 的 ID 小,先锁 A。FOR UPDATE 会申请排他锁。
SELECT id FROM account WHERE id = 1 FOR UPDATE;
-- B 的 ID 大,再锁 B。
SELECT id FROM account WHERE id = 2 FOR UPDATE;
-- 两个账户都锁住后,再按实际转账方向操作:B 扣款,A 收款。
UPDATE account SET balance = balance - 100 WHERE id = 2;
UPDATE account SET balance = balance + 100 WHERE id = 1;
COMMIT; -- 事务结束,释放锁。这里仍假设两个账户都存在、余额充足,只演示锁的顺序;实际业务还需要检查余额和更新结果。A 给 B 转钱时,也执行相同的两条锁定查询,只是后面的扣款、加款方向相反。
两笔转账同时执行时,就会变成:
| 步骤 | 事务 1 | 事务 2 |
|---|---|---|
| ① | 先锁住 A | |
| ② | 也想先锁 A,于是等待,此时还没锁 B | |
| ③ | 继续锁 B,完成转账并提交 | 等待 A 的锁 |
| ④ | 释放两个账户的锁 | 取得 A 的锁,再锁 B,完成自己的转账 |
关键:后来的事务在第一个账户那里就排队了,没有先占住第二个账户,因此不会挡住前面的事务。 这就避免了前面“各拿一把锁,互相等待”的死锁。实际转账涉及其他记录时,也要考虑那些记录的加锁顺序,不能认为只统一两个账户的顺序就能消除所有死锁。
生产代码还应做到下面五点
① 捕获死锁错误,必要时把整个事务重新执行,但限制重试次数。
例如,转账做到一半发生死锁,InnoDB 检测后选择回滚了这笔转账事务。应用收到死锁错误后,可以重新发起这笔转账。
注意:要从开启事务开始,重新执行整个业务事务,不能只重试报错的那条 UPDATE。 因为被选中回滚的事务,前面已经完成的修改也被撤销了。
第一次:开始事务 → A 扣款 → 发生死锁 → 整个事务被回滚
第二次:重新开始事务 → 重新检查 → A 扣款 → B 加款 → 提交怎么捕获死锁?数据库会抛出异常,应用用 try-catch 捕获。
MySQL 检测到死锁并回滚其中一个事务后,会向该事务所在的应用返回死锁错误,常见错误码是 1213。如果使用 JDBC,可以从 SQLException 中读取错误码:
// 示意代码:首次执行 + 最多重试 2 次,共尝试 3 次。
for (int attempt = 1; attempt <= 3; attempt++) {
try {
// 每次调用都开启一个新事务:
// 检查余额 → A 扣款 → B 加款 → 提交。
transferInNewTransaction();
return; // 成功,结束。
} catch (SQLException e) {
// 不是死锁,或者尝试次数用完,就向外抛出。
if (e.getErrorCode() != 1213 || attempt == 3) {
throw e;
}
// 是死锁且还有次数,下一轮重试整个事务。
// 实际代码在这里加入短暂退避,避免立即再次争抢。
}
}这里的 transferInNewTransaction() 是示意方法,需要负责开启事务、提交,以及失败后的回滚和资源清理,并把异常向外抛出,不能吞掉异常。外层代码也需要声明或处理 SQLException。
第一次转账
→ 数据库发现死锁
→ 回滚被选中的事务,向应用返回 1213 错误
→ catch 捕获异常
→ 判断是死锁,并且还有重试次数
→ 开启新事务,重新执行完整转账如果使用 Spring,数据库异常通常会被包装成 Spring 的异常,要结合实际异常类型和底层错误码判断,不能直接照搬上面捕获 SQLException 的写法。 重试放在事务外层,每次尝试通过能够真正开启新事务的方法执行;不要在同一个已经失败的事务里反复执行 SQL。使用 @Transactional 时,还要避免同一个对象内部调用导致事务代理不生效。
可以按业务设置较小的重试上限,例如最多再试 2 次;每次重试前短暂退避,避免多个请求立即再次争抢。超过上限就提示失败或交给后续处理,不能无限重试。只有整个流程适合重新执行时才重试;如果里面已经调用了外部扣款接口,数据库回滚不会自动撤销外部扣款。
② 保证转账请求幂等:同一笔转账重复请求,也只扣一次钱。
“幂等”可以简单理解为:同一件事重复做请求,最终只产生一次业务效果。
例如,用户发起一笔转账,系统已经提交成功,但返回结果时网络断了。用户不知道是否成功,又点了一次。如果系统把它当成新转账,就可能再扣一次。
常见做法是给每笔转账一个唯一业务单号,例如 TRANSFER_001:
- 首次请求:记录业务单号,并完成扣款、加款。
- 重复请求:仍然使用
TRANSFER_001;系统识别为同一笔转账,返回已有结果,不重复扣款。 - 用户另发起一笔真正的新转账:使用新的业务单号。
业务单号要有数据库唯一约束,记录转账和修改余额要放在同一个事务里。 不能只靠“先查一下有没有这个单号”,因为两个并发请求可能同时查到“没有”。同一个单号还应核对转出账户、转入账户和金额,避免被用于不同转账。
死锁导致整个事务回滚后,可以带着原业务单号重试;如果上一笔其实已经提交成功,业务单号也能防止重复处理。
③ 缩短事务:拿到锁后尽快处理完,不要让别人一直排队。
例如下面这个流程会让账户锁被持有很久:
开始事务 → 修改账户并取得锁 → 调用远程服务等待 3 秒 → 提交这 3 秒内,其他想修改同一账户的事务也可能被挡住。远程服务越慢,排队越严重。
更合适的安排是:
- 不依赖账户实时状态的准备工作,尽量在开启事务前完成。
- 事务里只保留必要的查询、检查和数据库修改。
- 必须受锁保护的余额检查,仍然在事务里完成,不能为了缩短事务提前查完就直接相信旧结果。
- 通知、短信等操作通常放到提交后处理;如果要求可靠送达,可以在同一事务里记录“待发送任务”,提交后再发送并支持重试。
不是把所有检查都搬到事务外,而是减少持锁期间不必要的等待。
④ 使用合适索引:尽量直接找到目标记录,减少扫描和锁定范围。
例如,只想修改 ID 为 1 的账户:
UPDATE account
SET balance = balance - 100
WHERE id = 1;如果 id 是主键,数据库可以直接定位目标账户,不必为了找到它而扫描大量其他账户。
如果改用缺少合适索引的条件,数据库可能扫描更多记录。在某些隔离级别和执行计划下,扫描过程中会锁住更多索引记录或间隙,影响本来不相关的请求。
所以要看 WHERE 条件是否用到了合适索引,并结合 EXPLAIN 检查执行计划。 不是给表随便加一个索引就够了,也不能认为“最终只改一行,就一定只涉及这一行的锁”。
⑤ 大量数据分批处理:减少一笔事务占着大量锁的时间。
例如,要更新 10 万条历史记录。如果全部放在一个事务里,可能在很长时间内持有大量锁,让其他业务等待。
如果业务允许分批成功,可以按主键划分,每批处理一部分并提交,例如每批 500 条:
第一批:处理一批记录 → 提交 → 释放本批持有的锁
第二批:处理下一批记录 → 提交 → 释放本批持有的锁
……直到处理完成注意:只是把 SQL 拆成多条,但最后才统一提交,并不能达到每批提交释放锁的效果。 分批任务还需要记录进度,失败后能继续,重复执行不会把同一批业务效果算两次。
批次大小要根据实际耗时和竞争情况调整,500 只是例子。转账的扣款和加款必须一起成功或一起失败,不能拆成两批分别提交。 只有允许分批提交的任务才适合这样处理。
7.1.1 普通等待、超时和死锁,怎么区分
| 现象 | 含义 | 应用处理 |
|---|---|---|
| 锁等待 | B 等 A,A 仍能继续 | 控制事务时长,检查阻塞来源 |
| 锁等待超时,常见错误 1205 | 等待超过限制 | 明确结束失败事务,再决定是否重试 |
| 死锁,常见错误 1213 | 形成等待环,被检测后选取事务回滚 | 对整个业务事务有限重试,保持幂等 |
死锁检测通常由 innodb_deadlock_detect 控制;关闭时不能假设数据库立即检测并打断等待环。默认情况下,锁等待超时通常只回滚当前语句,而死锁回滚会撤销被选中的整个事务。应用不能在报错后不检查事务状态就继续提交剩余工作。
八、🚀 实践:观察一次行锁等待
8.1 前置准备
- MySQL 8.0 或 8.4;
- 使用 InnoDB 测试表;
- 准备两个独立数据库连接,分别作为事务 A 和事务 B;
- 下面包含建表和写入,只能在个人测试库练习,不要在共享库或生产库直接执行。
8.2 准备测试数据
CREATE TABLE lock_demo_account (
id BIGINT PRIMARY KEY,
balance DECIMAL(12, 2) NOT NULL
) ENGINE = InnoDB;
INSERT INTO lock_demo_account(id, balance) VALUES (1, 1000.00);8.3 事务 A 锁住记录
在连接 A 中执行:
START TRANSACTION;
SELECT balance
FROM lock_demo_account
WHERE id = 1
FOR UPDATE;
-- 暂时不要提交,用来观察另一个事务等待。8.4 事务 B 尝试修改同一记录
在连接 B 中执行:
START TRANSACTION;
UPDATE lock_demo_account
SET balance = balance - 100
WHERE id = 1;连接 B 会等待,因为连接 A 持有目标记录的排他锁。
回到连接 A 执行:
COMMIT;连接 A 释放锁后,连接 B 才能继续。最后在连接 B 中提交:
COMMIT;这个实验说明:锁不是让所有事务都停止,而是让访问同一冲突资源的事务按照顺序继续。
8.5 接着验证:RR 快照仍是 900,当前读却看到 800
上一个实验结束后,余额是 900,两个连接都已提交。下面是供读者在测试库手动运行的步骤;本篇仅编写示例,不连接数据库执行。
① 连接 B:新开 RR 事务,先建立快照。
-- SET TRANSACTION 只指定下一次事务,执行前应确保上一个事务已结束。
SET TRANSACTION ISOLATION LEVEL REPEATABLE READ;
START TRANSACTION;
SELECT balance FROM lock_demo_account WHERE id = 1;
-- 预期:900.00,建立本事务的普通读取视图。② 连接 A:扣款,暂时保留锁。
START TRANSACTION;
UPDATE lock_demo_account
SET balance = balance - 100
WHERE id = 1;
-- 当前记录变成 800.00,先不提交。③ 连接 B:先看旧版本,再尝试锁定当前记录。
SELECT balance FROM lock_demo_account WHERE id = 1;
-- 预期:900.00,通常立即返回,不必等 A。
SELECT balance FROM lock_demo_account WHERE id = 1 FOR UPDATE;
-- 此处等待:B 需要当前记录上的排他锁,A 还未释放。④ 连接 A 提交,让 B 继续。
COMMIT;
-- A 释放锁,B 上一步的 FOR UPDATE 返回 800.00。⑤ 连接 B:再普通读取,然后结束事务。
SELECT balance FROM lock_demo_account WHERE id = 1;
-- 预期仍是 900.00:B 没有修改记录,普通读取继续使用旧视图。
COMMIT;
-- B 释放 FOR UPDATE 获得的锁。记住这三个结果:普通读 900 → 锁定读等待后得到 800 → 普通读仍是 900。这把上一篇的 Read View 与本篇的锁放在了同一条时间线上。
8.6 结果不符合预期时,先检查什么
- B 一直不等待:检查 A 是否已经自动提交,两个窗口是否确实使用独立连接,以及目标行是否存在。
- B 普通查询直接看到
800:检查 B 是否使用 RC、是否提前提交,或首次普通查询是否发生在 A 提交之后。 - 等待变成 1205 超时:结束本轮两个连接的事务,再从已知数据状态重新练习,避免沿用部分执行的事务。
- 排查现场可使用第三个连接只读查看
performance_schema.data_locks与performance_schema.data_lock_waits;需要具备相应查看权限。
九、📚 补充阅读:意向锁如何协调表锁和行锁
这部分可以在理解前面的记录加锁后再读,不影响掌握扣款和库存的基本用法。
假设一张表是商场,每一行是一家店。某事务要锁住第 101 号店,不需要封锁整个商场,但会先在商场门口挂一块状态牌:
IS:里面有店铺正在加共享锁
IX:里面有店铺正在加排他锁这块牌子就是表级的意向锁。它不是把整张表的数据锁住,而是帮助另一个想加表锁的事务快速判断:表内是否已经存在可能冲突的行锁,不必逐行检查。
| 意向锁 | 表达的意思 |
|---|---|
| IS(意向共享锁) | 准备在表中的某些记录上加共享锁 |
| IX(意向排他锁) | 准备在表中的某些记录上加排他锁 |
9.1 什么业务场景会用到意向锁
日常下单、扣库存、转账时就会自动用到,通常不需要应用主动申请意向锁。
例如,下单时扣减一件商品的库存:
-- 假设 id 是主键,商品存在。
UPDATE product
SET stock = stock - 1
WHERE id = 1001
AND stock > 0;数据库会先在 product 表上申请意向排他锁(IX),再对要修改的商品记录申请行级排他锁(X)。
可以把表上的 IX 理解成门口的一块提示牌:
里面有人准备修改某些行。如果你想锁整张表,需要先检查是否和我冲突。
9.2 什么时候这块提示牌会发挥作用
当另一个任务想独占整张表时,就需要检查表级锁是否兼容。
例如,一个维护任务要申请商品表的整表写锁。LOCK TABLES product WRITE 是显式申请表级写锁的一种方式,这里只说明场景,不需要为了理解意向锁去执行它。
下单事务:在 product 表上持有 IX,并锁住商品 1001,尚未提交
维护任务:想取得不兼容的整表写锁
→ 表级锁检查发现冲突,需要等待
→ 不必逐行检查每件商品有没有行锁实际申请整表锁时也可能先等待元数据锁等其他锁;这里重点理解意向锁如何参与表锁与行锁的协调。
如果另一个用户只是买另一件商品,通常可以继续。 因为两个事务的 IX 意向锁之间兼容,不会仅仅因为表上已经有 IX 就挡住对方;后续是否等待,还要看它们申请的具体记录锁、间隙锁等是否冲突。
记住:扣库存、转账会自动产生意向锁;它主要协调“锁某些行”和“锁整张表”之间的冲突,不是把所有商品都锁住。
面试够用
意向锁是表级锁,用来协调表锁和行锁;它表达“表内某些记录将持有什么锁”,不等于直接锁住整张表的全部数据。
十、📌 场景对照与快速回顾
10.1 把锁类型放回真实场景
| 业务动作 | 主要机制 | 为什么 |
|---|---|---|
| 商品详情页查看库存 | MVCC 快照读 | 只展示数据,尽量不阻塞写入 |
| 秒杀扣一件库存 | 原子 UPDATE + 排他锁 | 必须围绕当前库存安全扣减 |
| 查完库存还要做多步判断 | SELECT ... FOR UPDATE | 防止判断到更新之间被别人改掉 |
| 审核记录且暂时不允许修改 | FOR SHARE | 多人可审核,冲突修改需等待 |
| 锁定金额范围内的订单 | Record/Gap/Next-Key Lock | 同时保护现有记录和相关插入范围 |
| 两个账户互相转账 | 排他锁 + 统一加锁顺序 | 防止反向持锁形成死锁 |
把前面的知识连起来
锁不是为了让数据库变慢,而是让会破坏结果的并发操作排队;索引决定“去哪找数据”,也会影响锁触及多大范围。
共享锁(S) = 多个共享锁可以兼容,阻止冲突的排他锁
排他锁(X) = 修改当前记录时协调冲突操作
意向锁 = 在表级表达记录加锁意图,协调表锁和行锁
Record Lock = 锁住已有索引记录
Gap Lock = 锁住索引记录之间的间隙
Next-Key Lock = 记录锁 + 记录前的间隙锁
死锁 = 事务互相持锁等待,形成等待环能用一条条件更新完成的并发修改,优先使用原子更新;需要先查询再做多步判断时,可在短事务中使用锁定读。保持一致的加锁顺序、合适的索引和较短的事务,并处理死锁重试。
相关笔记:05.MySQL 事务、04.MySQL 索引、06.MySQL 隔离级别、07.MySQL MVCC
十一、🎤 高频面试题
11.1 MVCC 能完全代替锁吗
不能。MVCC 主要通过 Read View 和 undo 版本链优化普通快照读;写写冲突、当前读和锁定读仍然需要锁。
11.2 什么是 next-key lock
next-key lock 可以理解为记录锁与记录前间隙锁的组合。InnoDB 在范围当前读或范围修改中使用它保护已存在的索引记录及相关插入范围,从而处理常见幻读问题。
11.3 如何减少死锁
保持一致的资源访问顺序,缩短事务,使用合适索引缩小加锁范围,避免大事务;应用还要捕获死锁异常,对适合重试的短事务进行有限重试并保证幂等。