分类:架构与高并发 · 作者:管理员 · 发布于 2026-10-25 · 阅读 0 · 标签:分库分表 / ShardingSphere / 读写分离 / 高并发 / 电商中台
一、场景:订单表过千万后,三个瓶颈同时爆发
电商中台的订单表是最典型的"增长无上限"业务表。当数据量从百万涨到千万级,三个问题会先后出现:
- 慢查询:非分片维度查询全表扫描,深分页
LIMIT 800000,20要回表扫几十万行,接口响应从 30ms 劣化到 3s; - 锁竞争:下单高峰期写入集中在热点范围,行锁、间隙锁互相等待,数据库 CPU 打满;
- 单库容量:单实例磁盘、连接池、备份窗口全部逼近上限,扩容只能纵向升级,成本高且终有天花板。
这时"加索引、加缓存"已经救不回来,必须做数据拆分。本文以订单表拆分实战为例,讲清从垂直拆分到水平拆分的完整路线与坑。
二、拆分路线:先垂直,后水平
垂直拆分是按业务域拆库:订单库、用户库、商品库各归各的服务,配合微服务边界天然完成。它能缓解单库压力,但解决不了"订单表本身数据量爆炸"。
水平拆分才是治本方案:按某个字段(分片键)把同一张表的数据打散到多个库/多张表,每片只存一部分。选择分片键是第一步,也是最关键的一步:
- 按
user_id分片:用户维度的"我的订单"查询天然落在单片,路由精准; - 按
order_id分片:订单全局唯一,适合按订单号精确查询; - 按时间分片:适合归档类数据,但热点集中在最新片,不均匀。
实战建议:业务查询维度决定分片键。电商订单 80% 的查询是"某个用户的订单列表"和"某笔订单详情",前者必须带 user_id,后者可以用 order_id。我的做法是:按 user_id % 16 分库分表,同时订单表冗余 user_id 字段,让所有查询都能带分片键。
三、ShardingSphere-JDBC 落地:配置与分布式 ID
首选 ShardingSphere-JDBC 这种客户端分片方案而非中间件代理:它直接作为数据源接入应用,无额外网络跳转,适合对延迟敏感的 OLTP 场景。先引入依赖:
<dependency>
<groupId>org.apache.shardingsphere</groupId>
<artifactId>shardingsphere-jdbc-core-spring-boot-starter</artifactId>
<version>5.3.2</version>
</dependency>
核心分片配置(逻辑表 t_order 拆 16 片,分布在 2 个库):
spring:
shardingsphere:
datasource:
names: ds0, ds1
ds0:
type: com.zaxxer.hikari.HikariDataSource
driver-class-name: com.mysql.cj.jdbc.Driver
jdbc-url: jdbc:mysql://db-order-0.example.com:3306/order_db?useSSL=false
username: app_user
password: ******
ds1:
# 同上,指向 db-order-1.example.com
rules:
sharding:
tables:
t_order:
actual-data-nodes: ds$->{0..1}.t_order_${0..7}
table-strategy:
standard:
sharding-column: user_id
sharding-algorithm-name: order_inline
key-generate-strategy:
column: order_id
key-generator-name: snowflake
sharding-algorithms:
order_inline:
type: INLINE
props:
algorithm-expression: t_order_${user_id % 8}
key-generators:
snowflake:
type: SNOWFLAKE
props:
sql-show: false
这里两个关键点:
- 分片算法用 INLINE 表达式:按
user_id % 8分表、user_id % 2选库(库表组合共 16 片),路由逻辑一眼可读; - 主键用雪花算法:全局唯一、趋势递增、无需依赖中心化发号器。注意 order_id 由框架生成,业务里拿 order_id 反查时如果没带 user_id,就会触发广播路由,见下文坑 1。
四、五个必踩的坑
- 查询不带分片键 → 广播路由。ShardingSphere 对不带分片键的 SQL 采用广播路由,会把语句发到全部 16 张真实表再归并。线上曾有一个后台订单导出接口没带 user_id,一次查询打到 16 张表,数据库连接瞬间耗尽。规范:所有订单查询方法强制携带 user_id 参数,必要时用 MyBatis 拦截器在 SQL 层兜底校验。
- 跨分片 JOIN。非绑定表 JOIN 会触发笛卡尔积路由(16×16=256 次执行),性能灾难。解法:订单与订单明细表配置为绑定表(同一分片键),JOIN 只在片内进行;跨业务域的关联查询改为宽表、两次查询内存组装或走 Elasticsearch。
- 深分页。分片后
ORDER BY create_time LIMIT 800000,20会变成"每片都取 800020 行再归并",比单库更慢。解法:游标分页——前端把上一页最后一条的 create_time + order_id 传回,SQL 改WHERE create_time < ? ORDER BY create_time DESC LIMIT 20,每片只取 20 行。 - 分布式 ID 与分片键不一致。如果 order_id 由雪花算法生成而分片键是 user_id,恰好两者路由规则不同,一旦按 order_id 查询就会被路由到错误分片。要么 order_id 内嵌 user_id/分片信息,要么查询必须带 user_id 走标准路由。
- 取模分片扩容难。
user_id % 8想扩到 16 片,存量数据全部错位,不能原地改。实践方案:一致性哈希(迁移量小但实现复杂)或双倍扩容——新库旧库短暂并存,双写 + 迁移任务补偿,校验通过后切流。互联网大厂普遍选后者。
五、叠加读写分离:主从延迟的坑
分片解决容量,读写分离解决吞吐。配置一主一从后,ShardingSphere 会按 SQL 语义自动把 SELECT 路由到从库。但立刻会遇到主从延迟问题:订单支付成功后跳转详情页,从库还没同步到数据,页面显示"未支付"。
解法是按业务容忍度分级:
- 强一致读(支付结果、余额查询)→ 强制路由主库,用 HintManager 实现:
// 强制本次查询走主库,仅对当前线程生效
try (HintManager hintManager = HintManager.getInstance()) {
hintManager.setWriteRouteOnly();
Order order = orderMapper.selectByOrderId(userId, orderId);
}
- 弱一致读(订单列表、商品浏览)→ 走从库,接受秒级延迟;
- 事务内的读默认走主库(避免读到事务中未提交的中间态),不要手动干预;
- 关键写操作后若必须立即读,优先"写后直接查主库"或把结果写缓存,而不是依赖从库。
六、最佳实践清单
- 拆之前先问自己:索引、缓存、归档能不能解决?数据量没到千万级、QPS 没到瓶颈时,拆分带来的复杂度远大于收益;
- 分片键一旦确定就不允许数据库结构里出现"无分片键的裸表",在 DAO 层收口所有订单查询;
- 订单明细、订单扩展表与订单主表配绑定表同键分片,避免跨片 JOIN;
- 90 天前的历史订单定期归档到冷库(按月分片或直接迁出),热表数据量维持可控;
- 上线前压测覆盖:无分片键查询、深分页、跨片聚合三类"高危 SQL",用 sql-show 打开日志核对路由结果;
- 监控分片数据分布均匀性,某片数据量明显偏大说明分片键选择不当(例如把热点商家 ID 当分片键)。
分库分表不是银弹,它把单库问题换成了路由、归并、分布式事务问题。但电商订单这类"只增不减、查询维度清晰"的业务,选对分片键 + 收口查询规范,就能在千万级数据上稳定运行。记住:拆分是架构能力,规范才是长期护城河。