说在前
来到公司熟悉完项目框架之后,大概一周左右就上手开发了。
在这个过程中,有遇到几个接口响应时间有点点慢的问题,就想着看看能不能从Redis拿数据或者拿一部分,应该会快很多。结果的确快了很多,但是当我看到Redis中存的数据时,感觉可以有改进,就有了这篇文章,思考一下怎么改造改造它。
Redis原结构
首先我们来想想Redis应该存一些什么东西?以我开发ERP两月半的经验【狗头】,就我们的系统我觉得可以存
- 最先能想到热点数据,这不用说,任何系统高频被查询的数据放Redis是最合适的,大大缓解数据库压力
- 用户信息(会话、token、部门、角色、权限)
- 数据字典表(一般是一个key和多个value)
- 系统配置数据
- 一些比较固定的数据且经常用的,改动频率很低的数据
- 复杂、耗性能的计算结果
- …
我那几个响应时间比较慢的接口,查的都是热点表,不仅数据量大,字段还多。
我看了一下项目Redis中的数据,猜到了这部分数据肯定是缓存了的,于是翻了一下同事是怎么存的:
@Slf4j
@Component
public class DataYncRedisConfig{
@Autowired
private IProjectService iProjectService;
@Autowired
private IClienteleService iClienteleService;
@Autowired
private StringRedisTemplate stringRedisTemplate;
@PostConstruct
public void startScheduledTask() {
log.info("初始化业务模块redis数据...");
//项目
List<Project> projectList = iProjectService.getAllProject();//这一步是获取所有project
sysInitialization(projectList, RedisConstants.PROJECT_DATA_KEY);//调用方法存到redis
//客商
List<Clientele> clienteleList = iClienteleService.getAllClientele();//获取所有客商信息
sysInitialization(clienteleList, RedisConstants.CLIENTELE_DATA_KEY);//存redis
log.info("初始化业务模块redis数据(成功)");
}
/**
* 更新表的数据到redis
*/
public void sysInitialization(Object sysDataList,String DATA_KEY){
// 将 SysUser 列表转换为 JSON 格式的字符串
ObjectMapper objectMapper = new ObjectMapper();
try {
// 将 sysUserList 转换为 JSON 字符串
String sysDataListJson = objectMapper.writeValueAsString(sysDataList);
stringRedisTemplate.delete(DATA_KEY);
// 存储到 Redis 中
stringRedisTemplate.opsForValue().set(DATA_KEY, sysDataListJson);
} catch (Exception e) {
e.printStackTrace();
}
}
}
@PostConstruct
注解的方法在 Spring 容器初始化 Bean 的最后一个阶段 执行,所以容器初始化完,Redis就有数据了。很直接,这里直接selectAll,把所有数据灌进redis,用的是字符串类型。
那数据库和Redis是怎么同步的呢?
@Log4j2
@Component
@Aspect
public class BusinessRedisAspect {
@Resource
private IProjectService iProjectService;
@Resource
private IClienteleService iClienteleService;
@Resource
private DataYncRedisConfig dataYncRedisConfig;
// 切入点(项目)
@Pointcut("execution(* com.xxx.business.project.*.service.IProjectService.add*(..)) || " +
"execution(* com.xxx.business.project.*.service.IProjectService.delete*(..)) || " +
"execution(* com.xxx.business.project.*.service.IProjectService.update*(..)) ||"+
"execution(* com.xxx.business.project.*.service.IProjectService.insert*(..)) ")
public void projectRepositoryMethods() {}
/**
* 切面更新redis数据
*/
@After("projectRepositoryMethods()")
public void afterUserRepositoryMethod() {
List<Project> projectList=iProjectService.getAllProject();
log.info("表project数据变动,更新redis");
dataYncRedisConfig.sysInitialization(projectList, RedisConstants.PROJECT_DATA_KEY);
}
// 切入点(客商)
@Pointcut("execution(* com.xxx.business.clientele.*.service.IClienteleService.add*(..)) || " +
"execution(* com.xxx.business.clientele.*.service.IClienteleService.delete*(..)) || " +
"execution(* com.xxx.business.clientele.*.service.IClienteleService.update*(..)) ||"+
"execution(* com.xxx.business.clientele.*.service.IClienteleService.insert*(..)) ")
public void clienteleRepositoryMethods() {}
/**
* 切面更新redis数据
*
*/
@After("clienteleRepositoryMethods()")
public void afterPostRepositoryMethod() {
List<Clientele> clienteleList=iClienteleService.getAllClientele();
log.info("表lientele数据变动,更新redis");
dataYncRedisConfig.sysInitialization(clienteleList, RedisConstants.CLIENTELE_DATA_KEY);
}
}
可以看到,这里用了AOP,切点是Service下的add,delete,update和insert,这里有add和insert两个新增只是为了解决开发小伙伴命名习惯的不同,策略是数据库动了就去主动更新Redis,而且是全量更新,相当于再灌一次数据到Redis。
用字符串类型存数据的话,好像只能用全量更新。
且更重要的是每次用到缓存中的数据,只能把所有数据拉下来(代码如下),再反序列化,做过滤。
@Slf4j
@Service
public class SysProjectDataServiceImpl implements SysProjectDataService {
@Autowired
private StringRedisTemplate stringRedisTemplate;
/**
* 通过redis获取业务模块全部项目的List
*/
public List<Project> getUserListFromRedis() {
String PROJECT_DATA_KEY = RedisConstants.PROJECT_DATA_KEY;
// 从 Redis 中获取 JSON 字符串
String sysProjectListJson = stringRedisTemplate.opsForValue().get(PROJECT_DATA_KEY);
if (sysProjectListJson == null) {
log.info("没有数据");
return Collections.emptyList(); // 返回空列表
}
// 使用 ObjectMapper 反序列化 JSON 字符串为 List<SysProject>
ObjectMapper objectMapper = new ObjectMapper();
try {
List<Project> sysProjectList = objectMapper.readValue(sysProjectListJson, new TypeReference<List<Project>>() {});
return sysProjectList;
} catch (Exception e) {
log.error("反序列化失败,错误信息:{}", e.getMessage());
e.printStackTrace();
return Collections.emptyList(); // 如果反序列化失败,返回空列表
}
}
}
在开发过程中,有一天Redis变得很慢(我觉得不是突然变慢的,可能是越来越慢),然后领导换了一个服务器性能好一点的Redis,就没啥事了。
我去看了一下Redis中某些Key的大小
cz:0>MEMORY USAGE sys_user_data_key:
"458816"
cz:0>MEMORY USAGE project_data_key:
"49224"
cz:0>MEMORY USAGE sys_unit_data_key:
"28736"
cz:0>MEMORY USAGE clientele_data_key:
"28744"
cz:0>MEMORY USAGE sys_post_data_key:
"98368"
这里有一些代码里没有出现的数据(sys_user_data_key,sys_unit_data_key等),是来自别的服务,都是同一个项目的数据,也是相同的存储方式和更新策略。
我查了下多大算BigKey?怎么说的都有,应该是由机器的性能和网络条件来决定。
我不太清楚原来那台服务器的参数规格,这个数据大小可能对它来说算BigKey了?
改进思路
我们开发的系统初期还没有那么多的用户和很大的并发量,用户量起来了的话,初始化、更新都一股脑灌进Redis的做法肯定是不可取的,搞崩Redis只是时间问题,还会浪费大量的流量。
每次取数据也要加载所有数据,这对JVM也是一个挑战…
现在我们可以只把字符串类型换成hash类型,初始化可以照旧,缓存所有数据,但更新和查询必须要拿着key去操作。
粗略来说,Hash类型要到5000个元素或者占用50M才算BigKey,比字符串类型的阈值要大得多,所以暂时还不用担心Key太大的问题。
@Slf4j
@Component
public class DataYncRedisConfig {
@Autowired
private IProjectService iProjectService;
@Autowired
private IClienteleService iClienteleService;
@Autowired
private StringRedisTemplate stringRedisTemplate;
private final ObjectMapper objectMapper = new ObjectMapper();
@PostConstruct
public void startScheduledTask() {
log.info("初始化业务模块redis数据...");
//项目
List<Project> projectList = iProjectService.getAllProject();
sysInitialization(projectList, RedisConstants.PROJECT_DATA_KEY_PREFIX);
//客商
List<Clientele> clienteleList = iClienteleService.getAllClientele();
sysInitialization(clienteleList, RedisConstants.CLIENTELE_DATA_KEY_PREFIX);
log.info("初始化业务模块redis数据(成功)");
}
/**
* 更新表的数据到redis,使用Hash类型存储
*/
public <T> void sysInitialization(List<T> dataList, String keyPrefix) {
if (CollectionUtils.isEmpty(dataList)) {
return;
}
//删除旧的缓存数据
Set<String> keys = stringRedisTemplate.keys(keyPrefix + "*");//(*表示匹配全部)
if (keys != null && !keys.isEmpty()) {
stringRedisTemplate.delete(keys);
}
//批量存储数据到Hash
dataList.forEach(data -> updateSingleData(data, keyPrefix));
}
/**
* 更新单条数据(增量更新)
*/
public <T> void updateSingleData(T data, String keyPrefix) {
try {
String id = getIdFromObject(data);
if (id != null) {
String hashKey = keyPrefix + id;
Map<String, String> valueMap = objectMapper.convertValue(data, new TypeReference<Map<String, String>>() {});
stringRedisTemplate.opsForHash().putAll(hashKey, valueMap);
}
} catch (Exception e) {
log.error("更新Redis数据失败,keyPrefix: {}, data: {}", keyPrefix, data, e);
}
}
/**
* 删除单条数据
*/
public void deleteSingleData(Long id, String keyPrefix) {
try {
String hashKey = keyPrefix + id;
stringRedisTemplate.delete(hashKey);
} catch (Exception e) {
log.error("从Redis删除数据失败,key: {}", keyPrefix + id, e);
}
}
/**
* 从对象中获取ID值
*/
private <T> String getIdFromObject(T data) {
try {
if (data instanceof Project) {
return String.valueOf(((Project) data).getId());
} else if (data instanceof Clientele) {
return String.valueOf(((Clientele) data).getId());
}
//通用方法,通过反射获取ID
Method getIdMethod = data.getClass().getMethod("getId");
Object idValue = getIdMethod.invoke(data);
return idValue != null ? idValue.toString() : null;
} catch (Exception e) {
//可以作其他处理
log.error("获取对象ID失败", e);
return null;
}
}
}
配置类的改动主要把数据存为Hash类型,key是前缀+id,前缀在RedisConstants定义。
还有增加了按Id更新、删除的逻辑,供切面类新增、更新和删除调用。
用于同步的切面类:
@Log4j2
@Component
@Aspect
public class BusinessRedisAspect {
@Resource
private DataYncRedisConfig dataYncRedisConfig;
//项目切入点(捕获增改方法)
@Pointcut("execution(* com.xxx.business.project.*.service.IProjectService.add*(..)) || " +
"execution(* com.xxx.business.project.*.service.IProjectService.insert*(..)) || " +
"execution(* com.xxx.business.project.*.service.IProjectService.update*(..))")
public void projectAddOrUpdateMethods() {}
//删除方法
@Pointcut("execution(* com.xxx.business.project.*.service.IProjectService.delete*(..))")
public void projectDeleteMethods() {}
//客商切入点(捕获增改方法)
@Pointcut("execution(* com.xxx.business.clientele.*.service.IClienteleService.add*(..)) || " +
"execution(* com.xxx.business.clientele.*.service.IClienteleService.insert*(..)) || " +
"execution(* com.xxx.business.clientele.*.service.IClienteleService.update*(..))")
public void clienteleAddOrUpdateMethods() {}
//删除方法
@Pointcut("execution(* com.xxx.business.clientele.*.service.IClienteleService.delete*(..))")
public void clienteleDeleteMethods() {}
/**
* 处理项目新增或更新
*/
@AfterReturning(pointcut = "projectAddOrUpdateMethods()", returning = "result")
public void afterProjectAddOrUpdate(JoinPoint joinPoint, Object result) {
if (result instanceof Project) {
Project project = (Project) result;
log.info("项目数据变更,增量更新Redis: {}", project.getId());
//调用Redis新增或更新方法
dataYncRedisConfig.updateSingleData(project, RedisConstants.PROJECT_DATA_KEY_PREFIX);
}
}
/**
* 处理项目删除
*/
@AfterReturning("projectDeleteMethods()")
public void afterProjectDelete(JoinPoint joinPoint) {
Object[] args = joinPoint.getArgs();
if (args != null && args.length > 0 && args[0] instanceof Long) {
Long id = (Long) args[0];
log.info("项目数据删除,从Redis移除: {}", id);
//调用Redis删除方法
dataYncRedisConfig.deleteSingleData(id, RedisConstants.PROJECT_DATA_KEY_PREFIX);
}
}
/**
* 处理客商新增或更新
*/
@AfterReturning(pointcut = "clienteleAddOrUpdateMethods()", returning = "result")
public void afterClienteleAddOrUpdate(JoinPoint joinPoint, Object result) {
if (result instanceof Clientele) {
Clientele clientele = (Clientele) result;
log.info("客商数据变更,增量更新Redis: {}", clientele.getId());
dataYncRedisConfig.updateSingleData(clientele, RedisConstants.CLIENTELE_DATA_KEY_PREFIX);
}
}
/**
* 处理客商删除
*/
@AfterReturning("clienteleDeleteMethods()")
public void afterClienteleDelete(JoinPoint joinPoint) {
Object[] args = joinPoint.getArgs();
if (args != null && args.length > 0 && args[0] instanceof Long) {
Long id = (Long) args[0];
log.info("客商数据删除,从Redis移除: {}", id);
dataYncRedisConfig.deleteSingleData(id, RedisConstants.CLIENTELE_DATA_KEY_PREFIX);
}
}
}
切面类把改动(add,insert,update)和删除(delete)分开处理。
这样一套下来就可以避免全量更新啦。
如果熟悉AOP和反射的同学,这样的改动还是比较好懂的。
取数据也不用拉取全部了,可以指哪打哪了~
//获取单个项目
Map<Object, Object> projectMap = stringRedisTemplate.opsForHash().entries("project:data:1");
//获取所有项目的key
Set<String> projectKeys = stringRedisTemplate.keys("project:data:*");
//获取所有项目
List<Map<Object, Object>> allProjects = projectKeys.stream()
.map(key -> stringRedisTemplate.opsForHash().entries(key))
.collect(Collectors.toList());
//批量获取......
以上操作全部都没有指定过期时间,还有可以优化的就是初始化的时候,看有没有办法先更新一些热点中的热点,其他数据用到了再放到缓存,这样就算用户量数据量起来了也不会给Redis太大的压力,然后缓存的也是热点中的热点,可以在数据库多加一列表示热度,或者引入一些别的组件(ES/Mongo)记录剩余的热点数据…(狠狠yy)