KEEL · 龙骨 · A CURRICULUM FOR THE AI ERA

02 · 事务、隔离级别与锁等待 — keel 龙骨

这一章回答:并发写到底什么时候会出问题,以及出现锁等待/死锁时怎么查。

这一章回答:并发写到底什么时候会出问题,以及出现锁等待/死锁时怎么查。

很多人对事务的理解停留在"要么都成功要么都失败"。真正的难点在并发:两个事务同时跑,彼此能看到对方的什么?这一点由隔离级别决定,而选错隔离级别会导致数据错乱,选得太严又会牺牲吞吐。

一、并发会带来的三类问题

问题 现象 举例
脏读 读到别人未提交的数据 读到一笔随后回滚的转账
不可重复读 同一事务内两次读同一行,结果不同 两次查询余额不一致
幻读 同一事务内两次按条件查询,行数不同 第二次查多出了新插入的行

二、四个隔离级别的取舍

级别 脏读 不可重复读 幻读 代价
读未提交 可能 可能 可能 几乎不用
读已提交(RC) 不会 可能 可能 互联网业务常用默认
可重复读(RR) 不会 不会 InnoDB 通过间隙锁基本避免 MySQL 默认
串行化 不会 不会 不会 吞吐极低,基本不用

实践建议:

三、锁的类型与什么时候会拿到

InnoDB 常见锁:

锁 触发 说明
行锁(记录锁) 命中索引的 UPDATE/DELETE、SELECT ... FOR UPDATE 只锁命中的行
间隙锁 / 临键锁 RR 下的范围查询加锁 防止区间内插入,也是死锁的主要来源
表级意向锁 DDL 或某些特殊场景 影响全表,DDL 要谨慎

两条铁律:

  1. WHERE 条件必须走索引,否则加锁范围会远大于预期(最坏情况锁表);
  2. 多表/多行更新统一顺序,这是避免死锁最有效的手段。

四、怎么排查锁等待与死锁

查看正在等待的事务:

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 段

它会告诉你两个事务各自持有什么锁、在等什么锁。读懂这一段是处理死锁的必备技能。

处理顺序:

  1. 找到持有锁最久的事务(通常是长事务);
  2. 判断它是正常业务还是异常卡住,必要时终止它;
  3. 从设计上消除:缩短事务、统一加锁顺序、拆分热点行。

五、热点行的处理

单行高频更新(如爆款商品库存)会造成大量事务排队。三种缓解手段:

手段 做法 代价
拆分热点行 把一行库存拆成 N 个子行,随机/轮询扣减 需要聚合查询真实余额
前置到 Redis Redis 预扣 + 异步落库(见 MQ 章节) 需要处理一致性补偿
排队串行化 同 SKU 的请求串行处理 延迟上升换来无冲突

六、事务写法的三条纪律

  1. 事务要短:事务里绝不出现网络调用、文件 IO、人工审核等待;
  2. 不要在事务里做重试或 sleep:持锁重试会把冲突放大(重试退避放在事务外);
  3. 明确 rollback:异常路径必须显式回滚并检查是否真的回滚成功。

动手:可观察结果

产出 判断标准
一次锁等待复现与定位 能用 data_lock_waits 指出谁阻塞了谁
一次死锁复现 能从 SHOW ENGINE INNODB STATUS 读出两个事务的持锁/等待关系
长事务监控 有一条告警规则(如事务运行 > 10s)
热点行优化对比 拆分前后:锁等待次数、吞吐、P99 三组数据

完成标志:给定一个包含死锁的日志片段,你能指出冲突的两条 SQL 与解决顺序。

故障注入

注入方式 观察
让 UPDATE 的 WHERE 不走索引 加锁范围是否远超预期、其他事务是否被大面积阻塞
两个事务以相反顺序更新两行 死锁是否被检测到,回滚了谁
在事务里调用一个慢 HTTP 接口 事务时长、锁等待、连接池占用
把隔离级别改为串行化 吞吐下降幅度
高并发更新同一行 锁等待队列长度与超时情况

自测题

  1. 不可重复读与幻读的区别是什么?为什么 RR 能防住前者而需要间隙锁防后者?
  2. 为什么"WHERE 必须走索引"在加锁语境下尤其重要?
  3. 死锁发生时数据库如何处理?应用层该怎么做(重试?放弃?)
  4. 长事务在 RR 下为什么特别危险?
  5. 三种热点行缓解手段各自的代价是什么?你会优先考虑哪个?

进入 keel 阅读