如何保证MySQL和Redis的缓存一致。从理论到实战。总结6种来感受一下。
理论知识
不好的方案
1.先写MySQL,再写Redis
图解说明:
这是一幅时序图,描述请求的先后调用顺序;
黄色的线是请求A,黑色的线是请求B;
黄色的文字是MySQL和Redis最终不一致的数据;
数据是从10更新为11;
后面的图为此规定
请求A、B都是先写MySQL,然后再写Redis,在高并发的情况下,如果请求A在写Redis时卡了一会,请求B已经一次完成了数据的更新,就会出现图中描述的问题。
图表述很清楚了,不过这里有个前提,就是对于读请求,先去读Redis,如果没有,再去读DB,但是读请求不会再写回Redis。就是读请求不会更新Redis。
2.先写Redis,再写MySQL
同1描述一样,秒懂。
3.先删除Redis,再写MySQL
和上面不一样的是,前面的请求A和B都是更新请求,这里的请求·A是跟新请求,但B请求是读请求,并且B的读请求会写回Redis。
请求A先删除缓存,可能因为卡顿,数据一直没有更新到MySQL,导致数据不一致。
这种情况出现的概率比较大,因为请求A更新MySQL可能会耗时比较长,而请求B的前两者都是查询,会比较快。
好的方案
4.先删除Redis,再写MySQL,再删除Redis
对于“先删除Redis,再写MySQL” ,如果要解决最后的不一致问题,其实再对Redis重新删除即可,这个就是“缓存双删”。
这个方案看看就行。
更好的方案是,异步串行化删除,即删除请求入队列
异步删除除对线上业务无影响,串行化处理保障并发情况下正确删除。
5.先写MySQL,再删除Redis
对于上面这种情况,对于第一次查询,请求B查询的数据10,但是MySQL的数据是11,只存在这一次不一致的情况,对于不是强一致的情况,对于不是强一致性要求的业务,可以容忍。对秒杀,库存就不行。
当请求B进行第二次查询时,因为没命中Redis,会重新擦汗一次DB,然后再回写到Redis。
这里需要满足两个条件:
缓存刚好自动失效;
请求B从数据库查10,回写缓存的消耗,比请求A写数据库,并且删除缓存的还长。
对于第二个条件,我们都知道更新DB肯定比查询耗时要长,所以出现这个情况的概率很小,同时满足上述条件情况更小。
6.先写MySQL,通过Binlog,异步更新Redis
这个方案,主要是监听MySQL的Binlog,然后通过异步的方式,将数据更新到Redis,这种方案有个前提,查询的请求,不会写回Redis。
这个方案,保证MySQL和Redis的最终一致性,但是如果中途请求B需要查询数据,如果缓存无数据,就直接查DB;如果缓存有数据,查询的数据也会存在不一致的情况。
所以这个方案,是实现最终一致性的终极方案,但是不能保证实时性。
几种方案比较
我们对比上述讨论的6种方案:‘
1.先写Redis,再写MySQL
这种方案,坑定是不会用,万一DB挂了,你把数据写到缓存,DB无数据,这个是灾难性的;
如果写DB失败,对Redis进行逆操作,那如果逆向操作失败,是不是得又搞个重试?
2.先写MySQL,再写Redis
对于并发量、一致性要求不高的项目,很多就是这么用的,我之前也经常这么搞
但是不建议这么做;
当Redis瞬间不可用的情况,需要报警出来,然后线下处理。
3.先删除Redis,再写MySQL
有懂得回答?
4.先删除Redis,再写MySQL,再删除Redis
这种方式虽然可行,但是感觉复杂,还要搞个消息队列去异步删除Redis。
5.先写MySQL,再删除Redis
比较推荐这总方案,删除Redis如果失败,可以再多重试几次,否则报警出来;
这个方案,是实时性最好的方案,在一些高并发场景种,推荐。
6.先写MySQL,通过Binlog。异步更新Redis
对于异地容灾,数据汇总,建议用这种,比如binlog+kafka,数据得一致性也可以达到秒级;
纯粹得高并发场景,不建议这种方案,入抢购,秒杀等。
个人结论:
实时性一致方案:采用“先写MySQL ,再删除Redis”的策略,这种情况下虽然也会存在两者不一致,但是需要满足的条件有点苛刻,所以是满足实时性条件下,能尽量满足一致性的最优解。
最终一致性方案:采用“先写MySQL,通过Binlog,异步更新Redis“,可以通过Binlog,结合消息队列异步更新Redis,是最终一致性的最优解。
项目实战
数据更新
因为项目对实时性要求高,所以采用方案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 = Excetion.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();
RedisClien.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;
}
测试用例
@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: