分库分表与数据迁移:从拆分策略到双写切换
分库分表与数据迁移:从拆分策略到双写切换
本文不推销某个中间件,而是回答三件事:什么时候才真的该拆、分片键一旦选错为什么几乎无法补救、以及怎么在不停止业务的前提下把数据从单库迁到分片集群并安全回滚。迁移章节给出可直接照做的六个阶段。
先别拆:四个更便宜的手段
分库分表是不可逆的架构升级:跨片 join 消失、事务变复杂、扩容要搬数据、运维成本翻倍。在此之前,按性价比顺序试这四件事:
1. 索引与 SQL 优化
一条缺索引的查询把 QPS 从 5000 打到 50,加索引就解决了
2. 缓存 / 读写分离
读多写少的场景,加从库 + 热点缓存能撑很久
(注意:读写分离解决读,不解决单表数据量)
3. 冷热分离 / 归档
订单表 80% 是两年以上的冷数据,迁到归档库后主表直接瘦身一半
4. 分区表 / 大表拆小表
按时间做物理分区,查询裁剪到单个分区,运维成本远低于分片
该拆的信号
不是"数据量到 500 万就必须拆",而是看瓶颈落在哪:
| 信号 | 具体表现 | 拆分能解决吗 |
|---|---|---|
| 单表行数过亿,索引层级变深 | 等值查询仍然走索引,但范围扫描变慢 | 能 |
| 单库磁盘/备份窗口顶不住 | 备份要 6 小时,DDL 锁表越来越久 | 能 |
| 单库写入 QPS 打满 | 主库 CPU 长期 80%+,从库延迟持续增长 | 能 |
| 单库连接数不够 | 连接池排队,应用报获取连接超时 | 能 |
| 慢查询、锁等待 | 单条 SQL 或热点行锁 | 不能,先优化 SQL |
垂直拆分与水平拆分
垂直拆分(按业务/列拆)
├─ 按业务域:订单库、用户库、商品库 → 服务边界清晰,最常见
└─ 按列:把大字段(详情、JSON、附件)拆到扩展表
特点:降低单库压力,但单表数据量不变,且跨库 join 变多
水平拆分(按行拆,即分片 sharding)
├─ 单库多表:order_0 ... order_63(省了拆库成本,连接数没省)
└─ 多库多表:ds_0.order_0 ... ds_3.order_15
特点:真正解决容量与写入,但牺牲了关系型的大部分便利
生产实践:先垂直(对齐微服务边界),再做水平
分片键:选错几乎无法补救
分片键决定了「一条数据落在哪个片」,也决定了「哪些查询能单库完成」。三个硬性要求:
① 高基数:性别、状态这种低基数字段 → 所有数据挤在一两个片
② 分布均匀:按时间戳分片 → 新数据全落在最后一个片(写热点)
③ 绝大多数核心查询必带它:不带分片键 = 全片广播
真正的难点是同一个业务有多条访问路径。以订单为例:
用户维度:我的订单列表 → 分片键应该是 user_id
商家维度:店铺订单、对账 → 分片键应该是 merchant_id
订单维度:按 order_no 查询 → 哪个都对不上
现实中的解法是「主分片键 + 冗余索引」:
主分片键:user_id(覆盖最高频的 C 端查询)
附带手段:
├─ 基因法:把 user_id 的低位混进 order_no,让订单号本身可路由
├─ 冗余:订单表冗余 merchant_id,同时建"商家维度"的异构表
└─ 异构索引:商家后台查询走 ES / 宽表,不回 MySQL 分片
路由算法对比
| 方案 | 扩容代价 | 实现难度 | 适用 |
|---|---|---|---|
hash(key) % N |
高:N 变化导致几乎全量重分布 | 最低 | 分片数长期不变 |
| 范围(range) | 低:加片即可 | 低 | 按时间/ID 段,天然有热点风险 |
| 一致性哈希 | 低:只搬相邻一段 | 中 | 节点频繁增减、缓存类 |
| 映射表 | 低:改表即可迁移 | 中(多一次查询,需缓存) | 需要灵活迁移单个大客户 |
| 预分片 | 低:新增物理库承接逻辑片 | 中 | 长期方案首选 |
预分片值得单独说:一开始就规划 1024 张逻辑表,映射到少量物理库;扩容时把一部分逻辑表整体搬到新库,路由规则不变。业务代码里永远是 table_${hash % 1024},物理落点由配置决定——这样扩容不需要改代码、也不需要重算全部数据。
另外,扩容优先按倍数扩(2 → 4 → 8),这样 % N 的映射关系可以复用一半以上,避免全量重分布。
全局唯一 ID
分片之后自增主键不再全局唯一,三种常见做法:
号段模式(Leaf-segment)
从数据库批量取一段(如 1~1000)缓存在内存,用完再取
优点:纯数字、趋势递增、对 DB 压力小 | 缺点:依赖 DB,重启浪费一段
雪花算法(Snowflake)
41 位时间戳 + 10 位机器号 + 12 位序列
优点:本地生成、无依赖 | 缺点:依赖时钟,回拨要处理;机器号要分配
分片内自增 + 位嵌入
ID = 分片号 << 40 | 分片内自增,天然可按 ID 路由
优点:路由零成本 | 缺点:ID 泄露分片信息,扩容受位宽限制
对外暴露的订单号另做一层混淆(加盐映射或独立发号器),不要直接把数据库 ID 给前端。
跨片查询的四种应对
① 广播 + 内存聚合
SELECT ... WHERE status = 1 ORDER BY create_time DESC LIMIT 10
发到所有片,各取 10 条,内存归并
限制:片数 × 单次条数要可控(分页深了必然爆)
② 冗余字段 / 宽表
把常用维度冗余进主表,避免 join;代价是写放大与一致性维护
③ 异构索引(ES / ClickHouse)
运营后台、多维筛选、模糊搜索全部走异构存储,MySQL 只承担按主键/分片键的精确查询
④ 明确禁止
没有分片键的查询直接拒绝,从代码层面挡住 —— 这是最省事的方案
深分页是分片后的重灾区:
原始:SELECT * FROM t WHERE user_id=? ORDER BY id LIMIT 100000, 20
每个片都要扫描并丢弃 10 万行
改写为游标:SELECT * FROM t WHERE user_id=? AND id > :last_id
ORDER BY id LIMIT 20
或者二次查询法:先查各片的 id 边界,再按 id 批量回表
迁移:六个阶段,每一步都可回滚
目标:不停机、不丢数据、随时能退回单库。
阶段 0 准备
建好新分片集群,写好双写与路由开关(灰度维度:用户 ID 白名单)
阶段 1 全量迁移
用 DTS / mysqldump + binlog 位点,记录迁移开始时的 binlog position
大表分批(按主键区间)导,控制单批 1000~5000 行,避免主库压力
阶段 2 增量追平
从记录的位点订阅 binlog,持续写入新库
追到延迟 < 1 秒,说明新库已基本跟上
阶段 3 双写
应用同时写旧库与新库(先写旧库再写新库,或包在一个本地事务外)
新库写失败只告警不回滚主流程,靠补偿任务修复
这一步要观察:写入耗时增量、失败率、增量延迟
阶段 4 全量校验
├─ 数量比对:按分片分桶 count
├─ 抽样比对:按主键哈希抽 1%,逐字段 compare
└─ 校验和:对关键列做 SUM/MD5 分段比对
差异项进修复队列,重放 binlog 或按旧库回写
阶段 5 灰度切读
按用户白名单把读流量切到新库,观察错误率与延迟;异常立即关开关回旧库
读稳定后再逐批扩大比例
阶段 6 切写 + 收尾
写入全部切到新库,旧库转为只读并保留 1~2 个发布周期
期间保持反向同步(新库 → 旧库),保留回滚能力
确认稳定后停双写、下线旧库、清理灰度开关
关键设计点:
- 回滚成本最低的做法是"保留旧库可写",而不是依赖反向同步的完备性。反向同步只做兜底,不做主路径。
- 双写不要放进同一个数据库事务(跨库也做不到),用「本地消息表 + 补偿」保证最终一致。
- 迁移期间不要做 DDL,新旧表结构一旦漂移,校验会一直报差异。
- 监控要比平时更细:双写失败数、binlog 延迟、校验差异数、切读后新库的错误率,四项都要有告警。
中间件选型
| 方案 | 形态 | 优点 | 代价 |
|---|---|---|---|
| ShardingSphere-JDBC | 客户端 jar | 无额外部署,性能损耗小 | 与语言/框架耦合,升级要发版 |
| ShardingSphere-Proxy | 独立代理 | 语言无关,运维集中 | 多一跳网络,代理本身要高可用 |
| Vitess | 代理 + 管控 | 大规模 MySQL 分片成熟方案 | 组件多,学习成本高 |
| 自研路由层 | 代码内 | 完全可控 | 分页、聚合、事务全要自己实现 |
如果只是"按用户 ID 分片 + 主键查询",客户端分片足矣;如果需要多语言接入或 DBA 统一管控,选代理模式。
常见坑
- 分片键选成时间或状态:写入全落到最后一片,分片等于没分。
- 分片数量拍得太少:一开始只分 4 片,两年后每片又过亿,只能停机重分。初期按 3~5 年容量预留,用预分片摊薄成本。
- 跨片 join 写进业务代码:上线才发现只能小数据量内存 join,数据一大就 OOM。
- 分布式事务滥用 2PC:跨片 2PC 会显著抬高延迟并放大故障半径,优先本地消息表 / 最终一致。
- 深分页没改造:
LIMIT 100000,20在 16 个片上各扫 10 万行,接口直接超时。 - 双写不做校验:以为双写成功就是一致,实际上新库写失败被吞掉,切完才发现少数据。
- 没有灰度维度:一次性全量切换,出问题只能整体回滚,回滚本身又是一次迁移。
- 全局 ID 依赖单点:发号器成唯一瓶颈,或雪花算法的机器号重复导致 ID 冲突。
总结
| 阶段 | 关键决策 | 判断标准 |
|---|---|---|
| 要不要拆 | 先优化、缓存、读写分离、归档 | 瓶颈是否真的在单机容量/写入 |
| 怎么拆 | 先垂直对齐业务,再水平分片 | 分片键覆盖核心查询路径 |
| 路由 | 预分片 + 映射表 | 扩容不改代码、只搬数据 |
| 跨片查询 | 冗余字段、异构索引、禁止无线索查询 | 复杂查询不落在 MySQL 分片上 |
| 迁移 | 全量 + 增量 + 双写 + 校验 + 灰度 | 每一步都能在分钟级回滚 |
| 一致性 | 本地消息表 + 补偿 + 校验 | 不依赖跨库事务 |
分库分表真正的成本不在拆分那一刻,而在拆分之后的每一年:每一次新增查询都要先问"带分片键了吗",每一次扩容都要重新校验数据。所以决策时要问的不是"能不能拆",而是"有没有更便宜的方案把这次拆分再推迟两年"。
- 点赞
- 收藏
- 关注作者
评论(0)