单表两亿行扛不住了:一次分库分表的复盘

orders 单表两亿行,加索引 DDL 卡几小时,单机磁盘连接数全到顶。分库分表是最后手段,先归档争取喘息。几周做完:垂直拆 vs 水平拆、选分片键 buyer_id+订单号基因法、hash 取模一次给足分片数、攻克分布式 ID 与跨片分页、双写灰度迁移全程对账。

2024 年我们的订单表终于到了不得不动它的那一天。这张 orders 表单表已经堆了将近两亿行,体积几百 GB。日常的按订单号查询还撑得住,可只要稍微复杂一点的操作就开始要命:给它加一个索引,DDL 跑了几个小时还没完;月底跑一个稍大范围的统计,直接把数据库拖到所有接口一起变慢;就连一次失误的全表扫描,都能让整个库的 IO 打满。我们都清楚,这张表已经到了单机扛不动的临界点,必须分库分表了。但分库分表绝不是"把一张表拆成几张"这么轻巧,它是一项牵一发动全身的大手术。投了相当长的时间做方案、做迁移、做平滑上线,本文复盘这次实战。

问题背景

业务:电商订单系统,单库单表 orders
事故现象:
- orders 单表 ~2 亿行,几百 GB
- 加索引 DDL 跑几小时,期间风险极高
- 稍大范围的统计查询,拖慢整个库
- 单机磁盘、内存、连接数都逼近上限
- 备份、恢复时间长到无法接受

现场排查:
# 1. 看表的体量
mysql> SELECT table_rows,
       data_length/1024/1024/1024 AS data_gb,
       index_length/1024/1024/1024 AS idx_gb
       FROM information_schema.tables
       WHERE table_name = 'orders';
table_rows: 198400000   data_gb: 312   idx_gb: 96

# 2. 一次 DDL 的痛
ALTER TABLE orders ADD INDEX idx_buyer (buyer_id);
# 跑了 3 小时还在 copy to tmp table...

# 3. 看慢查询,大范围扫描把 buffer pool 冲垮
SELECT COUNT(*) FROM orders
WHERE create_time >= '2024-01-01';
# 扫几千万行,buffer pool 命中率暴跌,
# 连带其它正常查询一起变慢

# 4. 单机瓶颈是全方位的
- 磁盘:几百 GB,且还在以每月数百万行增长
- 内存:buffer pool 装不下热数据
- 连接:单实例 max_connections 有上限
- 可用性:这一个库挂了,整个订单系统全挂

根因:
1. 数据量远超单机 MySQL 的舒适区
2. 所有订单挤在一张表、一个库,无法水平扩展
3. 单点:容量、性能、可用性都卡在这一台机器上

修复 1:先别急着分——能不分就不分

=== 分库分表是"最后手段",不是第一反应 ===
它会引入一大堆新的复杂度(后面几节全是)。
动手之前,先把成本更低的方案逐一评估掉。

=== 方案 A:数据归档,把冷数据搬走 ===
订单有很强的时间属性:三年前的订单,
几乎不会再被在线查询,只是偶尔要查档。
把超过 N 个月的历史订单,迁到一张归档表
(甚至迁到成本更低的存储)。
在线的 orders 只保留"热"数据。
很多情况下,归档完,单表瞬间瘦身一大半,
根本到不了非分不可的地步。

=== 方案 B:读写分离 ===
如果瓶颈主要是"读"(查询多、写不多),
加从库、读写分离,能把读压力分散掉。
注意:它不解决"容量"问题 —— 每个从库
都是全量数据,该多大还是多大。

=== 方案 C:MySQL 分区表(partition)===
把一张表按规则(如按月)分成多个物理分区,
对应用【透明】,SQL 不用改。
它能让"按分区键的查询"只扫一个分区,
也让"删除一整个月数据"变成秒级 DROP PARTITION。
但它仍是【单机】方案 —— 突破不了单机的
磁盘、CPU、连接数上限。

=== 我们的判断 ===
我们先做了归档,单表从 2 亿降到约 8000 万,
争取到了喘息时间。但订单仍在快速增长,
单机的容量、可用性单点问题依旧在 ——
评估下来,水平分库分表无法回避,只是
归档让我们能"从容地"做,而不是"救火式"地做。

修复 2:分库还是分表?垂直拆 vs 水平拆

=== 先分清两种完全不同的"拆" ===

【垂直拆分】—— 按"字段 / 业务"拆
- 垂直分表:一张宽表,把不常用的大字段
  (如订单的 detail_json、备注)拆到另一张表。
  让主表更"窄",一页能装下更多行。
- 垂直分库:按业务把表分到不同库。
  用户库、订单库、商品库各自独立。
- 特点:拆完,每张表的【行数没变】,
  只是变窄了 / 分到不同库了。
  它解决不了"单表行数太多"这个核心问题。

【水平拆分】—— 按"行"拆
- 把一张表的【行】,按某种规则分散到
  多张结构完全相同的表 / 多个库里。
- orders 拆成 orders_0 ~ orders_15,
  每个用户的订单按规则落到其中一张。
- 特点:每张表的行数变成了原来的 1/N。
  这才是解决"单表两亿行"的正解。

=== 四种组合 ===
1. 只分表不分库:N 张表还在【一个库】里。
   解决了单表行数,但磁盘/连接/CPU 还是单机。
2. 只分库不分表:不同库,但每库还是单张大表。
   分散了机器压力,单表行数没解决。
3. 分库又分表:行数和单机瓶颈一起解决。← 我们的选择
4. 垂直 + 水平结合:大型系统的常态。

=== 我们的方案 ===
水平分库分表:4 个库,每库 16 张表,共 64 张。
8000 万行 / 64 ≈ 单表 125 万行,回到舒适区。
4 个库分散到不同实例,磁盘、连接、可用性
都被分摊开。

修复 3:选分片键——整个方案最重要的一个决定

=== 分片键(sharding key)是什么 ===
就是"用哪个字段来决定一行数据落到哪个库、哪张表"。
这个选择一旦定下、数据迁完,几乎【无法更改】。
它直接决定了分库分表之后好不好用。

=== 选分片键的三条标准 ===
1. 足够分散:用它算出来的分布要均匀,
   不能让某几张表特别大、其它表很空(数据倾斜)。
2. 贴合最高频的查询:绝大多数查询的 WHERE 里
   都应该带着分片键 —— 这样查询能被直接路由到
   【一张表】,而不是扫全部 64 张。
3. 业务上稳定:分片键的值不应该会变
   (一行数据不该今天在 A 表、改个字段就要挪到 B 表)。

=== 我们的纠结:用 order_id 还是 buyer_id ===
- 用 order_id 分片:
  优点:订单号天然唯一、分布均匀。
  缺点:"查某个用户的所有订单"是超高频操作,
  而这个查询的 WHERE 里没有 order_id,
  会变成 64 张表全扫一遍 —— 灾难。
- 用 buyer_id 分片:
  优点:"查某用户所有订单"直接路由到一张表。
  一个用户的所有订单天然聚在一起。
  缺点:"按 order_id 查单个订单"又不带 buyer_id 了。

=== 我们的最终解法:buyer_id 分片 + 订单号内嵌路由信息 ===
1. 分片键选 buyer_id —— 优先保住最高频的
   "查我的订单"列表。
2. 对于"按订单号查":在生成 order_id 时,
   把 buyer_id 的分片信息【编码进订单号】。
   解析订单号就能反推出它在哪个库哪张表。
   这样按订单号查,也能精准路由。
口诀:分片键服从【最高频的查询】,
其它查询用"基因法"(把路由信息塞进 ID)来兼容。
// === "基因法":把分片信息编进订单号 ===
// 生成订单号时,末尾几位 = buyer_id 的分片基因
public String genOrderId(long buyerId) {
    long seq = idGenerator.next();          // 全局唯一序列
    int gene = (int) (buyerId % 64);        // buyer 的分片基因
    // 订单号 = 序列号 拼上 2 位分片基因
    return String.format("%s%02d", seq, gene);
}

// 按订单号查时,直接从订单号尾部解析出分片位置
public int resolveShard(String orderId) {
    return Integer.parseInt(
        orderId.substring(orderId.length() - 2));
}
// 这样:按 buyerId 查 -> buyerId % 64 路由
//       按 orderId 查 -> 解析尾部基因路由
// 两个高频查询都不用扫全表。

修复 4:分片算法——range 还是 hash

=== 算法一:范围分片(range)===
按分片键的"区间"分:
  buyer_id 0~1000万    -> 库 0
  buyer_id 1000~2000万 -> 库 1
优点:
- 扩容简单:新数据是新区间,加个库 4、库 5 即可,
  老数据完全不用动。
- 范围查询友好。
缺点:
- 容易热点:最新注册的用户最活跃,
  他们全落在最后一个库 —— 新库被打爆、老库很闲。

=== 算法二:哈希取模分片(hash)===
shard = hash(分片键) % 分片总数
优点:
- 分布非常均匀,几乎不会数据倾斜或热点。
缺点(致命):
- 扩容是噩梦。分片数从 64 改成 128,
  取模的结果全变了 —— 几乎【每一行】都要搬家。
  一次重新分片,等于把全量数据重迁一遍。

=== 算法三:一致性哈希 ===
扩容时只影响【相邻】的一部分数据,
不像取模那样全量重排。代价是实现更复杂。

=== 我们的选择:hash 取模,但一步到位预留好分片数 ===
我们用 hash 取模(buyer_id % 64),图它分布均匀。
为了规避"扩容要全量重迁"这个最大的坑,
我们采取了一个关键策略:【一次性把分片数定得足够大】。
- 不按当前数据量设计,按【未来 5~10 年的预估】设计。
- 64 张表,即使单表到 500 万,总量也能扛 3.2 亿,
  足够撑很多年。
- 与其频繁扩容、每次都全量重迁,不如一开始
  就把表建够。空表几乎不占成本。

=== 还有一个技巧:库和表的"二级路由" ===
库号 = hash(key) / 16 % 4      (4 个库)
表号 = hash(key) % 16          (每库 16 表)
将来真要扩容,可以"成倍扩库":
把库从 4 变 8,让原来库 0 的一半表整体搬到新库,
表本身不用拆 —— 这种"分表不变、只搬表"的扩容,
比重新 hash 全部数据,代价小得多。

修复 5:分片后绕不开的几道难题

// === 难题 1:分布式 ID,不能再用自增主键 ===
// 单表时主键 AUTO_INCREMENT,各表独立自增,
// 64 张表会生成大量重复的 id。必须换全局唯一 ID。
// 常见方案:雪花算法(Snowflake)
//   64 位 = 时间戳 + 机器号 + 序列号,
//   趋势递增、全局唯一、不依赖数据库。
long id = snowflake.nextId();
// 也可以用号段模式(从一张表批量取一段 id 缓存到内存)。

// === 难题 2:跨分片查询 ===
// 不带分片键的查询(如"按收货手机号查订单")
// 没法路由,只能【广播】到所有 64 张表,
// 再把结果聚合。这种查询要尽量避免。
// 实在需要:为这类维度单独建一份"映射表"或
// 走 ES 等外部索引,而不是让 MySQL 扫全部分片。

// === 难题 3:全局排序与分页 ===
// "所有订单按时间倒序,取第 100~120 条" ——
// 分片后这是个大坑。第 100 条在哪张表?不知道。
// 朴素做法:每张表都取前 120 条,汇总 64*120 条,
// 在内存里重新排序,再切出 100~120。
// 翻得越深,每张表要取的越多,代价越大。
// 缓解:① 业务上限制深翻页(参考深分页优化那套);
//      ② 带上分片键,让分页只发生在一张表内;
//      ③ 用 ES 做这类全局排序分页。

// === 难题 4:跨分片事务 ===
// 一个操作要改两个不同分片的数据,
// 本地事务管不了了 —— 退化成分布式事务问题。
// (参考分布式事务那套:TCC / 本地消息表 / Saga)
// 设计上要尽量让"一次业务操作"落在【同一个分片】内,
// 从源头减少跨片事务。这也是选 buyer_id 做分片键的
// 又一个好处:一个用户的下单操作,数据都在同一片。

// === 难题 5:全局唯一约束失效 ===
// "手机号全局唯一"这种约束,单表靠唯一索引,
// 分片后每张表的唯一索引只在本表内有效。
// 要全局唯一,得靠单独的"号码注册表"或外部校验。

修复 6:数据迁移与平滑上线

=== 难点不在"分",在"不停服地迁过去" ===
订单系统不可能停机几天做迁移。
要在【线上持续读写】的同时,把数据迁到新结构,
还要保证一条都不错、一条都不丢。

=== 我们用的"双写 + 灰度"方案 ===

第 1 步:新库新表建好,代码里接入分库分表中间件
        (如 ShardingSphere),但还不切流量。

第 2 步:开启【双写】。
        所有订单的写操作,同时写老库 和 新分片库。
        以老库为准,新库的写失败只告警、不影响主流程。
        这一步让"增量数据"开始进入新库。

第 3 步:迁移【存量数据】。
        用一个后台任务,把老库的历史订单
        按分片规则,一批批搬到新分片库。
        边搬边和双写并行,注意处理"搬的过程中
        这条数据又被改了"的情况(以更新时间比对)。

第 4 步:全量【数据对账】。
        存量迁完,逐条核对老库和新库:
        条数一致、关键字段一致。不一致的修掉。
        对账通过,才有资格切读。

第 5 步:【灰度切读】。
        读流量先切 1% 到新库,观察;
        再 10%、50%、100%,逐步放量。
        每一步都盯着监控和对账,有问题立刻切回老库。

第 6 步:读写都切到新库,稳定运行一段时间后,
        停掉双写,老库降级为备份,最后下线。

=== 经验 ===
1. 双写期间老库始终是"事实标准",新库出问题
   随时能退回来 —— 这是整个迁移的安全垫。
2. 灰度一定要能【快速回滚】,且回滚预案
   在切流量之前就演练过。
3. 对账不是做一次,迁移全程要持续对账。
4. 别在业务高峰期做切换。

优化效果

指标                      改造前              改造后
=============================================================
orders 物理结构           单库单表 2 亿行      4 库 x 16 表 = 64
单表行数                  ~2 亿               ~125 万(舒适区)
加索引 DDL                几小时,风险极高     单表分钟级
按用户查订单              单表扫描              路由到一张表
按订单号查                单表                 解析基因路由一张表
主键                      表内自增             雪花算法全局唯一
单机瓶颈                  磁盘/连接/CPU 单点   4 实例分摊
可用性                    一库挂全系统挂       单库故障只影响 1/4
深翻页 / 跨片查询          —                   限制深翻页 + ES 兜底

治理过程:
- 数据归档争取喘息时间:1 周
- 分片方案设计(键/算法/中间件选型):1 周
- 跨片难题改造(ID/分页/事务):2 周
- 双写 + 存量迁移 + 全量对账:2 周
- 灰度切读 + 稳定观察 + 老库下线:2 周

避坑清单

  1. 分库分表是最后手段,先评估数据归档、读写分离、分区表这些更轻量的方案
  2. 垂直拆分按字段/业务拆,解决不了单表行数多;水平拆分按行拆,才是正解
  3. 分片键是整个方案最重要的决定,一旦迁完几乎无法更改,要慎之又慎
  4. 分片键要分散均匀、贴合最高频查询、业务上稳定,服从最高频的那个查询
  5. 次高频查询用基因法兼容:把分片路由信息编码进 ID,解析 ID 即可路由
  6. hash 取模分布均匀但扩容要全量重迁,分片数要一次按未来多年预估给足
  7. 分库分表后自增主键失效,必须换雪花算法等全局唯一 ID 方案
  8. 不带分片键的查询只能广播全部分片,跨片全局排序分页是大坑,用 ES 兜底
  9. 尽量让一次业务操作落在同一分片内,从源头减少跨分片分布式事务
  10. 迁移用双写+灰度,老库始终是事实标准做安全垫,全程对账,回滚预案先演练

总结

这次订单表的分库分表,是我做过的技术改造里战线最长、也最不敢掉以轻心的一次。它给我的第一个、也是贯穿全程的体会是:分库分表绝对不是看到大表的第一反应,它应该是被排在最后的那个手段。它之所以让人望而生畏,不是"把一张表拆成几张"这个动作本身有多难,而是它拆开的那一刻,会凭空冒出来一大堆原本根本不存在的新问题——主键怎么保证全局唯一、不带分片键的查询怎么办、跨分片怎么排序分页、跨分片的事务怎么处理。这些问题,每一个单拎出来都够写一篇复盘。所以动手之前,我强迫自己先把成本低得多的方案一个一个评估过去:这张订单表有非常强的时间属性,三年前的订单几乎不会再被在线查询,那么数据归档就是性价比极高的一招——我们先做了归档,把单表从两亿行硬生生压回到八千万,这一下就为我们争取到了宝贵的喘息时间,让后面的分库分表可以从容地、按部就班地做,而不是在数据库快要崩的边缘救火式地做。归档、读写分离、分区表,这些都该在掏出"分库分表"这把重武器之前被认真考虑一遍;只有当你确认单机的容量、连接数、可用性这些瓶颈是真的、结构性地绕不过去了,才轮到它上场。真正决定分库分表之后这个系统好不好用的,是分片键的选择,这是整个方案里最重也最不可逆的一个决定——它一旦定下来、数据一旦按它迁完,你几乎就再没有回头路了。选分片键时我们在 order_id 和 buyer_id 之间纠结了很久,这个纠结本身特别有代表性:用订单号分片,分布最均匀,但"查我的所有订单"这个全系统最高频的操作,WHERE 里偏偏没有订单号,它会退化成把六十四张表全部扫一遍,这是灾难;用买家 id 分片,"查我的订单"能精准落到一张表,可"按订单号查单个订单"又不带买家 id 了。最后我们想通的原则是:分片键必须无条件服从那个最高频的查询,所以选了 buyer_id;而对于次高频的"按订单号查",我们用了"基因法"——在生成订单号的时候,就把买家 id 的分片信息编码进订单号的尾部,这样拿到一个订单号,解析它的尾巴就能反推出它在哪个库哪张表。一个分片键保住最高频查询,基因法兼容次高频查询,这是这次方案设计里我最满意的一笔。分片算法上我们选了 hash 取模,看中的是它分布均匀、几乎不会出现数据倾斜;但 hash 取模有一个致命的代价必须提前规避——它的扩容是噩梦,分片数一旦从六十四改成一百二十八,取模的结果几乎全变,等于要把全量数据重新搬一遍家。我们的对策是干脆不去碰扩容这件事:分片数不按当前的数据量设计,而是按未来五到十年的增长预估,一次性把六十四张表全部建好。空表本身几乎不占任何成本,与其将来频繁扩容、每次都全量重迁,不如一开始就把容量给足。分片之后那几道绕不开的难题,每一道我们都得正面处理:自增主键彻底失效了,换成了雪花算法来保证全局唯一;不带分片键的查询没法路由,只能广播到所有分片,这种查询要么从设计上避免,要么交给 ES 这样的外部索引去扛;全局排序分页是个尤其深的坑,翻得越深,每张分片表要预取的数据就越多,我们的办法是一边在产品层面限制深翻页,一边把这类全局排序的需求迁到 ES 上;而跨分片事务,我们更多是从源头去回避它——尽量让一次完整的业务操作的数据都落在同一个分片里,这恰恰又是选 buyer_id 做分片键带来的额外好处,一个用户的整个下单流程,数据天然就在同一片上。但要说这次改造里最惊险、最考验人的部分,其实不是怎么"分",而是怎么在系统持续对外读写、一刻不停机的前提下,把两亿行数据安全地迁到新结构上,还要保证一条不错、一条不丢。我们用的是双写加灰度的方案:先让所有写操作同时写老库和新分片库,以老库为绝对的事实标准,这样增量数据就开始流进新库;再用后台任务把存量的历史订单按分片规则一批批搬过去;搬完做全量逐条对账,条数和关键字段都核对一致了,才有资格开始切读;切读也不是一把全切,而是先放百分之一的读流量到新库,观察没问题再到百分之十、百分之五十,直到百分之百。这整个过程里,老库始终亮着、始终是那个可以随时退回去的事实标准,它就是我们最大的一块安全垫;而每一步切流量之前,快速回滚的预案都必须是已经演练过的。回头看,这次分库分表真正教给我的,是对"水平扩展"这四个字的敬畏。在动手之前,我以为水平扩展就是"加机器、把数据摊开",听起来很美好;做完之后我才明白,单机数据库那种"一张表、一个事务、一个自增主键、一个唯一索引就搞定一切"的简单和确定性,是一种你拥有时浑然不觉、一旦失去才追悔莫及的奢侈。分库分表换来了容量和可用性上几乎无限的伸展空间,但它收走的,是这份简单。所以这件事真正的成本,从来不是迁移那几周的人力,而是此后这个系统里每一个开发,在写每一条 SQL 时,都必须先在脑子里回答一个问题——我这条查询,带分片键了吗?它能落到一张表上,还是会捅到全部六十四张?这个问题会一直跟着这个系统,直到它退役的那一天。

—— 别看了 · 2026
声明:本站所有文章,如无特殊说明或标注,均为本站原创发布。任何个人或组织,在未征得本站同意时,禁止复制、盗用、采集、发布本站内容到任何网站、书籍等各类媒体平台。如若本站内容侵犯了原著者的合法权益,可联系我们进行处理 邮箱1846861578@qq.com。
技术教程

用户付了两次款:一次接口幂等性设计的复盘

2026-5-20 17:04:35

软件分享

双指针对撞 完全指南:速查、踩坑与最佳实践

2026-5-18 18:03:41

0 条回复 A文章作者 M管理员
    暂无讨论,说说你的看法吧
个人中心
购物车
优惠劵
今日签到
有新私信 私信列表
搜索