大家好,我是楼仔呀。
之前写过一篇《高频面试:如何保障 MySQL 和 Redis 的数据一致性?》,阅读量直奔 7K,但是里面只有理论,没有实战,今天就结合技术派项目,告诉大家如何去实现 MySQL 和 Redis 的一致性。
在讲解实战部分之前,我们还是先回顾一下理论知识,根据网上的众多解决方案,我们总结出 6 种:
你可以先想想,技术派会采用哪种方案呢?
温馨提示:如果你对理论知识已经非常清楚,可以直接跳到文章的实战部分。
图解说明:
请求 A、B 都是先写 MySQL,然后再写 Redis,在高并发情况下,如果请求 A 在写 Redis 时卡了一会,请求 B 已经依次完成数据的更新,就会出现图中的问题。
这个图已经画的很清晰了,我就不用再去啰嗦了吧,不过这里有个前提,就是对于读请求,先去读 Redis,如果没有,再去读 DB,但是读请求不会再回写 Redis。 大白话说一下,就是读请求不会更新 Redis。
同“先写 MySQL,再写 Redis”,看图可秒懂。
这幅图和上面有些不一样,前面的请求 A 和 B 都是更新请求,这里的请求 A 是更新请求,但是请求 B 是读请求,且请求 B 的读请求会回写 Redis。
请求 A 先删除缓存,可能因为卡顿,数据一直没有更新到 MySQL,导致两者数据不一致。
这种情况出现的概率比较大,因为请求 A 更新 MySQL 可能耗时会比较长,而请求 B 的前两步都是查询,会非常快。
对于“先删除 Redis,再写 MySQL”,如果要解决最后的不一致问题,其实再对 Redis 重新删除即可,这个也是大家常说的“缓存双删”。
为了便于大家看图,对于蓝色的文字,“删除缓存 10”必须在“回写缓存10”后面,那如何才能保证一定是在后面呢?网上给出的第一个方案是,让请求 A 的最后一次删除,等待 500ms。
对于这种方案,看看就行,反正我是不会用,太 Low 了,风险也不可控。
那有没有更好的方案呢,我建议异步串行化删除,即删除请求入队列
异步删除对线上业务无影响,串行化处理保障并发情况下正确删除。
如果双删失败怎么办,网上有给 Redis 加一个缓存过期时间的方案,这个不敢苟同。个人建议整个重试机制,可以借助消息队列的重试机制,也可以自己整个表,记录重试次数,方法很多。
简单小结一下:
对于上面这种情况,对于第一次查询,请求 B 查询的数据是 10,但是 MySQL 的数据是 11,只存在这一次不一致的情况,对于不是强一致性要求的业务,可以容忍。(那什么情况下不能容忍呢,比如秒杀业务、库存服务等。)
当请求 B 进行第二次查询时,因为没有命中 Redis,会重新查一次 DB,然后再回写到 Reids。
这里需要满足 2 个条件:
对于第二个条件,我们都知道更新 DB 肯定比查询耗时要长,所以出现这个情况的概率很小,同时满足上述条件的情况更小。
这种方案,主要是监听 MySQL 的 Binlog,然后通过异步的方式,将数据更新到 Redis,这种方案有个前提,查询的请求,不会回写 Redis。
这个方案,会保证 MySQL 和 Redis 的最终一致性,但是如果中途请求 B 需要查询数据,如果缓存无数据,就直接查 DB;如果缓存有数据,查询的数据也会存在不一致的情况。
所以这个方案,是实现最终一致性的终极解决方案,但是不能保证实时性。
我们对比上面讨论的 6 种方案:
个人结论:
因为项目对实时性要求高,所以采用方案 5,先写 MySQL,再删除 Redis 的方式。
下面只是一个示例,我们将文章的标签放入 MySQL 之后,再删除 Redis,所有涉及到 DB 更新的操作都需要按照这种方式处理。
这里加了一个事务,如果 Redis 删除失败,MySQL 的更新操作也需要回滚,避免查询时读取到脏数据。
@Override
@Transactional(rollbackFor = Exception.class)
public void saveTag(TagReq tagReq) {
TagDO tagDO = ArticleConverter.toDO(tagReq);
// 先写 MySQL
if (NumUtil.nullOrZero(tagReq.getTagId())) {
tagDao.save(tagDO);
} else {
tagDO.setId(tagReq.getTagId());
tagDao.updateById(tagDO);
}
// 再删除 Redis
String redisKey = CACHE_TAG_PRE + tagDO.getId();
RedisClient.del(redisKey);
}
@Override
@Transactional(rollbackFor = Exception.class)
public void deleteTag(Integer tagId) {
TagDO tagDO = tagDao.getById(tagId);
if (tagDO != null){
// 先写 MySQL
tagDao.removeById(tagId);
// 再删除 Redis
String redisKey = CACHE_TAG_PRE + tagDO.getId();
RedisClient.del(redisKey);
}
}
@Override
public void operateTag(Integer tagId, Integer pushStatus) {
TagDO tagDO = tagDao.getById(tagId);
if (tagDO != null){
// 先写 MySQL
tagDO.setStatus(pushStatus);
tagDao.updateById(tagDO);
// 再删除 Redis
String redisKey = CACHE_TAG_PRE + tagDO.getId();
RedisClient.del(redisKey);
}
}
这个也很简单,先查询缓存,如果有就直接返回;如果未查询到,需要先查询 DB ,再写入缓存。
我们放入缓存时,加了一个过期时间,用于兜底,万一两者不一致,缓存过期后,数据会重新更新到缓存。
@Override
public TagDTO getTagById(Long tagId) {
String redisKey = CACHE_TAG_PRE + tagId;
// 先查询缓存,如果有就直接返回
String tagInfoStr = RedisClient.getStr(redisKey);
if (tagInfoStr != null && !tagInfoStr.isEmpty()) {
return JsonUtil.toObj(tagInfoStr, TagDTO.class);
}
// 如果未查询到,需要先查询 DB ,再写入缓存
TagDTO tagDTO = tagDao.selectById(tagId);
tagInfoStr = JsonUtil.toStr(tagDTO);
RedisClient.setStrWithExpire(redisKey, tagInfoStr, CACHE_TAG_EXPRIE_TIME);
return tagDTO;
}
/**
* @author Louzai
* @date 2023/5/5
*/
@Slf4j
public class MysqlRedisService extends BasicTest {
@Autowired
private TagSettingService tagSettingService;
@Test
public void save() {
TagReq tagReq = new TagReq();
tagReq.setTag("Java");
tagReq.setTagId(1L);
tagSettingService.saveTag(tagReq);
log.info("save success:{}", tagReq);
}
@Test
public void query() {
TagDTO tagDTO = tagSettingService.getTagById(1L);
log.info("query tagInfo:{}", tagDTO);
}
}
我们看一下 Redis:
127.0.0.1:6379> get pai_cache_tag_pre_1
"{\"tagId\":1,\"tag\":\"Java\",\"status\":1,\"selected\":null}"
以及结果输出:
这篇文章很基础,也非常适用,大家可以直接下载技术派项目,里面都有代码和测试用例
后面我会把 RabbitMQ、ES、Nacos、MongoDB 和 prometheus 都集成到技术派项目,不为其它的,存粹为了自娱自乐。
文章标题:技术派中的缓存一致性解决方案
分享地址:http://www.shufengxianlan.com/qtweb/news8/447058.html
网站建设、网络推广公司-创新互联,是专注品牌与效果的网站制作,网络营销seo公司;服务项目有等
声明:本网站发布的内容(图片、视频和文字)以用户投稿、用户转载内容为主,如果涉及侵权请尽快告知,我们将会在第一时间删除。文章观点不代表本网站立场,如需处理请联系客服。电话:028-86922220;邮箱:631063699@qq.com。内容未经允许不得转载,或转载时需注明来源: 创新互联