Spring Boot整合Sharding-JDBC实现日志表按月按周分表实战

Spring Boot整合Sharding-JDBC实现日志表按月按周分表实战

本文基于实际项目经验,详细介绍如何在Spring Boot项目中整合ShardingSphere-JDBC,实现日志表的按月、按周分表功能。包含完整的配置、代码实现和最佳实践。

📋 目录

  1. 项目背景
  2. 技术选型
  3. 环境准备
  4. 按月分表实现
  5. 按周分表实现
  6. 分表名缓存机制
  7. 完整配置示例
  8. 常见问题与解决方案
  9. 总结

项目背景

在日志服务中,随着业务量的增长,单表数据量急剧增加,导致查询性能下降。为了解决这个问题,我们采用了分表策略

  • 任务日志表(tm_task_log) :按月分表,格式为 tm_task_log_yyyyMM
  • 注册记录表(tm_vc_cam_register_record) :按周分表,格式为 tm_vc_cam_register_record_yyyy_wNN

通过ShardingSphere-JDBC实现透明的分表路由,业务代码无需关心具体的分表逻辑。


技术选型

  • Spring Boot 2.x
  • ShardingSphere-JDBC 4.x:轻量级Java框架,提供分库分表能力
  • MyBatis-Plus:持久层框架
  • Redis:缓存分表名列表
  • Hutool:Java工具类库

环境准备

1. Maven依赖

pom.xml 中添加ShardingSphere-JDBC依赖:

xml 复制代码
<dependency>
    <groupId>org.apache.shardingsphere</groupId>
    <artifactId>sharding-jdbc-spring-boot-starter</artifactId>
    <version>4.1.1</version>
</dependency>

2. 数据库准备

确保MySQL数据库已创建,后续会自动创建分表。


按月分表实现

1. 精确分片算法(PreciseShardingAlgorithm)

用于处理 =IN 查询:

java 复制代码
package cn.xxx.log.sharding;

import cn.hutool.core.date.DateUtil;
import lombok.extern.slf4j.Slf4j;
import org.apache.shardingsphere.api.sharding.standard.PreciseShardingAlgorithm;
import org.apache.shardingsphere.api.sharding.standard.PreciseShardingValue;

import java.util.Collection;
import java.util.Date;

/**
 * tm_task_log 表按月分片算法 - 精确分片
 * 根据 create_time 字段按月(yyyyMM格式)进行分表
 * 用于 = 和 IN 查询
 */
@Slf4j
public class PreciseTaskLogSharding implements PreciseShardingAlgorithm<Date> {

    private static final String TABLE_PREFIX = "tm_task_log_";

    /**
     * 精确分片(处理 = 和 IN 查询)
     * @param availableTargetNames 所有可用的物理表名
     * @param shardingValue 分片键的值(如 create_time = '2025-11-15 10:30:00')
     * @return 目标物理表名
     */
    @Override
    public String doSharding(Collection<String> availableTargetNames, 
                              PreciseShardingValue<Date> shardingValue) {
        // 1. 获取分片键的时间值
        Date create_time = shardingValue.getValue();
        if (create_time == null) {
            log.warn("分片值为空,使用当前月份");
            return TABLE_PREFIX + DateUtil.format(DateUtil.date(), "yyyyMM");
        }

        // 2. 解析时间为yyyyMM格式(如2025-11-15 → 202511)
        String monthSuffix = DateUtil.format(create_time, "yyyyMM");
        // 3. 拼接目标物理表名
        String targetTableName = TABLE_PREFIX + monthSuffix;

        log.debug("精确分片路由到表: {}, 分片值: {}", targetTableName, create_time);
        return targetTableName;
    }
}

核心逻辑

  • 从分片值中提取 create_time 字段
  • 将日期格式化为 yyyyMM(如:202511)
  • 拼接表名前缀,得到完整表名:tm_task_log_202511

2. 范围分片算法(RangeShardingAlgorithm)

用于处理 BETWEEN>< 等范围查询:

java 复制代码
package cn.xxx.log.sharding;

import cn.hutool.core.date.DateUtil;
import cn.xxx.common.core.constant.VaatCacheConstants;
import cn.xxx.common.core.utils.DateHelper;
import cn.xxx.common.core.utils.SpringUtils;
import cn.xxx.common.redis.service.RedisService;
import lombok.extern.slf4j.Slf4j;
import org.apache.shardingsphere.api.sharding.standard.RangeShardingAlgorithm;
import org.apache.shardingsphere.api.sharding.standard.RangeShardingValue;

import java.util.Collection;
import java.util.Date;
import java.util.HashSet;
import java.util.Set;

/**
 * tm_task_log 表按月分片算法 - 范围分片
 * 处理 BETWEEN AND、>、< 等范围查询
 */
@Slf4j
public class RangeTaskLogSharding implements RangeShardingAlgorithm<Date> {

    private static final String TABLE_PREFIX = "tm_task_log_";

    /**
     * 范围分片(处理BETWEEN AND、>、<等查询)
     * @param availableTargetNames 所有可用的物理表名
     * @param shardingValue 分片键的范围值(如 create_time BETWEEN '2025-11-01' AND '2025-12-31')
     * @return 所有匹配的物理表名
     */
    @Override
    public Collection<String> doSharding(Collection<String> availableTargetNames, 
                                         RangeShardingValue<Date> shardingValue) {
        // 1. 获取范围的开始和结束时间
        Date startDate = shardingValue.getValueRange().lowerEndpoint();
        Date endDate = shardingValue.getValueRange().upperEndpoint();
        
        // 2. 从Redis获取所有已存在的分表名(避免查询不存在的表)
        RedisService redisService = SpringUtils.getBean(RedisService.class);
        Set<String> tableSet = redisService.getCacheSet(VaatCacheConstants.TASK_LOG_TABLES_CACHE_KEY);
        
        // 3. 遍历时间范围,生成所有涉及的年月后缀
        Set<String> targetTableNames = new HashSet<>();
        Date currentDate = startDate;
        while (!currentDate.after(endDate)) {
            String monthSuffix = DateHelper.format(currentDate, "yyyyMM");
            String targetTableName = TABLE_PREFIX + monthSuffix;
            
            // 只添加已存在的表名
            if (tableSet.contains(targetTableName)) {
                targetTableNames.add(targetTableName);
            }
            // 移动到下一个月
            currentDate = DateUtil.offsetMonth(currentDate, 1);
        }

        log.debug("范围分片路由到表: {}, 范围: {} 到 {}", targetTableNames, startDate, endDate);
        // 4. 返回所有匹配的表名(ShardingSphere会自动查询这些表并合并结果)
        return targetTableNames.isEmpty() ? tableSet : targetTableNames;
    }
}

核心逻辑

  • 获取查询的时间范围(开始时间和结束时间)
  • 从Redis缓存中获取所有已存在的分表名
  • 遍历时间范围内的所有月份,生成对应的表名
  • 只返回已存在的表名,避免查询不存在的表导致错误

按周分表实现

1. 精确分片算法

java 复制代码
package cn.xxx.log.sharding;

import cn.hutool.core.date.DateTime;
import cn.hutool.core.date.DateUtil;
import lombok.extern.slf4j.Slf4j;
import org.apache.shardingsphere.api.sharding.standard.PreciseShardingAlgorithm;
import org.apache.shardingsphere.api.sharding.standard.PreciseShardingValue;

import java.util.Collection;
import java.util.Date;

/**
 * tm_vc_cam_register_record 表按周分片算法 - 精确分片
 * 根据 create_time 字段按周(yyyy_wNN格式)进行分表
 * 表名格式:tm_vc_cam_register_record_2025_w02
 */
@Slf4j
public class PreciseRegisterRecordSharding implements PreciseShardingAlgorithm<Date> {

    private static final String TABLE_PREFIX = "tm_vc_cam_register_record";

    @Override
    public String doSharding(Collection<String> collection, 
                             PreciseShardingValue<Date> preciseShardingValue) {
        // 1. 获取分片键的时间值
        Date create_time = preciseShardingValue.getValue();
        if (create_time == null) {
            log.warn("分片值为空,使用当前周份");
            DateTime date = DateUtil.date();
            return String.format("%s_%d_w%02d", TABLE_PREFIX, date.year(), date.weekOfYear());
        }

        // 2. 提取年份和周数
        int year = DateUtil.year(create_time);
        int week = DateUtil.weekOfYear(create_time);
        
        // 3. 拼接目标物理表名(格式:tm_vc_cam_register_record_2025_w02)
        String targetTableName = String.format("%s_%d_w%02d", TABLE_PREFIX, year, week);

        log.debug("精确分片路由到表: {}, 分片值: {}", targetTableName, create_time);
        return targetTableName;
    }
}

核心逻辑

  • 提取日期的年份和周数(使用Hutool的 weekOfYear() 方法)
  • 格式化为 yyyy_wNN 格式(如:2025_w02
  • 拼接表名:tm_vc_cam_register_record_2025_w02

2. 范围分片算法

java 复制代码
package cn.xxx.log.sharding;

import cn.hutool.core.date.DateUtil;
import cn.xxx.common.core.constant.VaatCacheConstants;
import cn.xxx.common.core.utils.SpringUtils;
import cn.xxx.common.redis.service.RedisService;
import lombok.extern.slf4j.Slf4j;
import org.apache.shardingsphere.api.sharding.standard.RangeShardingAlgorithm;
import org.apache.shardingsphere.api.sharding.standard.RangeShardingValue;

import java.util.*;

/**
 * tm_vc_cam_register_record 表按周分片算法 - 范围分片
 */
@Slf4j
public class RangeRegisterRecordSharding implements RangeShardingAlgorithm<Date> {

    private static final String TABLE_PREFIX = "tm_vc_cam_register_record";

    @Override
    public Collection<String> doSharding(Collection<String> collection, 
                                         RangeShardingValue<Date> rangeShardingValue) {
        // 1. 获取范围的开始和结束时间
        Date startDate = rangeShardingValue.getValueRange().lowerEndpoint();
        Date endDate = rangeShardingValue.getValueRange().upperEndpoint();
        
        // 2. 从Redis获取所有已存在的分表名
        RedisService redisService = SpringUtils.getBean(RedisService.class);
        Set<String> tableSet = redisService.getCacheSet(VaatCacheConstants.REGISTER_RECORD_TABLES_CACHE_KEY);
        
        // 3. 遍历时间范围,生成所有涉及的周表名
        Set<String> targetTableNames = new HashSet<>();
        Date currentDate = startDate;
        while (!currentDate.after(endDate)) {
            String targetTableName = String.format("%s_%d_w%02d", 
                TABLE_PREFIX, 
                DateUtil.year(currentDate), 
                DateUtil.weekOfYear(currentDate));
            
            if (tableSet.contains(targetTableName)) {
                targetTableNames.add(targetTableName);
            }
            // 移动到下一个周
            currentDate = DateUtil.offsetWeek(currentDate, 1);
        }

        log.debug("范围分片路由到表: {}, 范围: {} 到 {}", targetTableNames, startDate, endDate);
        return targetTableNames.isEmpty() ? tableSet : targetTableNames;
    }
}

核心逻辑

  • 按周遍历时间范围(使用 DateUtil.offsetWeek()
  • 生成每周对应的表名
  • 只返回已存在的表名

分表名缓存机制

为了提高范围查询的性能,我们将所有分表名缓存到Redis中。在应用启动时,通过 ApplicationRunner 自动加载:

java 复制代码
package cn.xxx.log.config;

import cn.xxx.common.core.constant.VaatCacheConstants;
import cn.xxx.common.redis.service.RedisService;
import cn.xxx.log.mapper.VCCamRegisterRecordMapper;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.boot.ApplicationArguments;
import org.springframework.boot.ApplicationRunner;
import org.springframework.stereotype.Component;

import java.util.HashSet;
import java.util.List;
import java.util.Set;

/**
 * 应用启动时缓存分表名到Redis
 */
@Slf4j
@Component
@RequiredArgsConstructor
public class CacheLogTableRunner implements ApplicationRunner {

    private final RedisService redisService;
    private final VCCamRegisterRecordMapper vcCamRegisterRecordMapper;

    /**
     * 缓存分表名到Redis
     */
    @Override
    public void run(ApplicationArguments args) throws Exception {
        // 1. 从数据库查询所有分表名
        List<String> tableNames = vcCamRegisterRecordMapper.getAllSplitTables();
        Set<String> originalTableNames = new HashSet<>(tableNames);
        
        // 2. 删除旧的缓存
        redisService.deleteObject(VaatCacheConstants.REGISTER_RECORD_TABLES_CACHE_KEY);
        
        // 3. 缓存新的表名列表
        redisService.setCacheSet(VaatCacheConstants.REGISTER_RECORD_TABLES_CACHE_KEY, originalTableNames);
        
        log.info("缓存分表名成功: key={}, count={}", 
            VaatCacheConstants.REGISTER_RECORD_TABLES_CACHE_KEY, 
            originalTableNames.size());
    }
}

Mapper查询方法

xml 复制代码
<!-- VCCamRegisterRecordMapper.xml -->
<select id="getAllSplitTables" resultType="java.lang.String">
    SELECT DISTINCT table_name
    FROM information_schema.tables
    WHERE table_schema = DATABASE()
      AND table_name LIKE 'tm_vc_cam_register_record_%'
    ORDER BY table_name
</select>

完整配置示例

application.yml 配置

yaml 复制代码
spring:
  shardingsphere:
    datasource:
      names: ds0
      ds0:
        type: com.zaxxer.hikari.HikariDataSource
        driver-class-name: com.mysql.cj.jdbc.Driver
        jdbc-url: jdbc:mysql://xxx:3306/vaat-log?useUnicode=true&characterEncoding=utf8&zeroDateTimeBehavior=convertToNull&useSSL=false&serverTimezone=GMT%2B8
        username: root
        password: password
    sharding:
      tables:
        # 任务日志表 - 按月分表
        tm_task_log:
          actual-data-nodes: ds0.tm_task_log_*
          table-strategy:
            standard:
              sharding-column: create_time
              precise-algorithm-class-name: cn.xxx.log.sharding.PreciseTaskLogSharding
              range-algorithm-class-name: cn.xxx.log.sharding.RangeTaskLogSharding
        # 注册记录表 - 按周分表
        tm_vc_cam_register_record:
          actual-data-nodes: ds0.tm_vc_cam_register_record_*
          table-strategy:
            standard:
              sharding-column: create_time
              precise-algorithm-class-name: cn.xxx.log.sharding.PreciseRegisterRecordSharding
              range-algorithm-class-name: cn.xxx.log.sharding.RangeRegisterRecordSharding
    props:
      sql.show: true  # 打印SQL,方便调试

配置说明

  1. datasource:配置数据源信息
  2. sharding.tables :配置分表规则
    • actual-data-nodes:实际数据节点,使用通配符 * 匹配所有分表
    • table-strategy.standard:标准分表策略
      • sharding-column:分片键字段名(必须是Date类型)
      • precise-algorithm-class-name:精确分片算法类
      • range-algorithm-class-name:范围分片算法类

常见问题与解决方案

1. 分片键必须包含在查询条件中

问题 :如果查询条件中没有分片键(create_time),ShardingSphere会查询所有分表,性能很差。

解决方案

  • 尽量在查询条件中包含 create_time
  • 如果必须全表查询,考虑使用其他方案(如定时任务汇总)

2. 分表不存在导致查询失败

问题:范围查询时,如果时间范围内某些分表不存在,会报错。

解决方案

  • 在范围分片算法中,从Redis缓存获取已存在的表名
  • 只查询已存在的表,避免查询不存在的表

3. 跨年/跨月查询性能问题

问题:查询时间跨度很大时,会查询很多分表。

解决方案

  • 限制查询时间范围(如最多查询3个月)
  • 对于历史数据,考虑归档到其他存储

4. 分表名缓存更新

问题:新增分表后,Redis缓存未更新。

解决方案

  • 创建新表后,手动刷新缓存
  • 或定时任务定期刷新缓存
java 复制代码
// 刷新缓存示例
public void refreshTableCache() {
    List<String> tableNames = mapper.getAllSplitTables();
    redisService.setCacheSet(CACHE_KEY, new HashSet<>(tableNames));
}

使用示例

1. 插入数据(自动路由到对应分表)

java 复制代码
@Service
public class TaskLogService {
    
    @Autowired
    private TmTaskLogMapper taskLogMapper;
    
    public void saveLog(TmTaskLog log) {
        // create_time 会自动用于分片路由
        log.setCreateTime(new Date());
        taskLogMapper.insert(log);
        // 数据会自动插入到 tm_task_log_202511 表(假设当前是2025年11月)
    }
}

2. 精确查询

java 复制代码
// 查询指定时间的数据
LambdaQueryWrapper<TmTaskLog> wrapper = new LambdaQueryWrapper<>();
wrapper.eq(TmTaskLog::getCreateTime, DateUtil.parse("2025-11-15 10:30:00"));
List<TmTaskLog> logs = taskLogMapper.selectList(wrapper);
// 自动路由到 tm_task_log_202511 表

3. 范围查询

java 复制代码
// 查询时间范围内的数据
LambdaQueryWrapper<TmTaskLog> wrapper = new LambdaQueryWrapper<>();
wrapper.between(TmTaskLog::getCreateTime, 
    DateUtil.parse("2025-11-01"), 
    DateUtil.parse("2025-12-31"));
List<TmTaskLog> logs = taskLogMapper.selectList(wrapper);
// 自动查询 tm_task_log_202511 和 tm_task_log_202512 两个表,并合并结果

总结

通过ShardingSphere-JDBC实现分表功能,我们获得了以下收益:

  1. 透明分表:业务代码无需关心分表逻辑,像操作单表一样简单
  2. 自动路由:根据分片键自动路由到正确的分表
  3. 性能提升:单表数据量减少,查询性能显著提升
  4. 灵活扩展:可以轻松添加新的分表,无需修改业务代码

关键要点

  • ✅ 分片键必须是Date类型
  • ✅ 查询条件尽量包含分片键
  • ✅ 范围查询时使用Redis缓存表名列表
  • ✅ 精确分片和范围分片算法必须同时实现
  • ✅ 表名格式要规范,便于程序解析

注意事项

  • ⚠️ 分表后,跨表查询性能会下降,尽量限制查询范围
  • ⚠️ 新增分表后要及时更新Redis缓存
  • ⚠️ 定期清理历史数据,避免分表过多

希望本文能帮助你在项目中成功实现分表功能!如有问题,欢迎留言讨论。


参考资源


作者 :vaat
日期 :2025-01
版权声明:本文为原创文章,转载请注明出处。

相关推荐
weixin_3993806915 小时前
OA 系统假死问题分析与优化
java·运维
豆沙沙包?15 小时前
2026年--Lc334-2130. 链表最大孪生和(链表转数组)--java版
java·数据结构·链表
千寻技术帮15 小时前
10347_基于Springboot的新疆旅游管理系统
spring boot·mysql·旅游·在线旅游
柒.梧.15 小时前
SSM常见核心面试问题深度解析
java·spring·面试·职场和发展·mybatis
踏浪无痕15 小时前
SQLInsight:从JDBC底层到API调用的零侵入SQL监控方案
数据库·后端·开源
杨章隐16 小时前
Java 解析 CDR 文件并计算图形面积的完整方案(支持 MultipartFile / 网络文件)@杨宁山
java·开发语言
Renhao-Wan16 小时前
Java 并发基石:AQS (AbstractQueuedSynchronizer)
java·开发语言
程序员iteng16 小时前
AI一键图表生成、样式修改的绘图开源工具【easy-draw】
spring boot·开源·node.js
zlp199216 小时前
xxl-job java.sql.SQLException: interrupt问题排查(二)
java·开发语言