前言
CRUD 是指 Create、Read、Update、Delete 这四个单词的缩写。后端程序员经常被称为“CRUD Boy”,因为很多工作无非就是增删改查,技术含量较低。
最近工作中遇到一个标准的 CRUD 需求,简单来说就是管理员往运营平台上添加一些快捷按钮,用户就可以在客户端页面上查看并点击这些快捷按钮了。
是不是很简单,就是对快捷按钮这个对象类进行增删改查操作,没有什么难度。
引入缓存
那么,我们便来加点难度。
我们都知道从数据库获取数据是一个比较耗时的操作,那如何让用户更快获取到数据呢?
对,答案就是引入缓存机制,从缓存中获取数据耗时会降低很多,能大大提升用户体验。
通常数据库用的 MySQL,缓存用的是 Redis,本文也采用这样的技术栈进行介绍,当然选用其他数据库和缓存也可以,原理是相同的。
然而,如何才能保障 MySQL 和 Redis 的数据一致性呢?
保障一致性
1. 先写 Redis,再写 MySQL
首先想到的就是,将数据在 Redis 和 MySQL 中都写一遍,我们来画个图看看这种方式可能出现的问题。
这是一副时序图,描述了请求的先后调用顺序。
请求 A、B 都是先写 Redis,然后再写 MySQL,在高并发情况下,如果请求 A 在写 MySQL 时卡了一会儿,请求 B 已经依次率先完成了数据的更新,就会出现 Redis 和 MySQL 数据不一致的问题。
此方案不可用,那么换个写入顺序试试。
2. 先写 MySQL,再写 Redis
如图,请求 A 有可能在写 Redis 时卡顿,造成最终不一致的情况。
看来两种先后写入数据库和缓存的方案都不可用,我们得考虑一下其他方案。
其实写入缓存这个操作不一定要在操作数据库前后进行,也可以在用户读取数据时进行。
具体来说,对于读请求,先去读 Redis,如果有就直接返回;如果没有,再去读 MySQL,之后将数据回写入 Redis。
那么我们就可以在操作 MySQL 前或后,先将 Redis 删掉,等待读请求去回写。于是就有了以下两种方案。
3. 先删 Redis,再写 MySQL
我们假设现在 MySQL 中的数据是 10,请求 A 要将此数据更新为 11。请求 B 是一个读请求。
请求 A 先删除缓存,可能因为卡顿,请求 B 完成查询后,请求 A 的数据才更新到 MySQL,导致 MySQL 和 Redis 的数据不一致。
这种情况出现的概率比较大,因为一般情况下查询时长小于更新时长。请求 A 是更新操作,可能耗时会比较长,而请求 B 的前两步都是查询,会非常快。
此方案也不可用,那么换个顺序再看看。
4. 先写 MySQL,再删 Redis
假设请求 B 的第一次查询发生在请求 A 更新完 MySQL 但还未删除缓存时,请求 B 查询到的数据是 10,但是 MySQL 的数据已经更新为 11了,发生了不一致的情况。
但只存在这一次不一致的情况,对于不是强一致性要求的业务,可以容忍。(那什么情况下不能容忍呢,比如秒杀业务、库存服务等。)
当请求 B 进行第二次查询时,因为没有命中 Redis,会重新查一次 DB,然后再回写到 Reids。此时 Redis 和 MySQL 中的数据一致,也就是说,这种方式保障了数据的最终一致性。
还有以下这种情况也会导致不一致:
请求 B 查询时,缓存已经超时失效,所以从 MySQL 中查询,之后回写 Redis 用时较长,回写前请求 A 已经执行完了更新 MySQL 操作和删除 Redis 操作了,从而导致了数据不一致情况的发生。
我们都知道更新操作肯定比查询耗时要长,请求 A 更新MySQL + 删除 Redis 的耗时大概率大于请求 B 查询和回写的时间,所以出现这个情况的概率很小,同时满足缓存超时失效的概率就更小了。
这个方案总算能满足需求了,开始愉快的编码吧。
编码
添加依赖和配置
在 pom.xml
中添加 Redis 相关依赖
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-redis</artifactId>
</dependency>
<!-- 对象池,使用redis时引入 -->
<dependency>
<groupId>org.apache.commons</groupId>
<artifactId>commons-pool2</artifactId>
</dependency>
在配置文件中写上 Redis 的连接信息
spring:
redis:
host: 127.0.0.1
password: 123456
# 连接超时时间(记得添加单位,Duration)
timeout: 10000ms
# Redis默认情况下有16个分片,这里配置具体使用的分片
database: 0
lettuce:
pool:
# 连接池最大连接数(使用负值表示没有限制) 默认 8
max-active: 8
# 连接池最大阻塞等待时间(使用负值表示没有限制) 默认 -1
max-wait: -1ms
# 连接池中的最大空闲连接 默认 8
max-idle: 8
# 连接池中的最小空闲连接 默认 0
min-idle: 0
数据更新
在Service层实现类中的新增、修改、删除方法后都加上删除redis的代码。
同时,需要加上事务,如果 Redis 删除失败,MySQL 的更新操作也需要回滚,避免查询时读取到脏数据。
/**
* 新增数据
*
* @param quickButton 实例对象
* @return 实例对象
*/
@Override
@Transactional(rollbackFor = Exception.class)
public QuickButton insert(QuickButton quickButton) {
// 先写MySQL
this.quickButtonDao.insert(quickButton);
// 后删Redis
redisManager.delete(REDIS_KEY);
return quickButton;
}
/**
* 修改数据
*
* @param quickButton 实例对象
* @return 实例对象
*/
@Override
@Transactional(rollbackFor = Exception.class)
public QuickButton update(QuickButton quickButton) {
// 先写MySQL
this.quickButtonDao.update(quickButton);
// 后删Redis
redisManager.delete(REDIS_KEY);
return this.queryById(quickButton.getId());
}
/**
* 通过主键删除数据
*
* @param id 主键
* @return 是否成功
*/
@Override
@Transactional(rollbackFor = Exception.class)
public boolean deleteById(Integer id) {
// 先写MySQL
boolean result = this.quickButtonDao.deleteById(id) > 0;
// 后删Redis
redisManager.delete(REDIS_KEY);
return result;
}
数据获取
先去读 Redis,如果有就直接返回;如果没有,再去读 MySQL,之后将数据回写入 Redis。
此外,在写入 Redis 时,为了兜底,我们加了一个过期时间。这样万一两者不一致,缓存过期后,数据会重新更新到缓存。
public List<QuickButton> queryAll() {
// 先查Redis
String buttonListStr = redisManager.get(REDIS_KEY);
if (StrUtil.isNotEmpty(buttonListStr)) {
// 如果有,直接返回
return JSONUtil.toBean(buttonListStr, new TypeReference<List<QuickButton>>() {}, false);
} else {
// 没有,从MySQL中查
List<QuickButton> buttonList = this.quickButtonDao.queryAll();
// 写入Redis,并设置过期时间
redisManager.set(REDIS_KEY, JSONUtil.toJsonStr(buttonList), 10, TimeUnit.MINUTES);
return buttonList;
}
}
测试
先给出代码:
@Test
public void save(){
QuickButton quickButton = new QuickButton();
quickButton.setTitle("LLM");
quickButton.setDescription("语言大模型");
quickButton.setCategoryId(0);
quickButton.setUrl("https://openai.com");
quickButtonService.insert(quickButton);
log.info("入库成功:{}", quickButton);
}
@Test
public void query(){
log.info("查询结果:{}",quickButtonService.queryAll());
}
我们先执行一下 save 方法:
Redis里此时没有缓存。
再来执行一下 query 方法:
查询 Redis, 发现已经写入了:
用AOP技术优化代码
上面的代码已经实现了我们想要的功能,但是不够完美。
Service 实现类中每个对数据库进行改动的方法都加了删除 Redis 的操作。本例作为演示,已经有了3行重复代码,实际项目中,对数据库改动的方法远大于3个,会造成大量冗余代码。
那怎么办呢,这里我使用了Spring AOP,执行完操作 DB 的方法后,在切面中执行删除 Redis 操作。
@Aspect
@Component
public class RedisDeleteAspect {
public static final String REDIS_KEY = "QUICK_BUTTON:ALL";
@Resource
private RedisUtil redisManager;
@Pointcut("execution(* com.xuqiming.cache.service.impl.QuickButtonServiceImpl.insert*(..)) "
+ "|| execution(* com.xuqiming.cache.service.impl.QuickButtonServiceImpl.update*(..)) "
+ "|| execution(* com.xuqiming.cache.service.impl.QuickButtonServiceImpl.delete*(..))")
public void redisDeletePointcut() {}
@After("redisDeletePointcut()")
public void deleteFromRedis() {
// 删除Redis操作
redisManager.delete(REDIS_KEY);
}
}
总结
经过以上分析,我们得出一条结论:在满足实时性的条件下,不存在两者完全保持一致的方案,只有最终一致性方案。
采用“先写 MySQL,再删除 Redis”的策略,这种情况虽然也会存在两者不一致,但是发生的概率非常低,所以是满足实时性条件下,能尽量满足一致性的最优解。
还有一些其他优秀的方案,如“缓存双删”、“监听 MySQL 的 Binlog 异步更新 Redis”等,感兴趣的读者可查看文末的参考资料进行学习。
评论区