KEEL · 龙骨 · A CURRICULUM FOR THE AI ERA
02 · 事务、隔离级别与锁等待 — keel 龙骨
这一章回答:并发写到底什么时候会出问题,以及出现锁等待/死锁时怎么查。
这一章回答:并发写到底什么时候会出问题,以及出现锁等待/死锁时怎么查。
很多人对事务的理解停留在"要么都成功要么都失败"。真正的难点在并发:两个事务同时跑,彼此能看到对方的什么?这一点由隔离级别决定,而选错隔离级别会导致数据错乱,选得太严又会牺牲吞吐。
一、并发会带来的三类问题
| 问题 | 现象 | 举例 |
|---|---|---|
| 脏读 | 读到别人未提交的数据 | 读到一笔随后回滚的转账 |
| 不可重复读 | 同一事务内两次读同一行,结果不同 | 两次查询余额不一致 |
| 幻读 | 同一事务内两次按条件查询,行数不同 | 第二次查多出了新插入的行 |
二、四个隔离级别的取舍
| 级别 | 脏读 | 不可重复读 | 幻读 | 代价 |
|---|---|---|---|---|
| 读未提交 | 可能 | 可能 | 可能 | 几乎不用 |
| 读已提交(RC) | 不会 | 可能 | 可能 | 互联网业务常用默认 |
| 可重复读(RR) | 不会 | 不会 | InnoDB 通过间隙锁基本避免 | MySQL 默认 |
| 串行化 | 不会 | 不会 | 不会 | 吞吐极低,基本不用 |
实践建议:
- 大多数业务用默认的 RR 即可,不要为了"更安全"盲目提到串行化;
- 长事务是 RR 下最大的隐患:一致性读要维护大量 undo,可能拖垮整个实例。监控里应专门盯"运行时间超过 N 秒的事务";
- 如果用了读已提交并配合 ROW 格式 binlog,可以减少间隙锁带来的阻塞。
三、锁的类型与什么时候会拿到
InnoDB 常见锁:
| 锁 | 触发 | 说明 |
|---|---|---|
| 行锁(记录锁) | 命中索引的 UPDATE/DELETE、SELECT ... FOR UPDATE |
只锁命中的行 |
| 间隙锁 / 临键锁 | RR 下的范围查询加锁 | 防止区间内插入,也是死锁的主要来源 |
| 表级意向锁 | DDL 或某些特殊场景 | 影响全表,DDL 要谨慎 |
两条铁律:
WHERE条件必须走索引,否则加锁范围会远大于预期(最坏情况锁表);- 多表/多行更新统一顺序,这是避免死锁最有效的手段。
四、怎么排查锁等待与死锁
查看正在等待的事务:
SELECT * FROM information_schema.innodb_trx WHERE trx_state = 'LOCK WAIT'\G
-- 关注 trx_started(已等待多久)、trx_query(在等什么 SQL)
SELECT * FROM performance_schema.data_lock_waits;
-- 谁等谁一目了然
最近的死锁信息:
SHOW ENGINE INNODB STATUS\G -- 查看 LATEST DETECTED DEADLOCK 段
它会告诉你两个事务各自持有什么锁、在等什么锁。读懂这一段是处理死锁的必备技能。
处理顺序:
- 找到持有锁最久的事务(通常是长事务);
- 判断它是正常业务还是异常卡住,必要时终止它;
- 从设计上消除:缩短事务、统一加锁顺序、拆分热点行。
五、热点行的处理
单行高频更新(如爆款商品库存)会造成大量事务排队。三种缓解手段:
| 手段 | 做法 | 代价 |
|---|---|---|
| 拆分热点行 | 把一行库存拆成 N 个子行,随机/轮询扣减 | 需要聚合查询真实余额 |
| 前置到 Redis | Redis 预扣 + 异步落库(见 MQ 章节) | 需要处理一致性补偿 |
| 排队串行化 | 同 SKU 的请求串行处理 | 延迟上升换来无冲突 |
六、事务写法的三条纪律
- 事务要短:事务里绝不出现网络调用、文件 IO、人工审核等待;
- 不要在事务里做重试或 sleep:持锁重试会把冲突放大(重试退避放在事务外);
- 明确 rollback:异常路径必须显式回滚并检查是否真的回滚成功。
动手:可观察结果
| 产出 | 判断标准 |
|---|---|
| 一次锁等待复现与定位 | 能用 data_lock_waits 指出谁阻塞了谁 |
| 一次死锁复现 | 能从 SHOW ENGINE INNODB STATUS 读出两个事务的持锁/等待关系 |
| 长事务监控 | 有一条告警规则(如事务运行 > 10s) |
| 热点行优化对比 | 拆分前后:锁等待次数、吞吐、P99 三组数据 |
完成标志:给定一个包含死锁的日志片段,你能指出冲突的两条 SQL 与解决顺序。
故障注入
| 注入方式 | 观察 |
|---|---|
让 UPDATE 的 WHERE 不走索引 |
加锁范围是否远超预期、其他事务是否被大面积阻塞 |
| 两个事务以相反顺序更新两行 | 死锁是否被检测到,回滚了谁 |
| 在事务里调用一个慢 HTTP 接口 | 事务时长、锁等待、连接池占用 |
| 把隔离级别改为串行化 | 吞吐下降幅度 |
| 高并发更新同一行 | 锁等待队列长度与超时情况 |
自测题
- 不可重复读与幻读的区别是什么?为什么 RR 能防住前者而需要间隙锁防后者?
- 为什么"
WHERE必须走索引"在加锁语境下尤其重要? - 死锁发生时数据库如何处理?应用层该怎么做(重试?放弃?)
- 长事务在 RR 下为什么特别危险?
- 三种热点行缓解手段各自的代价是什么?你会优先考虑哪个?