先说结论:用 SpringBoot 2.7.18 + ShardingSphere-JDBC 5.2.1,配合 PostgreSQL、Druid、MyBatis-Plus 做按月分表,是目前 Java 后端在“数据量中等、单表增长可预期、不想引入额外中间件”场景下很顺手的一套组合。我这次改造的是一套订单库,单表涨到几千万行,最终选了按月分表而不是按用户 ID 哈希,目的很明确:让业务查询、归档、清理都跟时间对齐。这篇文章会从选型讲到上线,把版本权衡、分片规则、自动建表、常见坑点都写清楚,适合正在被单表数据量追着跑、正准备上分表方案的开发朋友参考。
1. 为什么是这套组合:选型思路与版本权衡
1.1 分表方案对比:为什么选 ShardingSphere-JDBC
很多人第一反应是“用 MyBatis-Plus 的动态表名不就行了”。确实,MyBatis-Plus 有 TableNameHandler 可以动态替换表名,看起来很简单,但它本质上是字符串拼接,只能解决“知道去哪张表”的问题,解决不了多表聚合、跨表分页、JOIN 时路由一致性这些问题。你写一条 SELECT * FROM t_order WHERE create_time BETWEEN ...,框架不知道这张表被拆成了几十张,只会对着不存在的逻辑表执行,业务根本跑不起来。
ShardingSphere-JDBC 是另一个层次的东西。它工作在 JDBC 层,拿到 SQL 后做解析、改写、路由、归并,逻辑表 t_order 在你的代码里照常写,实际执行时会自动改写为 t_order_202401、t_order_202402 这类物理表。分页、排序、聚合这些操作,框架会在内存里做二次归并,对应用层基本透明。我选择它而不是 ShardingSphere-Proxy,主要是 JDBC 模式是嵌入式的,不需要额外部署中间件,App 直连数据库,性能损耗小,也没有多一跳的网络开销。缺点是不能跨应用共享同一个分片配置,但我们的系统本身就是一个单体后端,完全够用。
1.2 版本组合:为什么是 2.7.18 和 5.2.1
现网项目最怕“版本追新、填坑填到吐”。SpringBoot 我选了 2.7.18,这是 2.x 系列最后一个版本,属于生命周期末期的稳定版,bug 修复积累足够多,而且完全兼容 JDK 8。ShardingSphere 5.2.1 也是刻意选的,不是最新的 5.4、5.5,原因很简单:这套组合的讨论最多,社区案例最多,遇到的问题大多能在网上搜到解法。5.3 之后 ShardingSphere 对 SpringBoot 3 的支持开始推进,如果想升级 SpringBoot 3.x,就需要换 jakarta 命名空间,迁移成本会变大,而 2.7.18 + 5.2.1 这个搭配踩坑最少,适合先把业务跑通。
还有一点,ShardingSphere 5.x 的配置风格和 4.x 差异很大,网上很多教程还是 4.x 的写法,复制过来根本跑不起来。所以确定版本后,配置一定以官方 5.x 文档为准,别照抄老文章。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 设计思路:逻辑表、分片键与路由规则
2.1 表结构设计:逻辑表与物理表怎么对应
分表前必须先定好命名规范和表结构基线。逻辑表是业务代码里写的表名,比如 t_order;物理表是真实存在的表,按月份后缀命名:t_order_202401、t_order_202402。后缀格式统一为 yyyyMM,这样排序和正则匹配都容易,不要出现 t_order_202401 和 t_order_2024_01 混用的情况,否则配置表达式会写出天际。
使用 PostgreSQL 时我习惯统一用小写表名。PG 对未加双引号的表名会自动转小写,如果你建表时用了 "T_Order_202401" 这种带引号的大小写混合名字,后面路由匹配表名时极容易踩大小写不一致的坑。最好建表、实体、配置全部走小写,不要在这上面浪费排查时间。
2.2 分片键与分片算法选择
分片键选择了 create_time,这是订单类数据最自然的维度。按月分表的核心收益有两条:一是单表数据量被限制在“一个月”的规模内,对索引维护和查询性能是可控的;二是时间范围查询天然只会命中少量表,不需要扫全部分表。
分片算法有两个选择。第一是用内置的 INTERVAL 算法,配置简单,适合固定时间范围。第二是自定义 StandardShardingAlgorithm,适合需要动态处理时间范围的场景。我生产环境用的是内置 INTERVAL,同时配合每月定时建表来解决上限问题。下面这段配置是核心:
yaml复制spring:
shardingsphere:
datasource:
names: ds
ds:
type: com.alibaba.druid.pool.DruidDataSource
driver-class-name: org.postgresql.Driver
url: jdbc:postgresql://localhost:5432/order_db
username: postgres
password: your_password
rules:
sharding:
tables:
t_order:
actual-data-nodes: ds.t_order_$->{2023..2030}${(1..12).collect { num -> num.toString().padLeft(2, '0') }}
table-strategy:
standard:
sharding-column: create_time
sharding-algorithm-name: monthly
sharding-algorithms:
monthly:
type: INTERVAL
props:
datetime-pattern: "yyyy-MM-dd HH:mm:ss"
datetime-lower: "2023-01-01 00:00:00"
datetime-upper: "2030-01-01 00:00:00"
sharding-suffix-pattern: "yyyyMM"
datetime-interval-amount: 1
datetime-interval-unit: "MONTHS"
props:
sql-show: true
actual-data-nodes 是给路由用的物理表清单,用 Groovy 表达式生成 2023 到 2030 年每个月对应的表名。datetime-lower 和 datetime-upper 定义了分片算法能处理的边界。我实际运行中有一件事要特别提醒:这个边界一定要比当前数据时间更宽,而且要有定期维护机制,否则一旦业务数据超过 2030-01-01,框架找不到对应分片会直接报错。我会在文章后面讲自动建表任务方案,这个和分片边界是配套的。
如果你不想用 Groovy 表达式,也可以把 actual-data-nodes 写成手动的表名列表,例如 ds.t_order_202301,ds.t_order_202302,...。缺点是每月都得改配置文件,优点是直观、不容易出错。小规模项目用这种笨办法我也见过,线上跑得挺稳。
2.3 MyBatis-Plus 与分表共存:实体类要改哪些地方
MyBatis-Plus 在这套方案里不需要做太复杂的改造,核心是实体类上标注逻辑表名即可。
java复制@Data
@TableName("t_order")
public class Order {
@TableId(type = IdType.ASSIGN_ID)
private Long id;
private Long userId;
private BigDecimal amount;
@TableField(fill = FieldFill.INSERT)
private LocalDateTime createTime;
}
IdType.ASSIGN_ID 用的是雪花算法生成分布式 ID,避免分表后多表主键冲突。create_time 建议配合 MyBatis-Plus 的自动填充功能,插入数据时自动赋值,而不是依赖数据库默认值。这样能保证 Java 层拿到的 createTime 和数据库一致,后续如果做跨月分区归档也方便。
这里有个容易忽略的点:写库时如果没有给 createTime 赋值,或者赋值的是 null,ShardingSphere 无法根据分片键计算出目标表,就会直接抛异常。我见过很多同学在分表后遇到的第一个报错就是“Cannot find sharding rule for null”,排查半天发现是 createTime 没填充。
3. 落地实现:从 pom 依赖到核心配置
3.1 pom.xml 依赖清单
依赖这块主要是版本要盯住,别让 Maven 帮你乱升。我最终用的关键依赖如下:
xml复制<properties>
<java.version>1.8</java.version>
<mybatis-plus.version>3.5.3.1</mybatis-plus.version>
<shardingsphere.version>5.2.1</shardingsphere.version>
<druid.version>1.2.20</druid.version>
</properties>
<dependencies>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>
<dependency>
<groupId>com.baomidou</groupId>
<artifactId>mybatis-plus-boot-starter</artifactId>
<version>${mybatis-plus.version}</version>
</dependency>
<dependency>
<groupId>org.apache.shardingsphere</groupId>
<artifactId>shardingsphere-jdbc-core-spring-boot-starter</artifactId>
<version>${shardingsphere.version}</version>
</dependency>
<dependency>
<groupId>com.alibaba</groupId>
<artifactId>druid-spring-boot-starter</artifactId>
<version>${druid.version}</version>
</dependency>
<dependency>
<groupId>org.postgresql</groupId>
<artifactId>postgresql</artifactId>
<scope>runtime</scope>
</dependency>
</dependencies>
注意,shardingsphere-jdbc-core-spring-boot-starter 这个 artifactId 在 5.x 里是标准名称,网上有些老文章写的是 sharding-jdbc-spring-boot-starter,那是 4.x 的包名,别搞混。Druid 版本我选了 1.2.20,和 ShardingSphere 5.2.1 配合没有发现显著冲突。如果你使用更高版本的 Druid,建议先在测试环境验证连接池初始化和心跳检测是否正常。
3.2 application.yml:完整配置解读
除了上面提到的分片规则,数据源和 Druid 的配置也要一并写完整。一个常见的误区是只配置了分片规则,却忘了 Spring Boot 会自动装配自己的数据源,导致 ShardingSphere 没有接管数据库连接。
yaml复制spring:
shardingsphere:
datasource:
names: ds
ds:
type: com.alibaba.druid.pool.DruidDataSource
driver-class-name: org.postgresql.Driver
url: jdbc:postgresql://localhost:5432/order_db
username: postgres
password: your_password
# Druid 连接池参数
initial-size: 5
min-idle: 5
max-active: 20
max-wait: 60000
validation-query: SELECT 1
test-while-idle: true
test-on-borrow: false
为什么 validation-query 用 SELECT 1?PostgreSQL 本身就支持这种轻量查询,比 SELECT NOW() 这种写法更节省数据库资源,连接池做探活时开销更小。test-on-borrow 我设置成 false,避免每次从连接池拿连接都做一次校验,影响高并发下的性能。这个配置如果 test-while-idle 为 true,空闲连接会被定期探测,已经能满足绝大多数场景。
Druid 的 wall 过滤器在 ShardingSphere 场景下我建议先不要启用。原因后面第 4 章会详细讲,这里只提醒一句:WallFilter 做 SQL 注入校验时,不认识 ShardingSphere 改写后的分片 SQL,容易出现“合法 SQL 被拦截”的迷惑行为。上线初期保持配置干净,比什么都重要。
3.3 自动建表:让“表不存在”从根上消失
ShardingSphere 只负责路由,不负责建表。这是很多初用者踩的第一个大坑:配置好规则后,对不存在的物理表做插入,直接报 Table 't_order_202401' doesn't exist。所以必须自己实现建表逻辑。
我的做法是写一个启动监听器,应用启动后自动判断这个月和下个月的表是否存在,不存在就执行建表 SQL。核心逻辑如下:
java复制@Component
public class MonthTableInitializer implements ApplicationRunner {
@Resource
private JdbcTemplate jdbcTemplate;
private static final String CREATE_TABLE_SQL = "CREATE TABLE IF NOT EXISTS t_order_%s (" +
"id BIGINT PRIMARY KEY," +
"user_id BIGINT NOT NULL," +
"amount DECIMAL(12,2) NOT NULL," +
"create_time TIMESTAMP NOT NULL DEFAULT now())";
@Override
public void run(ApplicationArguments args) {
// 当前月和下个月都建出来,避免跨月那几秒出现空窗
List<YearMonth> months = List.of(YearMonth.now(), YearMonth.now().plusMonths(1));
for (YearMonth month : months) {
String tableName = "t_order_" + month.toString().replace("-", "");
jdbcTemplate.execute(String.format(CREATE_TABLE_SQL, tableName));
}
}
}
这只是一个简化版本,真实项目里建表 SQL 要和 MyBatis-Plus 实体、自动填充字段完全对齐,索引也要一并创建。仅仅建表不留索引,后面查询慢了你还是得回来补。表结构的索引建议统一 create_time 建 B-tree 索引,业务如果要按 user_id 查,再加联合索引,但要注意联合索引必须把 create_time 放在前面,否则分片路由对索引不友好。
更进一步,我建议写一个每月 1 日凌晨执行的定时任务,把未来 3 个月的表提前建好。原因很简单:跨月那一刻如果应用正好在跑任务,或者建表 SQL 因为锁等待失败了,当天的写入就会全部报错。预计未来 3 个月的表,既不会占用太多存储,又能给运维留出足够缓冲时间。
3.4 查询与写入示例:代码该怎么写
Mapper 层完全不用写分表相关代码,继承 MyBatis-Plus 的 BaseMapper 即可。
java复制@Mapper
public interface OrderMapper extends BaseMapper<Order> {
}
Service 层写业务查询时,核心纪律是:必须带分片键条件。比如查某个用户 2024 年 1 月的订单:
java复制LambdaQueryWrapper<Order> wrapper = Wrappers.lambdaQuery(Order.class)
.eq(Order::getUserId, 1001L)
.ge(Order::getCreateTime, LocalDateTime.of(2024, 1, 1, 0, 0, 0))
.lt(Order::getCreateTime, LocalDateTime.of(2024, 2, 1, 0, 0, 0));
Page<Order> page = orderMapper.selectPage(new Page<>(1, 10), wrapper);
这段查询会被 ShardingSphere 改写成针对 t_order_202401 的 SQL,路由结果非常精确。这里有个优化细节:时间范围最好用 >= 和 < 而不是 BETWEEN ... AND ...,两者结果相同,但 [start, end) 这种写法在时间边界上更不容易出错,也更方便代码里用变量拼接。
如果要查跨月数据,比如 3 月 15 日到 5 月 20 日,ShardingSphere 会根据范围值自动路由到 t_order_202403、t_order_202404、t_order_202405 三张表,然后做归并返回。这个机制是透明的,但要注意:跨月查询的数据量如果很大,内存归并的开销会明显增加。因此,业务层面还是尽量限制查询跨度,比如前端默认只允许选择最近 3 个月。
4. 实战遇到的坑与排查技巧
4.1 最危险的操作:不带分片键的全表路由
分表之后,所有 SQL 都必须遵守一条纪律:查询条件里要么有时间范围,要么有具体的分片键值。否则 ShardingSphere 无法精确路由,会把 SQL 广播到配置的所有物理表上。
我做改造时就吃过一次亏。后台管理页面有一个“用户订单列表”,原 SQL 只传 user_id,不传时间。上线后数据库 CPU 直接飙满,日志里全是几十张表同时执行的 SELECT ... WHERE user_id = ?。排查过程倒是简单,开启 sql-show 后看到 Actual SQL 里的路由结果是所有物理表,马上就知道问题出在哪了。
解决方式有两个:第一,业务查询必须加上默认时间范围,比如“最近 90 天”;第二,如果某些查询实在无法带时间条件,就换一个以用户 ID 为分片键的表来承接,或者考虑加一层缓存。总之,让业务条件主动配合分片键,而不是让框架硬扛。
4.2 分页 count 不准:小心跨表归并的坑
MyBatis-Plus 的分页查询默认会生成一条 SELECT COUNT(*) AS total 的统计 SQL。在单表环境这句话没问题,但分表后,框架会先把 count 语句分发到每个物理表执行,再把所有结果相加作为 total。大多数场景下这个逻辑是对的,可一旦分页 SQL 里带有去重、JOIN、子查询,内存归并的结果就可能和你预想的不一致。
我当时遇到的具体问题是:订单表 JOIN 了订单明细表,分页返回的 total 明显偏大。排查后确认是 JOIN 后记录数被多张表重复统计了。解决方法是改写 count 查询,让统计落到一张主表上,或者干脆不用 MyBatis-Plus 的分页,改用手写 SQL 的 COUNT(*) FROM (原SQL) tmp 临时表方式。对于订单这类数据,我更推荐手动控制 count,虽然多写几行代码,但结果可控。
4.3 Druid 的 wall 过滤器与 ShardingSphere 的兼容问题
这个坑比较隐蔽。Druid 的 wall 过滤器本意是防止 SQL 注入,但它校验的是应用发过来的原始 SQL。ShardingSphere 拿到原始 SQL 后,解析、改写、再路由的过程,和 wall 过滤器的检查逻辑存在冲突。典型现象是:应用启动正常,部分查询在逻辑表上执行没问题,到了改写后的物理表 SQL 那一步,直接被 wall 拦截,报类似 sql injection violation 的错误。
处理方式很粗暴:在 Druid 配置里不要启用 wall 过滤器。如果你的团队安全规范要求必须有 SQL 注入防护,那建议把防护放到数据库账号权限层面,或者使用独立的 SQL 审计中间件,不要在连接池层面去做。连接池的职责是连接管理,不是安全审计。
4.4 常见问题速查表
| 报错/现象 | 可能原因 | 解决办法 |
|---|---|---|
Table 't_order_202401' doesn't exist |
物理表没建 | 实现自动建表逻辑,提前建好当月及未来表 |
Cannot find sharding rule with name [monthly] |
分片算法名拼写错误或缩进层级不对 | 检查 sharding-algorithms 下的 monthly 命名是否与 sharding-algorithm-name 一致 |
| 插入时提示无法定位分片 | createTime 为 null,或未传给框架 |
实体类增加自动填充,确保写库前字段有值 |
| 查询一下子路由到所有表 | 查询条件没有分片键 | 业务上强制加时间范围约束 |
| 分页 total 偏大 | JOIN/去重导致归并统计错误 | 改用手动 count 查询,或单独统计 |
| Druid 拦截合法 SQL | wall 过滤器冲突 | 去掉 wall filter 配置,从其他层面做安全防护 |
路由结果全是 ds 而不是物理表 |
数据源名和分片表达式不匹配 | 确认 datasource.names 是 ds,表达式里前缀一致 |
| 时间边界外的数据无法写入 | datetime-upper/lower 不够宽 |
更新配置边界,或改为自定义分片算法 |
5. 上线后的数据维护与扩展建议
5.1 月度建表任务:别让表数量成为事故点
分表上线后,维护工作要比单表多一项:月表管理。我做了两个定时任务,第一个是每月 1 日凌晨 1 点创建未来 3 个月的表,第二个是每天检查一次当前月份表是否存在。第二个任务看似冗余,但它能在异常情况下自动补表,防止“建表任务挂了导致白天写入失败”这种极端事件。
定时任务实现和前面的 ApplicationRunner 类似,只不过放在 @Scheduled 注解的方法里。这里有一个执行顺序的细节:如果应用中同时有 @Scheduled 任务和 ShardingSphere 配置,一定要确保定时任务的线程池配置合理,不要挤占业务线程资源。我习惯单独定义一个 ThreadPoolTaskScheduler,和业务线程池隔离。
5.2 历史数据归档与清理策略
按月分表一个额外的好处是数据归档变得很简单。超过一年或者两年的历史表,可以直接用脚本导出后从库里删除,不用像单表那样小心翼翼地 DELETE 大事务。我用的是 PostgreSQL 的 pg_dump 按月导出,然后写了一个清理任务,自动删除 12 个月以前的物理表。
清理前一定要确认业务侧没有跨这么长时间的查询需求。如果有,可以把历史表合并归档到一个独立的“冷库”数据库,或者用视图把已归档表和在线表联合起来对外提供查询接口。归档表直接删掉是最省事的,但业务如果突然要统计“两年前的订单量”,你会非常被动。宁可做个只读的归档库,也别直接删数据。
5.3 后续扩展方向
这套方案在数据量继续增长时,有几个扩展方向可以提前想想。一是引入 PostgreSQL 原生的分区表能力作为底层存储,让 ShardingSphere 的分片逻辑和 PG 的 partition 能够结合;二是如果查询维度逐渐转向用户维度,可以考虑增加一个按 user_id 分片的冗余表,或者把核心用户数据迁到更适合的存储层;三是跨月聚合分析类需求变多时,可以考虑引入 OLAP 引擎,把在线订单表和离线分析链路分开。
这些都是后话,当前阶段先把按时间分表的稳定性做好,比什么都强。分表不是银弹,它只是让单表数据量保持在一个可控规模的手段。
最后说一点个人体会。分表改造的技术难度其实不是最难的,难的是业务查询习惯的调整和运维体系的及时跟上。自动建表、分片键纪律、问题排查手段,这三样东西一定要在改造第一天就建起来。如果你的业务常年查询都带不上时间范围,那再好的分表方案也白搭,先把数据访问模式梳理清楚,再动手写配置。希望这篇记录能帮你少走点弯路,也欢迎交流你们在分表实践中遇到的其他坑。
