Spring Boot整合Sharding-JDBC实现日志表按月按周分表实战
本文基于实际项目经验,详细介绍如何在Spring Boot项目中整合ShardingSphere-JDBC,实现日志表的按月、按周分表功能。包含完整的配置、代码实现和最佳实践。
📋 目录
项目背景
在日志服务中,随着业务量的增长,单表数据量急剧增加,导致查询性能下降。为了解决这个问题,我们采用了分表策略:
- 任务日志表(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,方便调试
配置说明:
- datasource:配置数据源信息
- 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实现分表功能,我们获得了以下收益:
- 透明分表:业务代码无需关心分表逻辑,像操作单表一样简单
- 自动路由:根据分片键自动路由到正确的分表
- 性能提升:单表数据量减少,查询性能显著提升
- 灵活扩展:可以轻松添加新的分表,无需修改业务代码
关键要点:
- ✅ 分片键必须是Date类型
- ✅ 查询条件尽量包含分片键
- ✅ 范围查询时使用Redis缓存表名列表
- ✅ 精确分片和范围分片算法必须同时实现
- ✅ 表名格式要规范,便于程序解析
注意事项:
- ⚠️ 分表后,跨表查询性能会下降,尽量限制查询范围
- ⚠️ 新增分表后要及时更新Redis缓存
- ⚠️ 定期清理历史数据,避免分表过多
希望本文能帮助你在项目中成功实现分表功能!如有问题,欢迎留言讨论。
参考资源
作者 :vaat
日期 :2025-01
版权声明:本文为原创文章,转载请注明出处。