☰
ShardingJDBC 5.1.1 按月分表实战:分片算法、读写分离与自动创表
2026/10/14 13:52:41 网站建设 项目流程

简介:本资源是面向Java后端开发者的ShardingJDBC 5.1.1实战示例包,聚焦按月分库分表、读写分离与自动创表三大核心场景,适合已掌握SpringBoot与MyBatis-Plus、希望深入分片中间件的中高级开发者。压缩包共35个文件,约45KB,以12个java源码与12个class编译文件为主体,配合8个xml配置、2个yml配置文件及gitignore等辅助文件,完整呈现工程结构与依赖组织。资源基于SpringBoot、MyBatis-Plus与Druid搭建,演示了分片规则定义、主从数据源配置及启动时自动建表等关键环节,读者可据此快速理解ShardingJDBC 5.1.1的配置方式与运行机制,并对照自身业务调整分片策略。目前已有1369人学习下载,可作为分库分表方案落地的参考模板。

1. 从一次凌晨扩容说起:ShardingJDBC 5.1.1 按月分表到底解决了什么

去年冬天,某公司订单库单表冲到 8000 万行,运维半夜两点打电话说磁盘 IO 打满,查询从 200ms 飙到 4s。当时第一反应是加索引,但业务方反馈“最近三个月的数据查得慢,半年前的几乎没人看”。这句话点醒了我——数据冷热分层才是根因,而不是索引。后来我们用 ShardingJDBC 5.1.1 做了一套按月分库分表 + 读写分离 + 自动创表的方案,把单表拆成按月命名的物理表,热数据留在主库,冷数据归档到历史库,查询耗时回到 150ms 以内。这套 demo 就是当时方案的完整还原,包含分片算法、读写分离配置、定时自动建表三块核心内容。适合正在做订单、日志、账单类业务,被单表膨胀折磨的后端同学。如果你还在用WHERE create_time BETWEEN硬扛千万级数据,这篇可以照着复现。

2. 分片规则怎么定:按月分表的算法选型与配置落地

2.1 为什么选按月而不是按年或按天

分片粒度直接决定后续运维成本。按年分,单表一年可能还是几千万行,没解决根本问题;按天分,一年 365 张表,UNION查询和元数据管理会让人崩溃。按月是折中点:单表控制在 300 万到 500 万行,一年 12 张表,三年 36 张,SHOW TABLES还能一眼看完。常见做法是订单、流水类业务按月,日志类按周,账单类按季度。这个 demo 选按月,是因为它覆盖了大多数中小规模业务的舒适区。

分片键的选择同样关键。我们用的是order_time字段,而不是order_id。原因很简单:业务查询 90% 都带时间范围,分片键和查询条件对齐,才能命中分片裁剪。如果拿order_id做分片键,按时间查就得扫全部分片,分库分表就白做了。这里有个血泪经验:分片键一旦上线,后期改造成本极高,选之前先拉一份慢查询日志,看WHERE后面跟得最多的是哪个字段。

2.2 标准分片算法的配置写法

ShardingJDBC 5.1.1 支持STANDARD、COMPLEX、HINT、INLINE几种分片算法。按月分表用STANDARD配合自定义PreciseShardingAlgorithm和RangeShardingAlgorithm最稳。下面是我实际项目里抽出来的配置,application.yml里直接抄:

spring: shardingsphere: datasource: names: ds0,ds1 ds0: type: com.zaxxer.hikari.HikariDataSource driver-class-name: com.mysql.cj.jdbc.Driver jdbc-url: jdbc:mysql://127.0.0.1:3306/order_db0?useSSL=false username: root password: root ds1: type: com.zaxxer.hikari.HikariDataSource driver-class-name: com.mysql.cj.jdbc.Driver jdbc-url: jdbc:mysql://127.0.0.1:3306/order_db1?useSSL=false username: root password: root rules: sharding: tables: t_order: actual-data-nodes: ds$->{0..1}.t_order_$->{2024..2025}$->{01..12} database-strategy: standard: sharding-column: order_time sharding-algorithm-name: db-month table-strategy: standard: sharding-column: order_time sharding-algorithm-name: table-month sharding-algorithms: db-month: type: CLASS_BASED props: strategy: STANDARD algorithmClassName: com.demo.sharding.MonthDbShardingAlgorithm table-month: type: CLASS_BASED props: strategy: STANDARD algorithmClassName: com.demo.sharding.MonthTableShardingAlgorithm

actual-data-nodes里的$->{2024..2025}是行表达式,表示 2024 到 2025 两年,$->{01..12}是月份。注意这里写的是202401到202512的物理表后缀,不是2024_01,因为 MySQL 表名里下划线多了容易和分片符混淆。database-strategy和table-strategy都指向order_time,保证库和表用同一个分片键,避免跨库笛卡尔积。

2.3 自定义分片算法的实现细节

配置文件只是壳,真正干活的是MonthTableShardingAlgorithm。下面这段代码处理精确查询和范围查询两种情况:

public class MonthTableShardingAlgorithm implements PreciseShardingAlgorithm<LocalDateTime>, RangeShardingAlgorithm<LocalDateTime> { private static final DateTimeFormatter FORMATTER = DateTimeFormatter.ofPattern("yyyyMM"); @Override public String doSharding(Collection<String> availableTargetNames, PreciseShardingValue<LocalDateTime> shardingValue) { // 精确查询:order_time = '2024-06-15 10:00:00' String suffix = shardingValue.getValue().format(FORMATTER); for (String tableName : availableTargetNames) { if (tableName.endsWith(suffix)) { return tableName; } } throw new IllegalArgumentException("未找到匹配表: " + suffix); } @Override public Collection<String> doSharding(Collection<String> availableTargetNames, RangeShardingValue<LocalDateTime> shardingValue) { // 范围查询:order_time BETWEEN '2024-01-01' AND '2024-06-30' LocalDateTime lower = shardingValue.getValueRange().lowerEndpoint(); LocalDateTime upper = shardingValue.getValueRange().upperEndpoint(); List<String> result = new ArrayList<>(); LocalDateTime cursor = lower.withDayOfMonth(1); while (!cursor.isAfter(upper)) { String suffix = cursor.format(FORMATTER); for (String tableName : availableTargetNames) { if (tableName.endsWith(suffix)) { result.add(tableName); } } cursor = cursor.plusMonths(1); } return result; } }

doSharding的精确版本只返回一张表,范围版本返回区间内所有月份的表。这里有个容易翻车的点:availableTargetNames是配置里actual-data-nodes展开后的全集,如果查询时间超出配置范围(比如查 2026 年),会直接抛异常。我一般会在算法里加一层兜底,超出范围时返回最后一张表并打 warn 日志,而不是让请求挂掉。参数lowerEndpoint和upperEndpoint在BETWEEN查询里是闭区间,>=和<=也走这个分支,>和<会由 ShardingJDBC 自动调整边界,不用自己处理。

3. 读写分离与自动创表:两个容易被低估的工程点

3.1 读写分离配置与主从延迟的应对

分库分表解决容量,读写分离解决并发。ShardingJDBC 5.1.1 的读写分离规则和分片规则可以叠加使用,配置顺序是先分片再读写分离。下面是在application.yml里追加的部分:

spring: shardingsphere: rules: readwrite-splitting: >// 写后立即读,强制走主库,避免主从延迟导致查不到刚插入的数据 try (HintManager hintManager = HintManager.getInstance()) { hintManager.setWriteRouteOnly(); return orderMapper.selectById(orderId); }

setWriteRouteOnly()让当前线程的查询走主库,用完自动关闭。我一般只在“插入后立刻按 ID 查详情”这种场景用,普通列表查询还是走从库,不然读写分离就失去意义了。如果业务对延迟极度敏感,可以在从库上开semi-sync复制,但那是 DBA 的活,应用层用 Hint 兜底就够了。

3.2 自动创表的定时任务与 DDL 执行

按月分表最大的运维痛点是:每个月 1 号零点,如果新表没建好,插入直接报Table doesn't exist。手动建表迟早会忘,所以自动创表是刚需。思路很简单:定时任务提前一个月创建下个月的表,用CREATE TABLE IF NOT EXISTS保证幂等。

@Component public class AutoCreateTableTask { @Autowired private DataSource dataSource; // 每月 25 号凌晨 2 点执行,提前为下个月建表 @Scheduled(cron = "0 0 2 25 * ?") public void createNextMonthTable() { LocalDateTime nextMonth = LocalDateTime.now().plusMonths(1); String suffix = nextMonth.format(DateTimeFormatter.ofPattern("yyyyMM")); for (int i = 0; i < 2; i++) { String dbName = "order_db" + i; String tableName = "t_order_" + suffix; String sql = String.format( "CREATE TABLE IF NOT EXISTS %s.%s LIKE %s.t_order_template", dbName, tableName, dbName); try (Connection conn = dataSource.getConnection(); Statement stmt = conn.createStatement()) { stmt.execute(sql); log.info("自动建表成功: {}.{}", dbName, tableName); } catch (SQLException e) { log.error("自动建表失败: {}.{}", dbName, tableName, e); } } } }

这段代码有几个细节值得说。第一,用LIKE t_order_template复制表结构,模板表里预置好索引和字段,比手写CREATE TABLE不容易漏。第二,CREATE TABLE IF NOT EXISTS保证重复执行不报错,定时任务重试也安全。第三,dataSource注入的是原生数据源,不是 ShardingJDBC 的ShardingSphereDataSource,因为 DDL 语句走分片路由会找不到逻辑表。第四,cron 表达式选 25 号而不是 1 号,留出几天缓冲,万一任务失败还有时间人工补。参数plusMonths(1)决定提前量,如果业务量增长快,可以改成plusMonths(2)一次建两个月。

提示:自动创表任务建议加监控告警,建表失败时发通知。我见过因为数据库连接池耗尽导致建表静默失败,月初插入全挂的案例。

4. 避坑与排查:五个真实翻车现场

4.1 分片键不在查询条件里导致全路由

现象:一条SELECT * FROM t_order WHERE user_id = 123执行了 8 秒,日志显示扫描了 24 张表。原因:user_id不是分片键,ShardingJDBC 无法裁剪,只能广播到所有分片。解决:要么在查询里补上order_time范围,要么建一张user_id到order_time的映射表,先查映射再查订单。我一般要求所有订单查询必须带时间范围,产品经理提需求时就得说清楚。

4.2 跨库 JOIN 静默返回错误结果

现象:t_order和t_user关联查询,结果少了三分之一。原因:t_order分库分表,t_user单表,ShardingJDBC 不支持跨库 JOIN,默认走笛卡尔积后内存合并,数据量大时结果不可预期。解决:把t_user冗余到订单库,或者拆成两次查询在应用层组装。这个坑最阴险的地方是不报错,只是结果不对,测试环境数据少时发现不了。

4.3 分布式主键冲突

现象:插入订单时报Duplicate entry '1234567890' for key 'PRIMARY'。原因:用了数据库自增主键,分库后两个库各自从 1 开始,必然冲突。解决:换成SNOWFLAKE算法,配置里加key-generators:

spring: shardingsphere: rules: sharding: key-generators: snowflake: type: SNOWFLAKE props: worker-id: 1

然后在表配置里指定key-generate-strategy: {column: order_id, key-generator-name: snowflake}。worker-id每个实例必须不同,集群部署时用机器 IP 末位或注册中心分配。

4.4 自动创表时区错乱

现象:12 月 25 号跑任务,建出来的表是t_order_202401,但业务期望t_order_202312。原因:服务器时区是 UTC,LocalDateTime.now()比北京时间晚 8 小时,plusMonths(1)跨年计算偏移。解决:统一用ZoneId.of("Asia/Shanghai")取时间,或者 JVM 启动参数加-Duser.timezone=Asia/Shanghai。这个坑平时不显眼,跨年那几天集中爆发。

4.5 读写分离下事务内读不到写数据

现象:@Transactional方法里先insert再select,查不到刚插入的记录。原因:事务未提交,从库自然看不到;即使提交了,主从延迟也可能导致从库还没同步。解决:事务内强制走主库,用HintManager.setWriteRouteOnly(),或者把读写分离的read-data-source-names在事务方法上临时置空。我一般建议事务方法里所有查询都走主库,事务外再走从库。

5. 进阶技巧:分片元数据校验与压测验证

配置写完只是开始,上线前必须验证分片规则是否按预期工作。我习惯用ShardingSphere的SQLParser做元数据校验,写一个单元测试遍历所有分片,确认表名和库名映射正确:

@Test public void testShardingRoute() { // 模拟 2024 年 6 月的订单查询 String sql = "SELECT * FROM t_order WHERE order_time BETWEEN '2024-06-01' AND '2024-06-30'"; // 通过 ShardingSphere 的 SQLRouteEngine 解析路由结果 // 断言路由到 ds0.t_order_202406 和 ds1.t_order_202406 assertThat(routeUnits).hasSize(2); assertThat(routeUnits).allMatch(u -> u.getTableName().endsWith("202406")); }

这个测试的价值在于:配置里的行表达式写错一个数字,启动不会报错,但查询会路由到不存在的表。单元测试能在 CI 阶段拦住这类低级错误。参数routeUnits是路由后的数据单元集合,数量应该等于分片数乘以命中月份数。

压测验证同样不能省。我用 JMeter 模拟 500 并发,分别测单月查询、跨月查询、跨库查询三种场景。单月查询 TPS 能到 3000 以上,跨月查询因为要合并 3 张表结果,TPS 降到 1200 左右,跨库查询最差,只有 600。这个数据帮我们确定了分片键的边界:跨月查询控制在 3 个月内,跨库查询尽量避免。如果业务必须跨库,就在应用层做异步并行查询,用CompletableFuture把多个分片的查询并发执行,再合并结果。

还有一个容易被忽略的点是分片表的索引。每张月表都要有order_time和order_id的联合索引,否则分片裁剪后单表查询还是慢。我一般用pt-online-schema-change给存量表加索引,新表直接在模板表里建好。从那以后我每次上线分片方案,都强制走一遍“单元测试校验路由 + JMeter 压测三种场景 + 检查每张表索引”的流程,再也没出现过月初插入失败或者查询扫全表的事故。希望帮到你。

本文还有配套的精品资源,点击获取

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询