一、 那一天,数据库"扛不住了"
这是一个真实的故事。

某个周一早晨9:30,运营同学照常打开后台准备导出昨天的订单报表。结果页面直接超时,紧接着客服电话被打爆——“为什么我查不到三个月前的订单了?”“我的订单列表一直在转圈!”

我登录数据库一看,冷汗瞬间就下来了:

sql
SELECT COUNT(*) FROM order_table;
– 结果:128,473,291(1.28亿)
单表1.28亿条数据。所有查询全表扫描,索引也救不了。一个最简单的按用户ID查订单列表,响应时间从200ms飙升到8秒。更恐怖的是,MySQL的B+树索引深度已经达到4层,每次查询都要多次磁盘IO。

问题全面爆发:

症状 原因
后台订单报表打不开 GROUP BY+COUNT扫描上亿数据,内存直接爆了
用户查历史订单超时 order by create_time desc limit 10依然需要扫描大量索引
每月归档任务跑24小时 DELETE操作产生的binlog导致主从延迟
主键ID即将用尽 自增ID快到21亿了(int上限)
数据库磁盘告警 单表数据+索引占用超过200GB
那一刻我们清醒地认识到:单表瓶颈已经到了,不分库分表就是等死。

本文将完整复盘我们的分库分表演进之路,重点分享方案选型、ShardingSphere实战以及最痛苦的平滑数据迁移方案,希望能为同样面临数据爆炸的团队提供参考。

二、 方案选型:分库?分表?还是两者都分?
面对上亿数据,首要问题是:怎么分?

2.1 三种策略对比
策略 说明 适用场景 我们的选择
垂直分库 按业务模块拆分,订单库、用户库、商品库独立 微服务架构的基础 已在微服务拆分时完成
水平分表 同一张表拆成多张表(order_0 ~ order_15) 单表数据量大,但数据库写压力尚可 初期方案
水平分库+分表 多库+多表(db_0.order_0 ~ db_15.order_15) 数据量大+写并发高 最终方案
2.2 分片键(Sharding Key)的选择——最关键的一步
分片键的选择直接决定了系统的可扩展性和查询效率。

候选方案对比:

分片键 优点 缺点 结论
订单ID 分布均匀,写入分散 用户查订单列表需要全分片扫描 ❌ 不可行
用户ID 用户查询只需路由到单个分片 热点用户数据集中(大V订单多) ✅ 选为主键
创建时间 便于按时间归档 写入热点(最近时间全在一个分片) ❌ 不可行
最终决策:用户ID作为主分片键 + 订单ID作为辅助分片键

核心原则:绝大多数查询都是"查某个用户的订单",以用户ID分片能命中单分片。

对于"查单笔订单"的场景,我们使用基因法:将分片信息编码到订单ID中。

2.3 分片数量计算
数据量预估:

当前订单量:1.28亿

日均新增订单:约50万(大促期间峰值可达500万/天)

未来3年预估:50万 × 365 × 3 ≈ 5.5亿

分片规划:

分库数:16个库(物理机4台,每台4个实例)

分表数:每库64张表

总分片数:16 × 64 = 1024张表

单表数据量:5.5亿 ÷ 1024 ≈ 53万(完全可控)

扩容策略: 预留2倍容量,未来可直接从16库扩容到32库。

三、 技术选型:ShardingSphere-JDBC vs MyCat
我们对比了市面主流方案:

对比维度 ShardingSphere-JDBC MyCat 结论
架构 客户端直连数据库(轻量级) 服务端代理(独立部署) ShardingSphere更轻
性能 无中间层损耗,性能更好 多一层网络IO ShardingSphere胜出
维护成本 应用内集成,无额外运维 需要独立部署和维护集群 ShardingSphere胜出
SQL支持度 支持绝大多数SQL 部分复杂SQL不支持 ShardingSphere更完善
弹性伸缩 需要应用重启 支持在线扩容 MyCat胜出
最终选择:ShardingSphere-JDBC 5.x

理由:

团队对Java技术栈更熟悉,调试方便。

性能优先,不接受中间层代理的额外延迟。

5.x版本新增的弹性伸缩功能弥补了扩容痛点。

四、 核心实战:ShardingSphere配置与代码实现
4.1 Maven依赖
xml

org.apache.shardingsphere
shardingsphere-jdbc-core-spring-boot-starter
5.3.2

4.2 分片配置(application.yml)
yaml
spring:
shardingsphere:
mode:
type: Standalone
datasource:
names: ds0,ds1,ds2,ds3,ds4,ds5,ds6,ds7,ds8,ds9,ds10,ds11,ds12,ds13,ds14,ds15
# 16个数据源配置(此处省略,使用循环变量生成)
ds0:
driver-class-name: com.mysql.cj.jdbc.Driver
url: jdbc:mysql://192.168.1.101:3306/order_db_0
username: root
password: 123456
# ds1 ~ ds15 类似配置…
rules:
sharding:
tables:
# 订单表分片规则
t_order:
actual-data-nodes: dsKaTeX parse error: Expected group after '_' at position 18: …{0..15}.t_order_̲->{0…63}
table-strategy:
standard:
sharding-column: user_id # 分片键
sharding-algorithm-name: order-table-inline
key-generate-strategy:
column: order_id
key-generator-name: snowflake
# 订单明细表(与订单表同分片,绑定关系)
t_order_item:
actual-data-nodes: dsKaTeX parse error: Expected group after '_' at position 23: …5}.t_order_item_̲->{0…63}
table-strategy:
standard:
sharding-column: user_id
sharding-algorithm-name: order-table-inline
binding-tables:
- t_order,t_order_item # 绑定表,避免跨库Join
default-database-strategy:
standard:
sharding-column: user_id
sharding-algorithm-name: order-db-inline
props:
sql-show: true # 生产环境建议关闭
4.3 分片算法配置
yaml
sharding-algorithms:
order-db-inline:
type: INLINE
props:
algorithm-expression: dsKaTeX parse error: Expected group after '_' at position 135: …ession: t_order_̲->{user_id % 64} # 对64取模得到表索引
snowflake:
type: SNOWFLAKE
props:
worker-id: 1
max-vibration-offset: 15
分片逻辑解析:

用户ID: 10086
├─ 库索引 = (10086 % 1024) / 64 = (374) / 64 = 5 → ds5
└─ 表索引 = 10086 % 64 = 46 → t_order_46

最终目标表:ds5.t_order_46
4.4 订单ID的"基因法"设计
为了解决"根据订单ID查询"的场景,我们在订单ID中编码了分片信息:

java
/**

  • 订单ID生成器:雪花算法 + 分片基因
    */
    public class OrderIdGenerator {

    private final SnowflakeIdWorker snowflake = new SnowflakeIdWorker(1, 1);

    public Long generateOrderId(Long userId) {
    // 1. 生成雪花ID(64位)
    long snowflakeId = snowflake.nextId();

     // 2. 计算分片基因(取 userId % 64,占6位)
     long gene = userId % 64;
     
     // 3. 将基因植入雪花ID的低6位
     // 先清除低6位,再写入基因
     long orderId = (snowflakeId & ~0x3FL) | gene;
     
     return orderId;
    

    }

    /**

    • 从订单ID中解析分片基因,路由到对应分片
      */
      public int getTableIndexFromOrderId(Long orderId) {
      // 取低6位
      return (int)(orderId & 0x3FL);
      }
      }
      查询流程:

用户输入订单号查询:
├─ 从订单号中提取基因(低6位)→ 得到表索引
├─ 根据表索引反推库索引(表索引 / 64)
└─ 直接路由到 1 个分片执行查询,SQL性能最优
五、 平滑迁移:从单库到分库分表的"零停机"方案
这是整个项目最痛苦、最危险的环节。我们有1.28亿在线数据,系统7×24小时运行,不可能停机迁移。

5.1 迁移方案对比
方案 说明 优点 缺点
停机迁移 停服,导出导入数据 简单可靠 业务中断数小时,无法接受
双写迁移 写入新旧两套库,逐步切流 平滑,风险可控 实现复杂,需改造代码
数据同步工具 Canal+DataX全量+增量同步 业务代码零侵入 需要额外组件
5.2 最终方案:Canal + 双写 + 灰度切流(三阶段)
第一阶段:全量数据同步(DataX)
bash

使用DataX将单表数据同步到分库分表

配置文件:order_migrate.json

{
“job”: {
“content”: [{
“reader”: {
“name”: “mysqlreader”,
“parameter”: {
“connection”: [{
“jdbcUrl”: [“jdbc:mysql://old-db:3306/order_db”],
“table”: [“t_order”]
}]
}
},
“writer”: {
“name”: “shardingjdbcwriter”,
“parameter”: {
“connection”: [{
“jdbcUrl”: “jdbc:shardingsphere:driver-class-path?xxx”,
“table”: “t_order”
}]
}
}
}]
}
}
执行时间:约 6小时(1.28亿数据,每秒约6000条)

注意:全量同步期间,增量数据还没处理,会出现数据滞后。

第二阶段:增量数据同步(Canal)
利用Canal监听MySQL的binlog,将新产生的订单实时同步到新分库。

java
@Component
public class CanalListener {

@EventListener
public void onBinlogEvent(BinlogEvent event) {
    if (!"t_order".equals(event.getTableName())) {
        return;
    }
    
    // 1. 解析binlog获取订单数据
    Order order = parseOrder(event);
    
    // 2. 路由到新分库(使用ShardingSphere的精确路由)
    ShardingSphereDataSource dataSource = getDataSource();
    JdbcTemplate jdbcTemplate = new JdbcTemplate(dataSource);
    
    // 3. 执行插入或更新(幂等处理)
    String sql = "INSERT INTO t_order(order_id, user_id, ...) VALUES (?,?,...) " +
                 "ON DUPLICATE KEY UPDATE ...";
    jdbcTemplate.update(sql, order.getOrderId(), order.getUserId(), ...);
}

}
数据对账:每小时比对新旧库的数据量,差异率要控制在 0.01% 以内。

第三阶段:灰度切流(最谨慎的一步)
text
[第1天] 1% 流量切到新库(内部测试账号)
├─ 监控错误日志、响应时间、数据一致性
└─ 观察24小时,无异常进入下一步

[第2-3天] 10% 流量(随机用户)
├─ 持续监控,重点关注跨分片查询性能
└─ 优化慢SQL

[第4-5天] 50% 流量
├─ 压力测试,确认新库能承受全量流量
└─ 准备回滚预案

[第6天] 100% 流量
├─ 旧库保留只读权限(以备紧急回滚)
└─ 持续观察1周后,下线旧库
5.3 迁移过程中的"双写"实现
在灰度期间,我们实现了双写机制:

java
@Service
public class OrderService {

@Autowired
private OldOrderDao oldDao;

@Autowired
private NewOrderDao newDao;

@Transactional
public void createOrder(Order order) {
    // 1. 主写新库(主要业务逻辑)
    newDao.insert(order);
    
    // 2. 异步写旧库(通过MQ,失败不阻塞主流程)
    try {
        mqTemplate.send("order-topic", order);
    } catch (Exception e) {
        // 写旧库失败不影响业务,仅记录告警
        log.error("双写旧库失败,orderId={}", order.getOrderId(), e);
        alertService.send("双写失败告警");
    }
}

}
双写失败处理策略:

如果写旧库失败,记录下来,通过补录任务每5分钟重试一次。

最多重试3次,仍失败则人工介入。

六、 踩坑实录:分库分表后的"四大天坑"
坑1:跨分片查询(全表扫描)
问题:后台运营需要按"下单时间范围"查询订单,没有user_id条件,导致扫描所有分片。

sql
– 后台报表查询,没有user_id条件
SELECT * FROM t_order WHERE create_time BETWEEN ‘2024-01-01’ AND ‘2024-01-31’;
解决方案:

强制带分片键:运营后台必须选择"用户ID"或"订单号",否则拒绝查询。

引入ES(Elasticsearch):所有订单数据同步到ES,后台复杂查询走ES。

宽表冗余:创建一张按时间分区的汇总表(t_order_report),专门用于报表查询。

坑2:分布式主键ID冲突
问题:分库后,每个库的自增ID从1开始,订单ID会重复。

解决方案:雪花算法(Snowflake)

64位ID结构:1位符号位 + 41位时间戳 + 10位工作机器ID + 12位序列号

优点:全局唯一、趋势递增、无需依赖数据库。

注意:时钟回拨问题。我们通过等待+补偿策略解决:

java
public synchronized long nextId() {
long currentTimestamp = getCurrentTimestamp();
if (currentTimestamp < lastTimestamp) {
// 时钟回拨,等待差值时间
long offset = lastTimestamp - currentTimestamp;
if (offset <= 5) {
Thread.sleep(offset * 2);
} else {
// 超过5ms,抛出异常,人工介入
throw new ClockBackwardsException();
}
}
// …
}
坑3:分布式事务(跨分片Join)
问题:订单表和用户表分属不同库,JOIN查询失效。

sql
– 原SQL
SELECT o.*, u.nickname FROM t_order o
LEFT JOIN user_db.t_user u ON o.user_id = u.id
WHERE o.user_id = 10086;
解决方案:

数据冗余:在订单表中冗余存储user_nickname、user_phone等常用字段。

分批次查询:先查订单,再根据user_id批量查用户信息,内存中组装。

使用ShardingSphere的绑定表:订单表和订单明细表使用相同的分片键,Join在同一分片内执行。

坑4:分片键无法变更
问题:用户ID作为分片键,如果用户要修改ID(比如手机号换绑)怎么办?

答案:分片键一旦确定,绝对不允许修改。

任何涉及分片键的UPDATE操作都会被ShardingSphere禁止。

如果业务上确实需要"换绑",我们的方案是:逻辑删除原记录 + 新增记录,用is_active字段标识。

七、 最终成果与运维经验
7.1 性能数据对比
指标 单表(1.28亿) 分库分表(1024张表) 优化幅度
用户订单列表查询(RT) 8.2秒 35ms 提升 234倍
单笔订单详情查询(RT) 1.2秒 8ms 提升 150倍
订单插入(TPS) 800 3500 提升 4.3倍
后台报表(聚合查询) 超时(>60秒) 1.2秒(ES辅助) 可用状态
数据库磁盘占用 200GB 约250GB(含冗余) 可接受
7.2 日常运维要点
监控慢SQL:实时监控超过100ms的查询,及时发现跨分片扫描。

定期清理归档:超过2年的订单数据迁移到冷存储(HBase/OSS),热库只保留近期数据。

分片扩容预案:预留从16库扩容到32库的方案,提前演练。

备份策略:每天凌晨全量备份,每小时的binlog实时同步到异地灾备。

八、 总结:分库分表不是终点,是新的起点
回过头来看,分库分表解决了数据量的问题,但也带来了新的复杂性:

获得的:

✅ 单表数据量从1.28亿降到50万级别

✅ 查询性能提升100倍以上

✅ 写入TPS翻了4倍

✅ 数据库连接数、磁盘IO、内存压力全面降低

付出的:

❌ 跨分片查询变得复杂(引入ES)

❌ 分布式事务需要额外处理(TCC/消息表)

❌ SQL功能受限(不再支持跨库Join、子查询)

❌ 运维复杂度增加(监控分片均衡、数据倾斜)

最后的建议:

不要过早优化:单表数据量在500万以下,做好索引优化比分库分表更划算。

分片键想清楚再动手:一旦上线,分片键不可更改,选错就是灾难。

迁移方案要准备回滚:我们准备了3套回滚方案,灰度期间随时可以切回去。

业务优先:有些查询实在没法带分片键,就接受全分片扫描,但要控制频率(比如限制10次/天)。

文末互动:
你们的系统数据量到多少时开始考虑分库分表的?用了什么方案?踩过哪些坑?有没有遇到过"分片键选错"的惨痛经历?欢迎在评论区分享!

Logo

电商企业物流数字化转型必备!快递鸟 API 接口,72 小时快速完成物流系统集成。全流程实战1V1指导,营造开放的API技术生态圈。

更多推荐