ShardingSphere 自定义分片算法 + Redis 动态规则加载实战
本文结合数据系统的真实场景,完整介绍如何在 Spring Boot 项目中集成 ShardingSphere-JDBC 5.x,
实现基于设备编码(code)的自定义分表路由,并通过 Redis 缓存动态加载分片规则,做到规则热感知、查询零感知。
一、背景与问题
1.1 业务场景
在数据系统中,数据采集层每分钟会向数据库写入大量测点数据(流量、压力、水质等)。
以一个中等规模的水厂为例,管辖 200+ 个监测点,按分钟粒度写入,一年约产生 1 亿+ 行数据。
单表存储会带来以下问题:
| 问题 | 影响 |
|---|---|
| 单表数据量过大 | 索引膨胀,查询变慢 |
| 写入压力集中 | 锁竞争加剧,延迟升高 |
| 维护困难 | 历史数据清理、备份代价高 |
1.2 分片策略选型
常见的分表维度有:按时间(月/天)、按业务主键哈希、按业务分类。
本系统选择按设备编码(code)分表,原因如下:
- SCADA 查询最高频的场景是「指定测点 + 时间范围」,code 命中后只需在单张物理表内查询
- 设备编码是业务侧稳定的标识符,不随时间变化
- 同一设备的历史数据聚集在同一张物理表,避免跨表扫描
分表数量:scada_minute_data_00 ~ scada_minute_data_64,共 65 张物理表。
二、整体架构
┌─────────────────────────────────────────────────────────────┐
│ Spring Boot 应用 │
│ │
│ ┌──────────────┐ @DS("sharding-data-source") │
│ │ 业务 Service │──────────────────────────────────────┐ │
│ └──────────────┘ │ │
│ ▼ │
│ ┌─────────────────────────────────────────────────────────┐│
│ │ ShardingSphere-JDBC(逻辑层) ││
│ │ ││
│ │ 逻辑表: scada_minute_data ││
│ │ 分片键: code ││
│ │ 分片算法: CodeTimeStandardSharding(CLASS_BASED) ││
│ └───────────────────────────┬─────────────────────────────┘│
│ │ 路由 │
│ ┌───────────────┼───────────────┐ │
│ ▼ ▼ ▼ │
│ scada_minute_data_00 ... scada_minute_data_64 │
└─────────────────────────────────────────────────────────────┘
分片规则动态来源:
DB(collection_rules.sharding_num) → Redis → CodeTimeStandardSharding
三、核心组件拆解
3.1 采集规则表(collection_rules)
分片规则存储在业务表 collection_rules 中,关键字段如下:
sql
CREATE TABLE `collection_rules` (
`id` BIGINT NOT NULL COMMENT '主键',
`code` VARCHAR(64) NOT NULL COMMENT '采集编码(分片键)',
`desc` VARCHAR(255) COMMENT '指标说明',
`dim_time` VARCHAR(128) COMMENT '时间维度(JSON格式)',
`source` CHAR(1) COMMENT '数据来源 0生产 1集抄',
`table_type` CHAR(1) COMMENT '表类型 0业务指标 1监测点',
`sharding_num` INT COMMENT '分片编号(路由核心字段)',
-- ...
PRIMARY KEY (`id`)
);
sharding_num 是路由核心:管理员在配置测点时就预先指定每个设备编码应落到哪个分片序号,
运行期不再重新计算,路由结果完全可预期。
对应的 Java 实体:
java
@Data
public class CollectionRules extends BaseEntity {
/** 采集编码 ------ 分片键 */
private String code;
/** 分片编号 ------ 决定路由到哪张物理表 */
private Integer shardingNum;
// 其他业务字段...
}
3.2 启动时加载规则到 Redis(ShardingTablesLoadRunner)
java
@Component
@Order(1) // 最先执行,确保分片算法初始化前规则已就绪
@Slf4j
public class ShardingTablesLoadRunner implements CommandLineRunner {
@Resource
private CollectionRulesMapper rulesMapper;
@Resource
private StringRedisTemplate stringRedisTemplate;
/** Redis Key */
public static final String CODE_NUM_MAP_KEY = "ioc_system:sharding:code:num:map";
@Override
public void run(String... args) {
// 1. 将自身和 Redis 客户端注入到分片算法(静态引用桥接)
CodeTimeStandardSharding.setLoadRunner(this);
CodeTimeStandardSharding.setStringRedisTemplate(stringRedisTemplate);
// 2. 启动时预热 Redis
loadValueToRedis();
}
/** 从 DB 读取所有 code→shardingNum 映射,序列化后写入 Redis */
public synchronized String loadValueToRedis() {
List<CollectionRules> rulesList = rulesMapper.selectAllCode();
String value = JSON.toJSONString(rulesList);
stringRedisTemplate.opsForValue().set(CODE_NUM_MAP_KEY, value);
return value;
}
}
要点说明:
@Order(1):CommandLineRunner按 Order 排序执行,确保在业务请求到达之前规则已加载完毕synchronized:防止多线程(如规则刷新接口)并发写 Redis 导致数据不一致- SQL 仅查询
id, code, sharding_num三个字段,最小化数据传输
对应 Mapper SQL:
xml
<select id="selectAllCode" resultType="com.ruoyi.project.scada.domain.CollectionRules">
select id, code, sharding_num
from collection_rules
order by sharding_num
</select>
3.3 自定义分片算法(CodeTimeStandardSharding)
java
@Slf4j
public class CodeTimeStandardSharding implements StandardShardingAlgorithm<String> {
// 静态引用,由 ShardingTablesLoadRunner 启动时注入
private static StringRedisTemplate stringRedisTemplate;
private static ShardingTablesLoadRunner loadRunner;
public static void setStringRedisTemplate(StringRedisTemplate t) {
CodeTimeStandardSharding.stringRedisTemplate = t;
}
public static void setLoadRunner(ShardingTablesLoadRunner runner) {
CodeTimeStandardSharding.loadRunner = runner;
}
/** 获取规则:优先 Redis,未命中则回源 DB 并重建缓存 */
public static String getRuleList() {
String value = stringRedisTemplate.opsForValue().get(
ShardingTablesLoadRunner.CODE_NUM_MAP_KEY);
if (StrUtil.isEmpty(value)) {
// Redis 缓存失效时,触发 DB 兜底 + 重写缓存
value = loadRunner.loadValueToRedis();
}
return value;
}
/**
* 精确分片(处理 = 和 IN 查询)
*
* @param availableTargetNames 当前逻辑表对应的所有物理表名集合
* @param preciseShardingValue 分片键值(即 code 字段的实际值)
*/
@Override
public String doSharding(Collection<String> availableTargetNames,
PreciseShardingValue<String> preciseShardingValue) {
// 1. 从 Redis 拉取规则列表
String value = getRuleList();
List<CollectionRules> rulesList = BeanUtil.copyToList(
JSON.parseObject(value, List.class), CollectionRules.class);
// 2. 构建 code → CollectionRules 的快速查找 Map
Map<String, CollectionRules> rulesMap = rulesList.stream()
.collect(Collectors.toMap(CollectionRules::getCode, r -> r));
// 3. 校验分片键非空
if (StrUtil.isEmpty(preciseShardingValue.getValue())) {
throw new RuntimeException("缺少分片键值!");
}
// 4. 拿到该 code 对应的 shardingNum,取模得到物理表编号
Integer num = rulesMap.get(preciseShardingValue.getValue()).getShardingNum();
String actualTableName = String.format(
preciseShardingValue.getLogicTableName() + "_%02d",
num % availableTargetNames.size() // 取模保证不越界
);
// 5. 验证目标表确实存在
if (availableTargetNames.contains(actualTableName)) {
return actualTableName;
}
throw new RuntimeException("逻辑表名不存在:" + actualTableName);
}
/** 范围分片(暂不支持,SCADA 场景均为精确 code 查询)*/
@Override
public Collection<String> doSharding(Collection<String> collection,
RangeShardingValue<String> rangeShardingValue) {
return Collections.emptyList();
}
@Override
public String getType() { return "CLASS_BASED"; }
@Override
public void init() { }
}
路由公式:
actualTable = logicTableName + "_" + String.format("%02d", shardingNum % tableCount)
例:shardingNum = 7,物理表共 65 张 → 7 % 65 = 7 → 路由到 scada_minute_data_07
3.4 多数据源整合(DataSourceConfiguration)
ShardingSphere 自身是一个 DataSource 实现,需要将它注册进 Dynamic Datasource 的路由体系中,
使业务代码能通过 @DS("sharding-data-source") 注解透明切换:
java
@Configuration
@AutoConfigureBefore({DynamicDataSourceAutoConfiguration.class,
ShardingSphereAutoConfiguration.class})
public class DataSourceConfiguration {
@Autowired
private DynamicDataSourceProperties properties;
@Lazy
@Resource
private DataSource shardingDataSource; // ShardingSphere 创建的数据源
@Bean
public DynamicDataSourceProvider dynamicDataSourceProvider() {
Map<String, DataSourceProperty> datasourceMap = properties.getDatasource();
return new AbstractDataSourceProvider() {
@Override
public Map<String, DataSource> loadDataSources() {
Map<String, DataSource> map = createDataSourceMap(datasourceMap);
// 将 ShardingSphere 数据源注入到 Dynamic Datasource 的路由表
map.put("sharding-data-source", shardingDataSource);
return map;
}
};
}
/** 设置为 @Primary,使 Spring 默认注入动态数据源而非 ShardingSphere 原始数据源 */
@Primary
@Bean
public DataSource dataSource() {
DynamicRoutingDataSource ds = new DynamicRoutingDataSource();
ds.setPrimary(properties.getPrimary());
// ... 其他属性设置
return ds;
}
}
Mapper 层通过注解声明使用哪个数据源:
java
@Mapper
@DS("sharding-data-source") // 走 ShardingSphere 分片数据源
public interface ShardingScadaDataMapper {
List<ScadaDataTable> getMinuteDataCurve(@Param("codes") List<String> codes,
@Param("start") String start,
@Param("end") String end);
// ...
}
@Mapper
@DS("data") // 走普通业务数据源
public interface CollectionRulesMapper {
List<CollectionRules> selectAllCode();
// ...
}
四、YAML 配置详解
yaml
spring:
shardingsphere:
mode:
type: memory # 内存模式,无需额外注册中心
datasource:
names: sharding
sharding:
type: com.alibaba.druid.pool.DruidDataSource
url: jdbc:mysql://host:3306/nsdd_data?useUnicode=true&characterEncoding=UTF-8
username: root
password: xxxxxxxx
driver-class-name: com.mysql.cj.jdbc.Driver
rules:
sharding:
tables:
scada_minute_data: # 逻辑表名
# 65 张物理表,用 Groovy 表达式声明
actual-data-nodes: >
sharding.scada_minute_data_0$->{0..9},
sharding.scada_minute_data_$->{10..64}
table-strategy:
standard:
sharding-column: code # 分片键
sharding-algorithm-name: alg_standard
key-generate-strategy:
column: id
key-generator-name: snowflake # 雪花 ID 生成
key-generators:
snowflake:
type: SNOWFLAKE
sharding-algorithms:
alg_standard:
type: CLASS_BASED # 使用自定义算法
props:
strategy: STANDARD
algorithmClassName: com.ruoyi.framework.sharding.CodeTimeStandardSharding
几个容易踩坑的配置点:
| 配置项 | 说明 |
|---|---|
mode.type: memory |
5.x 必须显式配置,否则默认尝试连接注册中心 |
CLASS_BASED |
使用类路径自定义算法的关键标识 |
strategy: STANDARD |
对应 StandardShardingAlgorithm 接口 |
| Groovy 范围表达式 | $->{0..9} 生成 _0 ~ _9,注意两位补零需在算法内 String.format("%02d", ...) 处理 |
五、数据流全链路
HTTP 请求(带 code 参数)
│
▼
ShardingScadaDataMapper.getMinuteDataCurve(codes, start, end)
│
▼ @DS("sharding-data-source") 切换至 ShardingSphere
ShardingSphere 拦截 SQL
│
▼ 解析 WHERE code = 'XXX_CODE'
CodeTimeStandardSharding.doSharding() 被调用
│
├── 1. stringRedisTemplate.get("ioc_system:sharding:code:num:map")
│ │
│ ├── 命中 → 直接使用缓存规则
│ └── 未命中 → loadValueToRedis() 回源 DB,写回 Redis
│
├── 2. 查 Map:code "XXX_CODE" → shardingNum = 23
│
└── 3. 计算物理表:23 % 65 = 23 → scada_minute_data_23
│
▼
执行真实 SQL:SELECT * FROM scada_minute_data_23 WHERE ...
六、注意事项与优化建议
6.1 ShardingSphere 与 Dynamic Datasource 整合顺序问题
问题根源:
ShardingSphere 的 ShardingSphereAutoConfiguration 和 Dynamic Datasource 的 DynamicDataSourceAutoConfiguration
都会向 Spring 容器注册 DataSource Bean。两者同时存在时,若没有明确的初始化顺序和主次关系,
会出现以下典型异常:
BeanCurrentlyInCreationException:循环依赖,ShardingSphere DataSource 依赖的 Bean 还未初始化DataSource注入歧义:Spring 不知道该注入哪个 DataSource,导致 MyBatis 使用了错误的数据源- Druid 监控页面报错:
DruidDataSourceAutoConfigure尝试二次创建 DataSource 并失败
三步解决方案:
① 用 @AutoConfigureBefore 控制初始化优先级
java
@Configuration
@AutoConfigureBefore({
DynamicDataSourceAutoConfiguration.class,
ShardingSphereAutoConfiguration.class
})
public class DataSourceConfiguration {
// 本类最先初始化,再由它决定 ShardingSphere/Dynamic Datasource 的组合关系
}
② 用 @Lazy 打破 ShardingSphere DataSource 的初始化死锁
java
// 不加 @Lazy 时:DataSourceConfiguration 初始化 → 需要 shardingDataSource
// → 触发 ShardingSphereAutoConfiguration → 需要 DataSourceConfiguration → 死锁
@Lazy
@Resource
private DataSource shardingDataSource;
③ 排除 Druid 的自动配置类,避免三方数据源重复注册
yaml
spring:
autoconfigure:
exclude: com.alibaba.druid.spring.boot.autoconfigure.DruidDataSourceAutoConfigure
注意 :此排除项仅适用于
druid-spring-boot-starter依赖类型。若使用的是原生druid依赖(非 starter),则不需要此配置,否则会报 ClassNotFoundException。
6.2 静态字段注入的权衡
为什么不能直接 @Autowired?
ShardingSphere 的 CLASS_BASED 算法是通过 YAML 中的 algorithmClassName 配置,
由 ShardingSphere 在内部用 Class.forName(...).newInstance() 反射创建实例的。
这个实例不在 Spring 容器中 ,自然无法使用 @Autowired / @Resource 注入任何 Bean。
当前方案:静态字段桥接
Spring 容器启动
│
▼
ShardingTablesLoadRunner.run() ← CommandLineRunner,由 Spring 管理
│
├── CodeTimeStandardSharding.setStringRedisTemplate(redisTemplate)
└── CodeTimeStandardSharding.setLoadRunner(this)
│
▼
静态字段持有引用,算法实例(非 Spring Bean)通过静态字段访问 Spring 资源
静态字段的线程安全性分析:
| 操作 | 线程安全? | 原因 |
|---|---|---|
启动时 setXxx() 写入 |
安全 | @Order(1) 保证单线程顺序执行,写入完成后才接受请求 |
运行期 doSharding() 读取静态字段 |
安全 | 引用只在启动时写入一次,之后只读 |
loadValueToRedis() 并发刷新缓存 |
安全 | 方法已加 synchronized 关键字 |
ApplicationContextHolder 方案(已采用,优于静态 setter):
通过 ApplicationContextAware 让算法类按需从 Spring 容器取 Bean,无需在启动时手动注入静态字段,
解耦更彻底,且对多实例、热重载场景更友好:
java
// SpringContextHolder.java ------ 已在项目中注册为 Spring Bean
@Component
public class SpringContextHolder implements ApplicationContextAware {
private static ApplicationContext context;
@Override
public void setApplicationContext(ApplicationContext ctx) {
SpringContextHolder.context = ctx;
}
public static <T> T getBean(Class<T> clazz) {
return context.getBean(clazz);
}
}
java
// CodeTimeStandardSharding 内部按需获取,不再依赖静态 setter
private static StringRedisTemplate getRedisTemplate() {
return SpringContextHolder.getBean(StringRedisTemplate.class);
}
private static ShardingTablesLoadRunner getLoadRunner() {
return SpringContextHolder.getBean(ShardingTablesLoadRunner.class);
}
6.3 Redis 缓存的失效与一致性问题
问题背景:
若 Redis Key ioc_system:sharding:code:num:map 不设置 TTL 且无主动更新机制,会出现:
- 新增设备编码后路由失败 :新 code 写入 DB 但缓存未刷新 →
rulesMap.get(code)返回null→ NPE - Redis 重启后无兜底:缓存丢失时若 DB 也在恢复中,所有分片查询抛异常
已实现的三层保障方案:
① 业务写入时主动刷新(第一道防线)
在 CollectionRulesService 的新增/修改方法末尾,立即触发缓存重建,确保 DB 与缓存实时一致:
java
@Service
public class CollectionRulesServiceImpl {
@Resource
private ShardingTablesLoadRunner loadRunner;
public void add(CollectionRules rules) {
rulesMapper.add(rules);
// 新增设备编码后立即重建分片规则缓存,保证下一次路由即可命中
loadRunner.loadValueToRedis();
}
public void update(CollectionRules rules) {
rulesMapper.update(rules);
loadRunner.loadValueToRedis();
}
}
② TTL + 定时任务兜底(第二道防线)
loadValueToRedis() 写入 Redis 时设置 2 小时 TTL,并配合定时任务每小时续期,
防止缓存因 Redis 重启或异常丢失后长时间无数据:
java
@Component
@Order(1)
@Slf4j
public class ShardingTablesLoadRunner implements CommandLineRunner {
@Resource private CollectionRulesMapper rulesMapper;
@Resource private StringRedisTemplate stringRedisTemplate;
public static final String CODE_NUM_MAP_KEY = "ioc_system:sharding:code:num:map";
@Override
public void run(String... args) {
CodeTimeStandardSharding.setLoadRunner(this);
CodeTimeStandardSharding.setStringRedisTemplate(stringRedisTemplate);
loadValueToRedis();
}
public synchronized String loadValueToRedis() {
List<CollectionRules> rulesList = rulesMapper.selectAllCode();
String value = JSON.toJSONString(rulesList);
// 设置 2 小时 TTL,防止缓存永久驻留
stringRedisTemplate.opsForValue().set(CODE_NUM_MAP_KEY, value, 2, TimeUnit.HOURS);
log.info("分片规则已加载到 Redis,共 {} 条", rulesList.size());
return value;
}
/** 每小时定时刷新,保证 TTL 到期前缓存始终有效 */
@Scheduled(fixedRate = 3600_000)
public void scheduleRefresh() {
log.info("定时刷新分片规则缓存...");
loadValueToRedis();
}
}
③ 运维刷新 HTTP 接口(第三道防线)
暴露管理端接口,支持运维人员在规则变更后手动触发缓存重建,无需重启应用:
java
@RestController
@RequestMapping("/admin/sharding")
public class ShardingAdminController {
@Resource
private ShardingTablesLoadRunner loadRunner;
@PostMapping("/refresh")
public AjaxResult refreshRules() {
loadRunner.loadValueToRedis();
return AjaxResult.success("分片规则缓存已刷新");
}
}
6.4 每次分片都重新解析 JSON 的性能优化
问题分析:
原始 doSharding() 每次被调用都会执行 Redis 网络请求 → JSON 反序列化 → Bean 拷贝 → Stream 构建 Map,
在 SCADA 高频写入场景下(每分钟批量写入 200+ 测点)造成显著的 CPU 和 GC 压力。
已实现的优化:本地内存热缓存 + 双重检查锁
在算法类内引入 volatile 修饰的本地 Map 缓存,5 分钟内命中本地缓存直接返回(O(1)),
超期后加锁从 Redis 重新加载,兼顾性能与实时性:
java
@Slf4j
public class CodeTimeStandardSharding implements StandardShardingAlgorithm<String> {
private static StringRedisTemplate stringRedisTemplate;
private static ShardingTablesLoadRunner loadRunner;
// 本地内存热缓存:code → shardingNum,避免每次路由都走 Redis + JSON 反序列化
private static volatile Map<String, Integer> localRulesCache = null;
private static volatile long lastLoadTime = 0L;
private static final long LOCAL_CACHE_TTL_MS = 5 * 60 * 1000L; // 本地缓存 5 分钟有效
public static void setStringRedisTemplate(StringRedisTemplate t) {
CodeTimeStandardSharding.stringRedisTemplate = t;
}
public static void setLoadRunner(ShardingTablesLoadRunner runner) {
CodeTimeStandardSharding.loadRunner = runner;
}
/** 获取 Redis 原始规则字符串;缓存未命中时回源 DB */
private static String getRuleList() {
String value = stringRedisTemplate.opsForValue()
.get(ShardingTablesLoadRunner.CODE_NUM_MAP_KEY);
if (StrUtil.isEmpty(value)) {
value = loadRunner.loadValueToRedis();
}
return value;
}
/**
* 获取本地内存规则 Map(双重检查锁)
* 5 分钟内直接返回内存 Map(O(1) 无网络开销)
* 超期后加锁从 Redis 重新加载并重建 Map
*/
private static Map<String, Integer> getRulesMap() {
long now = System.currentTimeMillis();
if (localRulesCache != null && (now - lastLoadTime) < LOCAL_CACHE_TTL_MS) {
return localRulesCache;
}
synchronized (CodeTimeStandardSharding.class) {
if (localRulesCache != null && (now - lastLoadTime) < LOCAL_CACHE_TTL_MS) {
return localRulesCache; // double-check
}
String value = getRuleList();
List<CollectionRules> list = BeanUtil.copyToList(
JSON.parseObject(value, List.class), CollectionRules.class);
localRulesCache = list.stream()
.collect(Collectors.toMap(CollectionRules::getCode,
CollectionRules::getShardingNum));
lastLoadTime = System.currentTimeMillis();
log.debug("本地分片规则缓存已刷新,共 {} 条", localRulesCache.size());
}
return localRulesCache;
}
@Override
public String doSharding(Collection<String> availableTargetNames,
PreciseShardingValue<String> preciseShardingValue) {
if (StrUtil.isEmpty(preciseShardingValue.getValue())) {
throw new RuntimeException("缺少分片键值!");
}
// 直接命中本地内存 Map,零网络开销
Map<String, Integer> rulesMap = getRulesMap();
Integer num = rulesMap.get(preciseShardingValue.getValue());
if (num == null) {
throw new RuntimeException("未找到编码对应的分片规则: " + preciseShardingValue.getValue());
}
String actualTableName = String.format(
preciseShardingValue.getLogicTableName() + "_%02d",
num % availableTargetNames.size());
if (availableTargetNames.contains(actualTableName)) {
return actualTableName;
}
throw new RuntimeException("物理表不存在:" + actualTableName);
}
@Override
public Collection<String> doSharding(Collection<String> collection,
RangeShardingValue<String> rangeShardingValue) {
return Collections.emptyList();
}
@Override public String getType() { return "CLASS_BASED"; }
@Override public void init() { }
}
调用链路对比:
- 优化前:每次路由 → Redis 网络请求 → JSON 反序列化 → Bean 拷贝 → Stream 构建 Map
- 优化后:每次路由 → 本地
HashMap.get(code)(O(1),纯内存,无 GC 压力)
6.5 范围查询的处理与 IN 查询优化
范围查询(RangeShardingValue):
当前 doSharding(RangeShardingValue) 返回空集合,意味着不支持 code 字段的范围查询 。
SCADA 场景的查询都是精确指定 code(= 或 IN),时间范围通过物理表内的 time 字段过滤,
这与业务访问模式完全吻合。
若将来有按 code 范围查询的需求(如 code LIKE 'Factory%'),需要额外实现范围分片,
或改为全表广播查询后在应用层聚合。
IN 查询的路由行为:
ShardingSphere 在遇到 WHERE code IN ('A', 'B', 'C') 时,会对每个值分别调用一次 doSharding(PreciseShardingValue),
然后将结果去重合并为最终的物理表集合。因此:
IN查询可能路由到多张物理表并并行执行doSharding()的调用次数 =IN列表的长度,高频大列表查询时注意本地缓存的重要性(见 6.4)
6.6 物理表命名规范与 Groovy 表达式的对齐
命名规范的必要性:
ShardingSphere 的 actual-data-nodes 使用 Groovy 表达式生成物理表名列表,算法返回的表名
必须与之完全一致 (包括前导零),否则 availableTargetNames.contains(actualTableName) 为 false,
路由失败抛出异常。
易出错场景对照:
| Groovy 表达式 | 生成的表名示例 | 算法应返回 | 错误写法 |
|---|---|---|---|
data_0$->{0..9} |
data_00~data_09 |
data_07 |
data_7(缺少前导零) |
data_$->{10..64} |
data_10~data_64 |
data_23 |
data_023(多余零) |
java
// 正确:%02d 保证 0~9 补零为 00~09,10+ 正常显示
String actualTableName = String.format(
preciseShardingValue.getLogicTableName() + "_%02d",
num % availableTargetNames.size()
);
本项目的 actual-data-nodes 拆分原因:
yaml
# 不能写成 scada_minute_data_$->{0..64},因为这会生成 _0、_1...而非 _00、_01...
# 必须分段:0~9 段加前导 0 处理
actual-data-nodes: sharding.scada_minute_data_0$->{0..9},sharding.scada_minute_data_$->{10..64}
6.7 扩容与数据迁移
当前 65 张物理表若未来不够用,扩容步骤如下:
① 评估影响范围
取模数为 availableTargetNames.size()(即物理表数量),扩容后取模基数变化,
已有数据的路由结果会改变,必须进行数据迁移。
② 扩容步骤
1. 预先创建新物理表(如 scada_minute_data_65 ~ scada_minute_data_99)
2. 停写(或切换到临时方案),进行全量数据迁移
- 对每条历史数据重新计算新 shardingNum % newTableCount,写入目标表
3. 更新 YAML 中的 actual-data-nodes 表达式
4. 更新 collection_rules 中各 code 的 sharding_num 分配(若需重新均衡)
5. 重启应用 / 刷新 Redis 缓存
③ 零停机扩容的替代思路
- 保持旧表数量不变,新增 code 直接分配到新表(新旧 shardingNum 各自路由)
- YAML 中补充新表的
actual-data-nodes,算法自动适配 - 这种方式无需迁移旧数据,但物理表分布可能不均匀
小结:注意事项一览
| # | 问题 | 影响等级 | 已采用方案 |
|---|---|---|---|
| 6.1 | ShardingSphere 与 Dynamic Datasource 初始化冲突 | 🔴 高 | @AutoConfigureBefore + @Lazy + 排除 Druid 自动配置 |
| 6.2 | 算法类无法注入 Spring Bean | 🟡 中 | SpringContextHolder 按需取 Bean,解耦彻底 |
| 6.3 | Redis 缓存无 TTL + 无主动刷新 | 🔴 高 | 业务写入主动刷新 + 2h TTL + 定时任务 + 运维接口(三层保障) |
| 6.4 | 每次分片重复反序列化 JSON | 🟡 中 | 本地内存热缓存(5min)+ 双重检查锁,路由零网络开销 |
| 6.5 | IN 查询多表路由 + 范围查询不支持 | 🟢 低 | 符合业务场景,了解路由行为即可 |
| 6.6 | 物理表名格式与 Groovy 表达式不对齐 | 🔴 高 | %02d 格式化 + 分段 Groovy 表达式,已对齐 |
| 6.7 | 扩容时取模基数变化导致路由错位 | 🟡 中 | 扩容前评估影响,制定数据迁移或追加方案 |
七、完整代码汇总
CollectionRules.java(领域对象)
java
@Data
public class CollectionRules extends BaseEntity {
private Long id;
/** 采集编码 ------ 分片键 */
private String code;
private String desc;
private String dimTime;
private String source;
private String tableType;
private String parent;
private String unit;
/** 分片编号 ------ 路由核心 */
private Integer shardingNum;
private String remark;
}
ShardingTablesLoadRunner.java(启动加载器,含 TTL + 定时刷新)
java
@Component
@Order(1)
@Slf4j
public class ShardingTablesLoadRunner implements CommandLineRunner {
@Resource private CollectionRulesMapper rulesMapper;
@Resource private StringRedisTemplate stringRedisTemplate;
public static final String CODE_NUM_MAP_KEY = "ioc_system:sharding:code:num:map";
@Override
public void run(String... args) {
CodeTimeStandardSharding.setLoadRunner(this);
CodeTimeStandardSharding.setStringRedisTemplate(stringRedisTemplate);
loadValueToRedis();
}
/** 从 DB 加载规则,写入 Redis(2h TTL),并使本地热缓存立即失效 */
public synchronized String loadValueToRedis() {
List<CollectionRules> rulesList = rulesMapper.selectAllCode();
String value = JSON.toJSONString(rulesList);
stringRedisTemplate.opsForValue().set(CODE_NUM_MAP_KEY, value, 2, TimeUnit.HOURS);
CodeTimeStandardSharding.invalidateLocalCache(); // 主动失效本地热缓存
log.info("分片规则已加载到 Redis,共 {} 条", rulesList.size());
return value;
}
/** 定时任务:每小时刷新一次,保证 TTL 到期前缓存始终有效 */
@Scheduled(fixedRate = 3600_000)
public void scheduleRefresh() {
log.info("定时刷新分片规则缓存...");
loadValueToRedis();
}
}
CollectionRulesServiceImpl.java(业务写入时主动刷新)
java
@Service
public class CollectionRulesServiceImpl {
@Resource private CollectionRulesMapper rulesMapper;
@Resource private ShardingTablesLoadRunner loadRunner;
public void add(CollectionRules rules) {
rulesMapper.add(rules);
loadRunner.loadValueToRedis(); // 新增编码后立即刷新路由规则
}
public void update(CollectionRules rules) {
rulesMapper.update(rules);
loadRunner.loadValueToRedis();
}
}
ShardingAdminController.java(运维刷新接口)
java
@RestController
@RequestMapping("/admin/sharding")
public class ShardingAdminController {
@Resource private ShardingTablesLoadRunner loadRunner;
@PostMapping("/refresh")
public AjaxResult refreshRules() {
loadRunner.loadValueToRedis();
return AjaxResult.success("分片规则缓存已刷新");
}
}
CodeTimeStandardSharding.java(自定义分片算法,含本地热缓存)
java
@Slf4j
public class CodeTimeStandardSharding implements StandardShardingAlgorithm<String> {
private static StringRedisTemplate stringRedisTemplate;
private static ShardingTablesLoadRunner loadRunner;
// 本地内存热缓存:code → shardingNum,5 分钟内无需走 Redis
private static volatile Map<String, Integer> localRulesCache = null;
private static volatile long lastLoadTime = 0L;
private static final long LOCAL_CACHE_TTL_MS = 5 * 60 * 1000L;
public static void setStringRedisTemplate(StringRedisTemplate t) {
CodeTimeStandardSharding.stringRedisTemplate = t;
}
public static void setLoadRunner(ShardingTablesLoadRunner runner) {
CodeTimeStandardSharding.loadRunner = runner;
}
/** 供 ShardingTablesLoadRunner 调用,主动失效本地缓存 */
public static void invalidateLocalCache() {
lastLoadTime = 0L;
log.debug("本地分片规则缓存已主动失效");
}
private static String getRuleList() {
String value = stringRedisTemplate.opsForValue()
.get(ShardingTablesLoadRunner.CODE_NUM_MAP_KEY);
if (StrUtil.isEmpty(value)) {
value = loadRunner.loadValueToRedis();
}
return value;
}
/** 双重检查锁获取本地规则 Map */
private static Map<String, Integer> getRulesMap() {
long now = System.currentTimeMillis();
if (localRulesCache != null && (now - lastLoadTime) < LOCAL_CACHE_TTL_MS) {
return localRulesCache;
}
synchronized (CodeTimeStandardSharding.class) {
if (localRulesCache != null && (now - lastLoadTime) < LOCAL_CACHE_TTL_MS) {
return localRulesCache;
}
String value = getRuleList();
List<CollectionRules> list = BeanUtil.copyToList(
JSON.parseObject(value, List.class), CollectionRules.class);
localRulesCache = list.stream()
.collect(Collectors.toMap(CollectionRules::getCode,
CollectionRules::getShardingNum));
lastLoadTime = System.currentTimeMillis();
log.debug("本地分片规则缓存已刷新,共 {} 条", localRulesCache.size());
}
return localRulesCache;
}
@Override
public String doSharding(Collection<String> availableTargetNames,
PreciseShardingValue<String> preciseShardingValue) {
if (StrUtil.isEmpty(preciseShardingValue.getValue())) {
throw new RuntimeException("缺少分片键值!");
}
Map<String, Integer> rulesMap = getRulesMap();
Integer num = rulesMap.get(preciseShardingValue.getValue());
if (num == null) {
throw new RuntimeException("未找到编码对应的分片规则: " + preciseShardingValue.getValue());
}
String actualTableName = String.format(
preciseShardingValue.getLogicTableName() + "_%02d",
num % availableTargetNames.size());
if (availableTargetNames.contains(actualTableName)) {
return actualTableName;
}
throw new RuntimeException("物理表不存在:" + actualTableName);
}
@Override
public Collection<String> doSharding(Collection<String> collection,
RangeShardingValue<String> rangeShardingValue) {
return Collections.emptyList();
}
@Override public String getType() { return "CLASS_BASED"; }
@Override public void init() { }
}
八、总结
| 设计要点 | 采用方案 | 解决的问题 |
|---|---|---|
| 分片键 | 设备编码(code) | 与查询模式完全对齐,无跨表扫描 |
| 物理表数量 | 65 张 | 满足亿级数据规模,分布均匀 |
| 规则存储 | DB 持久化 + Redis 缓存(2h TTL) | 可动态配置,读取高性能,防缓存永久驻留 |
| 规则加载 | 启动预热 + 业务写入主动刷新 + 定时任务 + 运维接口 | 四层保障,新增设备编码实时生效 |
| 路由性能 | 本地内存热缓存(5min)+ 双重检查锁 | 路由零网络开销,O(1) HashMap 查找 |
| Spring 集成 | SpringContextHolder 按需取 Bean + @Lazy 延迟注入 |
彻底解耦,避免循环依赖 |
| 主键生成 | 雪花算法(SNOWFLAKE) | 分布式唯一 ID,无序列争抢 |
| 表命名 | %02d 格式化 + 分段 Groovy 表达式 |
表名与配置完全对齐,路由零歧义 |
这套方案在 数据高频写入 + 按测点精确查询的场景下运行稳定,规则变更无需重启应用 ,
本地热缓存将路由耗时从「Redis 往返 + 反序列化」降至「内存 HashMap 直查」,
在数据系统中有效支撑了亿级时序数据的高效存取。
技术栈:Spring Boot 2.5 · ShardingSphere-JDBC 5.1.1 · Dynamic Datasource · Redis · MyBatis-Plus