2024 年我们的订单表第一次成了系统的瓶颈。这张 orders 表当时已经攒到了一亿三千万行,单表体积超过 80GB,加索引、改 SQL 各种办法都用过,可只要是稍微复杂一点的查询,响应时间还是稳定地停在好几秒。DBA 给出的结论很干脆:单表已经到极限了,必须分库分表。可分库分表并不是"把一张表拆成几张"这么简单——分片键怎么选、主键 ID 怎么生成、跨分片的查询和分页怎么办、上亿存量数据怎么平滑迁移过去,每一个问题都是坑。我们投了两周做这次分库分表的落地,本文复盘这次实战。
问题背景
业务:订单中心,单表 orders,MySQL InnoDB
现状:
- orders 单表 1.3 亿行,数据文件 80GB+
- 复杂查询 P99 3-6 秒,加索引也压不下去
- 单表写入也开始吃力,QPS 上不去
为什么单表会到瓶颈:
# 1. B+ 树索引的高度随数据量增长
# 1.3 亿行,索引树更高,每次查询要多走几层磁盘 IO
# 2. 单表越大,缓冲池命中率越低,冷数据查询频繁回磁盘
# 3. 单机磁盘 / IO / 连接数,都是物理上限,加机器也救不了单表
# 测算:按业务增长,一年后 orders 会到 3 亿行 —— 必须现在动手
分库分表方案选型:
- 只分表不分库:解决单表大,但所有表还在一个库,
库的连接数 / IO 仍是瓶颈
- 分库又分表:把数据散到多个库多个表,容量和并发一起扩
-> 最终方案:4 个库,每库 16 张表,共 64 张分片表
要解决的核心问题:
1. 按什么字段分片(分片键)
2. 主键 ID 不能再用自增了,怎么生成全局唯一 ID
3. 不带分片键的查询、跨分片的分页怎么办
4. 1.3 亿存量数据怎么迁移、怎么不停机切换
修复 1:分片键的选择
=== 分片键(sharding key):决定一行数据落到哪个库哪张表 ===
分片键选错,是分库分表最致命的错误,而且事后极难补救。
=== 候选 1:按 user_id 分片 ===
路由:库号 = user_id % 4,表号 = user_id / 4 % 16
优点:
- "查某用户的所有订单"是最高频的查询,带 user_id,
能精准路由到一张表,极快
- 同一用户的订单都在一个分片,天然聚集
缺点:
- "按 order_no 查单个订单"不带 user_id -> 不知道在哪个分片
=== 候选 2:按 order_no 分片 ===
优点:按订单号查很快
缺点:"查某用户的订单列表"是最高频场景,却要扫所有分片 -> 灾难
=== 决策:选 user_id 作分片键 ===
原则:分片键要让【最高频的查询】能命中单一分片。
我们的最高频查询是"我的订单列表",必带 user_id,所以选它。
order_no 查询的问题,用"基因法"解决(见下)。
=== order_no 的"基因"技巧:让 order_no 里藏着分片信息 ===
生成 order_no 时,把 user_id 的分片位"编织"进去:
order_no = 时间戳 + 序列号 + (user_id 的低位分片基因)
这样拿到 order_no,不用 user_id 也能算出它在哪个分片。
-> 两种查询都能精准路由,这是分片键设计的关键技巧。
=== 数据倾斜:分片键要让数据尽量均匀 ===
取模 % 64 时,要确认 user_id 分布均匀,不会某个分片特别热。
用 user_id 一般没问题;若用"商家 id"分片,大商家会让单片爆炸 ——
这种高度倾斜的字段不适合直接做分片键。
修复 2:分布式主键 ID
// === 问题:分表后不能再用数据库自增主键 ===
// 64 张分片表各自维护自增,必然产生重复 id;
// 而且自增 id 暴露了订单量,也不安全。
// 需要一个【全局唯一】的 id 生成方案。
// === 方案 A:雪花算法(Snowflake)===
// 一个 64 位 long,结构:
// 1 位符号(恒 0) + 41 位时间戳 + 10 位机器号 + 12 位序列号
// - 41 位毫秒时间戳:可用约 69 年
// - 10 位机器号:支持 1024 个节点
// - 12 位序列号:同一毫秒内单节点可生成 4096 个 id
public class SnowflakeIdGenerator {
private final long workerId; // 机器号,0~1023
private long lastTimestamp = -1L;
private long sequence = 0L;
public synchronized long nextId() {
long now = System.currentTimeMillis();
if (now < lastTimestamp) {
// 时钟回拨!必须处理,否则会生成重复 id
throw new IllegalStateException("时钟回拨,拒绝生成 id");
}
if (now == lastTimestamp) {
sequence = (sequence + 1) & 4095; // 同毫秒内自增
if (sequence == 0) now = waitNextMillis(lastTimestamp); // 用满,等下一毫秒
} else {
sequence = 0L;
}
lastTimestamp = now;
return ((now - EPOCH) << 22) // 时间戳左移 22 位
| (workerId << 12) // 机器号左移 12 位
| sequence; // 序列号
}
private long waitNextMillis(long last) {
long ts = System.currentTimeMillis();
while (ts <= last) ts = System.currentTimeMillis();
return ts;
}
}
// 优点:本地生成,不依赖外部,性能极高;趋势递增,对 B+ 树索引友好。
// 注意:严重依赖机器时钟,【时钟回拨】会生成重复 id,必须显式处理。
// === 方案 B:数据库号段模式(美团 Leaf 思路)===
// 单独一张表存号段:每次取一个区间(如 [1000, 2000))到内存,
// 内存里发完了再去取下一段。把"每个 id 查库"摊薄成"每千个 id 查一次库"。
// 优点:不依赖时钟;缺点:依赖一个发号 DB。
// === 选型 ===
// 高并发、要趋势递增 -> 雪花算法(注意 workerId 分配 + 时钟回拨)
// 怕时钟问题、能接受弱依赖 DB -> 号段模式
修复 3:跨分片查询与分页
// === 坑 1:不带分片键的查询,要扫全部分片 ===
// "按手机号查订单",手机号不是分片键,只能 64 张表都查一遍。
// 解法 A:建"索引表"做映射(空间换精准路由)
// 单独建一张 phone -> user_id 的映射表(它本身也可按 phone 分片)
public List queryByPhone(String phone) {
Long userId = phoneIndexMapper.findUserId(phone); // 先查映射表
return orderMapper.queryByUser(userId); // 拿到 user_id 精准路由
}
// 解法 B:把数据同步一份到 ES,非分片键的复杂查询走 ES
// 订单写入时,同步一份到 Elasticsearch,
// 后台、运营的多条件检索全部走 ES,不碰分片库。
// === 坑 2:跨分片分页是个大坑 ===
// 需求:所有分片的订单,按时间倒序,取第 100 页(每页 20 条)。
// 错误想法:每个分片取第 100 页 20 条,再合并 —— 结果是错的!
// 因为全局第 100 页的数据,可能全部来自某一个分片。
// 正确但昂贵的做法:每个分片都取【前 100*20=2000 条】,
// 64 个分片共 12.8 万条捞到内存,统一排序,再取第 2001~2020 条。
// 翻页越深,要捞的数据越多 -> 深度分页在分片场景是灾难。
// 解法:禁止深度分页,改用"上一页最后一条的游标"
// 不传"第 N 页",传"上次最后一条的 create_time + id"
public List queryByCursor(Long userId, Long lastId, int size) {
// 带 user_id -> 单分片;用游标代替 offset,无论翻多深都很快
return orderMapper.selectAfter(userId, lastId, size);
}
// === 坑 3:跨分片聚合(count/sum/group by)===
// SELECT COUNT(*) 要在 64 张表各查一次再相加。
// 对账、报表类的聚合,建议走离线数仓 / ES,别在分片库上实时算。
// === 原则 ===
// 分库分表后,要尽一切努力让查询【带上分片键】。
// 带分片键 = 单分片查询 = 快;不带 = 全分片扫描 = 慢。
修复 4:存量数据迁移与不停机切换
=== 1.3 亿存量数据,要迁到 64 张分片表,且不能停机 ===
经典的"双写迁移"四步法:
--- 第 1 步:双写 ---
改造写入代码,新订单【同时】写老的单表 和 新的分片表。
此时:读还走老表,新数据在两边都有,老数据只在老表。
--- 第 2 步:存量数据迁移 ---
后台跑迁移任务,把老表 1.3 亿历史数据,按分片规则
一批一批(如每批 5000 条)灌进 64 张分片表。
迁移任务要可中断、可重跑、记录进度。
--- 第 3 步:数据校验 ---
迁移完成后,对账:
- 总行数:老表 count == 64 张分片表 count 之和
- 抽样比对:随机抽 N 条,逐字段比对老表与分片表是否一致
- 校验不通过,绝不进入下一步
--- 第 4 步:灰度切读 + 下线双写 ---
读流量:1% -> 10% -> 50% -> 100% 灰度切到分片表,
每一档观察监控,有问题立刻切回老表。
读全部切完、稳定运行一段时间后,
再把"双写"改成"只写分片表",老表降级为冷备。
=== 关键纪律 ===
- 全程可回滚:每一步出问题都能退回上一步
- 双写阶段如果写分片表失败,不能影响主流程(写老表为准)
-> 但要记录差异,事后补偿
- 切换选业务低峰期,且安排足够的观察窗口
修复 5:用 ShardingSphere 落地分片
# === 用 Apache ShardingSphere-JDBC 做分片,业务代码基本无感 ===
# 它是一个增强版的 JDBC 驱动,SQL 路由/改写/结果归并都由它做
spring:
shardingsphere:
datasource:
names: ds0,ds1,ds2,ds3 # 4 个物理库
ds0:
type: com.zaxxer.hikari.HikariDataSource
jdbc-url: jdbc:mysql://host0:3306/order
# ds1 / ds2 / ds3 同理
rules:
sharding:
tables:
orders:
# 真实表:ds0~3 库,每库 orders_0 ~ orders_15
actual-data-nodes: ds$->{0..3}.orders_$->{0..15}
# 分库:按 user_id % 4
database-strategy:
standard:
sharding-column: user_id
sharding-algorithm-name: db-mod
# 分表:按 user_id / 4 % 16
table-strategy:
standard:
sharding-column: user_id
sharding-algorithm-name: tbl-mod
# 主键用雪花算法
key-generate-strategy:
column: id
key-generator-name: snowflake
sharding-algorithms:
db-mod:
type: INLINE
props:
algorithm-expression: ds$->{user_id % 4}
tbl-mod:
type: INLINE
props:
algorithm-expression: orders_$->{user_id / 4 % 16}
key-generators:
snowflake:
type: SNOWFLAKE
// 业务代码完全不用改 —— 还是普通的 MyBatis 写法
// ShardingSphere 会拦截 SQL,自动算出该路由到哪个库哪张表
@Select("SELECT * FROM orders WHERE user_id = #{userId} ORDER BY id DESC")
List queryByUser(@Param("userId") Long userId);
// 这条 SQL 带了分片键 user_id,ShardingSphere 直接路由到单表,很快。
@Select("SELECT * FROM orders WHERE order_no = #{orderNo}")
Order queryByOrderNo(@Param("orderNo") String orderNo);
// 这条不带 user_id —— 若不做处理会扫全部 64 表;
// 配合"基因法"order_no,可自定义算法解析出分片,仍走单表。
修复 6:分片后的监控
# 分库分表后,要重点盯"全分片扫描"和"数据倾斜"
groups:
- name: sharding
rules:
# 1. 全分片扫描 SQL 占比(不带分片键的查询,性能杀手)
- alert: FullShardScanHigh
expr: rate(sharding_full_scan_total[5m]) / rate(sharding_query_total[5m]) > 0.05
for: 5m
annotations:
summary: "全分片扫描占比 > 5%,排查不带分片键的查询"
# 2. 数据倾斜(某个分片行数明显高于平均)
- alert: ShardDataSkew
expr: max(shard_row_count) / avg(shard_row_count) > 1.5
for: 10m
annotations:
summary: "分片数据倾斜,最大分片是平均的 1.5 倍,排查分片键分布"
# 3. 单分片慢查询
- alert: ShardSlowQuery
expr: shard_query_duration_seconds{quantile="0.99"} > 1
for: 5m
annotations:
summary: "{{ $labels.shard }} 分片 P99 > 1s,排查该分片索引/容量"
# 4. 雪花算法时钟回拨
- alert: SnowflakeClockBackward
expr: increase(snowflake_clock_backward_total[5m]) > 0
annotations:
summary: "雪花算法检测到时钟回拨,排查机器 NTP 同步"
优化效果
指标 治理前 治理后
=============================================================
orders 表 单表 1.3 亿行 64 分片表,单表 200 万行
按用户查订单 P99 3-6 秒 30-80ms
单表数据文件 80GB+ 单分片约 1.5GB
主键 ID 数据库自增 雪花算法全局唯一
按 order_no 查 - 基因法,单分片命中
非分片键查询 - 索引表 / ES
跨分片深度分页 - 禁用,改游标分页
存量迁移 - 双写迁移,不停机
分片可观测 无 全分片扫描/倾斜监控
落地过程:
- 分片方案设计 + 分片键选型:2 天
- 雪花算法 ID + ShardingSphere 接入:3 天
- 基因法 order_no + 非分片键查询改造:3 天
- 双写改造 + 1.3 亿存量数据迁移:3 天
- 数据校验 + 灰度切读 + 监控接入:3 天
避坑清单
- 单表到上亿行、数据文件几十 GB 时,加索引改 SQL 都救不了,要分库分表
- 分片键要让最高频的查询能命中单一分片,选错事后极难补救
- 用 user_id 做分片键,非分片键的 order_no 查询用"基因法"把分片信息编进去
- 分表后不能用数据库自增主键,用雪花算法或号段模式生成全局唯一 ID
- 雪花算法依赖机器时钟,时钟回拨会生成重复 id,必须显式检测处理
- 不带分片键的查询要扫全部分片,用索引表映射或同步到 ES 解决
- 跨分片深度分页是灾难,禁用 offset 分页,改用游标(上一页最后一条)
- 跨分片的 count/sum 聚合走离线数仓或 ES,不要在分片库实时算
- 存量数据迁移用双写四步法:双写→迁移→校验→灰度切读,全程可回滚
- 用 ShardingSphere 等中间件,业务代码基本无感,但仍要懂分片原理
总结
这次分库分表,是我参与过的改造里风险最高、链条最长的一次,做完之后我最深的体会是:分库分表绝不是"把一张大表机械地切成几十张小表"这么轻松,它是一次会牵动整个数据访问层的架构手术,而手术能不能成功,几乎在第一刀——也就是分片键的选择——下去的那一刻就决定了。分片键决定了每一行数据该落到哪个库、哪张表,它选对了,最高频的那个查询就能精准地命中单一分片,快得和单表时代没有区别;它选错了,那个最高频的查询就要扫遍所有分片,而且这个错误事后几乎无法修正,因为修正它意味着把上亿行数据按新规则重新搬一遍。我们最终选了 user_id 做分片键,因为"查我的订单列表"是压倒性的高频场景,它必带 user_id;而"按订单号查单个订单"这个同样重要、却不带 user_id 的场景,我们用了一个很巧妙的技巧——基因法,在生成订单号的时候,就把 user_id 的分片位编织进订单号里,这样光凭订单号也能反推出它在哪个分片。这个设计让两类核心查询都能走单分片,是整个方案的点睛之笔。除了分片键,分库分表还会连环引出一串必须解决的问题:数据库自增主键不能用了,得换成雪花算法这样的全局唯一 ID 生成器,而雪花算法又强依赖机器时钟,时钟一旦回拨就会生成重复 ID,这个隐患必须显式处理;不带分片键的查询会退化成全分片扫描,得靠额外的索引映射表或者把数据同步一份到 ES 来兜底;跨分片的深度分页更是个灾难,因为全局的第一百页数据可能全部来自某一个分片,你没法靠"每个分片各取一百页"来拼凑,唯一的出路是干脆禁用 offset 分页、改用游标。而所有这些技术问题加起来,可能还没有最后一件事难——把一亿三千万行存量数据,在系统不停机、用户无感知的前提下平滑地迁移过去。我们用的是经典的双写四步法:先让新写入同时落到老表和新分片表,再用后台任务慢慢把存量历史数据搬过去,然后做严格的总量和抽样对账,最后才把读流量按百分之一、百分之十、百分之五十的节奏灰度切到分片表上。这个过程中贯穿始终的一条纪律是:每一步都必须可回滚,任何一个环节出了问题,都能干净利落地退回上一步。回头看,分库分表是数据库扩展能力的天花板级手段,它确实能把单表的物理瓶颈彻底打破,但它的代价是整个数据层复杂度的显著上升——从此以后,团队写每一条 SQL 都得先在心里问一句"这条查询带分片键了吗"。所以我现在的态度是:分库分表是一剂猛药,不到单表实在扛不住的那一天,不要轻易动它;而一旦决定要动,就必须在分片键的选择上想到极致,因为那是这台手术里唯一一刀下去就无法重来的地方。
—— 别看了 · 2026