ShardingSphere自定义分片算法与Redis动态规则加载实战

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 且无主动更新机制,会出现:

  1. 新增设备编码后路由失败 :新 code 写入 DB 但缓存未刷新 → rulesMap.get(code) 返回 null → NPE
  2. 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

相关推荐
Share_Shun2 小时前
【定位引导】多点对位算法
算法
炽烈小老头2 小时前
【 每天学习一点算法 2026/03/18】全排列
学习·算法
Book思议-2 小时前
【数据结构实战】判断链表是否有环:快慢指针法(Floyd 判圈算法)
c语言·数据结构·算法·链表
liuyao_xianhui2 小时前
优选算法_位运算_只出现一次的数字3_C++
开发语言·数据结构·c++·算法·leetcode·链表·动态规划
Du_chong_huan2 小时前
《网络是怎样连接的》精读版 第五章总述
网络·计算机网络
lihao lihao2 小时前
滑动窗口
数据结构·算法
feng一样的男子2 小时前
Rocky Linux 9 配置 IPv6 完整指南
linux·网络
Jordannnnnnnn2 小时前
复试打卡day30
算法
郝学胜-神的一滴2 小时前
贪心策略实战Leetcode 860题:柠檬水找零问题的优雅解法
数据结构·c++·算法·leetcode·职场和发展