在 InnoDB 中,理解事务不能只记住“隔离越强,锁越多”。普通查询可以读取历史版本,加锁查询和更新则要协调当前记录;同一段业务逻辑采用不同读法,结果可能完全不同。
本文以 MySQL 8.0、InnoDB 为范围,用余额更新和范围查询说明三个问题:读到哪个版本、阻止哪些并发操作、冲突发生后如何恢复。
事务保证什么,业务还要保证什么
转账需要将扣款和入账放进同一个事务,使两项数据库变更一起提交或回滚。但“不允许余额为负”“收款账户必须存在”等业务约束,仍需要正确的 SQL、约束定义和应用处理。原子性不能自动推出业务一致性。
| ACID 性质 | 在这里承担的职责 |
|---|---|
| 原子性 | 失败时撤销本事务变更,避免只完成一半 |
| 一致性 | 正确事务保持已定义的数据与业务约束 |
| 隔离性 | 按选定级别限制并发事务可观察的相互影响 |
| 持久性 | 在相应日志刷盘与存储可靠性前提下保留已提交结果 |
redo、undo、锁和 MVCC 分别参与这些机制,但它们不是四个字母的一对一替代品。持久性还要核对 innodb_flush_log_at_trx_commit、binlog 配置及实际存储行为。
先区分几种常见异常:
| 异常 | 例子 |
|---|---|
| 脏读 | 读到另一个事务尚未提交、后来又被回滚的余额 |
| 不可重复读 | 两次读取同一记录,看到其他事务已提交的修改或删除 |
| 幻读 | 相同谓词下符合条件的记录集合变化,不只限于 INSERT 或行数增加 |
| 丢失更新 | 两个请求都按余额 1000 计算,再分别写入 800 和 700,覆盖了其中一次扣款 |
InnoDB 的写锁阻止两个事务同时修改同一记录,但不能自动发现应用先前读取的 1000 已经过时。必须把约束放进更新,或使用锁定读、版本号等协调机制。
RC 与 RR 的核心差别是读视图
普通 SELECT 在 RC 和 RR 下通常是一致性非锁定读,不是“RC 读完释放共享锁、RR 把共享锁持有到事务末尾”。
| 隔离级别 | InnoDB 中的主要读行为 |
|---|---|
| Read Uncommitted | 普通读可能看见未提交版本 |
| Read Committed(RC) | 每次一致性读建立新的快照 |
| Repeatable Read(RR,默认) | 同一事务的一致性读复用首次此类读取建立的快照 |
| Serializable | 关闭 autocommit 时,普通 SELECT 隐式采用 FOR SHARE 语义,增加串行化约束 |
这些是实现行为,不能拿来给所有数据库的同名隔离级别下结论。Serializable 要求执行结果具有可串行化性质,并不等于所有事务只能一个接一个运行,也不保证错误业务代码自动正确。InnoDB 隔离级别
下面是说明快照建立时机的会话顺序,accounts 中预先存在 id=1、balance=1000:
| 顺序 | 会话 A(RR) | 会话 B |
|---|---|---|
| 1 | BEGIN | — |
| 2 | — | 更新余额为 900 并提交 |
| 3 | 普通 SELECT 读到 900,建立读视图 | — |
| 4 | — | 更新余额为 800 并提交 |
| 5 | 再次普通 SELECT 仍读到 900 | — |
| 6 | COMMIT | — |
因此快照一般不是在 BEGIN 那一刻建立。显式使用 WITH CONSISTENT SNAPSHOT 还涉及隔离级别等适用条件。本事务自己的写入对后续读取可见,所以也不能把快照解释成“事务内所有查询都看到一张永远不变的表”。一致性非锁定读
加锁读保护的是后续操作的前提
如果读取余额之后必须基于它做多步修改,可以在事务中使用锁定读:
START TRANSACTION;
SELECT balance FROM accounts WHERE id = 1 FOR UPDATE;
-- 应用在同一连接中校验并执行后续变更
COMMIT;
FOR UPDATE、UPDATE、DELETE 不按普通 SELECT 的旧读视图选择待操作版本。遇到冲突会等待,并按具体语句和隔离级别处理;它们也能看到本事务自己的未提交写入,不能简单说成“只读最新已提交数据”。
混合两种读法时要明确目的。例如先做快照查询,再更新此前由他人新插入的记录,之后 SELECT 可能看见自己刚更新的记录。这是可见性规则共同作用的结果,不是加上一把间隙锁就能把此前的读取追溯修正。
如果需求只是“余额足够才扣款”,可以把检查和修改合在同一语句:
UPDATE accounts
SET balance = balance - 100
WHERE id = 1 AND balance >= 100;
调用方必须检查影响行数。需要与另一账户入账一起完成时,仍要把两步置于同一事务;影响行数为 0 时回滚并返回相应业务结果。若请求可能重复,还需要业务幂等标识,原子扣减并不识别重复付款。
从索引记录理解锁范围
InnoDB 的记录锁落在索引记录上。下面三个概念分别回答“锁已有对象”与“阻止新增对象”:
| 类型 | 含义 |
|---|---|
| Record Lock | 锁定一条索引记录 |
| Gap Lock | 阻止向某个索引间隙插入 |
| Next-Key Lock | 一条记录及其前面的间隙 |
假设索引键为 10、25、30,记录 25 对应的 next-key 区间可以表示为 (10, 25]。范围锁可能覆盖谓词以外的一部分间隙,这是按实际索引边界锁定的结果。RR 下唯一索引的完整等值查询找到记录时,通常只需记录锁;查找不存在值、部分联合键或范围时不能套用这一简化。
Gap Lock 的目标是阻止插入,不是让所有 gap 锁彼此排斥。同一间隙的 gap 锁可以共存,而插入意向会与阻止该位置插入的锁发生等待。RC 减少了搜索和扫描的间隙锁,但外键与重复键检查仍可能使用它。InnoDB 锁类型
S/X 的冲突必须限定在同一受保护对象和具体锁类型上。表级 IS/IX 用于协调表锁与记录锁,不等同于 DDL 所涉及的元数据锁。
没有合适索引不意味着发生锁升级。 全表扫描可能接触并锁定很大范围,在 RR 与 RC 下保留非匹配记录锁的行为也不同。其效果可能严重限制写入,但不能写成“InnoDB 把所有行锁升级为一个表锁”。普通一致性读也不因此必然被这些记录锁阻塞。
死锁诊断要还原等待环
原稿中的历史排查涉及 user_praise/plan_hot 更新以及 coupon 条件更新。仅凭这些 SQL,不能认定它们属于同一个等待环,也不能证明原因必然是“二级索引先锁、主键后锁”。
能支持结论的证据应包括当时的事务语句、索引定义、执行计划,以及各事务持有和请求的锁。下面是一个独立的说明性调度:
| 顺序 | 事务 A | 事务 B |
|---|---|---|
| 1 | 更新主键 1,持有该记录锁 | — |
| 2 | — | 更新主键 2,持有该记录锁 |
| 3 | 更新主键 2,等待 B | — |
| 4 | — | 更新主键 1,等待 A |
两个事务都通过主键更新,仍形成循环等待。因此“先 SELECT id,再 UPDATE WHERE id=?”不能保证没有死锁。它可能缩小扫描范围,但多行顺序、二级索引维护、外键和其他事务路径依然需要核对。
MySQL 客户端可以查看最近一次死锁:
SHOW ENGINE INNODB STATUS;
SELECT * FROM performance_schema.data_locks;
SELECT * FROM performance_schema.data_lock_waits;
记录已经被回滚清理后,实时锁表不一定还能还原现场;可以按排障需要启用 innodb_print_all_deadlocks 保存更多事件。处理时依次核对对象、索引、锁模式、等待顺序与事务边界,不用“两个 SQL 长得相似”代替分析。
减少冲突的办法包括缩短事务、按统一顺序处理相关记录、改善访问路径。死锁受害事务通常需要整体重试,并设置次数、退避和幂等保护;锁等待超时与死锁的默认回滚范围不同,不能混用一套假定。数据库外的支付、发信等副作用尤其不能无条件重放。死锁处理
分页优化不能悄悄改变加锁语义
带 FOR UPDATE 的大偏移分页可能扫描并锁定大量记录,确切范围取决于索引、隔离级别和执行计划。改成普通 SELECT 虽然减少了锁,却也移除了原来的并发保护,不能把它当成等价优化。
若业务按 status=1 顺序领取订单,可在同一事务中考虑游标范围查询:
SELECT id FROM orders
WHERE status = 1 AND id > 456891
ORDER BY id
LIMIT 20
FOR UPDATE;
配套评估 (status, id) 索引,读取后在同一事务内更新领取状态,再提交。LIMIT 20 只限制返回量,不保证恰好访问或锁住 20 条记录。多消费者领取还要明确等待、跳过已锁定行和业务公平性的要求。
普通展示列表则可以使用非锁定查询;它与任务领取的需求不同。分页性能细节见《MySQL 核心技术:索引原理与查询优化》。
从业务约束选择事务边界
| 需求 | 设计重点 |
|---|---|
| 单行余额或库存条件更新 | 原子条件更新、影响行数、幂等键 |
| 多记录联合修改 | 同一事务、统一访问顺序、失败后整体处理 |
| 多次查询需要同一视图 | 明确 RR 一致性快照和长事务成本 |
| 每次查询希望看到新的已提交状态 | 评估 RC,接受语句间视图变化 |
| 跨服务业务 | 本地事务结合幂等、消息或补偿协议 |
不能为了缩短事务,就把外部付款提前执行后宣称问题解决:外部成功而数据库失败仍然需要业务状态机与恢复机制。相关设计见《分布式事务:方案选型与工程实践》。
最终要回答的不是“哪个隔离级别最好”,而是哪些业务前提必须在并发下保持、用哪种读取与更新表达、失败之后怎样恢复。把这三件事落实,锁日志和执行计划才有可解释的业务含义。