1. 项目概述:为什么我们需要自定义按月分表算法?
在分布式数据库中间件ShardingSphere的实际应用中,分片策略的选择直接决定了系统的可扩展性和运维复杂度。官方提供了多种内置的分片算法,比如取模、范围、哈希等,这些算法在常规的按用户ID、订单ID分库分表场景下非常高效。然而,当业务需求变得复杂,特别是面对时间序列数据时,这些标准算法就可能显得力不从心。想象一下一个典型的场景:一个拥有海量日志、交易记录或监控数据的系统,数据天然地按照时间维度(年、月、日)增长。如果简单地按ID取模,新月份的数据会随机散落到各个历史表中,导致针对某个月份的数据查询变得异常困难,需要扫描所有分表,性能急剧下降。同时,数据归档和清理也变得几乎不可能,因为你无法简单地“删除”某个旧月份对应的物理表。
这时,“按月分表”的需求就浮出水面。其核心目标非常明确:将同一个月产生的数据,路由到同一张物理表中。例如,t_order_202401、t_order_202402…… 这样,查询2024年1月的数据,ShardingSphere可以精准定位到t_order_202401这一张表,避免了全表扫描。数据归档时,直接备份或删除t_order_202301这样的表即可,操作清晰,对在线业务影响最小。虽然ShardingSphere内置了IntervalShardingAlgorithm可以处理时间范围分片,但在一些定制化需求面前,比如需要根据非时间字段(但蕴含时间信息)进行分片,或者分表名的生成规则需要特殊处理(例如按财年、按季度周),自定义算法就成了必须掌握的技能。掌握自定义分片算法,意味着你获得了根据业务特性量身打造最优分片方案的能力,这是从“会用”中间件到“精通”中间件的关键一步。
2. 核心需求与设计思路拆解
2.1 业务场景与痛点分析
“按月分表”不是一个凭空想象的需求,它源于几种非常普遍的业-务模型:
- 日志与审计系统:操作日志、系统日志、安全审计日志每天产生海量数据。运营和风控部门最常进行的操作就是“查询某用户在某时间段内的所有操作”。按月分表后,此类查询的效率提升是数量级的。
- 交易与流水系统:电商订单、支付流水、银行交易记录。财务结算、对账、报表生成几乎都是按月维度进行。将每月数据物理隔离,极大方便了月度结算和税务申报。
- 物联网与监控数据:传感器上报的数据、应用性能监控(APM)指标。这些数据具有强烈的时间序列特性,按时间分片是自然的选择,便于做历史数据滚动和实时热数据查询。
如果不采用按月分表,会面临哪些具体痛点?
- 查询性能低下:
SELECT * FROM t_log WHERE create_time BETWEEN ‘2024-01-01’ AND ‘2024-01-31’。如果数据散落在100个表中,这个查询就需要执行100次(或在某些实现下合并结果),IO和CPU开销巨大。 - 维护成本高昂:想要清理一年前的数据,你无法直接
DROP TABLE,只能执行DELETE FROM t_log WHERE create_time < ‘2023-01-01’。这个操作会产生巨大的事务日志,可能锁表,影响在线业务,并且删除后会产生大量的表碎片。 - 扩展性不灵活:当数据持续增长,想增加分片数量时,内置的取模算法需要重新迁移数据,而按月分表只需要为新月份创建新表即可,无需迁移历史数据,实现了真正的平滑扩展。
2.2 自定义算法 vs 内置算法选型
ShardingSphere 5.x之后,分片算法体系已经非常清晰。我们为什么非要自定义,而不直接用内置的INTERVAL算法?
INTERVAL算法:它非常适合纯时间字段作为分片键的、固定时间间隔的场景。你配置一个起始时间,一个分片间隔(如1 MONTH),它就能自动计算分片。但它不够灵活,例如:- 你的分片键不是
create_time,而是order_no,但订单编号中嵌入了年月信息(如NO202401010001)。 - 你需要更复杂的分表名规则,比如
{logicTable}_y{year}m{month}。 - 你的“月”不是自然月,而是业务定义的财务月。
- 你需要根据分片键的值,结合其他上下文(如租户ID)进行动态路由。
- 你的分片键不是
自定义
StandardShardingAlgorithm:这是我们的选择。它提供了doSharding方法,让我们可以编写任意Java逻辑来决定数据应该落到哪个真实表。这带来了终极的灵活性。我们的设计思路可以概括为:- 输入:获取分片键的值(来自SQL中
WHERE条件或INSERT的值)。 - 解析:从分片键值中提取出“年月”信息。这可能是一个
Date对象,也可能是一个字符串或数字。 - 计算:根据“年月”信息,计算出对应的目标物理表后缀,如
_202401。 - 输出:将逻辑表名与后缀拼接,返回目标真实表名的集合。
- 配置化:通过
ShardingSphere的配置,将算法的属性(如日期格式、后缀连接符)外部化,增强通用性。
- 输入:获取分片键的值(来自SQL中
2.3 算法接口与核心类解读
在编码之前,必须理解ShardingSphere为我们定义的“游戏规则”。核心接口是org.apache.shardingsphere.sharding.spi.ShardingAlgorithm。
对于我们常用的精准分片(=, IN)和范围分片(BETWEEN, >, <),主要实现两个子接口:
StandardShardingAlgorithm<T>:用于处理=和IN查询的精准分片。这是我们实现按月分表的主要接口。它定义了doSharding方法,返回单个或多个确切的目标分片。RangeShardingAlgorithm<T>:用于处理BETWEEN,>,<等范围查询的分片。它定义了doSharding方法,返回一个范围内的所有可能目标分片集合。
为什么我们通常同时实现这两个接口?考虑查询:SELECT * FROM t_order WHERE create_time = ‘2024-01-15’,这调用Standard算法。 考虑查询:SELECT * FROM t_order WHERE create_time BETWEEN ‘2024-01-01’ AND ‘2024-03-31’,这调用Range算法。 一个健壮的按月分表算法,必须能同时处理这两种情况,否则范围查询会出错。因此,我们会创建一个类,同时实现StandardShardingAlgorithm和RangeShardingAlgorithm接口。ShardingSphere会根据SQL条件自动选择调用哪个方法。
3. 核心细节解析与实操要点
3.1 分片键的提取与处理
分片键是分片算法的输入源。在doSharding方法中,我们会收到一个Collection<String>类型的availableTargetNames(所有可用的真实表名,如[t_order_202401, t_order_202402, t_order_202403])和一个RangeShardingValue或PreciseShardingValue对象。
关键点在于如何从ShardingValue中拿到我们需要的值。
// 精准分片值(对应 = 和 IN) public class PreciseShardingValue<T extends Comparable<?>> { private final String logicTableName; // 逻辑表名,如 “t_order” private final String columnName; // 分片列名,如 “create_time” private final T value; // 分片键的实际值,如 “2024-01-15 10:30:00” } // 范围分片值(对应 BETWEEN, >, <) public class RangeShardingValue<T extends Comparable<?>> { private final String logicTableName; private final String columnName; private final Range<T> valueRange; // 一个范围,有上下界 }实操要点1:类型转换与空值处理分片键的值T是泛型,它可能是Date、LocalDateTime、Long(时间戳)或String。我们的算法必须能处理多种类型。通常,我们会在配置中指定一个“日期格式”模式,然后在算法初始化时进行统一转换。
// 在算法的init方法中,读取配置 private DateTimeFormatter dateTimeFormatter; @Override public void init(Properties props) { String pattern = props.getProperty(“datetime-pattern”, “yyyy-MM-dd HH:mm:ss”); this.dateTimeFormatter = DateTimeFormatter.ofPattern(pattern); } // 在doSharding中,统一转换为LocalDateTime进行处理 LocalDateTime shardingValue = convertToLocalDateTime(preciseShardingValue.getValue());注意:必须考虑分片键值为
NULL的情况。在INSERT时,如果分片键是NULL,ShardingSphere会将其传递给算法。你需要决定如何处理——是抛异常拒绝,还是路由到一个默认的表(如当前月份表)。这需要在算法逻辑中明确处理,否则会抛出NullPointerException。
3.2 年月信息的提取与表后缀生成
提取出时间对象后,下一步是生成物理表后缀。这听起来简单,但有几个细节容易踩坑。
1. 时区问题:你的应用服务器、数据库服务器可能位于不同时区。java.util.Date和java.time.LocalDateTime在不同环境下解析可能会产生差异。最佳实践是,在业务系统中,所有时间在存入数据库前,都统一转换为UTC时间或一个指定的时区(如Asia/Shanghai),并以时间戳或格式化的字符串存储。在分片算法中,也使用相同的时区进行解析和计算。
2. 后缀格式:表后缀格式需要与实际存在的物理表名严格匹配。如果你的表是t_order_202401,那么后缀就是_202401。如果你的表是t_order_2024_01,后缀就是_2024_01。这个格式必须在算法中固化,或者通过配置传入。
// 生成后缀的通用方法 private String generateTableSuffix(LocalDateTime dateTime) { // 格式化为年月,如 202401 int year = dateTime.getYear(); int month = dateTime.getMonthValue(); // 这里可以根据配置决定连接符,如 “_” + year + month return “_” + year + String.format(“%02d”, month); // 月份补零至关重要 }3. 月份补零:String.format(“%02d”, month)这行代码至关重要。没有它,1月会变成_20241,而你的表名是_202401,导致匹配失败,数据无法插入。
3.3 精准分片与范围分片的协同实现
这是实现中最核心的部分。我们需要在一个类里写好两套逻辑。
精准分片 (StandardShardingAlgorithm):
@Override public String doSharding(Collection<String> availableTargetNames, PreciseShardingValue<LocalDateTime> preciseShardingValue) { // 1. 获取分片值 LocalDateTime shardingValue = preciseShardingValue.getValue(); if (shardingValue == null) { // 处理null值策略,例如路由到当前月份或抛出异常 return routeForNullValue(availableTargetNames); } // 2. 生成目标表后缀 String targetSuffix = generateTableSuffix(shardingValue); // 3. 拼接完整表名 String targetTable = preciseShardingValue.getLogicTableName() + targetSuffix; // 4. 检查目标表是否在可用列表中(非常重要!) for (String each : availableTargetNames) { if (each.equals(targetTable)) { return each; // 找到并返回 } } // 5. 如果找不到,说明这个月份的表还没有创建。这里有两种策略: // a) 抛异常,让应用层感知并动态建表。 // b) 返回null,ShardingSphere会抛出异常。我们通常选择a,更可控。 throw new ShardingSphereException(“No table found for suffix: “ + targetSuffix); }范围分片 (RangeShardingAlgorithm): 范围分片的逻辑是:给定一个时间范围,找出这个范围内所有可能涉及到的月份对应的表。
@Override public Collection<String> doSharding(Collection<String> availableTargetNames, RangeShardingValue<LocalDateTime> rangeShardingValue) { // 1. 获取范围 Range<LocalDateTime> range = rangeShardingValue.getValueRange(); LocalDateTime lower = range.hasLowerBound() ? range.lowerEndpoint() : null; LocalDateTime upper = range.hasUpperBound() ? range.upperEndpoint() : null; // 2. 处理无限范围,例如 `WHERE create_time > ‘2024-01-01’` if (lower == null && upper == null) { // 查询所有表?这通常不是好主意,可能返回所有可用表,或根据业务限制 return availableTargetNames; } // 3. 计算起始和结束年月(月份数) int startMonth = lower != null ? totalMonths(lower) : Integer.MIN_VALUE; int endMonth = upper != null ? totalMonths(upper) : Integer.MAX_VALUE; // 4. 遍历所有可用表,筛选出在范围内的表 Set<String> result = new LinkedHashSet<>(); for (String tableName : availableTargetNames) { // 4.1 从表名中解析出年月(需要知道表名格式) int tableMonth = extractMonthFromTableName(tableName); // 4.2 判断是否在范围内 [startMonth, endMonth] if (tableMonth >= startMonth && tableMonth <= endMonth) { result.add(tableName); } } return result; } // 一个工具方法:将LocalDateTime转换为从某个基准点开始的月份总数,方便比较 private int totalMonths(LocalDateTime dateTime) { return dateTime.getYear() * 12 + dateTime.getMonthValue() - 1; // -1 使1月=0 }实操心得:范围分片的
doSharding方法返回的是集合。这意味着一次查询可能会命中多张表,ShardingSphere会发起多表查询并合并结果。因此,务必确保你的extractMonthFromTableName方法高效且准确,避免在表非常多时成为性能瓶颈。另外,对于BETWEEN查询,上下界是包含的,我们的判断逻辑也需要是闭区间。
4. 完整实现与配置实战
4.1 自定义算法类完整代码
下面是一个同时支持精准分片和范围分片的、配置化的按月分表算法完整示例。我们假设分片键是LocalDateTime类型,表后缀格式为_yyyyMM。
package com.example.sharding.algorithm; 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.apache.shardingsphere.sharding.api.sharding.standard.RangeShardingAlgorithm; import org.apache.shardingsphere.sharding.api.sharding.ShardingAutoTableAlgorithm; import java.time.LocalDateTime; import java.time.format.DateTimeFormatter; import java.time.format.DateTimeParseException; import java.util.*; public class CustomMonthShardingAlgorithm implements StandardShardingAlgorithm<LocalDateTime>, RangeShardingAlgorithm<LocalDateTime> { private Properties props; private DateTimeFormatter dateTimeFormatter; private String tableSuffixPattern; // 例如 “_yyyyMM” @Override public String getType() { // 这个类型名称,将在YAML配置中引用 return “CUSTOM_MONTH”; } @Override public void init(Properties properties) { this.props = properties; // 读取日期时间格式,默认ISO格式 String datePattern = properties.getProperty(“datetime-pattern”, “yyyy-MM-dd HH:mm:ss”); this.dateTimeFormatter = DateTimeFormatter.ofPattern(datePattern); // 读取表后缀格式,默认 “_yyyyMM” this.tableSuffixPattern = properties.getProperty(“table-suffix-pattern”, “_yyyyMM”); } @Override public String doSharding(Collection<String> availableTargetNames, PreciseShardingValue<LocalDateTime> preciseShardingValue) { LocalDateTime shardingValue = preciseShardingValue.getValue(); String logicTableName = preciseShardingValue.getLogicTableName(); // 处理分片键为NULL的情况:路由到当前月份表 if (shardingValue == null) { shardingValue = LocalDateTime.now(); // 或者可以抛异常:throw new IllegalArgumentException(“Sharding value cannot be null”); } String targetTableName = logicTableName + generateSuffix(shardingValue); // 精确匹配可用表 for (String tableName : availableTargetNames) { if (tableName.equals(targetTableName)) { return tableName; } } // 未找到对应表,抛出明确异常,提示建表 throw new RuntimeException(String.format(“Target table [%s] does not exist. Please create it first.”, targetTableName)); } @Override public Collection<String> doSharding(Collection<String> availableTargetNames, RangeShardingValue<LocalDateTime> rangeShardingValue) { Range<LocalDateTime> range = rangeShardingValue.getValueRange(); LocalDateTime lower = range.hasLowerBound() ? range.lowerEndpoint() : null; LocalDateTime upper = range.hasUpperBound() ? range.upperEndpoint() : null; // 如果上下界都为空,返回所有表(慎用,可能性能极差) if (lower == null && upper == null) { return availableTargetNames; } // 计算范围的起始和结束月份索引 int startMonthIndex = lower != null ? toMonthIndex(lower) : Integer.MIN_VALUE; int endMonthIndex = upper != null ? toMonthIndex(upper) : Integer.MAX_VALUE; // 遍历所有可用表,筛选出月份索引在范围内的表 Set<String> result = new LinkedHashSet<>(); for (String tableName : availableTargetNames) { try { int tableMonthIndex = extractMonthIndexFromTableName(tableName, rangeShardingValue.getLogicTableName()); if (tableMonthIndex >= startMonthIndex && tableMonthIndex <= endMonthIndex) { result.add(tableName); } } catch (Exception e) { // 忽略无法解析的表名(理论上不应该存在) continue; } } return result; } /** * 生成表后缀,如 “_202401” */ private String generateSuffix(LocalDateTime dateTime) { // 使用配置的格式生成后缀 // 这里简化处理,直接使用年月。更复杂的格式可以用 DateTimeFormatter int year = dateTime.getYear(); int month = dateTime.getMonthValue(); // 确保与 tableSuffixPattern 逻辑一致 return String.format(“_%d%02d”, year, month); } /** * 将 LocalDateTime 转换为月份索引(从公元1年1月开始计数) */ private int toMonthIndex(LocalDateTime dateTime) { return dateTime.getYear() * 12 + dateTime.getMonthValue() - 1; } /** * 从物理表名中解析出月份索引 * @param physicalTableName 物理表名,如 “t_order_202401” * @param logicTableName 逻辑表名,如 “t_order” * @return 月份索引 */ private int extractMonthIndexFromTableName(String physicalTableName, String logicTableName) { // 移除逻辑表名前缀,得到后缀 “_202401” String suffix = physicalTableName.substring(logicTableName.length()); // 假设后缀格式为 “_yyyyMM” String yearMonthStr = suffix.substring(1); // 去掉开头的下划线 int year = Integer.parseInt(yearMonthStr.substring(0, 4)); int month = Integer.parseInt(yearMonthStr.substring(4)); return year * 12 + month - 1; } @Override public Properties getProps() { return this.props; } }4.2 Spring Boot + YAML 配置详解
算法写好了,接下来是如何在项目中集成和配置。我们以Spring Boot项目,使用YAML配置文件为例。
1. 引入依赖 (pom.xml):确保你引入了ShardingSphere-JDBC的Spring Boot Starter。
<dependency> <groupId>org.apache.shardingsphere</groupId> <artifactId>shardingsphere-jdbc-core-spring-boot-starter</artifactId> <version>5.3.2</version> <!-- 请使用最新稳定版 --> </dependency>2. 应用配置文件 (application.yml):这是将自定义算法与具体表绑定起来的关键。
spring: shardingsphere: datasource: names: ds0 # 你的数据源名称 ds0: type: com.zaxxer.hikari.HikariDataSource driver-class-name: com.mysql.cj.jdbc.Driver jdbc-url: jdbc:mysql://localhost:3306/sharding_db?useUnicode=true&characterEncoding=utf8&useSSL=false&serverTimezone=UTC username: root password: root rules: sharding: # 1. 定义分片算法 sharding-algorithms: # 算法名称,可以自定义,在下面分表规则中引用 order-table-month-sharding: type: CLASS_BASED # 使用基于类的自定义算法 props: # 关键配置:指定我们自定义算法的全限定类名 strategy: standard algorithmClassName: com.example.sharding.algorithm.CustomMonthShardingAlgorithm # 传递给算法的自定义属性 datetime-pattern: “yyyy-MM-dd HH:mm:ss” table-suffix-pattern: “_yyyyMM” # 2. 定义分表规则 tables: # 逻辑表名 ‘t_order’ t_order: # 指定真实的数据节点,这里使用了Groovy表达式,表示表名动态生成 # ds0.t_order_202401, ds0.t_order_202402 ... actual-data-nodes: ds0.t_order_$->{202401..202412} # 初始可以先配置一年的表 # 指定分表策略 table-strategy: standard: # 分片列名 sharding-column: create_time # 引用上面定义的分片算法 sharding-algorithm-name: order-table-month-sharding # 开启SQL日志,方便调试 props: sql-show: true配置解析与注意事项:
actual-data-nodes:ds0.t_order_$->{202401..202412}这是一个Groovy表达式,它告诉ShardingSphere,t_order这个逻辑表对应的物理表范围是从t_order_202401到t_order_202412。这只是一个声明,并不会自动创建这些表!这些表需要你提前在数据库中创建好,或者通过其他方式(如Flyway)管理。sharding-algorithm-name: 必须与上面定义的sharding-algorithms下的键名(order-table-month-sharding)一致。algorithmClassName: 必须是自定义算法类的全限定名,并且该类必须有一个无参构造函数。- 动态表管理:上面的配置写死了2024年的12个月份表。对于按月分表,每个月都需要新表。有几种策略:
- 预创建:一次性创建未来几年的表。简单粗暴,但可能浪费空间。
- 动态创建:在算法的
doSharding方法中,如果发现目标表不存在,可以尝试连接数据库执行CREATE TABLE语句。但这需要算法持有DataSource,增加了复杂性,且要考虑并发建表问题。 - 外部调度:更推荐的做法是,使用一个独立的作业(如Quartz、XXL-Job),在每个月的月初,动态地向ShardingSphere的配置中添加下一个月的表节点,并提前创建好物理表。ShardingSphere 5.x支持部分动态配置刷新。
4.3 物理表创建与管理策略
自定义算法解决了路由问题,但物理表的管理同样重要。这里分享一个我常用的基于数据库DDL和版本工具的管理策略。
1. 表结构脚本:为每个月创建的表结构是完全相同的。我们可以准备一个基础的建表脚本模板。
-- t_order_YYYYMM.sql CREATE TABLE `t_order_%s` ( `id` bigint(20) NOT NULL COMMENT ‘订单ID’, `order_no` varchar(32) NOT NULL COMMENT ‘订单号’, `user_id` bigint(20) NOT NULL COMMENT ‘用户ID’, `amount` decimal(10,2) NOT NULL COMMENT ‘订单金额’, `create_time` datetime NOT NULL COMMENT ‘创建时间’, `update_time` datetime DEFAULT NULL COMMENT ‘更新时间’, PRIMARY KEY (`id`), KEY `idx_user_id` (`user_id`), KEY `idx_create_time` (`create_time`) -- 分片键索引至关重要! ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT=‘订单表_%s’;2. 使用Flyway管理表创建:在resources/db/migration目录下,我们可以创建版本化的SQL文件。但Flyway通常用于管理固定的表结构变更,对于每月动态增加的表,不太适用。
3. 推荐方案:程序化表管理服务编写一个简单的Spring Bean,用于检查并创建表。它可以在应用启动时运行,也可以被定时任务调用。
@Service public class TableManagerService { @Autowired private JdbcTemplate jdbcTemplate; /** * 检查并创建指定逻辑表下,对应某个月份的表 * @param logicTable 逻辑表名,如 “t_order” * @param yearMonth 年月,如 “202405” */ public void createTableIfAbsent(String logicTable, String yearMonth) { String physicalTableName = logicTable + “_” + yearMonth; String checkSql = “SELECT COUNT(*) FROM information_schema.TABLES WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = ?”; Integer count = jdbcTemplate.queryForObject(checkSql, Integer.class, physicalTableName); if (count != null && count == 0) { // 表不存在,创建它 String createTableSql = String.format( “CREATE TABLE `%s` LIKE `%s_template`”, // 假设有一个模板表 physicalTableName, logicTable ); // 或者执行完整的CREATE TABLE语句 jdbcTemplate.execute(createTableSql); log.info(“创建分表成功: {}”, physicalTableName); // 关键步骤:需要刷新ShardingSphere的元数据,使其感知新表 // 在ShardingSphere 5.x+,可以通过ContextManager刷新,但这通常需要重启或调用特定API // 更简单的方式是,将 actual-data-nodes 配置为包含动态范围,如 ‘ds0.t_order_$->{2023..2025}0->{1..12}‘,预配置足够多的表。 } } }重要提示:动态创建表后,最大的挑战是让ShardingSphere立即感知到新表的存在。在5.x版本中,如果
actual-data-nodes使用了表达式且新表名在表达式描述的范围内,ShardingSphere在下次SQL解析时可能会自动发现(取决于版本和配置)。最稳妥的方式还是预创建或使用支持动态节点管理的版本(如ShardingSphere-Proxy)。
5. 常见问题与排查技巧实录
在实际开发和上线过程中,自定义分片算法会遇到各种意想不到的问题。下面是我踩过的一些坑和总结的排查技巧。
5.1 数据路由错误或无法插入
现象:程序不报错,但数据没有插入到预期的月份表中,或者直接报错Table ‘xxx’ doesn‘t exist。
排查步骤:
- 开启SQL日志:在配置中设置
sql-show: true。查看ShardingSphere实际解析和重写后的SQL是什么。确认它是否指向了正确的物理表(如t_order_202405)。 - 检查分片键值:在算法的
doSharding方法入口处打日志,打印接收到的preciseShardingValue.getValue()。确认这个值是不是你期望的日期时间,格式是否正确,时区有没有问题。log.debug(“Sharding column: {}, value: {}“, preciseShardingValue.getColumnName(), preciseShardingValue.getValue()); - 检查表后缀生成逻辑:确认
generateSuffix方法生成的字符串(如_202405)是否与数据库中物理表名的后缀完全一致,包括大小写、连接符。一个空格或大小写差异都会导致匹配失败。 - 检查可用表名集合:在
doSharding方法中打印availableTargetNames。确认你期望的目标表是否在这个集合里。如果不在,说明actual-data-nodes配置没有覆盖这个月份。 - 处理NULL值:如果你的
INSERT语句没有指定分片键的值,或者值为NULL,分片算法收到的就是null。必须在算法中处理这种情况,否则会抛出NullPointerException。
5.2 范围查询性能低下或结果不对
现象:BETWEEN查询非常慢,或者查询结果缺少了某些月份的数据。
排查步骤:
- 确认范围分片算法生效:在
RangeShardingAlgorithm的doSharding方法中打日志,打印传入的lowerEndpoint和upperEndpoint,以及最终返回的表名集合。确认算法是否正确识别了查询范围。 - 检查范围边界:
BETWEEN ‘2024-01-01’ AND ‘2024-03-31’,算法应该返回202401,202402,202403对应的三张表。检查你的toMonthIndex和extractMonthIndexFromTableName逻辑对于边界的处理是否正确(是否包含端点)。 - 避免全表扫描:如果范围查询没有下限或上限(如
create_time > ‘2024-01-01’),你的算法可能返回了所有可用表。这会导致性能灾难。必须在业务层面或算法层面避免这种查询,或者强制要求范围查询必须带有合理的边界。 - 索引是否生效:即使路由正确,查询单张表,如果该表上
create_time字段没有索引,查询速度也会很慢。确保每个物理分表上都建立了针对分片键的索引。
5.3 自定义算法不生效
现象:配置了自定义算法,但ShardingSphere好像没调用它,或者抛ClassNotFoundException。
排查步骤:
- 检查依赖和包扫描:确保你的自定义算法类所在的包,在Spring Boot的主应用类扫描路径下,或者已经被显式配置。如果打包成JAR,确保类文件在正确的目录。
- 检查配置语法:YAML缩进非常严格。确保
sharding-algorithms和tables的缩进层级正确。算法类型type: CLASS_BASED和algorithmClassName属性不能拼错。 - 检查类名和路径:
algorithmClassName的值必须是全限定类名(包含包名),并且该类实现了正确的接口。可以尝试在项目启动后,看看这个类是否被Spring容器加载。 - 查看启动日志:ShardingSphere在启动时会加载并初始化配置的算法。查看应用启动日志,是否有关于初始化
CustomMonthShardingAlgorithm的信息,或者相关的错误堆栈。
5.4 分布式序列与分片键的协同
这是一个高级但常见的问题。如果你的主键id使用了ShardingSphere的分布式序列(如雪花算法),而分片键create_time是另一个字段,这通常没问题。但如果你希望根据主键的创建时间(通常嵌入在雪花ID中)来分片,就需要自定义复合分片键或从主键中解析时间。
解决方案:实现ComplexKeysShardingAlgorithm接口,或者在一个分片算法中同时处理id和create_time。更常见的做法是,确保create_time字段在插入时由应用层赋值为当前时间,并以其作为分片键,这样更清晰简单。
最后,自定义分片算法是ShardingSphere提供的高度灵活性所在,但也将复杂性转移给了开发者。在享受其带来的精准路由能力的同时,务必做好详尽的单元测试,覆盖各种边界情况(如闰月、日期格式异常、跨年范围查询、NULL值等),并在预发布环境中进行充分的数据路由验证,才能保证上线后的稳定运行。