【redis】CacheAside的数据不一致性问题

缓存的合理使用确提升了系统的吞吐量和稳定性,然而这是有代价的,这个代价便是缓存和数据库的一致性带来了挑战。

新增数据时,数据直接写入数据库,缓存中不存在对应记录。首次查询请求会触发缓存回填,即从数据库读取新数据并写入缓存,后续请求可直接命中缓存,所以新增数据时不会有数据不一致性问题。

当一条数据同时存在数据库、缓存,现在你要更新此数据,你会怎么更新?先更新数据库?还是先更新缓存?

有以下四种更新方式:

  • 先更新数据库后更新缓存
  • 先更新缓存后更新数据库
  • 先更新数据库后删除缓存
  • 先删除缓存后更新数据

接下来会逐一分析这四种更新方式带来的数据不一致性问题并解决。

先更新数据库后更新缓存

一种常见的操作是,设置一个过期时间,让写请求以数据库为准,过期后,读请求同步数据库中的最新数据给缓存。那么在加入了过期时间后,是否就不会有问题了呢?并不是这样。

时间 线程A(写请求) 线程B(写请求) 问题
T1 更新数据库为99
T2 更新数据库为88
T3 更新缓存为88
T4 更新缓存为99
T5 此时缓存的值为99,但是实际上数据库的值为88,数据不一致

数据不一致产生的场景:

  1. 并发写冲突:就是上面表格中的情况,线程A和线程B同时更新同一数据,线程A先完成数据库的更新,线程B然后完成数据库和缓存更新,最后线程A更新缓存,会导致缓存最终存储线程A的旧数据。
  2. 缓存更新失败:若数据库更新成功但缓存更新失败,后续请求会读取到旧缓存数据,直至缓存过期。

先更新缓存后更新数据库

先更新数据库后更新缓存会导致数据不一致,那你可能会想,这是否表示,我应该先让缓存更新,之后再去更新数据库呢?

时间 线程A(写请求) 线程B(写请求) 问题
T1 更新缓存为99
T2 更新缓存为88
T3 更新数据库为88
T4 更新数据库为99
T5 此时缓存的值为88,但是实际上数据库的值为99,数据不一致

数据不一致产生的场景:

  1. 并发写冲突:就是上面表格中的情况,线程A和线程B同时更新同一数据,线程A先完成缓存的更新,线程B然后完成缓存和数据库更新,最后线程A更新数据库,会导致缓存最终存储线程B的旧数据。
  2. 缓存成功但数据库失败:缓存存储新数据,数据库仍为旧值,后续请求直接命中缓存错误数据。
  3. 主从延迟问题:若数据库为主从架构,缓存更新后从库未同步完成,读请求可能从从库获取旧数据并覆盖缓存。

先删除缓存后更新数据库

既然更新数据库前后更新缓存都会导致数据不一致,那如果采取删除缓存的策略呢?也就是说我们在更新数据库的时候失效对应的缓存,让缓存在下次触发读请求时进行更新,是否会更好呢?

时间 线程A(写请求) 线程B(读请求) 问题
T1 删除缓存
T2 从数据库中读取值为100,设置缓存中的值为100
T3 更新数据库为99
T4 此时缓存的值为100,但是实际上数据库的值为99,数据不一致

数据不一致产生的场景:

  1. 读写并发冲突:就是上面表格中的情况,线程A先删除缓存,线程B然后从数据库读取旧的值并更新缓存,最后线程A更新数据库,会导致缓存最终存储数据库的旧数据。

针对这种场景,有个做法是所谓的"延迟双删策略",就是说,既然可能因为读请求把一个旧的值又写回去,那么我在写请求处理完之后,等到差不多的时间延迟再重新删除这个缓存值。

时间 线程A(写请求) 线程B(读请求) 线程C(读请求) 问题
T1 删除缓存
T2 从数据库中读取值为100,设置缓存中的值为100 读到脏数据
T3 更新数据库为99 读到脏数据
T4 sleep(N) 读到脏数据
T5 删除缓存
T6 从数据库中读取值为99,设置缓存中的值为99

这种解决思路的关键在于对N的时间的判断,如果N时间太短,线程A第二次删除缓存的时间依旧早于线程B把脏数据写回缓存的时间,那么相当于做了无用功。而N如果设置得太长,那么在触发双删之前,新请求看到的都是脏数据。

先更新数据库后删除缓存

那如果我们把更新数据库放在删除缓存之前呢,问题是否解决?我们继续从读写并发的场景看下去,有没有类似的问题。

时间 线程A(写请求) 线程B(读请求) 线程C(读请求) 问题
T1 更新数据库为99
T2 从数据库中读取值为100,设置缓存中的值为100 读到脏数据
T3 删除缓存
T4 从数据库中读取值为99,设置缓存中的值为99

可以看到,大体上,采取先更新数据库再删除缓存的策略是没有问题的,仅在更新数据库成功到缓存删除之间的时间差内------[T2,T3)的窗口,可能会被别的线程读取到老值,但是这个时间窗口非常的短

但是真实场景下,还是会有一个情况存在不一致的可能性,这个场景是读线程发现缓存不存在,于是读写并发时,读线程回写进去老值。并发情况如下:

时间 线程A(写请求) 线程B(读请求) 问题
T1 查询缓存,缓存缺失,查询数据库得到当前值100
T2 更新数据库为99
T3 删除缓存
T4 将100写入缓存
T5 此时缓存的值为100,但是实际上数据库的值为99,数据不一致

总的来说,这个不一致场景出现条件非常严格,因为并发量很大时,缓存不太可能不存在;如果并发很大,而缓存真的不存在,那么很可能是这时的写场景很多,因为写场景会删除缓存。

数据不一致性的根本原因

为什么我们几乎没办法做到缓存和数据库之间的强一致呢?

理想情况下,我们需要在数据库更新完后把对应的最新数据同步到缓存中,以便在读请求的时候能读到新的数据而不是旧的数据(脏数据)。但是很可惜,由于数据库和Redis之间是没有事务保证的,更新数据库和更新(删除)缓存不是一个原子操作,所以我们无法确保写入数据库成功后,写入Redis也是一定成功的;即便Redis写入能成功,在数据库写入成功后到Redis写入成功前的这段时间里,Redis数据也肯定是和MySQL不一致的。

所以说这个时间窗口是没办法完全消灭的,除非我们付出极大的代价,使用分布式事务等各种手段去维持强一致,但是这样会使得系统的整体性能大幅度下降,甚至比不用缓存还慢,这样不就与我们使用缓存的目标背道而驰了吗?

不过虽然无法做到强一致,但是我们能做到的是缓存与数据库达到最终一致,而且不一致的时间窗口我们能做到尽可能短,要是数据库更新完毕后,删除缓存失败了咋办?

对于这种情况,一种常见的解决方案就是使用消息中间件来实现删除的重试。大家知道,MQ一般都自带消费失败重试的机制,当我们要删除缓存的时候,就往MQ中扔一条消息,缓存服务读取该消息并尝试删除缓存,删除失败了就会自动重试。

异步延迟双删

延迟双删:先执行缓存清除操作,再执行数据库更新操作,延迟N秒之后再执行一次缓存清除操作,这样就不用担心缓存中的数据和数据库中的数据不一致了。

那么这个延迟N秒,N是多大比较合适呢?一般来说,N要大于一次写操作的时间,如果延迟时间小于写入缓存的时间,会导致请求A 已经延迟清除了缓存,但是此时请求B缓存还未写入,具体是多少,就要结合自己的业务来统计这个数值了。

通过订阅MySQL binlog的方式处理缓存

上面讲到的MQ处理方式需要业务代码里面显式地发送MQ消息。还有一种优雅的方式便是订阅MySQL的binlog,监听数据的真实变化情况以处理相关的缓存。

目前业界类似的产品有Canal,具体的操作图如下:

相关推荐
YDS8294 小时前
大营销平台 —— 活动SKU库存扣减业务及其一致性处理
java·spring boot·redis·rabbitmq·ddd
爬也要爬着前进19 小时前
redis主从搭建
数据库·redis·缓存
用户0953675158321 小时前
在系统的学习 redis 前的疑惑
redis·go
吴声子夜歌1 天前
Redis 3.x——集群故障转移
java·数据库·redis·集群
明豆1 天前
Redis 分布式缓存生产实战
redis·分布式·缓存
海兰1 天前
【高速缓存】RedisVL 指南:从向量搜索到 AI 应用落地
人工智能·redis
weixin_727535621 天前
双Token认证体系深度拆解:Spring Security + JWT + Redis
redis·spring·wpf
William Dawson1 天前
【华为云 MRS Redis 安全集群 Kerberos 认证接入实战(Spring Boot \+ Jedis)】
redis·安全·华为云
zhouhui0011 天前
AI帮我写了个Spring Boot校验,线上漏掉了这组边界条件
java·spring boot·redis·ai编程
吃饱了得干活1 天前
扫码登录如何实现?从原理到实战
java·spring boot·redis