单表一亿三千万行:一次订单表分库分表落地的复盘

orders 单表攒到 1.3 亿行、数据文件 80GB+,加索引改 SQL 都压不下复杂查询的几秒响应。两周落地分 4 库 16 表共 64 分片:分片键选型与基因法、雪花算法分布式 ID、跨分片查询与游标分页、1.3 亿存量数据双写迁移不停机、ShardingSphere 接入、分片监控。

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 天

避坑清单

  1. 单表到上亿行、数据文件几十 GB 时,加索引改 SQL 都救不了,要分库分表
  2. 分片键要让最高频的查询能命中单一分片,选错事后极难补救
  3. 用 user_id 做分片键,非分片键的 order_no 查询用"基因法"把分片信息编进去
  4. 分表后不能用数据库自增主键,用雪花算法或号段模式生成全局唯一 ID
  5. 雪花算法依赖机器时钟,时钟回拨会生成重复 id,必须显式检测处理
  6. 不带分片键的查询要扫全部分片,用索引表映射或同步到 ES 解决
  7. 跨分片深度分页是灾难,禁用 offset 分页,改用游标(上一页最后一条)
  8. 跨分片的 count/sum 聚合走离线数仓或 ES,不要在分片库实时算
  9. 存量数据迁移用双写四步法:双写→迁移→校验→灰度切读,全程可回滚
  10. 用 ShardingSphere 等中间件,业务代码基本无感,但仍要懂分片原理

总结

这次分库分表,是我参与过的改造里风险最高、链条最长的一次,做完之后我最深的体会是:分库分表绝不是"把一张大表机械地切成几十张小表"这么轻松,它是一次会牵动整个数据访问层的架构手术,而手术能不能成功,几乎在第一刀——也就是分片键的选择——下去的那一刻就决定了。分片键决定了每一行数据该落到哪个库、哪张表,它选对了,最高频的那个查询就能精准地命中单一分片,快得和单表时代没有区别;它选错了,那个最高频的查询就要扫遍所有分片,而且这个错误事后几乎无法修正,因为修正它意味着把上亿行数据按新规则重新搬一遍。我们最终选了 user_id 做分片键,因为"查我的订单列表"是压倒性的高频场景,它必带 user_id;而"按订单号查单个订单"这个同样重要、却不带 user_id 的场景,我们用了一个很巧妙的技巧——基因法,在生成订单号的时候,就把 user_id 的分片位编织进订单号里,这样光凭订单号也能反推出它在哪个分片。这个设计让两类核心查询都能走单分片,是整个方案的点睛之笔。除了分片键,分库分表还会连环引出一串必须解决的问题:数据库自增主键不能用了,得换成雪花算法这样的全局唯一 ID 生成器,而雪花算法又强依赖机器时钟,时钟一旦回拨就会生成重复 ID,这个隐患必须显式处理;不带分片键的查询会退化成全分片扫描,得靠额外的索引映射表或者把数据同步一份到 ES 来兜底;跨分片的深度分页更是个灾难,因为全局的第一百页数据可能全部来自某一个分片,你没法靠"每个分片各取一百页"来拼凑,唯一的出路是干脆禁用 offset 分页、改用游标。而所有这些技术问题加起来,可能还没有最后一件事难——把一亿三千万行存量数据,在系统不停机、用户无感知的前提下平滑地迁移过去。我们用的是经典的双写四步法:先让新写入同时落到老表和新分片表,再用后台任务慢慢把存量历史数据搬过去,然后做严格的总量和抽样对账,最后才把读流量按百分之一、百分之十、百分之五十的节奏灰度切到分片表上。这个过程中贯穿始终的一条纪律是:每一步都必须可回滚,任何一个环节出了问题,都能干净利落地退回上一步。回头看,分库分表是数据库扩展能力的天花板级手段,它确实能把单表的物理瓶颈彻底打破,但它的代价是整个数据层复杂度的显著上升——从此以后,团队写每一条 SQL 都得先在心里问一句"这条查询带分片键了吗"。所以我现在的态度是:分库分表是一剂猛药,不到单表实在扛不住的那一天,不要轻易动它;而一旦决定要动,就必须在分片键的选择上想到极致,因为那是这台手术里唯一一刀下去就无法重来的地方。

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

消息积压三百万:一次 Kafka 消费积压与重复消费的复盘

2026-5-20 13:19:27

技术教程

一个第三方接口拖垮整条交易链路:一次服务雪崩与限流熔断治理的复盘

2026-5-20 13:24:44

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