MySQL 原理图解 · 并发与分布式

第三辑。死锁、索引下推、分库分表、不停服迁移、CDC——前两个是并发写入的必答题,后三个是做过真实系统才答得出的题。

死锁:两个事务谁都不肯先松手

死锁不是 bug,是并发加锁的必然结果。要答好这道题,得能说清三件事:环是怎么形成的、InnoDB 怎么发现它、发现之后怎么查。

session A · trx 1001空闲
—
持有—
等待—
session B · trx 1002空闲
—
持有—
等待—
事务 A trx 1001 事务 B trx 1002 A 不等 B B 不等 A
此刻状态—
为什么—
关键点—

InnoDB 怎么发现的:不是等超时,是主动查环

每有一个事务进入锁等待,InnoDB 就会在等待图(wait-for graph)上从它出发递归找一遍,看能不能绕回自己。绕回来了就是环,立刻回滚其中一个。

这个机制是有代价的。innodb_deadlock_detect=ON 时,每个新进入等待的线程都要遍历一遍等待图,复杂度随并发线程数上升。热点行上几百个并发同时排队时,死锁检测本身会把 CPU 打满——这就是大促前常见的「什么都没干,CPU 100%」。解法是降低单行并发(拆分热点、排队削峰),而不是关掉检测。
关掉检测会怎样?死锁不会被发现,只能等 innodb_lock_wait_timeout 超时(默认 50 秒)。请求一直挂着不报错,比快速失败更难排查。所以生产上基本不开这个开关。

选了谁做牺牲者

不是随机,也不是「谁后到谁死」,而是回滚代价最小的那个——InnoDB 内部有个 trx_weight,约等于该事务已经加锁和修改的行数。上面这个例子里,如果 B 在同一个事务里之前还改过另外几行,它的权重更大,就会被回滚。

被回滚的事务收到的是 1213,不是超时:
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(很多公司这么做的真实原因)、或让条件走唯一索引
面试里最容易失分的一句:「死锁就是两个事务互相等」。这只说了现象。要补上「为什么 RR 下更容易死锁」——因为有间隙锁,锁的范围比你以为的大。再加一句「我们是靠统一加锁顺序 + 捕获 1213 重试解决的」,才算答完。

索引下推 ICP:少回表的那一步

联合索引里,有一些条件用不上索引定位,但这一列就在索引里。索引下推(Index Condition Pushdown)就是把这类条件交给存储引擎,在索引上先过滤一遍,不合格的行根本不回表。

-- 表:users(id PK, zip, name, address, ...)
-- 索引:idx_zip_name (zip, name)
SELECT * FROM users WHERE zip = '95054' AND name LIKE '%伟%';
idx_zip_name 的叶子节点条目(zip 全部命中,共 8 条)
回表次数
8
Server 层收到行数
8
最终结果
2
过滤发生在—
EXPLAIN Extra—
代价—

关键区分:能不能「定位」和能不能「过滤」是两件事

条件能用来定位吗能在索引层过滤吗原因
zip = '95054'能(等值匹配前缀)—直接决定扫描的起点和终点
name LIKE '张%'能(前缀也是范围)—可以缩窄扫描区间
name LIKE '%伟%'不能能(ICP)前导通配无法定位,但 name 在索引里,可以就地判断
address LIKE '%路%'不能不能address 不在索引里,必须回表拿到整行
一句话记住:ICP 只对「列在索引里、但不参与定位」的条件生效。这也是为什么 SELECT * 时它收益明显——省下的是整行读取(随机 IO),而不只是省一次判断。

怎么确认它真的生效了

看 EXPLAIN 的 Extra 里有没有 Using index condition。注意和另外两个容易混的区分开:

Extra含义有没有回表
Using index覆盖索引,只读索引就够没有
Using index condition索引下推,在索引层先过滤有,但变少了
Using whereServer 层拿回整行后再过滤有,且没优化
-- 定量验证: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 查」是最典型的翻车点。只要查询条件里没有分片键,中间件就只能把这条 SQL 广播到全部 32 个分片,再在内存里归并。平时没事,一旦这个接口上了高频调用,就是 32 倍的放大。

标准解法叫基因法:生成 order_no 时,把 user_id % 32 这个「基因」编进订单号的后几位。这样按订单号也能反解出分片号,一条 SQL 只打一个分片。

分完之后必然出现的五个新问题

问题现象常规解法
跨分片 JOIN两张表分片键不同,没法在一台机器上 JOIN冗余字段(把常用列冗余进主表)、绑定表(同分片键一起分)、或业务层内存拼装
跨分片聚合 / 排序COUNT / ORDER BY / GROUP BY 要在内存归并各分片算完再汇总;大屏类需求走预聚合表或数仓
深分页LIMIT 100000, 20 要在 32 个分片各取 100020 条改成游标分页(WHERE id > 上次最后一条),或业务上禁止跳页
分布式事务本地事务失效,跨库没法一起回滚尽量避免跨分片写;必须跨时用 TCC / 本地消息表 / Seata
全局唯一 IDAUTO_INCREMENT 在各分片会重复雪花算法(注意时钟回拨)、号段模式(Leaf)、Redis INCR

扩容:为什么是「翻倍」而不是「加一台」

取模法的致命问题是模数一变,几乎所有数据都要搬。32 改成 33,1001 % 32 = 9 但 1001 % 33 = 11,绝大多数行都要迁。

所以行业惯例是「双倍扩容」:32 → 64。原来第 k 个分片的数据,只会分到新的第 k 和第 k+32 个分片。每个老分片只需要搬一半数据出去,而且搬的时候可以按分片灰度、搬完再切。

更彻底的做法是一致性哈希或预分片(一开始就建 1024 个逻辑分片,物理库少一点,扩容时只挪逻辑分片的映射关系,不动数据)。
最后一个实话:能不分就不分。单表 2000 万行只是经验值,不是红线,真正该看的是索引能不能装进内存、单表数据量带来的 B+ 树层数。先做读写分离、加缓存、优化索引、归档历史数据,这些都用尽了再分片——分片是单行道,分完就很难回头。

不停服迁移:六步,每一步都能回滚

「怎么把库迁到新机器 / 新版本 / 新分片,不停机」——这题考的不是命令,是风险控制的顺序。核心原则只有一条:任何时刻都必须有一条退路。

老库 线上正在用 新库 新版本 / 新分片 写 写 读 读 同步
写—
读—
同步—
这一步的风险—
怎么回滚—

为什么必须「先双写、后切读、最后切写」

顺序为什么不能反过来
先双写不双写就切读,新库拿到的是旧数据 —— 回滚容易,但数据已经错了
再切读读是最容易回滚的一步(改白名单比例即可),拿它来验证新库能不能扛
最后切写写一旦切过去,老库就开始落后;这时只能靠反向同步回滚,成本最高,所以放最后

真正决定成败的三件事

一、双写必须能容忍失败。新库写失败不能把整个业务请求打挂。做法是「主写老库,异步写新库 + 落地失败队列补偿」。同步双写只在低峰、且已经验证新库性能之后才考虑。

二、数据校验必不可少,而且要看「差异为 0」。全量 checksum 按表 / 按分片 / 按时间段比对,增量再按 binlog 追一段时间。没有校验就切读写,等于闭着眼睛跳。

三、灰度要按业务维度,不是按机器维度。按 user_id 尾号或白名单切,1% → 10% → 50% → 100%。这样出问题时影响面可控,且能精准剥离。

面试官最想听的一句:「切换前我们会先做一次回滚演练——真的把流量切回老库跑一遍,确认反向同步是通的、延迟在可接受范围。」
绝大多数人只讲怎么切过去,讲不出怎么切回来。这一句能直接区分「看过文章」和「做过」。

CDC:不改业务代码,把变更实时送出去

CDC(Change Data Capture)就是订阅 binlog——把数据库的每一次行变更变成一条消息,推给下游。搜索索引、缓存、数仓、风控,全靠它。

MySQL 主库 业务写入发生在这 binlog ROW 格式的变更流 Canal / Debezium 伪装成从库去拉 Kafka 削峰 + 按主键分区 下游 ES / 数仓 / 缓存
它在做什么—
前置条件—
坑—

点上面任意一个方块,看它那一段的细节。

为什么不用「业务里发消息」

业务双写(改代码发 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 分区键要用主键,不是表名——用表名会导致所有表挤在一个分区,用随机键会导致同一行乱序、下游最终值是错的。
还有一件常被忽略的:DDL 不同步。binlog 里的 DDL 是语句不是行变更,大多数 CDC 工具默认跳过。加了字段之后,下游的 ES mapping 不会自动更新,要单独维护一条 DDL 变更通道(或者用 schema registry)。这在面试里是加分项。