ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

Sharding-JDBC范围分表实战:边界判断与路由配置避坑指南

Sharding-JDBC范围分表实战:边界判断与路由配置避坑指南 简介面向Java后端开发者的Sharding-JDBC范围分表实例解决大数据量增长下单库单表性能瓶颈问题。整套示例基于Sharding-JDBC应用层分库分表方案无需额外中间件即可透明路由SQL重点演示按时间区间或ID范围拆分数据将热点分散到不同分表同时兼顾数据一致性、事务处理等落地难点。压缩包共20个文件采用Maven工程结构组织包含6个Java源码及对应class编译文件用于实现分片逻辑并可直接运行另有properties与xml配置文件定义了数据源、分片键与分片算法附带的SQL脚本和操作说明文档可用于建表、验证路由结果并理解配置要点。整体仅21KB适合导入本地IDE对照学习目前已有835人学习下载适合正在学习分库分表或需要参考范围分片策略的中级Java工程师。借鉴其中的分片键、分片算法、分片策略和分片范围配置可以掌握Sharding-JDBC路由规则并基于脚本快速搭建自己的范围分表实验环境。1. 范围分表Sharding-JDBC 里最考验边界判断的拆表方式很多团队第一次接触范围分表是从「订单按月拆表」这个需求开始的一年前的订单放 t_order_2024今年的放 t_order_2025写入和查询都按 create_time 走。听起来很直白但真正用 Sharding-JDBC 落地时会发现范围分表的路由结果不是 SQL 自己决定的WHERE 条件只是输入真正决定数据落到哪张表的是分片算法对边界值的判断而边界判断恰恰是翻车最多的地方。范围分表适合有时间或数值序号属性的业务比如订单、流水、日志它能给范围查询带来天然裁剪能力也能顺便做冷热分离。这篇文章适合正在做分表选型、或者已经在用 Sharding-JDBC 但被路由结果坑过的人我会把配置、参数、验证方法和踩坑点一次讲清楚。2. 先定策略再写配置范围分片和哈希分片的选型边界2.1 范围分片解决什么问题范围查询与冷热分离范围分片的核心逻辑是把分片键的连续区间映射到一组物理表。比如分片键是订单创建时间区间单位是月那么 2025 年 6 月的订单只会进 t_order_202506 这一张表。这种策略最大的收益是查询裁剪一条WHERE create_time BETWEEN 2025-06-01 AND 2025-08-31的 SQL在路由阶段就能被缩减到 6 月、7 月、8 月三张表其余物理表根本不会收到请求。数据量越大这种裁剪带来的性能优势越明显。另一个常被忽略的价值是冷热分离。按时间范围分表后热点数据天然集中在最近的几张表归档时可以直接把几个月前的整张物理表迁到冷存储或者给老表做不同的索引策略而不需要改业务代码。我经手的项目里甚至有团队用范围分表把一年前的订单表直接压缩归档查询入口不变存储成本降了将近一半。但范围分片不是没有代价。它的最大弱点是数据倾斜订单量逐年增长2024 年可能只有几十万行2027 年可能上亿行每张物理表的负载完全不一样。哈希分片能把数据打散得很均匀但范围分片的均匀程度取决于业务分布设计分片键和区间时必须先看数据增长曲线不能想当然地平均分。2.2 和哈希分片对比等值查询、范围查询、扩容三个维度选分片策略前先列一张对比表会省掉很多后面返工的功夫。我一般从三个维度做判断对比维度范围分片哈希/取模分片等值查询按分片键精确路由到单表走索引效率高按哈希值取模直接命中单表效率同样高范围查询裁剪到少量连续分片性能好无法裁剪需要路由到所有分片再归并数据分布可能倾斜取决于业务数据的时间或数值分布分布均匀适合随机性强的 ID扩容方式增加新区间物理表即可旧数据基本不动取模基数变化已有数据需要重分布典型适用订单、流水、日志等带时间或序号的业务用户、设备、会话等随机 ID 业务主要风险数据倾斜、跨区间查询、边界判断错误范围查询退化为全路由、扩容迁移成本高这个表格能直接回答大多数选型问题如果业务查询里大量是「查最近一个月」「查某个时间区间」范围分片是明显更优的如果查询几乎都是「查某个用户的所有订单」那按 user_id 做哈希分片更省事。还有一种常见误用是「用哈希分片存时间数据」。有团队把订单表按 order_id 取模拆成 16 张表结果运营要拉「某天全平台订单量」时一条 SQL 要广播到 16 张表再合并数据库连接瞬间被打满。反过来说如果业务主查询是WHERE user_id ?硬要按订单时间范围分表每次查询都得跨好几张物理表归并反而更慢。所以正确顺序永远是先列查询模式再定分片键最后选分片策略。3. 用 Sharding-JDBC 配置范围分表YAML 配置与真实数据分布3.1 用 INTERVAL 内置算法按月份拆订单表最小 YAML 配置Sharding-JDBC 5.x 内置了一个按时间区间分片的算法INTERVAL配置好之后按月、按年拆表只需要几行 YAML。我先给一个最小配置后续再解释每个参数的含义# config-sharding.yaml dataSources: ds_0: dataSourceClassName: com.zaxxer.hikari.HikariDataSource driverClassName: com.mysql.cj.jdbc.Driver jdbcUrl: jdbc:mysql://127.0.0.1:3306/ds_0 username: root password: root rules: - !SHARDING tables: t_order: actualDataNodes: ds_0.t_order_$-{2024..2027} tableStrategy: standard: shardingColumn: create_time shardingAlgorithmName: order_interval shardingAlgorithms: order_interval: type: INTERVAL props: datetime-pattern: yyyy-MM-dd HH:mm:ss datetime-lower: 2024-01-01 00:00:00 datetime-upper: 2028-01-01 00:00:00 sharding-suffix-pattern: $-{2024..2027} datetime-interval-amount: 1 datetime-interval-unit: YEARS这段配置的核心逻辑是逻辑表t_order映射到ds_0库里的t_order_2024到t_order_2027四张物理表分片键是create_time。当 SQL 里出现create_time的等值或范围条件时INTERVAL算法会根据时间值算出落在哪一年然后拼接出对应的物理表名。datetime-lower和datetime-upper是算法能处理的边界范围sharding-suffix-pattern定义了物理表名的后缀格式。这里有个容易忽略的点$-{2024..2027}只是表名后缀不是算法自动生成的区间区间上下限由datetime-lower和datetime-upper控制。如果传入的create_time早于 lower 或晚于 upper算法会直接报错因为它在availableTargetNames里找不到合适的物理表。3.2 standard 策略的完整配置解析actualDataNodes 与分片键tableStrategy.standard表示使用标准分片策略它要求必须指定shardingColumn和一个支持精确与范围路由的算法。这是范围分表最推荐的策略类型因为它同时处理两种路由场景和IN走精确分片BETWEEN、、走范围分片。另一个常见策略是complex用于分片键不止一个的场景但范围分表通常不需要那么复杂。actualDataNodes的写法要特别注意它是逻辑表到物理表的映射表达式。ds_0.t_order_$-{2024..2027}会被展开成四组ds_0.t_order_2024、ds_0.t_order_2025、ds_0.t_order_2026、ds_0.t_order_2027。如果分片键的值是 2025 年的数据算法会从这四组里匹配出ds_0.t_order_2025。这里有一个我踩过的坑物理表必须真的在数据库里存在Sharding-JDBC 只做路由不会帮你建表。配置写好了但表没建第一次插入就会报错。分片键的选择是另一个关键点。shardingColumn必须是 WHERE 条件里真实存在的列而且建议是表里不会频繁更新的列。如果业务里改了create_time数据路由到的物理表并不会自动搬迁结果就是一条数据可能在两张表里都查不到这是最典型的「路由失效」场景。后续所有查询都必须带上这个分片键否则 Sharding-JDBC 无法对 SQL 做裁剪只能把请求广播到所有物理表。3.3 用一个 Java 入口验证路由结果打印每个分片的数据量配置写完不能直接上线我习惯先用一段 Java 代码把路由结果和数据分布打印出来确认数据和预期一致。用 ShardingSphere-JDBC 的 YAML 工厂可以快速加载上面的配置// config/YamlShardingSphereDataSourceFactory.java import org.apache.shardingsphere.driver.api.yaml.YamlShardingSphereDataSourceFactory; import javax.sql.DataSource; import java.io.File; import java.sql.Connection; import java.sql.ResultSet; import java.sql.Statement; public class RangeShardingVerify { public static void main(String[] args) throws Exception { // 加载 YAML 配置构建 ShardingSphere-JDBC 数据源 DataSource dataSource YamlShardingSphereDataSourceFactory.createDataSource( new File(config-sharding.yaml).toURI().toURL()); try (Connection conn dataSource.getConnection(); Statement stmt conn.createStatement()) { // 插入一条 2025 年 6 月的订单预期路由到 t_order_2025 stmt.executeUpdate(INSERT INTO t_order (id, user_id, create_time) VALUES (10001, 2001, 2025-06-15 10:00:00)); // 范围查询预期只命中 t_order_2024 和 t_order_2025 ResultSet rs stmt.executeQuery(SELECT COUNT(*) FROM t_order WHERE create_time BETWEEN 2024-06-01 00:00:00 AND 2025-06-30 23:59:59); rs.next(); System.out.println(范围查询命中订单数: rs.getInt(1)); rs.close(); // 先关闭结果集再执行下一条查询 // 逐张物理表核对数据分布 for (int year 2024; year 2027; year) { ResultSet cnt stmt.executeQuery( SELECT COUNT(*) FROM t_order_ year); cnt.next(); System.out.println(t_order_ year 行数: cnt.getInt(1)); cnt.close(); } } } }这段代码做了三件事插入一条数据验证精确路由执行一条范围查询验证裁剪效果再直接查物理表确认数据真的落在预期位置。第二个步骤是关键如果范围查询结果里出现了 2026 年的表数据说明边界判断出了问题需要回查配置里的时间上下限。运行前确保四张物理表已经建好且表结构完全一致。执行后你会看到t_order_2025的行数是 1其余表是 0范围查询的命中数也是 1。这样就说明路由是符合预期的。如果看到数据进了错误的表优先检查datetime-pattern和实际写入的时间格式是否一致格式不匹配是范围分表最常见的错误来源。4. 范围分表的参数与边界行为区间大小规划和跨分片查询代价4.1 分片键怎么选时间字段的几个隐藏坑选分片键时很多人只盯着「哪个字段查询多」忽略了字段本身的稳定性。我用一个真实的教训来说明某开发者的订单表里既有create_time又有update_time为了查询方便选了update_time做分片键结果订单一旦被修改update_time变化路由逻辑会认为这条数据属于另一张物理表。但数据本身还在原来的表里后续按新时间查不到按旧时间也查不到等于数据凭空消失了。解决办法是分片键必须满足两个条件查询条件里高频出现且值一旦写入就不再变化。订单场景首选create_time流水场景首选batch_no这类单调序号。如果确实需要用可能变化的字段做查询可以把它作为普通查询条件而不是分片键让查询走全路由代价是性能下降但至少数据不会丢。另一个隐藏坑是时间字段的类型不统一。数据库里存的是datetime但 YAML 里datetime-pattern写成yyyy-MM-dd那么带时分秒的create_time在边界比较时会被截断导致某些落在边界的记录路由错误。我的习惯是分片键列用datetime类型datetime-pattern精确到秒所有写入程序统一用yyyy-MM-dd HH:mm:ss格式从源头避免这类问题。4.2 区间大小怎么定数据量、分片数量和查询模式的三角关系区间大小不是拍脑袋定的它取决于三个因素的平衡单表数据量的承载上限、业务查询的时间跨度、分片数量对连接管理和元数据的影响。我一般按这个流程估算先估算单张物理表的合理数据量比如 MySQL 单表控制在 2000 万行以内或者按容量算控制在 20GB 以内。再统计业务最常见的查询时间跨度比如「查最近 3 个月」出现频率最高。用年数据量除以单表容量得出最少需要的分片数量然后选一个能让「最常见查询」裁剪到 1 到 3 张表内的区间单位。这个流程落地到参数选择上可以参考下面这张表区间单位适用年数据量查询特点主要风险按日单日数据量很大日查询多当天数据路由到单表日维度聚合快分片数量过多元数据膨胀跨月查询要拼很多表按周中等偏大周报查询多近一个月查询裁剪到 4 张表左右周边界判断容易出错业务理解成本高按月年数据量几千万到几亿最近三个月查询裁剪到 3 张表单月数据量暴涨时会倾斜按年年数据量不大但查询跨度大跨年查询裁剪明显单表数据量可能超限冷热差距大以订单系统为例如果一年产生 6000 万订单单表容量按 2000 万行算至少需要 3 张表按季度拆比较合理。但我实际见过更多团队选按月拆因为运营侧的查询模式几乎都是「本月 vs 上月」按月拆可以把查询裁剪到最多两张表体感最好。这里没有标准答案核心是让最常见查询只碰最少物理表。跨分片查询的代价也必须提前算进去。ORDER BY create_time LIMIT 20这种 SQL 在范围分表下Sharding-JDBC 会先让每个分片各自排序取前 20 条再在内存里做归并排序。分片数越多内存占用和响应延迟越高。所以分片总数不是越多越好一般建议单库分片数控制在 32 个以内跨分片查询时才能保证归并压力可控。5. 范围分表常见问题排查数据倾斜、路由失效与扩缩容翻车5.1 查询条件没带分片键导致全路由现象一条本来很快的订单查询突然变得极慢数据库 CPU 和连接数飙升慢查询日志里出现大量相同 SQL且执行时间从几十毫秒涨到几秒。原因SQL 的 WHERE 条件里没有出现分片键create_timeSharding-JDBC 不知道数据在哪张表只能把请求广播到所有物理表。数据量一大全路由的代价就被放大。解决业务查询强制带分片键这是最直接的手段。如果某些报表查询确实没法带就把它们拆到只读从库或者独立的分析库不要混在在线事务链路上。我还会在代码评审阶段加一个规则所有针对逻辑表的查询SQL 里必须显式包含分片键字段否则打回。5.2 时间类型不统一导致边界判断错位现象WHERE create_time BETWEEN 2025-06-01 AND 2025-06-30查不到数据但数据库里直接查物理表t_order_202506明明有数据。原因写入时create_time存的是带时分秒的完整时间但配置里datetime-pattern是yyyy-MM-dd边界比较时2025-06-30 23:59:59被截断成2025-06-30而算法认为这个值已经超出 6 月区间路由到了 7 月表。解决统一datetime-pattern为yyyy-MM-dd HH:mm:ss并把写入程序的格式化规则对齐。排错时可以临时打开 ShardingSphere 的路由日志看 SQL 实际被路由到了哪张物理表问题立刻能定位。5.3 INTERVAL 算法上限之外的数据写入失败现象业务进入 2028 年后订单插入开始报错错误信息提示找不到目标表但数据库里明明有t_order_2028。原因YAML 里datetime-upper配的是2028-01-01 00:00:00而actualDataNodes只枚举了2024..2027四张表。算法认为 2028 年的数据超出了它管理的区间范围直接拒绝路由。解决datetime-upper和actualDataNodes的后缀范围必须同步预留。我习惯把datetime-upper配成未来两年物理表也提前建好这样即使业务增长超过预期也有缓冲时间做扩容。这个是我见过最典型的「配置只管当下不管未来」翻车现场。5.4 跨分片 ORDER BY LIMIT 的排序内存翻车现象一个带ORDER BY create_time DESC LIMIT 10的查询在分片数从 4 张扩到 16 张后开始频繁慢查询甚至出现内存溢出的报警。原因分片表上做排序分页Sharding-JDBC 需要把每个分片排序后的结果在内存里归并。分片数越多参与归并的数据量越大内存和耗时都会成倍增长。尤其是有大OFFSET的深分页代价更高。解决限制单次查询的跨分片数量尽量让排序查询落在单一时间区间内深分页改成基于游标的方式比如用WHERE create_time ? ORDER BY create_time DESC LIMIT 10代替OFFSET。如果业务确实需要全局排序分页考虑引入独立的查询侧存储而不是在分片表上硬扛。5.5 扩容时以为「加个表就行」结果数据新旧不一致现象给配置加了t_order_2028新数据开始写入但跑跨年统计时发现 2028 年数据重复或缺失和直接查物理表的结果对不上。原因范围分表的扩容虽然不需要像取模分片那样全量重分布但旧表的归档逻辑和新表的写入逻辑之间出现了空窗。比如某个月的数据在月初已被归档但新配置又把该月路由到了新表就会产生一次写入落空或重复。解决扩容前先确认旧数据有没有完成迁移或归档再改配置。稳妥的做法是先把新表加入actualDataNodes观察一段时间写入是否正常再做旧表归档。所有分表变更都应该在低峰期操作并且先跑一遍 3.3 那种验证脚本确认数据分布没有异常。6. 进阶用自定义分片算法做路由审计让范围分表可运维配置型分表方案跑起来之后最大的问题是不透明SQL 发出去到底进了哪张表全靠日志和猜。我现在的习惯是写一个自定义分片算法在路由决策的同时把结果打印出来相当于给每次访问加了一层审计。Sharding-JDBC 5.x 里可以实现StandardShardingAlgorithm接口精确路由和范围路由是分开的两个方法// audit/AuditRangeShardingAlgorithm.java import org.apache.shardingsphere.sharding.api.sharding.standard.PreciseShardingValue; import org.apache.shardingsphere.sharding.api.sharding.standard.RangeShardingValue; import org.apache.shardingsphere.sharding.api.sharding.standard.StandardShardingAlgorithm; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import com.google.common.collect.Range; import java.util.Collection; import java.util.Date; import java.util.Set; import java.util.stream.Collectors; public class AuditRangeShardingAlgorithm implements StandardShardingAlgorithmDate { private static final Logger log LoggerFactory.getLogger(AuditRangeShardingAlgorithm.class); Override public String doSharding(CollectionString availableTargetNames, PreciseShardingValueDate shardingValue) { // 精确路由分片键等值匹配直接定位到单张物理表 String target availableTargetNames.stream() .filter(table - table.endsWith(suffixOf(shardingValue.getValue()))) .findFirst() .orElseThrow(() - new IllegalArgumentException( no table for column shardingValue.getColumnName() , value shardingValue.getValue())); log.info([range-audit] precise route: column{}, value{}, target{}, shardingValue.getColumnName(), shardingValue.getValue(), target); return target; } Override public CollectionString doSharding(CollectionString availableTargetNames, RangeShardingValueDate shardingValue) { // 范围路由根据 BETWEEN 的上下界裁剪出命中的物理表集合 RangeDate range shardingValue.getValueRange(); SetString targets availableTargetNames.stream() .filter(table - rangeIntersects(table, range)) .collect(Collectors.toSet()); log.info([range-audit] range route: column{}, range{}, targets{}, shardingValue.getColumnName(), range, targets); return targets; } private boolean rangeIntersects(String tableName, RangeDate range) { // 这里解析表名后缀年份判断区间是否有交集 // 实际生产环境建议把区间映射表放在配置里而不是靠表名推断 return tableName.endsWith(suffixOf(range.lowerEndpoint())); } private String suffixOf(Date value) { // 按年份取后缀例如 2025-06-15 - 2025 return String.valueOf(value.getYear() 1900); } }在 YAML 里注册这个算法时用CLASS_BASED类型指向你的实现类shardingAlgorithms: audit_range: type: CLASS_BASED props: strategy: standard algorithmClassName: com.example.AuditRangeShardingAlgorithm上线前我会再写一个区间边界校验用例遍历每个分片区间的起点和终点断言路由结果落在预期物理表。这个习惯帮我抓到了好几次边界问题——比如 12 月 31 日 23:59:59 的数据被路由到次年表或者闰年 2 月 29 日被当成 3 月 1 日处理。这类问题在配置型方案里几乎不会主动暴露只有靠边界用例才能兜住。配置型分片方案适合大多数业务但真正做到可运维必须把路由行为变成可见、可校验的东西。自定义算法的开销很低换来的是排障时不用再靠猜。我现在每上一个分表项目都会先写审计算法和边界用例再写业务代码这套流程下来路由方面的玄学问题确实少了很多。范围分表不是什么高深技术但边界判断的每一厘米误差最后都会变成生产事故提前把边界钉死后面才睡得着觉。希望帮到你。本文还有配套的精品资源点击获取
RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进