MySQL 分库分表从 0 到 1,800 万订单量如何平滑迁移

MySQL 分库分表从 0 到 1,800 万订单量如何平滑迁移

单表 800 万订单数据,SQL 查询开始出现明显延迟。DBA 下了最后通牒:三个月内必须做分库分表。

当时团队没有人有分库分表经验,我也是第一次。查了大量资料,趟了无数坑,最终在不停服的情况下完成了平滑迁移。

什么时候需要分库分表

不是数据量大了就要分,先排除其他方案:

  1. 读写分离:读多写少,加从库分流读请求
  2. 缓存前置:热点数据放 Redis,减少数据库压力
  3. 归档冷数据:把超过一定时间的订单移到归档表
  4. 索引优化:确认慢查询是否可以通过加索引解决

当这些手段都试过还是慢,或者业务预期数据量会单库无法承载时,再考虑分库分表。

判断标准:

  • 单表数据量超过 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
-- ...

适合:数据量特别大、记录均匀分布

分片键选择

分片键决定了数据如何分散。选错会导致:

  • 数据倾斜(热点数据集中在某个分片)
  • 跨分片查询(大部分查询要扫描多个分片)

选择原则:

  1. 查询高频:分片键是大部分查询的过滤条件
  2. 分布均匀:避免热点用户把数据全写到一个分片
  3. 避免跨分片关联:JOIN 操作尽量在单个分片内完成

订单场景常见的分片键:user_idorder_idcreated_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 支持

可以先考虑:

  1. 升级硬件(SSD、高内存)
  2. 读写分离 + 缓存
  3. 定期归档冷数据

分库分表是终极方案,代价很大,决策要慎重。

总结

分库分表平滑迁移的核心是双写 + 数据同步 + 灰度切换,关键点:

  1. 设计阶段充分评估:分片键、分片数、分片算法
  2. 历史数据迁移脚本要支持断点续传
  3. 双写期间新老库数据一致性要定期校验
  4. 灰度切读观察指标:延迟、错误率、数据一致性
  5. 全量切换后保留老库一段时间便于回滚

迁移过程中最大的风险是数据丢失和业务中断,按照这个方案一步步做,可以把风险控制在可接受范围内。

最后更新 4/20/2026, 6:02:32 AM