MySQL 核心技术:事务与锁机制

目录

在 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,接受语句间视图变化
跨服务业务 本地事务结合幂等、消息或补偿协议

不能为了缩短事务,就把外部付款提前执行后宣称问题解决:外部成功而数据库失败仍然需要业务状态机与恢复机制。相关设计见《分布式事务:方案选型与工程实践》。

最终要回答的不是“哪个隔离级别最好”,而是哪些业务前提必须在并发下保持、用哪种读取与更新表达、失败之后怎样恢复。把这三件事落实,锁日志和执行计划才有可解释的业务含义。

评论