MySQL 分库分表从 0 到 1,800 万订单量如何平滑迁移
MySQL 分库分表从 0 到 1,800 万订单量如何平滑迁移
单表 800 万订单数据,SQL 查询开始出现明显延迟。DBA 下了最后通牒:三个月内必须做分库分表。
当时团队没有人有分库分表经验,我也是第一次。查了大量资料,趟了无数坑,最终在不停服的情况下完成了平滑迁移。
什么时候需要分库分表
不是数据量大了就要分,先排除其他方案:
- 读写分离:读多写少,加从库分流读请求
- 缓存前置:热点数据放 Redis,减少数据库压力
- 归档冷数据:把超过一定时间的订单移到归档表
- 索引优化:确认慢查询是否可以通过加索引解决
当这些手段都试过还是慢,或者业务预期数据量会单库无法承载时,再考虑分库分表。
判断标准:
- 单表数据量超过 2000 万
- 磁盘空间开始紧张
- 备份时间超过 2 小时
- 慢查询数量持续高位
分库分表方案设计
垂直拆分 vs 水平拆分
垂直拆分:按业务模块拆分,把大表的不同字段分到不同库表
-- 原始表
orders(order_id, user_id, product_id, amount, status, created_at, updated_at, extra_data)
-- 垂直拆分成
orders(order_id, user_id, product_id, amount, status, created_at)
orders_ext(order_id, updated_at, extra_data)
适合:字段多、冷热数据分明、某些字段是 blob/text
水平拆分:按某个维度把数据切分到多个库表
-- 按 user_id % 4 拆分到 4 张表
orders_0, orders_1, orders_2, orders_3
-- user_id % 4 == 0 -> orders_0
-- user_id % 4 == 1 -> orders_1
-- ...
适合:数据量特别大、记录均匀分布
分片键选择
分片键决定了数据如何分散。选错会导致:
- 数据倾斜(热点数据集中在某个分片)
- 跨分片查询(大部分查询要扫描多个分片)
选择原则:
- 查询高频:分片键是大部分查询的过滤条件
- 分布均匀:避免热点用户把数据全写到一个分片
- 避免跨分片关联:JOIN 操作尽量在单个分片内完成
订单场景常见的分片键:user_id、order_id、created_at
我们选了 user_id 作为分片键,理由:
- 70% 的查询都带 user_id 过滤
- 用户订单分布相对均匀
- 按用户查订单是核心场景
分片算法
| 算法 | 优点 | 缺点 |
|---|---|---|
| 哈希取模 | 数据均匀 | 扩容困难 |
| 范围(时间/ID) | 扩容简单 | 容易热点 |
| 一致性哈希 | 扩容影响小 | 实现复杂 |
我们选了哈希取模,16 个分片,user_id % 16。
平滑迁移方案
不停机迁移的核心思路:双写 + 数据同步 + 灰度切换
第一阶段:准备新库
-- 创建 16 个新表(可以在同一物理库的不同逻辑库)
CREATE DATABASE sharding_0;
CREATE DATABASE sharding_1;
-- ... 16 个库
或者用 ShardingSphere 等中间件,配置逻辑表到物理表的映射。
第二阶段:历史数据同步
写一个数据迁移脚本:
# 伪代码 - 历史数据迁移
def migrate_orders(batch_size=1000):
last_id = 0
while True:
# 从老表分批读取
orders = query(
"SELECT * FROM orders WHERE order_id > ? ORDER BY order_id LIMIT ?",
last_id, batch_size
)
if not orders:
break
for order in orders:
# 计算目标分片
shard_index = order.user_id % 16
# 写入新表
insert_into_shard(shard_index, order)
last_id = orders[-1].order_id
print(f"Migrated {last_id} orders")
time.sleep(0.1) # 控制速度,避免压垮数据库
这个阶段不能停写,因为迁移可能持续几天甚至几周。
第三阶段:双写
修改应用层代码,所有写操作同时写老表和新表:
def create_order(order):
# 写老表(保持兼容)
db_old.execute("INSERT INTO orders ...", order)
# 计算分片,写新表
shard_index = order.user_id % 16
db_new[shard_index].execute("INSERT INTO orders_? ...", shard_index, order)
return order
第四阶段:数据校验
迁移一段时间后,新老数据要对比校验:
-- 检查数据一致性
SELECT COUNT(*) as old_count FROM orders;
SELECT SUM(cnt) as new_count FROM (
SELECT COUNT(*) as cnt FROM orders_0
UNION ALL SELECT COUNT(*) FROM orders_1
-- ... 16 张表
) t;
-- 抽样比对
SELECT order_id, user_id, amount FROM orders WHERE order_id % 100 = 0
MINUS
SELECT order_id, user_id, amount FROM orders_0 WHERE order_id % 16 = 0 AND order_id % 100 = 0
-- ... 对比各分片
发现不一致要修复,一般是历史迁移脚本的 bug。
第五阶段:灰度切读
先让 1% 的流量读新库,观察:
def get_order(order_id):
user_id = get_user_id_from_order(order_id) # 需要查询老表获取
shard_index = user_id % 16
if random.random() < 0.01: # 1% 流量
return db_new[shard_index].query("SELECT * FROM orders WHERE order_id = ?", order_id)
else:
return db_old.query("SELECT * FROM orders WHERE order_id = ?", order_id)
第六阶段:全量切换
灰度观察 1-2 周没问题,逐步把读流量切到新库:
- 10% -> 30% -> 50% -> 80% -> 100%
每一步观察 1-2 天,确认无异常再继续。
第七阶段:下掉老库
确认所有流量切到新库后,下线双写逻辑和老表。
-- 确认无引用后删除老表
DROP TABLE orders;
踩过的坑
1. 分页查询要改写
原来:
SELECT * FROM orders ORDER BY created_at DESC LIMIT 100, 20;
分库分表后,每个分片只有 1/16 的数据,简单的 LIMIT 会漏数据或重复。
改写成:
SELECT * FROM (
SELECT * FROM orders_0 ORDER BY created_at DESC LIMIT 100, 20
UNION ALL SELECT * FROM orders_1 ORDER BY created_at DESC LIMIT 100, 20
-- ... 16 个分片
) t ORDER BY created_at DESC LIMIT 100, 20;
这样能拿到全局排序结果,但性能会差一些。
更好的方案是用 ES 做订单搜索,数据库只做存储。
2. 分布式 ID 要提前规划
分库分表后自增 ID 会冲突,有几种方案:
- 雪花算法:时间戳 + 机器 ID + 序列号,不依赖数据库
- UUID:简单但无序,性能差
- 数据库号段:独立 ID 生成服务,每次从数据库批量获取
我们选了雪花算法,团队自己实现了一个 ID 生成器。
3. 跨分片事务处理
分布式事务不可避免,推荐方案:
- 最终一致性优先:用消息队列做异步补偿
- TCC:Try-Confirm-Cancel,适合强一致场景
- Seata:阿里的分布式事务框架,支持 AT 模式
4. 全局序列问题
统计查询如 SELECT COUNT(*) FROM orders WHERE user_id = ? 没问题,但 SELECT COUNT(*) FROM orders 没有意义(16 张表要聚合),业务上要避免这类查询。
迁移后的收益
迁移完成后:
- 单表数据量从 800 万降到 50 万(16 个分片平均)
- 慢查询从每天 200+ 条降到 10 条以内
- 写 QPS 从 2000 提升到 8000+
什么时候考虑不用分库分表
如果:
- 数据量 1000 万以内,查询延迟可接受
- 业务迭代快,团队没有余力维护分库分表复杂度
- 没有足够的 DBA 支持
可以先考虑:
- 升级硬件(SSD、高内存)
- 读写分离 + 缓存
- 定期归档冷数据
分库分表是终极方案,代价很大,决策要慎重。
总结
分库分表平滑迁移的核心是双写 + 数据同步 + 灰度切换,关键点:
- 设计阶段充分评估:分片键、分片数、分片算法
- 历史数据迁移脚本要支持断点续传
- 双写期间新老库数据一致性要定期校验
- 灰度切读观察指标:延迟、错误率、数据一致性
- 全量切换后保留老库一段时间便于回滚
迁移过程中最大的风险是数据丢失和业务中断,按照这个方案一步步做,可以把风险控制在可接受范围内。
