InnoDB 事务并发:隔离级别、MVCC、锁、日志与死锁¶
1. 事务真正解决的是“多步修改在并发和故障下仍然可解释”¶
数据库中的一条 UPDATE 很容易理解,难点从两条 SQL 必须共同成立开始出现。假设订单服务创建订单时需要同时写 orders 和 inventory:订单写成功但库存扣减失败,会留下无法履约的订单;库存已经扣减但进程崩溃在订单写入之前,则库存凭空减少。事务把一组操作定义成一个提交单元,提交后它们共同生效,回滚后恢复到事务开始前可接受的状态。
并发使问题进一步复杂。事务 A 和事务 B 可能同时修改同一行,也可能读取同一范围后分别插入数据。数据库需要回答三个问题:一个事务读取时应该看到哪个版本;多个事务想改同一批记录时谁需要等待;机器在修改的数据页还没有真正落盘时宕机,重启后怎样恢复已经提交的数据。InnoDB 分别通过 MVCC、锁、undo/redo 等机制共同完成这些工作。
2. ACID:四个字母对应四类约束¶
Atomicity(原子性)要求一个事务的修改整体提交或整体回滚。InnoDB 在回滚时需要知道怎样恢复被修改前的内容,因此修改会生成 undo 信息。Isolation(隔离性)描述并发事务之间能观察到什么,InnoDB 使用 MVCC 和锁实现不同隔离级别。Durability(持久性)要求事务一旦向客户端确认提交,即使数据页仍留在缓冲池(Buffer Pool),异常重启后也能恢复,redo log 是这里的核心机制。
Consistency(一致性)更接近事务执行前后数据需要满足业务规则和数据库约束。例如账户余额不能小于零、订单必须引用存在的用户、库存扣减和订单状态要满足业务状态机。数据库能提供主键、唯一键、外键、事务原子性与隔离性等工具,但业务一致性仍然依赖应用写出正确的事务逻辑。
3. 并发异常:先理解现象,再理解隔离级别¶
假设 account(id, balance) 中 id=1 的余额初始为 100。事务 B 将其修改为 50 但尚未提交,此时事务 A 如果能读到 50,这属于脏读;A 观察到了另一个可能最终回滚的事务状态。
不可重复读关注同一行。事务 A 第一次读取余额为 100,事务 B 将其更新为 50 并提交,事务 A 第二次读取同一行得到 50。两次读取针对同一记录,却因为其他事务的提交产生变化。
幻读关注满足一个查询条件的记录集合。事务 A 执行 SELECT * FROM task WHERE priority >= 10 得到 5 行;事务 B 插入一条 priority=20 的记录并提交;如果 A 再次执行相同范围查询看到 6 行,就出现了幻读。范围中“多出一行”说明仅锁住当前存在的记录还不够,当前并不存在的位置也可能需要被保护。
丢失更新常作为业务并发问题讨论。两个事务都先读取 counter=10,各自在应用里计算 counter+1,然后分别写回 11,最终结果仍是 11。数据库隔离级别的标准异常表不能替代对这种读改写模式的分析。实际业务通常采用原子 SQL、SELECT ... FOR UPDATE、版本号 CAS 或其他并发控制手段。
4. InnoDB 四种隔离级别¶
InnoDB 支持 READ UNCOMMITTED、READ COMMITTED、REPEATABLE READ 和 SERIALIZABLE。MySQL 8.4 的 InnoDB 默认隔离级别为 REPEATABLE READ(RR)。隔离级别控制普通一致性读创建快照的时机,也会影响锁定读和范围修改的加锁方式。
| 隔离级别 | 普通一致性读的可见性 | 典型现象 |
|---|---|---|
| READ UNCOMMITTED | 可以观察未提交版本 | 可能脏读 |
| READ COMMITTED(RC) | 每次一致性读创建新的快照 | 同一事务两次 SELECT 可能看到不同已提交版本 |
| REPEATABLE READ(RR) | 同一事务的普通一致性读复用第一次一致性读建立的快照 | 多次普通 SELECT 通常保持一致 |
| SERIALIZABLE | 普通 SELECT 的并发控制更严格,通常以锁定方式参与并发 | 并发度最低 |
RC 与 RR 最值得掌握。RC 中每一条普通一致性读都会建立新的“一致性视图”(Read View),所以第一次 SELECT 后其他事务提交的数据,第二次 SELECT 可以看到。RR 中同一事务后续普通一致性读通常复用第一次一致性读建立的一致性视图,因此其他事务之后提交的修改不会进入这个快照。
一个经常被说错的细节是“RR 在 START TRANSACTION 时立刻创建快照”。普通 START TRANSACTION 并不会自动建立一致性读快照。对于普通 RR 事务,快照通常由第一次一致性读建立。START TRANSACTION WITH CONSISTENT SNAPSHOT 才显式要求开始时建立一致性快照。
5. MVCC:同一逻辑行可以沿 undo 找到旧版本¶
MVCC(Multi-Version Concurrency Control,多版本并发控制)让读事务在很多情况下无需等待写事务释放行锁,就能读取符合自己快照的数据版本。InnoDB 的聚簇索引记录包含事务相关的隐藏信息,其中 DB_TRX_ID 记录最近一次插入或更新该行的事务标识,DB_ROLL_PTR 指向相关 undo log record。行被多次更新后,可以沿这些 undo 记录向前重建旧版本。
假设 task(id=1, status) 依次经历:事务 100 写入 PENDING,事务 120 改为 RUNNING,事务 150 改为 DONE。当前聚簇记录保存最新内容及其事务信息,undo 中保留足够的信息来重建此前版本。一个较早创建的一致性视图如果不能看到事务 150,就沿 DB_ROLL_PTR 找旧版本;如果事务 120 也不可见,就继续向前,直到找到可见版本或确认该行在快照中不存在。
Undo 同时承担回滚和一致性读两类职责。插入产生的 undo 在事务提交后不再需要给其他事务重建“插入之前的这行”,通常可以更早释放;更新产生的 undo 可能仍被旧快照需要,因此要等没有任何活跃一致性视图需要这些旧版本后,后台清理线程才能最终回收。长事务会长期持有旧快照,使 undo 历史记录不能及时清理。
6. 一致性视图怎样决定一个版本能不能看¶
理解一致性视图可以把问题简化成“创建快照时,哪些事务已经结束,哪些事务还活着”。一致性视图会记录创建时活跃的读写事务集合及事务 ID 的边界。读取某个版本时,InnoDB 查看该版本的 DB_TRX_ID:很早已经提交的版本可以看到;创建快照时仍然活跃的其他事务版本不能看到;快照创建之后才启动的事务版本也不能看到;当前事务自己的修改需要对自己可见。
举一个简化例子。一致性视图建立时,事务 20、25 仍活跃,下一个即将分配的事务 ID 为 30。某行版本由事务 18 生成,18 已经提交,因此当前快照可以看到。另一个版本由事务 25 生成,而 25 在快照建立时仍活跃,所以当前快照不能看到它。事务 31 在快照之后才启动,它生成的新版本也不能进入这个旧快照。
学习中经常要求解释“RC 与 RR 的 MVCC 差异”。版本链本身没有因为隔离级别变化而消失,核心差异在一致性视图的生命周期:RC 每次一致性读创建新的视图,RR 同一事务中的一致性读复用第一次建立的视图。于是相同的版本链在两种隔离级别下可能得到不同结果。
7. 快照读和当前读:同一事务可以看到两种不同时间语义¶
普通 SELECT 在 RC/RR 下通常属于一致性非锁定读,也就是常说的快照读。它根据一致性视图选择可见版本,不为了读取历史版本去等待其他事务的写锁。
SELECT ... FOR UPDATE、SELECT ... FOR SHARE、UPDATE、DELETE 属于锁定读或需要修改最新记录的操作。它们需要面向当前可操作的数据状态,并对扫描到的索引记录加锁。InnoDB 官方文档特别提醒,在 RR 事务里混合普通非锁定 SELECT 和锁定语句时,两者可能面对不同的数据库状态:普通 SELECT 仍看旧快照,锁定语句则基于当前状态执行加锁。
例如事务 A 在 RR 下先普通读取 status='PENDING',随后事务 B 把这一行改为 RUNNING 并提交。A 再执行相同普通 SELECT 时仍可能从快照重建出 PENDING;如果 A 随后执行 SELECT ... FOR UPDATE,它需要处理当前记录,并可能看到已经提交的新状态。这是理解“快照读”和“当前读”差异的关键案例。
8. InnoDB 的行锁实际锁在索引记录上¶
InnoDB 常被描述为“行级锁”,实际锁对象与索引访问路径紧密相关。记录锁锁住索引记录;如果 SQL 使用二级索引定位并需要对记录加排他锁,InnoDB 还会取得对应聚簇索引记录上的锁。SQL 扫描哪些索引记录,直接影响最终锁住多少数据。
因此索引设计会影响并发。执行 UPDATE task SET status='DONE' WHERE owner_id=42 时,如果 owner_id 有合适索引,扫描范围可以比较集中;如果没有可用索引,InnoDB 可能需要扫描整张表。官方文档明确指出,当 UPDATE/锁定读没有合适索引而必须扫描全表时,扫描到的记录都会被锁,其他会话的并发插入和更新会受到明显影响。
普通唯一主键等值查询是最容易分析的情况。在 RR 下执行 SELECT * FROM task WHERE id=20 FOR UPDATE,id 是唯一索引且条件唯一确定一条记录时,InnoDB 只需要对找到的索引记录加记录锁,不需要额外锁住它前面的间隙。
9. 间隙锁与临键锁:保护“当前还没有记录的位置”¶
只锁已有记录无法阻止其他事务在范围中插入新记录。假设索引中目前存在 10, 20, 30,事务 A 执行一个需要锁定 id > 20 范围的语句。如果只锁 30,事务 B 仍然可以插入 25,A 后续再次执行范围当前读就会多出一条记录。
间隙锁锁的是索引记录之间的空隙,例如 (20,30);它的主要作用是阻止其他事务在这个间隙插入新索引记录。临键锁可以理解为“记录锁 + 该记录前面的间隙锁”。对于现有记录 10,20,30,典型临键区间可表达为 (-∞,10]、(10,20]、(20,30]、(30,+∞)。范围扫描会根据实际索引访问路径取得相应区间上的锁。
在 InnoDB 默认 RR 下,范围型锁定读、UPDATE 和 DELETE 通常会使用间隙锁或临键锁来防止扫描范围内出现新的记录。RC 下大多数普通搜索和扫描不使用间隙锁,外键检查与重复键检查等场景仍可能使用,因此 RC 的并发插入空间更大,也允许标准意义上的幻读出现。
间隙锁的语义重点在“禁止向这个间隙插入新记录”。它和普通排他记录锁的冲突规则不同。多个事务可以同时持有同一间隙上的间隙锁,因为这些锁的目的都是阻止新记录插入。真正的插入会申请插入意向锁;如果目标间隙已经被间隙锁保护,插入需要等待。
10. 手工推演三个典型加锁场景¶
假设表只有主键 id,现有值为 10,20,30,隔离级别为 RR。
第一种是唯一等值:SELECT * FROM t WHERE id=20 FOR UPDATE。主键唯一并且条件精确定位现有记录,主要锁住 id=20 这条索引记录。其他事务不能更新或删除 20,但仍可在 20 和 30 之间插入 25。
第二种是范围当前读:SELECT * FROM t WHERE id>20 FOR UPDATE。执行计划沿主键索引扫描 30 以及索引末端的上界伪记录区域,为保护范围,典型上会覆盖 (20,30] 和 (30,+∞)。此时另一个事务想插入 25 或 40 都会落入被保护的间隙,需要等待事务 A 结束。
第三种是非索引条件:UPDATE t SET ... WHERE non_index_col=5。为了判断哪些行满足条件,执行引擎可能扫描大量聚簇索引记录并对扫描记录加锁。即使业务上最终只改少量行,锁足迹也可能很大。这就是“索引既影响查询成本,也影响并发锁范围”的原因。
精确锁区间最终受索引、查询条件、执行计划、隔离级别和 MySQL 版本影响。学习拿到具体 SQL 时,应先确认索引定义和数据分布,再沿实际索引扫描范围分析锁,而不能只看 WHERE 文本猜锁。
11. 幻读:快照语义和范围锁要分开讨论¶
RR 下普通一致性读依赖 MVCC 快照。事务 A 第一次查询某个范围后,事务 B 新插入一行并提交,A 后续再次执行相同普通 SELECT 时仍使用原一致性视图,因此新行对这个快照不可见。从结果上看,普通快照读避免了重复查询时看到新增行。
当前读的目标不同。SELECT ... FOR UPDATE 或 UPDATE 需要作用于当前可操作的数据。如果事务需要保证一个范围在事务期间不能被其他会话插入新的符合条件记录,就需要范围锁。InnoDB 在 RR 下通过临键锁同时保护现有索引记录和相邻间隙,从写入入口阻止新的符合条件记录插入。
所以回答“RR 如何处理幻读”时,需要先说清查询类型。快照读依赖一致性快照保持结果视图,当前读依赖临键锁和间隙锁保护扫描范围。把所有情况统一概括成“MVCC 解决幻读”会丢失最重要的实现差异。
12. SELECT ... FOR UPDATE 在业务里解决什么问题¶
考虑抢占一个待执行任务:
START TRANSACTION;
SELECT status
FROM jobs
WHERE id = 42
FOR UPDATE;
-- 应用确认 status = 'PENDING'
UPDATE jobs
SET status = 'RUNNING'
WHERE id = 42;
COMMIT;
事务 A 对目标记录取得排他锁后,事务 B 对同一记录执行冲突的锁定读或更新会等待。这样应用可以在一个事务里“读取当前状态 -> 判断 -> 修改”,避免两个工作线程同时把同一个任务当成自己的任务。
如果只执行普通 SELECT,再在应用里判断后 UPDATE,中间存在并发窗口。更轻量的方案可以直接使用带条件的原子 UPDATE,例如 UPDATE jobs SET status='RUNNING' WHERE id=42 AND status='PENDING',再检查 affected rows。业务选型取决于是否需要读取更多状态、执行多条相关修改以及冲突概率。
13. 悲观锁与乐观锁:冲突发生前后怎样控制并发¶
悲观锁和乐观锁描述的是两种并发控制策略。它们真正的区别在于应用怎样面对“其他请求可能同时修改这份数据”这件事。悲观锁在业务判断之前先取得数据库锁,让竞争者等待;乐观锁允许多个请求先并发读取和计算,最终写入时再检查自己读取的版本是否已经过期。两种方式最终执行数据库写操作时都会受到 InnoDB 的正常行锁和事务机制约束,所以这里讨论的是应用层如何组织读、判断和写的时序。
13.1 悲观锁:先取得修改权,再读取和判断¶
假设订单只能从 PENDING 变成 PAID,支付逻辑还需要同时检查订单金额、优惠券状态并写入支付记录。如果这些判断必须基于同一份当前状态完成,可以先在事务内执行锁定读:
BEGIN;
SELECT status, amount, coupon_id
FROM orders
WHERE id = 42
FOR UPDATE;
-- 应用检查当前状态,并执行同一事务内的相关修改
UPDATE orders
SET status = 'PAID'
WHERE id = 42;
COMMIT;
FOR UPDATE 会对实际搜索到的索引记录取得排他性质的锁。事务 A 持有目标行后,事务 B 对同一行执行冲突更新或锁定读时会进入等待,直到 A 提交或回滚。这样,“读取当前状态 -> 执行业务判断 -> 修改”被放在一个受锁保护的临界区里。MySQL 允许配合 NOWAIT 立即失败,或者在队列式任务场景使用 SKIP LOCKED 跳过已经被其他事务锁住的记录。
悲观锁的成本主要来自等待和连接占用。竞争越激烈,更多事务会排在同一行锁后面;事务持锁时间越长,后续请求等待越久。事务同时锁多行时还可能形成死锁,所以悲观锁场景通常要求事务短、加锁顺序稳定、锁定查询有合适索引。对于高度竞争但临界区很短的单行资源,主动排队有时比反复做失败的乐观更新更省资源。
13.2 乐观锁:写入时验证“我读到的版本仍然有效”¶
数据库里最常见的乐观锁实现是版本号。读取订单时同时拿到 version=7,应用可以在事务外完成较轻的计算,最终更新时把旧版本放进条件:
UPDATE orders
SET status = 'PAID',
version = version + 1
WHERE id = 42
AND status = 'PENDING'
AND version = 7;
影响 1 行说明版本检查通过,本次更新成功;影响 0 行说明这份数据在读取之后已经被其他事务修改,或者业务状态已经变化。此时应用必须重新读取最新数据,再基于新状态重新判断。继续拿旧的 version=7 原样重试没有意义,因为这个条件已经永久失效。
这个过程与 CAS(Compare And Swap,比较并交换)的思想一致:提交修改时同时比较“预期旧值”和“当前值”。CPU 原子指令里的 CAS 通常直接比较内存值;数据库业务更常用版本号或状态字段做条件更新。版本号还有一个好处:即使业务字段经历 A -> B -> A,版本已经递增,仍然能够识别数据在期间发生过变化,这可以避免只比较业务值时常说的 ABA 问题。
乐观锁适合冲突率较低、读取和计算可以并发进行的场景。它减少了读取阶段的锁等待,但冲突发生后,前面完成的读取、计算以及一次失败更新都成为额外成本。因此“读多写少”只是一个粗略经验,更重要的指标是同一份数据在同一时间窗口里的实际写冲突率。
13.3 高并发下乐观锁持续冲突时怎么办¶
这是学习中很常见的继续追问。假设 100 个请求几乎同时读取同一行的 version=7,最终只有最先提交的一个请求能把版本更新成 8,其余请求的条件更新都会影响 0 行。失败请求如果立刻重新读取并再次提交,又可能在 version=8、version=9 上继续彼此冲突。无限立即重试会制造大量无效查询、无效更新和应用计算,还可能让少数请求长期抢不到成功机会。
首先要把短暂冲突和持续热点分开处理。偶发冲突可以做有限次数重试,每次失败后重新读取最新数据并重新计算;重试之间加入退避和随机抖动,可以降低一批请求同时再次撞到同一版本的概率。重试必须有上限,例如 2~3 次后返回“数据已变化,请重新操作”、进入稍后重试队列,或者由上层业务决定降级。这里的核心原则是有界重试,线上不能把“乐观锁”实现成无休止的自旋。
如果冲突率长期很高,说明这份资源本身就是热点,继续使用乐观策略会反复浪费工作。此时可以改成悲观锁,让竞争请求在数据库里排队;如果操作可以表达成单条条件更新,直接使用原子 SQL 往往更简单,例如库存扣减:
这类 SQL 把条件检查和修改放在一次数据库写操作中,应用只根据影响行数判断成功或库存不足,省去了“读取版本 -> 计算 -> 版本冲突 -> 再读取”的循环。它仍会在 InnoDB 内部对同一记录串行修改,但应用层不再制造额外的版本竞争。
如果业务确实需要多步计算,同时所有请求都集中修改同一个热点对象,还可以把同一业务键的写请求放进同一串行执行通道,例如按 order_id 或 sku_id 路由到固定队列分区,让单个热点键按顺序处理。另一个方向是改变数据模型,把一个全局热点拆成多个可以独立更新的分片,再在读取时汇总。后两种方式已经开始改变系统架构,只有确认单行热点是主要瓶颈时才值得采用。
所以“乐观锁一直冲突怎么办”不能只回答“继续重试”。一个完整判断顺序是:先看冲突是否偶发;偶发冲突做有限重试、重新读取和退避;持续高冲突时评估单条原子更新能否解决;需要多步强一致判断时考虑悲观锁;单个业务键已经成为系统热点时,再考虑按键串行化或拆分热点。无论选择哪种方案,都要观察版本冲突率、平均/高分位重试次数、更新影响 0 行比例、锁等待时间和接口延迟,依据真实竞争程度调整方案。
13.4 两种策略怎样选择¶
选择时可以直接比较“等待成本”和“失败重做成本”。悲观锁让竞争请求等待,但成功获得锁后通常只执行一次业务逻辑;乐观锁允许请求先并发工作,但冲突的请求需要丢弃旧结果并重做。冲突很少、计算较轻时,乐观锁并发度高;冲突很高或一次业务计算很贵时,悲观锁或主动串行化可能更经济。多行、多步骤事务还要额外考虑持锁时间和死锁风险,单行简单状态迁移则优先检查能否用一条条件 UPDATE 完成。
工程上如果需要判断“高并发更新订单到底用乐观还是悲观”,不要只按“读多写少/写多读少”背结论。先说明冲突对象是不是同一行、冲突概率、一次失败需要重做多少工作、事务需要锁几行、能否用单条条件更新表达,再给出选择。这样的回答才能从锁的定义进入真实工程取舍。
14. 死锁:锁顺序形成等待环¶
死锁意味着多个事务形成循环等待。最经典的例子是两行数据 id=1 和 id=2:事务 A 先更新 1,事务 B 先更新 2;随后 A 等待 2,B 等待 1。只要双方都保持自己已有的锁等待另一把锁,就不会自然前进。
T1: UPDATE ... WHERE id=1; -- 持有 id=1
T2: UPDATE ... WHERE id=2; -- 持有 id=2
T1: UPDATE ... WHERE id=2; -- 等待 T2
T2: UPDATE ... WHERE id=1; -- 等待 T1,形成环
InnoDB 默认启用死锁检测。发现等待环后会选择一个事务回滚,使其他事务继续。应用必须把死锁当成一种可能发生的并发结果,捕获对应错误并重试整个事务。官方文档也明确要求死锁导致整个事务回滚后重试事务。
减少死锁通常依靠缩短事务、减少锁范围、为锁定查询提供合适索引,以及让不同代码路径按照一致顺序访问资源。例如所有转账事务都先锁较小账户 ID,再锁较大账户 ID,可以消除一类相反锁顺序产生的等待环。
15. 锁等待超时与死锁是两种故障¶
事务 A 持有一把锁,事务 B 等待这把锁,并不自动构成死锁。如果 A 仍然能够继续执行并提交,B 只是普通锁等待。等待超过 innodb_lock_wait_timeout 后会出现锁等待超时。
死锁存在循环依赖,InnoDB 可以通过等待关系检测提前发现,不需要等到超时时间。MySQL 8.4 默认情况下,死锁会回滚其中一个完整事务;锁等待超时默认只回滚发生超时的当前语句,除非启用了 innodb_rollback_on_timeout。应用处理这两类错误时要明确自己需要重新执行一条语句还是整个业务事务。
16. 长事务为什么危险¶
长事务会长期占有它已经获取的锁,使其他事务等待时间增加。即使事务只做普通快照读,没有持有大量行锁,它的一致性视图也可能让旧版本长期不能被后台清理。InnoDB 为了让这个旧快照继续重建历史行,需要保留相关更新 undo,undo 表空间和历史记录会增长,后台清理进度也可能落后。
另一个风险是大事务回滚成本。事务修改了大量记录后发生错误,undo 需要逐步把这些修改恢复。回滚本身可能持续很久。数据库事务边界应该围绕一个真正需要原子提交的业务单元设计,避免把网络调用、人工等待、长时间计算等无关操作放在持锁事务内部。
17. Undo、Redo 与 Binlog 各自记录什么¶
Undo log 服务于事务回滚和 MVCC 历史版本。它表达怎样从当前修改恢复到之前状态,或者提供足够信息重建旧行。Redo log 属于 InnoDB 的 WAL(Write-Ahead Logging,预写日志)体系,记录对数据页的修改,使事务提交时无需把所有脏页同步刷回表空间;只要相关 redo 已达到所需的持久化条件,崩溃恢复就可以从检查点之后重放修改。
这解释了为什么数据库更新一行时,数据页可以先留在缓冲池。若每次 COMMIT 都要求把所有涉及的数据页随机写回磁盘,提交延迟会很高。Redo 采用追加式日志路径记录修改,后台再通过检查点和刷脏逐步把数据页持久化。异常重启后,InnoDB 从检查点向后扫描 redo,恢复尚未写回数据文件的已记录修改。
Binary Log(binlog)位于 MySQL Server 层,记录数据库变更事件,主要用于复制和基于日志的恢复。InnoDB redo 描述存储引擎页修改和崩溃恢复,binlog 面向 MySQL 层的变更事件,两者职责不同。事务提交时需要保证存储引擎事务状态与 binlog 的提交状态协调,否则可能出现主库已经提交但复制日志缺失,或者 binlog 有事务而 InnoDB 最终没有提交的问题。
18. MySQL 为什么需要协调 InnoDB 提交与 Binlog¶
对启用 binlog 的 InnoDB 事务,MySQL 需要协调 InnoDB 的事务提交与 Server 层 binlog。可以把核心流程理解为:InnoDB 先把事务推进到 prepared 状态并持久化必要的 redo;Server 写入并按配置持久化 binlog;随后 InnoDB 完成 commit。崩溃恢复时,prepared 事务可以结合 binlog 中是否存在对应提交记录来决定完成提交还是回滚,从而让两套日志保持一致的提交结论。
这里的“两阶段提交”是 MySQL 内部协调存储引擎事务与 binlog 的提交协议,与跨多个独立数据库节点的业务分布式事务不是同一个讨论层次。学习继续追问 innodb_flush_log_at_trx_commit 和 sync_binlog 时,问题已经进入“日志什么时候真正 fsync 到持久介质、性能与数据丢失窗口如何取舍”。在强调可靠性的部署中,常见配置是 innodb_flush_log_at_trx_commit=1 与 sync_binlog=1。
19. 一个完整并发案例:库存扣减¶
表定义:
两个请求同时购买最后一个库存。如果应用先普通 SELECT 得到 stock=1,再分别在应用里判断,两个请求都可能认为库存充足。一个直接且常用的写法是把条件检查放进原子 UPDATE:
InnoDB 对同一主键记录的修改会串行获取排他记录锁。第一个事务把 stock 从 1 改到 0 后,第二个事务获得锁时重新评估写条件,stock > 0 已不成立,affected rows 为 0。这里无需先把数据读到应用再加锁判断,数据库的当前读和写锁已经提供了原子竞争点。
如果业务还需要同时创建订单、扣优惠券、记录库存流水,就可以把这些 SQL 放进一个事务。事务要尽量短,外部 RPC 或耗时计算放在事务外准备,减少持锁时间。
20. 排查锁等待与死锁应该看什么证据¶
生产环境遇到“SQL 突然卡住”时,先确认它是在执行慢,还是在等待锁。MySQL 8.4 可以通过 Performance Schema 的 data_locks、data_lock_waits 等信息观察锁与等待关系;SHOW ENGINE INNODB STATUS 可以查看最近一次 InnoDB 死锁的参与事务、持有锁和等待锁。高频死锁可以临时开启 innodb_print_all_deadlocks 把每次死锁记录到错误日志。
拿到死锁日志后按执行顺序重建等待图:事务 A 已持有什么、下一步在等什么;事务 B 已持有什么、下一步在等什么;SQL 使用了哪个索引、扫描了多少范围。随后再决定是统一锁顺序、缩小事务、补索引、减少范围锁,还是在应用增加合理的死锁重试。单纯增大 innodb_lock_wait_timeout 无法消除死锁环。
参考资料¶
- MySQL 8.4 Reference Manual, Transaction Isolation Levels.
- MySQL 8.4 Reference Manual, InnoDB Multi-Versioning.
- MySQL 8.4 Reference Manual, Consistent Nonlocking Reads.
- MySQL 8.4 Reference Manual, InnoDB Locking / Locks Set by Different SQL Statements.
- MySQL 8.4 Reference Manual, Phantom Rows / Next-Key Locking.
- MySQL 8.4 Reference Manual, Undo Logs / Redo Log / InnoDB and the ACID Model.
- MySQL 8.4 Reference Manual, Deadlocks in InnoDB / Error Handling.