先给结论:如果你的业务表已经过了“单表能扛”的阶段,又不想引入太重的中间件,那 SpringBoot 2.7.18 + ShardingSphere-JDBC 5.2.1 做按月分表,配合 PostgreSQL + Druid + MyBatis-Plus,是一套能直接落地的组合。这套方案我最近刚在一张日增几十万流水、单表数据量过亿的业务表上完整跑通,从 Maven 依赖到分片算法、从 Druid 连接池到 MyBatis-Plus 分页,中间踩了不少版本和配置的坑。这篇文章会把完整的选型逻辑、配置拆解、排错路径和生产化建议都写清楚,适合正在做订单、日志、交易流水、轨迹记录这类“天然带时间维度”分表需求的同学参考。
为什么强调“按月分表”而不是常见的“按年分表”?因为对大部分流水表来说,一年的数据量往往还是太大,而按天分表又会导致物理表数量过多、跨表查询的路由成本上升。按月分表是一个折中方案:单表数据量可控,冷热归档方便,按时间范围查询时最多路由到十几张表,性能也还在可接受范围内。下面直接进入正题。
1. 为什么是这几个版本:选型复盘与前置认知
1.1 什么样的表适合做按月分表
不是所有表都适合分表,更不是所有分表都应该按月来。我建议你先判断三个特征:
- 表有明确的时间列,而且绝大多数查询都会带上这个时间条件,比如订单的创建时间、流水的发生时间、日志的记录时间。
- 写入存在明显的“尾部热点”,也就是说最新的数据写入最频繁,历史数据基本只读。
- 单表数据量已经达到千万到亿级别,索引维护成本明显上升,插入性能出现抖动。
如果你的表符合这几个特征,按月分表就是一个比较合理的选择。反过来,如果业务查询基本都按用户 ID 维度走、时间条件只是偶尔过滤,那优先考虑按用户 ID 哈希分片,或者直接用分布式数据库,不要硬套时间分表。
1.2 版本组合为什么锁定在 2.7.18 和 5.2.1
先说 SpringBoot,2.7.18 是 SpringBoot 2.x 的最终维护版本,也是很多存量项目升级成本最低的终点。ShardingSphere-JDBC 6.x 已经全面转向 SpringBoot 3 / Jakarta EE,如果你的项目还停留在 JDK 8 和 javax 体系,SpringBoot 2.7.18 反而是最稳妥的长期版本。
再看 ShardingSphere-JDBC,5.2.1 是我在这个组合里实际验证过的版本。为什么不用更高版本?因为 5.3.x 之后对 SpringBoot 自动装配、YAML 配置结构的兼容性有过调整,有些配置项写法会变;而 5.1.x 在标准分片算法和范围查询上的表现又没有 5.2.x 完整。5.2.1 恰好处于一个“功能丰富度”和“配置稳定性”都比较平衡的位置。
依赖的 artifactId 也要注意,5.2.1 对应的 starter 已经改成了 shardingsphere-jdbc-core-spring-boot-starter,不是早期 4.x 时代的 sharding-jdbc-spring-boot-starter。如果你在搜索引擎里看到老文章,引用了旧包名,启动时会直接报一大堆自动配置类找不到的错误。
1.3 JDBC 模式和 Proxy 模式的取舍
ShardingSphere 有 JDBC 和 Proxy 两种形态,这里选 JDBC 是因为它直接嵌入在应用进程里,不需要额外部署一个 Proxy 节点,对中小团队来说运维成本最低。JDBC 模式本质上就是帮你包了一层 DataSource,应用拿到的还是一个标准数据源,MyBatis-Plus、Druid、事务管理器都感知不到分片逻辑,业务代码也不需要改动。
代价也很直接:分片逻辑在应用内存里计算,每个应用节点都要维护一份分片元数据;如果应用是多实例部署,连接数会被放大。所以 JDBC 模式更适合应用实例数量不多、单实例连接数可控的场景。如果你的服务有几十个 Pod,那建议认真评估 Proxy 模式或用分布式数据库,而不是硬上 JDBC。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 第一步先把数据和依赖铺好
2.1 PostgreSQL 中物理表的创建与命名规范
分表之前,物理表要先存在。ShardingSphere 只负责把逻辑表的路由改写为物理表,没有自动建表能力。我习惯的命名方式是 逻辑表名_yyyyMM,比如交易流水表逻辑名为 t_trade_flow,物理表就是 t_trade_flow_202501、t_trade_flow_202502。
PostgreSQL 表名和字段名默认会被折叠成小写,这本身不是问题,但如果你在建表脚本里给表名加了双引号,比如 CREATE TABLE "T_TRADE_FLOW_202501",那这个表名就会以大写形式存储。ShardingSphere 在做路由匹配时用的是小写逻辑名,两边的表名对不上,就会出现“明明表存在却报 relation not exist”的诡异问题。所以建议表名全部使用小写,不要给表名加双引号。
建表时还要特别注意分区表的冲突问题。PostgreSQL 原生支持 PARTITION BY RANGE,如果你已经用原生分区表,那就不需要 ShardingSphere 再来做分表;反过来也是一样,别把两层机制叠在一起,否则查询计划会变得非常不可控。我这里使用的是普通物理表,不用 PostgreSQL 原生分区,分片完全交给 ShardingSphere 处理。
2.2 pom.xml 依赖和版本排除细节
依赖这块最容易出问题,因为 ShardingSphere 会传递一堆依赖,和 SpringBoot 自带的版本管理打架。核心依赖如下:
xml复制<dependency>
<groupId>org.apache.shardingsphere</groupId>
<artifactId>shardingsphere-jdbc-core-spring-boot-starter</artifactId>
<version>5.2.1</version>
</dependency>
<dependency>
<groupId>com.baomidou</groupId>
<artifactId>mybatis-plus-boot-starter</artifactId>
<version>3.5.3.1</version>
</dependency>
<dependency>
<groupId>com.alibaba</groupId>
<artifactId>druid-spring-boot-starter</artifactId>
<version>1.2.20</version>
</dependency>
需要注意 ShardingSphere 5.2.1 自带的 SnakeYAML 版本可能偏低,在某些环境下会报安全漏洞扫描不通过。处理方式不是盲目升级 SnakeYAML 版本,而是确认 SpringBoot 2.7.18 自带的依赖版本能够统一覆盖它。不建议自行排除后再引高版本,因为 ShardingSphere 对 YAML 解析比较敏感,版本跨度太大会出现配置加载异常。
2.3 Druid 连接池与 ShardingSphere 的兼容配置
Druid 作为底层真实连接池没有问题,但配置方式要稍微调整。ShardingSphere 5.x 配置的数据源类型直接写 com.alibaba.druid.pool.DruidDataSource 即可,它会把 Druid 包装成自己的物理数据源。
这里有个常见误区:项目里如果引入了 druid-spring-boot-starter,SpringBoot 会自动创建一个 Druid 数据源,而 ShardingSphere 又会创建自己的数据源,两者叠加容易导致启动时出现多个数据源初始化顺序错乱的问题。我的做法是:主配置里不用 spring.datasource.druid.* 那一套,而是把真实数据源参数全部放到 spring.shardingsphere.datasource 下,由 ShardingSphere 统一创建;druid-spring-boot-starter 只负责提供 Druid 的类,不启用它的自动装配。
Druid 的监控过滤器也要注意,stat 过滤器一般没问题,但 wall 过滤器在 ShardingSphere 环境下容易误判改写后的 SQL。如果你启动了 wall,可能会看到 SQL 被拦截的异常。社区里不少人是直接关掉 wall,或者只保留 stat。我实际项目中也是关闭 wall,连接池参数如 maxActive、initialSize 放到 ShardingSphere 数据源配置里即可。
3. 核心分片规则配置:从 YAML 到自定义算法
3.1 数据源、逻辑表与实际表的映射
先看一份可直接改用的 YAML 骨架:
yaml复制spring:
shardingsphere:
mode:
type: Memory
datasource:
names: ds
ds:
type: com.alibaba.druid.pool.DruidDataSource
driver-class-name: org.postgresql.Driver
url: jdbc:postgresql://127.0.0.1:5432/business_db
username: your_user
password: your_password
maxActive: 20
initialSize: 5
rules:
sharding:
tables:
t_trade_flow:
actualDataNodes: ds.t_trade_flow_$->{202501..202512}
tableStrategy:
standard:
shardingColumn: create_time
shardingAlgorithmName: trade_flow_month_standard
shardingAlgorithms:
trade_flow_month_standard:
type: CLASS_BASED
props:
strategy: standard
algorithmClassName: com.example.sharding.TradeFlowMonthShardingAlgorithm
actualDataNodes 这里我写了 202501 到 202512 一共 12 张物理表。实际生产环境建议把未来 3 到 6 个月的表都提前建好,保证路由时节点列表里一定包含未来月份;否则到了月底最后一天,新数据已经属于下一月,而节点集合里没有对应表,SQL 会执行失败。
mode.type 用 Memory 是最简配置,重启后会重新初始化分片规则。如果你配置了 ZooKeeper 或 Nacos 作为注册中心,规则会持久化,但对单体分表场景没有必要,Memory 模式足够。
3.2 精确分片算法:把时间值翻译成物理表名
ShardingSphere 的 standard 策略需要同时提供精确分片和范围分片两个算法。精确分片用于等值查询,比如 create_time = '2025-03-15 12:00:00';范围分片用于区间查询,比如 create_time BETWEEN '2025-01-01' AND '2025-06-01'。
精确分片算法代码如下:
java复制public class TradeFlowMonthShardingAlgorithm implements PreciseShardingAlgorithm<Date> {
@Override
public String doSharding(Collection<String> availableTargetNames,
PreciseShardingValue<Date> shardingValue) {
Date value = shardingValue.getValue();
String tableSuffix = DateFormatUtils.format(value, "yyyyMM");
String logicTableName = shardingValue.getLogicTableName();
String actualTableName = logicTableName + "_" + tableSuffix;
if (!availableTargetNames.contains(actualTableName)) {
throw new IllegalStateException("分表节点中不存在表: " + actualTableName);
}
return actualTableName;
}
}
这里面有两个细节值得说。第一,availableTargetNames 是从 actualDataNodes 解析出来的物理表集合,算法内不能只拼表名返回,必须判断节点集合里是否包含目标表。这样如果忘记建未来月份的表,路由时可以第一时间抛出明确异常,而不是让 SQL 落到一个不存在的表上。第二,PreciseShardingAlgorithm<Date> 的泛型类型要和数据库字段类型对应,如果 Java 侧用的是 LocalDateTime,这里泛型就要写 LocalDateTime;如果用了 String 存时间,泛型也对应改成 String,类型不匹配会导致算法在运行期不被触发。
3.3 范围分片算法:跨月查询能不能跑通就看它
范围查询是分表方案里最容易翻车的点。如果不实现范围分片,ShardingSphere 在处理 BETWEEN、>、< 这类条件时只会路由到逻辑表对应的全部物理表,虽然结果不会错,但会导致 SQL 被广播到所有分片,性能直线下降。
范围分片算法的核心逻辑是把传入的 range 边界切成命中的月份,再根据月份过滤物理表集合:
java复制public class TradeFlowMonthShardingAlgorithm implements RangeShardingAlgorithm<Date> {
@Override
public Collection<String> doSharding(Collection<String> availableTargetNames,
RangeShardingValue<Date> shardingValue) {
Range<Date> valueRange = shardingValue.getValueRange();
String logicTableName = shardingValue.getLogicTableName();
Date lower = valueRange.hasLowerBound() ? valueRange.lowerEndpoint() : null;
Date upper = valueRange.hasUpperBound() ? valueRange.upperEndpoint() : null;
Set<String> result = new LinkedHashSet<>();
for (String targetName : availableTargetNames) {
String month = targetName.replace(logicTableName + "_", "");
Date monthStart = DateUtils.parseDate(month + "01", "yyyyMMdd");
Date monthEnd = DateUtils.addMonths(monthStart, 1);
if (isOverlap(lower, upper, monthStart, monthEnd)) {
result.add(targetName);
}
}
return result;
}
private boolean isOverlap(Date lower, Date upper, Date monthStart, Date monthEnd) {
if (lower != null && !lower.before(monthEnd)) {
return false;
}
if (upper != null && !upper.after(monthStart)) {
return false;
}
return true;
}
}
这段实现的核心思路是:不直接计算 SQL 条件落在哪些月份,而是反过来遍历所有物理表的月份区间,判断该表所在月份是否与查询范围有交集。这样做的好处是逻辑简单、不容易漏表,即使 actualDataNodes 中包含了不连续的月份也不会出错。缺点是需要遍历全部节点,但物理表数量通常只有几十张,遍历成本可以忽略。
3.4 主键策略:为什么分表后不能简单用自增 ID
PostgreSQL 的 BIGSERIAL 自增在单表场景很好用,但分表后如果每张表各自维护序列,不同物理表可能出现相同的 ID,一旦做跨表合并,主键就冲突了。所以分表场景通常用全局唯一主键。
ShardingSphere 默认集成了雪花算法,配置很简单:
yaml复制spring:
shardingsphere:
rules:
sharding:
keyGenerators:
snowflake:
type: SNOWFLAKE
props:
worker-id: 1
然后在分片表配置里指定主键生成策略:
yaml复制t_trade_flow:
actualDataNodes: ds.t_trade_flow_$->{202501..202512}
keyGenerateStrategy:
column: id
keyGeneratorName: snowflake
雪花 ID 的二进制结构里包含时间戳,整体趋势递增,对索引比较友好。不过要注意,雪花 ID 是 19 位 Long 类型,如果你的前端 JS 直接处理这个 ID,会丢失精度。常见的做法是后端转成 String 返回,或者 MyBatis-Plus 里把主键映射为 String 类型。
MyBatis-Plus 自带的主键策略 ASSIGN_ID 其实也默认使用雪花算法,如果你不想让 ShardingSphere 参与主键生成,可以只在 MP 里配置主键生成,但这样 ShardingSphere 就无法感知主键生成规则,跨表合并场景下某些操作会受影响。建议两类主键配置统一用一套,不要让 MP 的 ID 生成和 ShardingSphere 的 keyGenerator 同时生效。
4. 接入 MyBatis-Plus 后需要改写的 Mapper 层
4.1 实体注解、逻辑表名和 XML 书写规范
MyBatis-Plus 的实体类上,@TableName 一定要写逻辑表名,不要写物理表名。假设逻辑表叫 t_trade_flow,那实体上就是:
java复制@TableName("t_trade_flow")
public class TradeFlow {
@TableId(value = "id", type = IdType.INPUT)
private Long id;
private LocalDateTime createTime;
// 其他字段
}
注意 IdType.INPUT,因为主键由 ShardingSphere 的雪花 keyGenerator 生成,不是由 MyBatis-Plus 自动生成。如果这里写成 IdType.ASSIGN_ID,会出现 MP 先生成一个 ID、ShardingSphere 再生成一个 ID 的重复操作。
我在手写 XML 时踩过一个比较隐蔽的坑:MP 的 BaseMapper 内置方法没问题,但自己写的 SQL 里如果表名用 t_trade_flow 逻辑名,MyBatis-Plus 不会帮你改写成物理表,必须由 ShardingSphere 在 SQL 解析阶段完成改写。因此 XML 里必须写逻辑表名,而且在开发环境用 p6spy 或 Druid 的 SQL 日志看到的是改写后的物理表 SQL,不要被日志误导。如果你看到日志里查询的表是 t_trade_flow_202506,说明路由已经生效。
4.2 分页查询:绕开 MP 内置分页插件的做法
这是 MyBatis-Plus 接入 ShardingSphere 时争议最多的地方。MyBatis-Plus 的 PaginationInnerInterceptor 会把分页参数解析成 LIMIT 和 COUNT 等 SQL 片段,而 ShardingSphere 自己也要处理 LIMIT 改写,两者叠加起来经常出现几个现象:
- COUNT 查询被广播到所有物理表,再把结果累加,导致总数翻了几倍。
- 分页使用了
OFFSET时,每个物理表都执行了相同的 OFFSET,合并后数据严重错位。 - 某些版本的 MP 分页插件和 ShardingSphere 的 SQL 解析器冲突,直接报语法错误。
我落地时没有使用 MP 的 PaginationInnerInterceptor,分页查询全部通过自定义 Mapper SQL 实现。核心思路是:分页前先确定查询的时间范围,路由到具体物理表后,在应用层做归并。如果业务场景允许“只查最近 N 个月”,这个问题会简单很多,因为节点集合是可控的。
代码层面,我用的是 ShardingSphere 原生的 ShardingSphereDataSource 结合 MyBatis 的 Page 对象来管理分页,实际上最关键的是不要在分页插件层面做物理分页,而是把 LIMIT/OFFSET 交给 ShardingSphere 去改写。很多人问那 COUNT 怎么做?最简单可靠的方案是写一条独立的 COUNT 查询,走逻辑表,让 ShardingSphere 自己路由和合并,而不是依赖 MyBatis-Plus 自动生成的 COUNT SQL。
4.3 批量插入、连表查询和逻辑删除的边界
批量插入在分表场景下有一个重要限制:ShardingSphere 5.2.x 会根据分片键把批量数据拆分到对应物理表执行,但前提是批量插入的 SQL 里必须包含分片键字段,也就是 create_time。如果一次批量插入的数据跨了月份,ShardingSphere 会自动分成多条 SQL 分别插入;如果批量插入的数据全部是同一个月的,则只会路由到一张表。所以代码里 insertBatch 时一定要把 create_time 字段显式带上,不要依赖数据库默认值。
连表查询是分表方案的禁区之一。ShardingSphere 5.2.1 对绑定表的支持比 4.x 有改进,但仍然限制较多,尤其是一张分表 JOIN 另一张分表时,如果两边分片键不一致,路由和归并逻辑会非常复杂。我的项目里尽量避免分表和分表 JOIN,需要关联的数据提前冗余到一张宽表里,或者通过两次查询在应用层组装。
逻辑删除字段与 MyBatis-Plus 的 @TableLogic 可以正常使用,因为逻辑删除只是生成 UPDATE ... SET deleted = 1 WHERE id = ... 的条件,ShardingSphere 只需要根据 WHERE 里的分片键来路由。这里有个提醒:如果逻辑删除的更新操作只带了主键、没带 create_time,ShardingSphere 无法判断应该路由到哪张物理表,会报路由失败。所以使用 deleteById / updateById 这类方法时,实体对象里最好把 create_time 字段也带上。
5. 完整复盘:四类高频问题与完整排查路径
5.1 启动期:数据源初始化失败和依赖冲突
现象是应用启动时报数据源相关异常,错误里能看到 Druid 或 ShardingSphere 的类名,或者提示 Failed to configure a DataSource。这个阶段的问题大概率不是配置写错了,而是依赖冲突。
我的排查顺序是这样:第一步看 mvn dependency:tree,确认 shardingsphere-jdbc-core-spring-boot-starter 是否引入了和 SpringBoot 版本冲突的依赖;第二步看启动日志有没有多个自动配置类互相覆盖,比如同时存在 DruidDataSourceAutoConfigure 和 ShardingSphere 的数据源自动配置;第三步确认 application.yml 里没有残留原生的 spring.datasource 配置,因为 ShardingSphere 启动时会优先读取自己的数据源配置,如果两个配置都存在,可能初始化出两个数据源。
另外,如果项目里引入了 spring-boot-starter-jdbc,它的 DataSourceAutoConfiguration 也可能参与数据源竞争。建议在启动类上排除掉原生数据源自动配置:
java复制@SpringBootApplication(exclude = {DataSourceAutoConfiguration.class})
但这只是一个备选方案,不是所有项目都需要。正确的做法是等启动日志稳定后,查看 HikariDataSource 和 DruidDataSource 各自被实例化了几次。
5.2 路由期:表不存在、路由跨表异常的定位
运行期最典型的报错是 PostgreSQL 返回:
text复制relation "t_trade_flow_202512" does not exist
或者 ShardingSphere 返回:
text复制Cannot find table sharding data source.
收到这类错误,我建议按下面的链路排查,不要一上来就改代码。
第一,确认物理表是否真的存在以及表名大小写。用 \dt 查看 PostgreSQL 表名,如果表名显示为 t_trade_flow_202501,没问题;如果显示为带引号的大写表,那就要调整建表脚本或重命名。第二,检查 actualDataNodes 里的范围是否覆盖了查询的时间。表名集合是固定的,SQL 里的时间一旦超出范围,就算路由算法生成了新表名,也找不到匹配节点。第三,检查精确分片算法里 availableTargetNames.contains(actualTableName) 是否拦截了不合法的表名,如果算法直接返回了一个不在集合内的表,ShardingSphere 可能不会报错,而是等数据库执行时才发现表不存在。
路由问题最难排查的一种表现是:单月查询正常,跨月查询结果缺失或重复。这通常是范围分片算法只实现了精确分片,或者 RangeShardingAlgorithm 在判断区间重叠时写错了条件。我建议在算法里加临时日志,把 lower、upper、monthStart、monthEnd 全打出来,对照实际期望的表集合验证。
5.3 执行期:Druid SQL 解析和类型转换问题
执行期报错常见于 Druid 的 SQL 解析器不理解 ShardingSphere 改写后的 SQL。ShardingSphere 改写出的 SQL 通常带一层注释格式,比如:
sql复制/* ShardingSphere: ... */
SELECT ... FROM t_trade_flow_202506 ...
Druid 的 wall 过滤器对这类 SQL 比较敏感,因为它要解析注释和原始 SQL,当 SQL 被改写成多表 UNION ALL 结构时,wall 的拦截规则可能不匹配。如果你的报错信息里有 SQLException: sql parse error 且请求是从 Druid 过滤器抛出,优先检查有没有开 wall。把 wall 关掉或者换成 stat 过滤器,大多数问题都能解决。
另一个执行期问题是 PostgreSQL 的字段类型和 Java 类型映射不一致。比如 create_time 数据库字段是 timestamp without time zone,Java 实体用的是 LocalDateTime,分片键类型是 LocalDateTime,这没问题。但如果数据库字段是 timestamptz,Java 实体用的是 Date,ShardingSphere 路由比较时的类型转换就可能出问题。表现形式是精确查询不报错、范围查询结果不对,或者启动时配置校验失败。统一字段类型是规避这类问题的最好方式。
5.4 数据期:逻辑删除、时间精度与自动填充
MyBatis-Plus 的 MetaObjectHandler 自动填充和 ShardingSphere 在一起时需要考虑填充顺序。以 create_time 为例,如果实体里的 create_time 是由 MP 自动填充的,那么插入 SQL 生成时字段已经被赋值,分片算法可以正常拿到值;但如果你的业务代码先插入、后填充,或者 MP 填充发生在 SQL 执行阶段之后,分片键就会变成 null,导致路由失败。
我遇到过的一个真实问题是:MP 自动填充把 create_time 设置成了 LocalDateTime.now() 的纳秒值,而数据库字段精度只到毫秒,结果每次手动执行同样的 SQL 都路由到不同表。后来在测试环境对比数据才发现是精度问题。为了避免这种问题,时间字段建议统一到毫秒精度,分片算法只精确到 yyyyMM,不要依赖时分秒部分。
时间精度还有一个隐性影响:范围分片的边界判断是按月切分,如果查询条件带上了毫秒值,边界判断仍然按自然月进行,一般不会出问题。但如果你的业务存在“月底最后几秒”的边界数据,建议在范围分片算法中针对边界值做向后多取一个月的保护,否则可能出现刚好落在边界上的数据查询不到。
6. 投入生产前必做的几件事
6.1 用任务调度自动创建下一张分表
前面说过 ShardingSphere 不做自动建表,所以生产环境必须有定时任务负责在每个月结束前创建下一个月的物理表。建议不要等活动数据真的写进来才手工建表,而是通过 @Scheduled 或独立调度平台,在每个月的 25 号检查下一个月表是否存在,不存在就执行 DDL 创建。
创建表时建议参考上一张表的表结构,直接从 information_schema 或 pg_get_constraintdef 读取表结构,而不是在代码里硬编码一份建表 SQL。这样后续哪位同学加了索引或改了字段,定时任务建出来的新表也能保持一致。我踩过的坑是:项目初期在代码里硬编码了建表语句,后来有一次运维手工给线上表加了一个索引,但代码里的 DDL 没同步更新,月底自动建出来的新表少了索引,第二天查询就慢了几十倍。
6.2 冷热数据归档和索引规划
按月分表的一个直接好处是归档简单。比如生产环境只保留最近 12 个月的物理表节点,三个月前的数据直接通过工具导出到备份库,再从 actualDataNodes 列表里移除对应节点。注意移除节点并不是删除配置重启,而是确保后续路由不会把请求发到已经归档的表上。如果业务需要查询历史数据,可以让历史查询走另一个专门的归档库或只读副本,避免影响在线表。
索引规划方面,分片键本身是路由条件,必须建索引;业务上高频使用的查询条件,比如 user_id 或 order_no,也必须建索引。否则每个物理表都要做全表扫描,即使路由到了正确的表,也会被慢查询拖垮。还有一个容易忽略的点:每张物理表的索引名必须唯一,因为 PostgreSQL 的索引名在同一个 schema 下不允许重复。如果你建表时直接复制同一份 CREATE INDEX idx_create_time,从第二张表开始就会报索引已存在。
6.3 压测和监控要看哪些指标
分表方案上线前,压测重点不是看接口平均耗时,而是看路由后的 SQL 是否真的收敛到了预期的物理表集合。建议在测试环境打开 ShardingSphere 的 SQL 日志:
yaml复制spring:
shardingsphere:
props:
sql-show: true
sql-show 会打印改写前后的 SQL,改写的目标表集合一眼就能看出来。确认单月查询只路由到一张表、跨三个月查询只路由到三张表,逻辑就基本正确了。上线前记得把 sql-show 关掉,否则日志量会非常夸张,而且会打印完整参数,有数据泄露风险。
接着看 Druid 监控里的连接池指标。ShardingSphere 多实例部署时,每个实例都会持有真实连接池,连接数很容易被低估。压测时我一般同时观察活跃连接数、等待线程数、物理连接创建速率三个指标。如果活跃连接长期接近 maxActive 而等待线程持续增加,不是连接池不够大,而是某条 SQL 的路由范围过宽,导致单条 SQL 在多个物理表上串行或并发执行的时间过长。
最后还要确认 MyBatis-Plus 的执行日志没有出现 LIMIT 100 这种被错误改写的情况。分页问题是分表方案里最容易返工的功能点,我建议在测试用例里把分页查询的断言写成“总数正确、每页数据不重不漏”,别只看第一页有没有数据就放行。
整套方案跑下来,我最想提醒的就一句话:分表不是把表拆了就完事,路由规则的收敛性、物理表的生命周期管理、业务查询对分片键的依赖程度,每一个都比 YAML 配置本身更能决定这个方案能不能长期稳定运行。如果你在做的也是月度流水类数据,希望这篇实际踩坑后的复盘,能帮你缩短从框架选型到稳定上线的距离。
