完整代码已开源,可直接到Gitee仓库复制使用。
一、为什么要做分库分表
订单表是业务系统里增长最快的一张表。当单表数据量突破千万级,即使加了索引,查询响应也会明显变慢:B+ 树层级加深、缓存命中率下降、大事务锁表时间变长,问题会一个接一个冒出来。
分库分表是应对单表数据量过大的标准方案。本文以 t_order 表为例,演示如何实现按 user_id 的水平分片。除完整搭建流程外,本文还会重点说明两个容易被忽略的问题:
- 库和表用同一个分片键时,两级取模算法如果写错,一半的表会永远收不到数据(这是我在本项目初版配置里真实踩到的坑);
- 分页查询在分片后的性能陷阱,以及四种优化方案。
二、整体设计:2 库 2 表,按 user_id 取模
2.1 分片策略
| 配置项 | 值 |
|---|---|
| 数据源 | `ds_0`(order\_db\_0)、`ds_1`(order\_db\_1) |
| 分表 | 每库 `t_order_0`、`t_order_1` |
| 分片键 | `user_id` |
| 库路由 | `user_id % 2` |
| 表路由 | `user_id.intdiv(2) % 2` |
路由逻辑如下:
库路由:ds_${user_id % 2}
表路由:t_order_${user_id.intdiv(2) % 2}
2.2 路由验证:4 个 user_id 走遍 4 张表
| user\_id | 库算法 `user_id % 2` | 表算法 `user_id.intdiv(2) % 2` | 实际落点 |
|---|---|---|---|
| 1000 | 0 → ds\_0 | 500 % 2 = 0 | **ds\_0.t\_order\_0** ✅ |
| 1001 | 1 → ds\_1 | 500 % 2 = 0 | **ds\_1.t\_order\_0** ✅ |
| 1002 | 0 → ds\_0 | 501 % 2 = 1 | **ds\_0.t\_order\_1** ✅ |
| 1003 | 1 → ds\_1 | 501 % 2 = 1 | **ds\_1.t\_order\_1** ✅ |
连续 4 个 user_id 恰好覆盖全部 4 个数据节点,分布均匀。携带分片键查询时精准路由到单个分片;不携带时广播查询所有分片并在内存合并结果:这两种模式的性能差异巨大,第七节详细展开。
2.3 技术栈
| 技术 | 版本 |
|---|---|
| Java | 17 |
| Spring Boot | 3.2.5 |
| ShardingSphere-JDBC | 5.5.2 |
| MyBatis-Plus | 3.5.15 |
| MySQL | 8.x |
三、一个隐蔽的配置坑:两级取模共用同一个表达式
这个问题是我插测试数据时发现的。我写了个循环,插 1000 条数据(userId 从 1 递增),插完去数据库 count 了一下:ds_0.t_order_0 有 500 条,ds_1.t_order_1 有 500 条,另外两张表是 0。第一反应是路由没生效,把 actualDataNodes 检查了一遍没毛病,又去翻 SQL 日志,发现 Actual SQL 里压根没出现过 ds_0.t_order_1 这个节点。绕了一圈才反应过来:库和表的表达式都是 user_id % 2,同一个值算出来必然一样。
3.1 错误写法(本项目初版配置)
直觉上,很多人会把库和表的分片算法写成同一个表达式:
shardingAlgorithms:
db-inline:
type: INLINE
props:
algorithm-expression: ds_${user_id % 2} # 库
tbl-inline:
type: INLINE
props:
algorithm-expression: t_order_${user_id % 2} # 表:和库完全同步!
问题:同一个 user_id 对 2 取模,库和表得到的结果永远相同:
| user\_id | 库结果 | 表结果 | 实际落点 |
|---|---|---|---|
| 1001(奇) | ds\_1 | t\_order\_1 | ds\_1.t\_order\_1 |
| 1002(偶) | ds\_0 | t\_order\_0 | ds\_0.t\_order\_0 |
ds_0.t_order_1 和 ds_1.t_order_0 永远不会收到任何数据。配置里声明的 4 个数据节点,实际只有 2 个在工作:
- 物理容量浪费一半,你以为有 4 张表扛量;
- 广播查询白打一半,永远空的两张表照样被扫;
- 名义上“2 库 2 表”,实际分片粒度还是“2 库 1 表”。
3.2 正确写法:两级算法各消耗 key 的不同位段
user_id % 4 能产生 4 个槽位。把最低位分给库路由、次低位分给表路由,4 个组合就全部可达了:
rules:
- !SHARDING
tables:
t_order:
actualDataNodes: ds_${0..1}.t_order_${0..1}
databaseStrategy:
standard:
shardingColumn: user_id
shardingAlgorithmName: db-inline
tableStrategy:
standard:
shardingColumn: user_id
shardingAlgorithmName: tbl-inline
shardingAlgorithms:
db-inline:
type: INLINE
props:
algorithm-expression: ds_${user_id % 2}
tbl-inline:
type: INLINE
props:
algorithm-expression: t_order_${user_id.intdiv(2) % 2}
改动只有一处:表算法从 user_id % 2 改成 user_id.intdiv(2) % 2(intdiv 是 Groovy 的整数除法方法,INLINE 表达式原生支持;若直接写 user_id / 2,Groovy 会得到浮点结果)。路由验证见 2.2 的表格。
3.3 设计原则
同一个字段做两级分片时,第二级的取模必须基于“第一级已经消耗掉的部分之外”的剩余值域,否则两级路由完全同步,等于白分一层。
这个原则可以直接推广:扩容到 4 库 4 表时,库取 user_id % 4,表取 user_id.intdiv(4) % 4:每个层级消耗 key 的不同位段。
3.4 改完必须做的两件事
① 开 SQL 日志实测验证(见第六节 sql-show 配置),依次调用 /order/add?userId=1000 到 1003,观察控制台 Actual SQL,4 种落点都该出现。
② 已有数据必须迁移。改算法会改变存量数据的归属:user_id=1002 的旧数据在 ds_0.t_order_0,修复后新查询会路由到 ds_0.t_order_1:老数据直接“消失”了。演示项目清库重灌即可;生产环境需要按新算法重算所有数据落点并迁移(可用 ShardingSphere 数据迁移功能或双写过渡)。改分片算法之前,先想清楚存量数据怎么办。
3.5 更推荐的写法:AUTO 分片
ShardingSphere 5.x 提供了更简洁的 autoTables 写法,一个表达式直接声明“落在哪个库的哪张表”,从源头避免两级算法不一致:
rules:
- !SHARDING
autoTables:
t_order:
actualDataSources: ds_0,ds_1
shardingStrategy:
standard:
shardingColumn: user_id
shardingAlgorithmName: t_order-inline
shardingAlgorithms:
t_order-inline:
type: INLINE
props:
algorithm-expression: ds_${user_id % 2}.t_order_${user_id.intdiv(2) % 2}
四、数据库准备
创建两个数据库,每个库里建两张结构相同的表:
CREATE DATABASE IF NOT EXISTS order_db_0;
CREATE DATABASE IF NOT EXISTS order_db_1;
-- 在每个数据库中执行
CREATE TABLE t_order_0 (
id BIGINT PRIMARY KEY,
order_no VARCHAR(64) NOT NULL,
user_id BIGINT NOT NULL,
goods_name VARCHAR(128),
order_amount DECIMAL(10, 2),
create_time DATETIME
);
CREATE TABLE t_order_1 (
id BIGINT PRIMARY KEY,
order_no VARCHAR(64) NOT NULL,
user_id BIGINT NOT NULL,
goods_name VARCHAR(128),
order_amount DECIMAL(10, 2),
create_time DATETIME
);
注意主键设计:这里没用自增 ID,而是预留 BIGINT 类型给分布式 ID。分表后如果继续用数据库自增,多张分表(本例为 4 张)会出现主键冲突,这个问题后文用雪花算法解决。
五、代码实现
5.1 实体类:用雪花算法解决主键冲突
@Data
@TableName("t_order")
public class Order {
// 分布式ID,避免分表主键冲突
@TableId(type = IdType.ASSIGN_ID)
private Long id;
private String orderNo;
private Long userId;
private String goodsName;
private BigDecimal orderAmount;
private LocalDateTime createTime;
}
IdType.ASSIGN_ID 让 MyBatis-Plus 在插入时自动生成雪花 ID(趋势递增的 64 位 Long),彻底绕开分表后自增主键冲突的问题。
5.2 Mapper 和 Service
@Mapper
public interface OrderMapper extends BaseMapper<Order> {
}
@Service
public class OrderService extends ServiceImpl<OrderMapper, Order> {
}
代码层面完全感知不到分片的存在:没有手写分表逻辑,没有手动选数据源,一切交给 ShardingSphere 在 SQL 改写层完成。这也是 ShardingSphere-JDBC 相对于手动分表方案最大的优势。
5.3 分页插件配置
@Configuration
public class MybatisPlusConfig {
@Bean
public MybatisPlusInterceptor mybatisPlusInterceptor() {
MybatisPlusInterceptor interceptor = new MybatisPlusInterceptor();
interceptor.addInnerInterceptor(new PaginationInnerInterceptor(DbType.MYSQL));
return interceptor;
}
}
5.4 Controller:暴露四种查询场景
@RestController
@RequestMapping("/order")
@RequiredArgsConstructor
public class OrderController {
private final OrderService orderService;
@GetMapping("/add") // 新增订单
public String add(@RequestParam Long userId) { /* ... */ }
@GetMapping("/listByUserId") // 携带分片键 → 精准路由
public Object listByUserId(@RequestParam("userId") Long userId) { /* ... */ }
@GetMapping("/list") // 不带分片键 → 广播查询
public Object list() { /* ... */ }
@GetMapping("/page") // 分页查询
public Object pageOrders(
@RequestParam(value = "pageNum", defaultValue = "1") Integer pageNum,
@RequestParam(value = "pageSize", defaultValue = "10") Integer pageSize) { /* ... */ }
}
注意:这里的 /page 是不带分片键查询会广播到所有分片。
六、先开 SQL 日志:看清楚 ShardingSphere 到底干了什么
在 application.yml 里加一行:
props:
sql-show: true
控制台日志:
2026-10-29T20:16:11.564+08:00 INFO 10320 --- [nio-8080-exec-7] ShardingSphere-SQL : Logic SQL: SELECT id,order_no,user_id,goods_name,order_amount,create_time FROM t_order WHERE (user_id = ?)
2026-10-29T20:16:11.564+08:00 INFO 10320 --- [nio-8080-exec-7] ShardingSphere-SQL : Actual SQL: ds_0 ::: SELECT id,order_no,user_id,goods_name,order_amount,create_time FROM t_order_0 WHERE (user_id = ?) ::: [1000]
会输出两种 SQL:
- Logic SQL:你写的逻辑 SQL(看起来像查一张普通的
t_order表) - Actual SQL:ShardingSphere 改写后真正下发到数据库的 SQL
排查分片路由问题,就看 Actual SQL 指向了几个分片。 前面第三节的配置坑,就是靠这行日志发现的:广播查询时配置里声明了 4 张表,日志却只出现 2 种落点。
七、分片后最大的坑:分页查询的两种命运
7.1 场景一:不带分片键的全局分页(性能陷阱)
执行 GET /order/page?page=2&size=10,日志长这样:
2026-10-29T20:18:06.368+08:00 INFO 10320 --- [nio-8080-exec-2] ShardingSphere-SQL : Logic SQL: SELECT COUNT(*) AS total FROM t_order
2026-10-29T20:18:06.368+08:00 INFO 10320 --- [nio-8080-exec-2] ShardingSphere-SQL : Actual SQL: ds_0 ::: SELECT COUNT(*) AS total FROM t_order_0 UNION ALL SELECT COUNT(*) AS total FROM t_order_1
2026-10-29T20:18:06.368+08:00 INFO 10320 --- [nio-8080-exec-2] ShardingSphere-SQL : Actual SQL: ds_1 ::: SELECT COUNT(*) AS total FROM t_order_0 UNION ALL SELECT COUNT(*) AS total FROM t_order_1
2026-10-29T20:18:06.372+08:00 INFO 10320 --- [nio-8080-exec-2] ShardingSphere-SQL : Logic SQL: SELECT id,order_no,user_id,goods_name,order_amount,create_time FROM t_order LIMIT ?,?
2026-10-29T20:18:06.372+08:00 INFO 10320 --- [nio-8080-exec-2] ShardingSphere-SQL : Actual SQL: ds_0 ::: SELECT id,order_no,user_id,goods_name,order_amount,create_time FROM t_order_0 LIMIT ?,? ::: [0, 20]
2026-10-29T20:18:06.372+08:00 INFO 10320 --- [nio-8080-exec-2] ShardingSphere-SQL : Actual SQL: ds_0 ::: SELECT id,order_no,user_id,goods_name,order_amount,create_time FROM t_order_1 LIMIT ?,? ::: [0, 20]
2026-10-29T20:18:06.372+08:00 INFO 10320 --- [nio-8080-exec-2] ShardingSphere-SQL : Actual SQL: ds_1 ::: SELECT id,order_no,user_id,goods_name,order_amount,create_time FROM t_order_0 LIMIT ?,? ::: [0, 20]
2026-10-29T20:18:06.372+08:00 INFO 10320 --- [nio-8080-exec-2] ShardingSphere-SQL : Actual SQL: ds_1 ::: SELECT id,order_no,user_id,goods_name,order_amount,create_time FROM t_order_1 LIMIT ?,? ::: [0, 20]
盯着日志看了两遍,发现三个问题:
- 一条逻辑 SQL 变成 4 条实际 SQL,广播到所有分片;
- 每条实际 SQL 都是
LIMIT 0, 20:ShardingSphere 把LIMIT 10, 10改写成了LIMIT 0, (page × size),因为每个分片里的“第 2 页”不等于全局的“第 2 页”,必须把每个分片前 20 条都拉回来; - 在内存里做归并排序和数据截取,把 4 个分片的结果合并后再取真正的第 11~20 条。
页码越深,代价越大:查第 100 页时,每个分片要拉 1000 条到内存。分片数 × 页码深度,线性放大内存消耗和查询耗时。
7.2 场景二:带分片键的精准分页(推荐做法)
把 5.4 节的 /page 接口升级为携带分片键的版本:
@GetMapping("/page")
public Object page(@RequestParam("page") Integer page,
@RequestParam("size") Integer size,
@RequestParam("userId") Long userId) {
Page<Order> pageParam = new Page<>(page, size);
LambdaQueryWrapper<Order> wrapper = new LambdaQueryWrapper<>();
wrapper.eq(Order::getUserId, userId)
.orderByDesc(Order::getCreateTime);
return orderMapper.selectPage(pageParam, wrapper);
}
再执行一次,日志变成:
2026-10-29T20:23:44.051+08:00 INFO 15408 --- [nio-8080-exec-4] ShardingSphere-SQL : Logic SQL: SELECT COUNT(*) AS total FROM t_order WHERE (user_id = ?)
2026-10-29T20:23:44.051+08:00 INFO 15408 --- [nio-8080-exec-4] ShardingSphere-SQL : Actual SQL: ds_0 ::: SELECT COUNT(*) AS total FROM t_order_0 WHERE (user_id = ?) ::: [1000]
2026-10-29T20:23:44.108+08:00 INFO 15408 --- [nio-8080-exec-4] ShardingSphere-SQL : Logic SQL: SELECT id,order_no,user_id,goods_name,order_amount,create_time FROM t_order WHERE (user_id = ?) LIMIT ?,?
2026-10-29T20:23:44.108+08:00 INFO 15408 --- [nio-8080-exec-4] ShardingSphere-SQL : Actual SQL: ds_0 ::: SELECT id,order_no,user_id,goods_name,order_amount,create_time FROM t_order_0 WHERE (user_id = ?) LIMIT ?,? ::: [1000, 10, 10]
只有一条实际 SQL,只打一个分片,LIMIT 10, 10 是真正的物理分页,无内存合并开销。性能差距是数量级的。
7.3 业务启示
说白了就看一件事:你的业务查询能不能天然带上分片键。大多数订单场景是成立的:用户查自己的订单,天然带 user_id。真正需要跨分片全局查询的,只有运营后台、报表这类低频场景,交给专门方案处理即可。
八、深度分页的四种优化方案
对于确实无法携带分片键的全局查询,按推荐度排序:
方案 A:业务上强制携带分片键(首选)
// 业务上限制只能查询某个用户的订单
wrapper.eq(Order::getUserId, userId);
能用业务规则解决的,永远比技术方案便宜。
方案 B:限制最大页码
if (page > 100) {
throw new IllegalArgumentException("不支持查询超过100页的数据");
}
简单但有效:把最深的分页场景直接挡掉,大多数产品页码不会超过 100。
方案 C:同步到 Elasticsearch
对于复杂的全局检索和深度分页需求,把数据同步到 ES,用 ES 的分布式架构处理跨分片查询。
方案 D:游标分页
wrapper.lt(Order::getCreateTime, lastCreateTime)
.orderByDesc(Order::getCreateTime)
.last("LIMIT 10");
用上一页最后一条记录的 create_time 作为游标,避免 OFFSET 全量回扫。适合“下拉加载更多”这种线性翻页场景。
九、分片键选择原则
分片键一旦选定,改起来要动数据迁移,所以选错了是伤筋动骨的事。核心标准有三条:
✅ 推荐:
- 高频查询字段。比如订单查询天然按
user_id过滤,这种场景路由永远精准 - 分布均匀的字段。某个分片数据特别多就会成为热点
- 低更新频率。分片键值一改,数据就得搬家
❌ 避免:
- 时间字段:会导致数据倾斜(老数据挤在一个分片)
- 像性别这种枚举值少的字段也别用,基数太低,分布必然不均
- 更新频繁的字段:分片键值变了,数据就要搬家
这三条标准需要同时满足。本文选 user_id 正是因为它同时满足:订单查询天然按用户过滤(高频查询)、用户量足够大分布均匀、且几乎不会变更(低更新频率)。
十、常见问题速查
Q1:怎么验证分片路由是否正确?
看控制台的 Actual SQL:精准路由只有一条 SQL 指向特定分片;广播路由是多条 SQL 指向所有分片。如果“2 库 2 表”在广播时只出现 2 种落点,大概率是第三节那个两级取模同步的坑。
Q2:配置了 4 张表,为什么只有 2 张有数据?
库和表算法都写了 user_id % 2,两级路由完全同步。把表算法改成 user_id.intdiv(2) % 2,让两级各消耗 key 的不同位段。注意修复后要迁移存量数据。
Q3:分页返回的数据顺序不对?
无分片键的全局分页必须带 ORDER BY,否则各分片返回顺序不确定,内存归并结果就是乱的。
Q4:以后想从 2 库 2 表扩到 4 库 4 表怎么办?
修改 actualDataNodes 和分片算法表达式:库取 user_id % 4,表取 user_id.intdiv(4) % 4。然后做数据迁移。取模分片法的扩容是全量迁移,如果预期会频繁扩容,应该在设计阶段就考虑一致性哈希或基因法。
十一、总结
最后总结一下。这个项目跑下来,最大的收获不是"怎么配 ShardingSphere",而是两件事:一是分页,不带分片键的查询会广播全部 4 个分片再做内存归并,页码越深越慢,业务上能带分片键就一定带上;二是分片键要一步选对,改一次就是一次数据迁移。
十二、源代码
完整代码已开源:shardingsphere-inline(Gitee)
转载请注明作者与原文链接
适合正在落地订单分库分表的后端团队,尤其能帮你避开“两级取模同步”和“无分片键深分页”两个高发坑,配置与排查思路可直接复用。