单表数据破亿,电商系统分库分表实战:从ShardingSphere到平滑数据迁移的完整方案
一、 那一天,数据库"扛不住了"
这是一个真实的故事。
某个周一早晨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);
}
}
查询流程:
- 从订单ID中解析分片基因,路由到对应分片
用户输入订单号查询:
├─ 从订单号中提取基因(低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次/天)。
文末互动:
你们的系统数据量到多少时开始考虑分库分表的?用了什么方案?踩过哪些坑?有没有遇到过"分片键选错"的惨痛经历?欢迎在评论区分享!
更多推荐




所有评论(0)