死锁:两个事务谁都不肯先松手
死锁不是 bug,是并发加锁的必然结果。要答好这道题,得能说清三件事:环是怎么形成的、InnoDB 怎么发现它、发现之后怎么查。
InnoDB 怎么发现的:不是等超时,是主动查环
每有一个事务进入锁等待,InnoDB 就会在等待图(wait-for graph)上从它出发递归找一遍,看能不能绕回自己。绕回来了就是环,立刻回滚其中一个。
innodb_deadlock_detect=ON 时,每个新进入等待的线程都要遍历一遍等待图,复杂度随并发线程数上升。热点行上几百个并发同时排队时,死锁检测本身会把 CPU 打满——这就是大促前常见的「什么都没干,CPU 100%」。解法是降低单行并发(拆分热点、排队削峰),而不是关掉检测。innodb_lock_wait_timeout 超时(默认 50 秒)。请求一直挂着不报错,比快速失败更难排查。所以生产上基本不开这个开关。选了谁做牺牲者
不是随机,也不是「谁后到谁死」,而是回滚代价最小的那个——InnoDB 内部有个 trx_weight,约等于该事务已经加锁和修改的行数。上面这个例子里,如果 B 在同一个事务里之前还改过另外几行,它的权重更大,就会被回滚。
ERROR 1213 (40001): Deadlock found when trying to get lock; try restarting transaction这个错误是可以重试的。业务代码里捕获 1213 后重新执行整个事务,是标准做法;而 1205(lock wait timeout)重试往往无效,因为它说明有别的事务长期占着锁。
线上怎么查
-- 1. 最近一次死锁的完整现场(含两条 SQL、各自的锁、被回滚的是谁)
SHOW ENGINE INNODB STATUS\G
-- 看 LATEST DETECTED DEADLOCK 那一段
-- 2. 当前谁在等谁(8.0,比 INNODB_LOCK_WAITS 更可靠)
SELECT * FROM performance_schema.data_lock_waits;
SELECT * FROM performance_schema.data_locks;
-- 3. 当前运行中的事务
SELECT trx_id, trx_state, trx_started, trx_mysql_thread_id,
trx_rows_locked, trx_rows_modified, trx_query
FROM information_schema.INNODB_TRX ORDER BY trx_started;
为什么会死锁:两个最常见的成因
| 成因 | 典型场景 | 怎么破 |
|---|---|---|
| 加锁顺序不一致 | 两个事务分别按 id=1→2 和 id=2→1 更新同一批行。批量更新、转账、扣库存最容易踩。 | 业务层统一顺序:按主键排序后再更新,这是最有效的一条 |
| 间隙锁互相占用 | RR 下 UPDATE ... WHERE a=1 走非唯一索引,会锁住记录之间的空隙。两个事务从两侧插入,各占一段间隙。 | 换 RC(很多公司这么做的真实原因)、或让条件走唯一索引 |
索引下推 ICP:少回表的那一步
联合索引里,有一些条件用不上索引定位,但这一列就在索引里。索引下推(Index Condition Pushdown)就是把这类条件交给存储引擎,在索引上先过滤一遍,不合格的行根本不回表。
-- 表:users(id PK, zip, name, address, ...)
-- 索引:idx_zip_name (zip, name)
SELECT * FROM users WHERE zip = '95054' AND name LIKE '%伟%';
关键区分:能不能「定位」和能不能「过滤」是两件事
| 条件 | 能用来定位吗 | 能在索引层过滤吗 | 原因 |
|---|---|---|---|
zip = '95054' | 能(等值匹配前缀) | — | 直接决定扫描的起点和终点 |
name LIKE '张%' | 能(前缀也是范围) | — | 可以缩窄扫描区间 |
name LIKE '%伟%' | 不能 | 能(ICP) | 前导通配无法定位,但 name 在索引里,可以就地判断 |
address LIKE '%路%' | 不能 | 不能 | address 不在索引里,必须回表拿到整行 |
SELECT * 时它收益明显——省下的是整行读取(随机 IO),而不只是省一次判断。怎么确认它真的生效了
看 EXPLAIN 的 Extra 里有没有 Using index condition。注意和另外两个容易混的区分开:
| Extra | 含义 | 有没有回表 |
|---|---|---|
Using index | 覆盖索引,只读索引就够 | 没有 |
Using index condition | 索引下推,在索引层先过滤 | 有,但变少了 |
Using where | Server 层拿回整行后再过滤 | 有,且没优化 |
-- 定量验证:EXPLAIN ANALYZE 会给出每一步的真实行数(8.0.18+)
EXPLAIN ANALYZE
SELECT * FROM users WHERE zip='95054' AND name LIKE '%伟%';
-- ICP 关闭:Index range scan 的 actual rows = 8
-- → 8 行全部交给 Server 层,6 行被丢掉
-- ICP 开启:Index range scan 的 actual rows = 2
-- → 索引层就只放出 2 行,回表 2 次
Handler_read_next 统计的是索引条目的读取数,开不开 ICP 都是 8——它衡量的是扫描范围,不是回表次数,拿它证明 ICP 生效会被追问穿。要看真实回表量,用 EXPLAIN ANALYZE 的 actual rows。分库分表:一条 SQL 落到哪张表
分片的核心只有一个公式:分片键 → 哈希 → 取模 → 分片号 → 库号 + 表号。但真正决定这套方案能不能活下来的,是「不是用分片键查的时候会怎样」。
-- 4 库 × 8 表 = 32 个分片,user_id 是分片键
shard = user_id % 32
db = shard / 8 -- 整除
tbl = shard % 8
分片键选错,等于没分
| 要求 | 为什么 | 反例 |
|---|---|---|
| 高基数 | 取值太少,数据分不开 | 用「性别」「状态」做分片键,32 个分片里一半是空的 |
| 分布均匀 | 热点分片会被打穿 | 用「注册年份」,老用户全挤在一个分片 |
| 查询必带 | 不带就无法路由,只能广播 | 订单表用 order_no 分片,但后台全按 user_id 查 |
| 不可变 | 分片键一改,整行要跨库搬家 | 用手机号分片,用户换号就得迁数据 |
标准解法叫基因法:生成
order_no 时,把 user_id % 32 这个「基因」编进订单号的后几位。这样按订单号也能反解出分片号,一条 SQL 只打一个分片。分完之后必然出现的五个新问题
| 问题 | 现象 | 常规解法 |
|---|---|---|
| 跨分片 JOIN | 两张表分片键不同,没法在一台机器上 JOIN | 冗余字段(把常用列冗余进主表)、绑定表(同分片键一起分)、或业务层内存拼装 |
| 跨分片聚合 / 排序 | COUNT / ORDER BY / GROUP BY 要在内存归并 | 各分片算完再汇总;大屏类需求走预聚合表或数仓 |
| 深分页 | LIMIT 100000, 20 要在 32 个分片各取 100020 条 | 改成游标分页(WHERE id > 上次最后一条),或业务上禁止跳页 |
| 分布式事务 | 本地事务失效,跨库没法一起回滚 | 尽量避免跨分片写;必须跨时用 TCC / 本地消息表 / Seata |
| 全局唯一 ID | AUTO_INCREMENT 在各分片会重复 | 雪花算法(注意时钟回拨)、号段模式(Leaf)、Redis INCR |
扩容:为什么是「翻倍」而不是「加一台」
取模法的致命问题是模数一变,几乎所有数据都要搬。32 改成 33,1001 % 32 = 9 但 1001 % 33 = 11,绝大多数行都要迁。
k 个分片的数据,只会分到新的第 k 和第 k+32 个分片。每个老分片只需要搬一半数据出去,而且搬的时候可以按分片灰度、搬完再切。更彻底的做法是一致性哈希或预分片(一开始就建 1024 个逻辑分片,物理库少一点,扩容时只挪逻辑分片的映射关系,不动数据)。
不停服迁移:六步,每一步都能回滚
「怎么把库迁到新机器 / 新版本 / 新分片,不停机」——这题考的不是命令,是风险控制的顺序。核心原则只有一条:任何时刻都必须有一条退路。
为什么必须「先双写、后切读、最后切写」
| 顺序 | 为什么不能反过来 |
|---|---|
| 先双写 | 不双写就切读,新库拿到的是旧数据 —— 回滚容易,但数据已经错了 |
| 再切读 | 读是最容易回滚的一步(改白名单比例即可),拿它来验证新库能不能扛 |
| 最后切写 | 写一旦切过去,老库就开始落后;这时只能靠反向同步回滚,成本最高,所以放最后 |
真正决定成败的三件事
一、双写必须能容忍失败。新库写失败不能把整个业务请求打挂。做法是「主写老库,异步写新库 + 落地失败队列补偿」。同步双写只在低峰、且已经验证新库性能之后才考虑。
二、数据校验必不可少,而且要看「差异为 0」。全量 checksum 按表 / 按分片 / 按时间段比对,增量再按 binlog 追一段时间。没有校验就切读写,等于闭着眼睛跳。
三、灰度要按业务维度,不是按机器维度。按 user_id 尾号或白名单切,1% → 10% → 50% → 100%。这样出问题时影响面可控,且能精准剥离。
绝大多数人只讲怎么切过去,讲不出怎么切回来。这一句能直接区分「看过文章」和「做过」。
CDC:不改业务代码,把变更实时送出去
CDC(Change Data Capture)就是订阅 binlog——把数据库的每一次行变更变成一条消息,推给下游。搜索索引、缓存、数仓、风控,全靠它。
点上面任意一个方块,看它那一段的细节。
为什么不用「业务里发消息」
| 业务双写(改代码发 MQ) | CDC(订阅 binlog) | |
|---|---|---|
| 一致性 | 业务写成功、发消息失败 → 不一致;要上本地消息表才勉强解决 | binlog 就是提交结果,天然一致 |
| 侵入性 | 每张表每个写入点都要改 | 零侵入,业务代码不用动 |
| 覆盖面 | 只能覆盖你改了的地方 | 覆盖全部变更,包括手工修数据、批量脚本 |
| 成本 | 每条消息一次网络往返 | 从库级别读取,异步、可批量 |
四条必须交代的前提
① binlog_format 必须是 ROW。STATEMENT 格式记的是 SQL 原文,下游没法还原出行变更;MIXED 更不可控。这是第一个卡点,很多老库默认还是 STATEMENT。
② binlog_row_image 建议 FULL。默认 MINIMAL 只记主键和被改的列——下游拿不到完整的前后镜像,做不了「把整行写进 ES」。要全字段就必须 FULL,但代价是 binlog 体积变大。
③ 开 GTID。断点续传靠位点,file + position 在切主之后会失效,GTID 不会。这也是现在生产默认开 GTID 的理由之一。
④ 下游必须幂等。CDC 的语义是至少一次(at-least-once),不是恰好一次。重启、位点回退、Kafka 重投,都会导致重复。落地时一律按主键 upsert,别用 INSERT。
① 大事务。一条
UPDATE 改了 200 万行,会生成一个巨大的事务,CDC 端要一次性在内存里拼出完整事务(Debezium 尤其明显),直接 OOM。解法是在源头上拆批。② 顺序。同一个主键的变更必须有序。Kafka 分区键要用主键,不是表名——用表名会导致所有表挤在一个分区,用随机键会导致同一行乱序、下游最终值是错的。