面试题:分库分表之后怎么在线扩容?不停机把 4 库扩到 8 库,双写、影子表、数据校验与回滚怎么做?
这是「每日一题」专栏的第二十篇。每天一道面试题,后端 + AI 混合路线,从原理到代码一次讲清。上一篇是 MoE 混合专家,今天按轮换回到后端/分布式方向,兑现昨天的预告:分库分表之后的在线扩容。这题的面试杀伤力很高——很多人张口就是「双写」两个字,但一追问「迁移期间读走哪边」「校验对不上怎么办」「出错怎么退回去」,就答不完整了。
一、题目与考点拆解
面试官的原话通常是这样:
你们订单表数据量太大,已经分了 4 个库。现在 4 个库又快满了,要扩到 8 个库。业务不能停,你怎么做?迁移过程中用户还在下单、查订单,怎么保证数据不丢、不错、不乱?
这一题真正考的是四件事,缺一件都算没答透:
- 扩容的数学:分片规则怎么设计,扩容时到底要挪多少数据,为什么「取模」是最坑的选法;
- 迁移的机制:存量怎么搬、增量怎么追、凭什么能追上;
- 正确性的证明:你怎么知道新老库真的一致,而不是「看起来没事」;
- 失败时的退路:任何一步错了,怎么在几分钟内退回去而不是通宵手忙脚乱。
后两条是区分「背过方案」和「真做过」的分水岭——大多数候选人能讲清第 1、2 条,栽在第 3、4 条上。
二、为什么扩容难:分片规则决定了你的工程量
先对齐一个前提:扩容的对象是水平分片(分库或分表),不是分区表、不是加从库。加从库解决的是读扩展,解决不了单表写容量和存储上限;一旦单表几亿行、B+ 树深了、磁盘满了,你只能把数据切开,而切开之后,扩容 = 把数据从旧的分片布局搬到新的分片布局。
选择分片规则时,最关键的一个判断是:扩容时有多少数据需要移动。
1. 取模分片:扩容的灾难
最直观的写法是 shard = hash(id) % N。4 个库时 N=4,扩到 8 个库时 N=8,问题立刻出现:同一个 id,hash % 4 和 hash % 8 的结果大部分都不一样,几乎全量数据都要搬家。
但如果扩容是成倍的(2→4、4→8、8→16),情况会好很多。以 4→8 为例:
- 若
hash % 8落在 0、1、2、3,则hash % 4等于hash % 8,位置不变; - 若
hash % 8落在 4、5、6、7,则位置变了,需要搬。
也就是说,成倍扩容时恰好只有一半的数据需要搬。这是「双倍扩容」被业界反复采用的唯一原因:它把最坏的全量迁移降到了 50%。反过来,4 库扩到 6 库这种「非成倍扩容」就不享受这个性质,移动比例接近 (N-M)/N,代价高得多,能不碰就别碰。
2. 一致性哈希 / 虚拟槽:为扩容而生的规则
Redis Cluster 的 16384 个槽、很多中间件的虚拟槽位,本质都是同一个思路:在「键」和「物理分片」之间插一层固定的抽象(槽),物理分片只认槽。扩容时新增一个物理节点,只需要把一部分「槽」连同槽里的数据移交过去,其余槽完全不动。
代价是:
- 多一层路由映射,客户端或中间件要维护槽到节点的映射表;
- 槽迁移期间会出现「部分槽在新、部分槽在旧」的中间态,路由要能处理「槽不在本节点」的重定向;
- 引入了一致性哈希的经典问题——热点键、数据倾斜需要额外手段(虚拟节点、加权)打散。
3. 结论先行
面试时最好先给一个框架再展开:分片规则优先选一致性哈希/槽位,扩容优先选成倍扩容,实在要用取模也只在成倍扩容下用。 规则选错,后面的工程量会放大十几倍——这是这类题的第一得分点。
三、三种扩容流派与代价对比
| 方案 | 停机 | 对业务侵入 | 增量一致性 | 适用场景 |
|---|---|---|---|---|
| 停机迁移 | 有(分钟~小时) | 无 | 天然一致 | 内部系统、有维护窗口 |
| 影子表 + 触发器 | 无 | 无(DB 层) | 触发写入,较可靠 | 无法改代码的遗留系统 |
| 双写(业务层) | 无 | 大 | 业务保证 + 对账 | 主流自研方案 |
| binlog CDC(Canal/DTS/Vitess) | 无 | 无 | 最终一致 + 对账 | 有成熟组件、追求透明 |
几个要点值得单独说:
- 停机迁移经常被面试官当成「错误答案」直接否掉,但真实世界里它是最可靠的——如果业务真的允许凌晨停 30 分钟,别为了炫技上双写。先说清楚「取舍由业务窗口决定」,反而显得成熟。
- 影子表 + 触发器的思路是:在旧表上建触发器,写入旧表时同步写新表。它不改业务代码,但触发器在源库上执行,会加重源库负担、增加锁竞争,MySQL 里大量触发器的维护成本也很高,现在更多是历史包袱方案,被 binlog CDC 取代。
- 双写和 binlog CDC 是当下的两条主线,下一节拆开讲。
四、主流方案:双写 + 增量迁移,完整时间线
核心思想只有一句:存量数据用离线搬迁,增量数据用实时同步追平,等两边追平并校验通过后,再原子地把流量切过去。
假设订单库从 4 库(db0db3)扩到 8 库(db0db7),分片键是 user_id,索引表 orders_0000 ~ orders_0031(每个库 8 张表)。下面是一条可以照抄的推进时间线。
Phase 0:准备(不动线上)
- 新建 8 库集群,建好表结构(
CREATE TABLE ... LIKE,但要重新评估索引和自增策略); - 铺好路由组件:客户端(或中间件)支持按版本加载路由规则,
v1 = 4库取模,v2 = 8库取模,规则存配置中心; - 选定切流开关:读开关、写开关、双写开关各自独立可控,方便灰度与回滚;
- 埋好监控:迁移速率、binlog 延迟、双写失败率、校验差异条数。
Phase 1:存量迁移(把老数据搬进新库)
按主键游标分批扫,不要用 OFFSET 深分页(越翻越慢):
-- 在源分片上按主键游标分批导出,last_id 从 0 开始,每批 1000 行
SELECT * FROM orders_0003
WHERE id > :last_id
ORDER BY id
LIMIT 1000;
拿到一批后,用新规则算出目标分片,逐行 INSERT ... ON DUPLICATE KEY UPDATE(或 REPLACE INTO)写入新库。注意:
- 必须幂等:迁移可能重跑,
upsert保证重复执行安全; - 限速:迁移任务全速跑会把源库 IO 打满,一定要有
QPS/带宽限速,且从只读从库 dump、低峰期跑; - 记录断点:每个分片记
last_id,中断可续传。
存量迁移只搬某时刻的快照,快照之后发生的修改,靠 Phase 2 的增量补上。
Phase 2:增量追平(双写 or CDC)
这是最关键的一步,两条路选一条:
路线 A:业务双写。 业务写操作同时写老库和新库(先老后新,或异步投递)。
- 优点:实现直观、不依赖外部组件、延迟低;
- 缺点:侵入业务代码;跨库写没有本地事务,只能靠「写老库成功、写新库失败就进补偿队列重试」来最终一致;写新库失败不能阻塞主流程,否则新库抖动会拖垮线上。
路线 B:binlog CDC。 用 Canal / 阿里 DTS / Vitess 订阅老库 binlog,按新规则重放到新库。
- 优点:对业务完全透明,不改一行代码;天然拿到所有变更(包括其他系统写的);
- 缺点:有秒级延迟,需要处理 DDL 同步、大事务、长事务;组件本身要运维;binlog 重放必须幂等(同一条变更可能被重复消费)。
无论哪条路,判断「追平」的标准是:增量延迟持续稳定在阈值内,且不再有积压。只有追平了,才允许进入校验和切流。
Phase 3:数据校验
见下一节,独立展开。
Phase 4:读切流(灰度)
切流前,读全部走老库。 新库只写不读,它是「影子」。原因很重要:双写/CDC 是异步的,老库某行刚被改、新库还没追上,此时读新库会读到旧值——这就是经典的「读到回退数据」。所以过渡期必须以老库为权威数据源(source of truth)。
校验通过后,读按比例灰度切到新库:
- 1% → 10% → 50% → 100%,每档观察错误率、延迟、主从延迟;
- 灰度期最好开双读比对:同一请求读新老两边,不一致就告警(而非直接报错),用于兜底发现漏迁的数据;
- 一旦异常,读开关一键切回老库。
Phase 5:写切流
读全量切到新库、稳定观察后,切写入:
- 把写从「双写老 + 新」改成「只写新库」;
- 可选:保留「写新 + 异步补写老」一小段时间,作为最后的安全垫;
- 老库停止接受写入,进入只读观察期。
Phase 6:下线老库
观察期(通常几天)内新库无异常、无校验差异后,老库先降权做备份,再择期物理下线。别急着删,回滚的底气全在这里。
五、数据校验:怎么证明「新老库真的一致」
「没报错」不等于「一致」。校验要分全量和增量两层。
1. 全量校验:分片聚合 checksum
别一行行比对(几亿行根本跑不完)。用聚合签名:对每批数据算一个 COUNT + 校验和,再跨分片聚合比对。
SELECT
COUNT(*) AS row_cnt,
BIT_XOR(
CRC32(CONCAT_WS('#',
id, user_id, amount, status,
DATE_FORMAT(updated_at, '%Y-%m-%d %H:%i:%s')
))
) AS row_sig
FROM orders_0003
WHERE id BETWEEN :lo AND :hi;
BIT_XOR 的妙处在于它满足交换律和结合律:把每个分片的签名再做一次 BIT_XOR,就能得到整张表的签名,与行的物理顺序无关,天然适配「老库 4 张表 vs 新库 8 张表」这种布局变化。
全量校验的坑,全是「两边算出来不一样但数据其实一样」:
- 浮点/十进制精度:金额用
DECIMAL,两边精度、舍入必须一致,最好CAST成字符串再算; - 时间精度与时区:
updated_at是不是毫秒精度、时区是否一致,DATE_FORMAT要截到统一的精度; - NULL 与分隔符:
CONCAT_WS会跳过NULL,导致('a', NULL)和(NULL, 'a')撞签名——要么给NULL统一哨兵值,要么用固定长度拼接; - 字符集与排序规则:
utf8mb4_bin和utf8mb4_general_ci算出来的结果可能不同; - 并发:校验和数据在变,全量比对必然有「校验期间被改的行」产生的假差异。所以全量校验要配合增量校验收敛。
2. 增量校验:把变更本身对齐
全量校验只能证明「某一瞬间一致」,迁移期间持续有写入,因此需要增量层:
- 双写方案:对每条写操作的结果(老库写完的值和新库写完的值)落一张对账表,逐条比对;
- CDC 方案:拿 binlog 里每条变更的目标值,与目标库实际值比对;
- 用版本号/时间戳兜底:给每行加
version或updated_at,只比version,能极大降低误报(但要保证两边 version 生成口径一致)。
收敛策略:先抽样(快速发现系统性问题,比如某类字段全错),再全量(收敛到「零差异」)。发现差异不要急着判死——先分类:是延迟造成的(等一会就一致)、还是规则/精度造成的(要修代码)、还是真的丢了(要补)。修完 bug 后重跑校验,直到连续多轮全绿。
六、回滚预案:每一步都要能退
在线扩容的成熟度,不看顺利时跑得多快,看出错时退得多干净。逐阶段设计退路:
- 存量迁移期出错:源库只读、没动线上数据,直接停任务重来,零影响;
- 增量/双写期不一致:写开关关掉双写,线上仍只写老库,新库清空重迁;
- 读切流期异常:读开关回退到老库——前提是老库数据必须仍然最新。所以读切流之前,写必须仍在老库(双写保留老库写入)。这条顺序千万别搞反;
- 写切流后才发现问题:进入「反向双写」——写新库为主、异步补写老库,把老库重新拉成最新的,再退回上一阶段;
- 终极兜底:老库一直在、数据没删,最差情况就是路由版本回退到
v1,回到 4 库运行,把这次扩容当作没发生。
这里有一个隐含要求:回滚要能在分钟级完成,靠的是「路由版本回退 + 开关切换」,绝不是「手工改代码重新发布」。所以路由规则必须外置到配置中心、带版本、客户端热加载——这是整个方案的基石。
七、路由切换的原子性:中间态才是魔鬼
扩容最危险的时刻,是「新旧布局并存」的那个中间态。要处理两件事:
1. 同一条数据在新旧库的可见性。 前面反复强调的结论——过渡期读走老库——本质是为了规避「同一条记录在新库是旧值」的中间态。只要读权威源是老库,无论增量延迟多大,用户读到的都是正确的。
2. 切流瞬间的原子性。 路由从 v1 切到 v2 时,必须保证「所有需要在新库找的数据都已经在新库」。因此切换顺序是刚性的:
存量迁移完成 → 增量追平 → 全量+增量校验通过 → 读灰度切新 → 写切新 → 下线老库
任何一步跳过或乱序(比如「校验没过就切读」),都会引入数据错乱。配置中心的推送到客户端有传播延迟,切换时最好加一个「规则版本 + 生效时间戳」,让客户端在同一逻辑时刻切换,避免不同实例用不同规则路由同一批数据。
八、踩坑清单
落到工程细节,这几条基本都是血泪:
- 自增主键冲突:老库
id是自增,迁移到新库若也用自增会撞号。方案:迁移时显式插入原 id,或全局改用分布式 ID(Snowflake / 号段),或新库自增步长错开(auto_increment_offset/increment)。 - 分片键选错:按
user_id分片,业务却经常按order_id查——扩容后照样全分片扫描,白扩。分片键必须贴合最高频的查询维度,否则要额外建路由索引表。 - 热点分片:按时间范围分片,流量全压到最新那个分片。要用哈希打散,或做「时间 + 哈希」的复合分片。
- 迁移压垮源库:无限速的全表扫 + 大量读,把线上从库拖垮。必须限速、走从库、低峰执行。
- 大事务 / 长事务:CDC 追不上一个跑了几分钟的大事务,延迟飙升。要监控单事务行数、必要时拆分。
- DDL 不同步:迁移期间老表加字段,CDC 只同步 DML 不同步 DDL,导致新库结构落后、写入报错。要建立 DDL 联动流程。
- 唯一约束跨分片:分片后「用户名唯一」这类约束无法靠单库唯一索引保证,得用全局唯一表或布隆过滤器 + 二次校验。
- 幂等没做全:双写重试、binlog 重放都可能重复执行,落库必须是
upsert,补偿任务也要去重。 - 校验误报淹没真问题:精度/时区/NULL 导致的假差异如果太多,真正的丢数据会被噪音掩盖。先把假差异清零,再谈收敛。
九、收尾
一句话总结这题:在线扩容的本质是「用异步同步维持一份影子数据,用校验证明它可信,用外置路由把它原子地变成权威」——三重正确性(顺序正确、校验正确、回滚正确)缺一不可。
面试里能拿满分的三个追问,是你自己主动讲出来的:
- 为什么迁移期间读必须留在老库? —— 异步同步有延迟,新库可能是旧值,老库是唯一权威源。
- 双写和 binlog CDC 怎么选? —— 能改代码、要低延迟、团队没有 CDC 组件时选双写;追求对业务透明、不想侵入代码时选 CDC,用延迟和运维成本换。
- 怎么证明一致、错了怎么退? —— 全量
BIT_XOR(CRC32(...))聚合签名 + 增量逐条对账,双管齐下;回滚靠「配置中心路由版本回退 + 独立开关」,分钟级完成,老库在观察期内绝不删除。
答完这三点,面试官基本会默认你「真的做过一次不停机扩容」,而不是背过一篇文章。
明日预告:轮换回 AI/LLM 方向 ——「长上下文是怎么做到的?RoPE 位置编码为什么要外推、位置插值(PI/NTK/YaRN)到底在插什么,为什么 32K 训练的模型能撑到 128K,以及长上下文和 RAG 该怎么选?」
相关阅读