Spring Boot 3.x + ShardingSphere 5.x + MyBatis-Plus 3.x:订单分库分表实战(含两个踩坑记录)

0 阅读13分钟

一个配置坑,一个分页坑

完整代码已开源,可直接到Gitee仓库复制使用。

一、为什么要做分库分表

订单表是业务系统里增长最快的一张表。当单表数据量突破千万级,即使加了索引,查询响应也会明显变慢:B+ 树层级加深、缓存命中率下降、大事务锁表时间变长,问题会一个接一个冒出来。

分库分表是应对单表数据量过大的标准方案。本文以 t_order 表为例,演示如何实现按 user_id 的水平分片。除完整搭建流程外,本文还会重点说明两个容易被忽略的问题:

  1. 库和表用同一个分片键时,两级取模算法如果写错,一半的表会永远收不到数据(这是我在本项目初版配置里真实踩到的坑);
  2. 分页查询在分片后的性能陷阱,以及四种优化方案。

二、整体设计: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实际落点
10000 → ds_0500 % 2 = 0ds_0.t_order_0 ✅
10011 → ds_1500 % 2 = 0ds_1.t_order_0 ✅
10020 → ds_0501 % 2 = 1ds_0.t_order_1 ✅
10031 → ds_1501 % 2 = 1ds_1.t_order_1 ✅

连续 4 个 user_id 恰好覆盖全部 4 个数据节点,分布均匀。携带分片键查询时精准路由到单个分片;不携带时广播查询所有分片并在内存合并结果:这两种模式的性能差异巨大,第七节详细展开。

2.3 技术栈

技术版本
Java17
Spring Boot3.2.5
ShardingSphere-JDBC5.5.2
MyBatis-Plus3.5.15
MySQL8.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_1t_order_1ds_1.t_order_1
1002(偶)ds_0t_order_0ds_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]

盯着日志看了两遍,发现三个问题:

  1. 一条逻辑 SQL 变成 4 条实际 SQL,广播到所有分片;
  2. 每条实际 SQL 都是 LIMIT 0, 20:ShardingSphere 把 LIMIT 10, 10 改写成了 LIMIT 0, (page × size),因为每个分片里的“第 2 页”不等于全局的“第 2 页”,必须把每个分片前 20 条都拉回来;
  3. 在内存里做归并排序和数据截取,把 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)

转载请注明作者与原文链接