Redis 缓存一致性
只要在数据库前面加了一层缓存,「缓存里的数据和库里的数据不一样」这个问题就注定绕不开。这篇整理一下我对缓存一致性的理解,以及几种常见方案各自的坑。
缓存一致性
首先我们先说说什么是缓存一致性问题,为什么要解决,以及方案是什么。
缓存一致性问题是指在使用缓存时,由于缓存数据与数据库数据的不一致性,可能会导致数据错误或者数据丢失等问题。这种问题在高并发场景下尤为常见,因为多个线程同时读写同一份数据时,很难保证数据的一致性。如何保证和Redis内存中的数据与DB数据库中的数据保持一致,并且不能出现数据不一致的情况(高并发场景中),如果出现数据不一致对于某些情况来说可能会出现大麻烦,缓存一致性的解决方案也很重要。
问题的根源在于:缓存和数据库是两个独立的存储,对它们的写入不是一个原子操作。无论先写哪个,两步之间都存在一个时间窗口,其他线程在这个窗口里读到的就可能是「一半新一半旧」的状态。再叠加线程调度的不确定性、数据库事务提交的时机、网络延迟,各种交错顺序都会出现。所以讨论一致性方案,本质上是在讨论:这个窗口能不能消除,消除不了的话,脏数据能存活多久、业务能不能接受。
以下是我列出常见的几种方案,以及它们在高并发场景下各自的问题。
直接写入缓存 (不推荐)
思路最直观:更新数据库之后,紧接着把最新值写进 Redis,让缓存始终有值,读请求也不会穿透到数据库。

在3,4步执行时高并发场景下无法保证写入Redis数据的Java线程会进行顺序执行(因为CPU时间分片问题,并且加上分布式、微服务情况更加明显)。才会导致最新数据可能被其他线程中的旧数据覆盖,如果发生了之后后续没有其他线程执行,可能缓存数据一直会保持旧数据情况。
换句话说,线程 A 先写库、线程 B 后写库,但写 Redis 的顺序完全可能反过来变成 B 先 A 后——最终缓存里留下的是 A 的旧值。而且这种覆盖没有任何自愈机制:只要后面没有新的写操作来「冲刷」这个 Key,脏数据就会一直留在缓存里被反复读到。
这里可能有人会直接使用分布式锁(单应用加JVM锁即可),加锁需要控制整个DB写入到Redis写入的流程,并且每个Key操作的函数范围需要自己把控,且效率肯定下降得厉害,不过也可以自己控制一下锁的粒度,加分布式锁这种方案比较适合强一致性场景,为了一致性牺牲性能(强一致性场景推荐)。
写入数据库后删除缓存(单删策略 - 不推荐)
既然「写缓存」会有覆盖问题,那就换个思路:更新数据库后只删缓存,让下一个读请求自己回源数据库、把新值加载回来。删除是幂等的,不存在新值被旧值覆盖的问题。

单删策略看似可以很好的避免写入被覆盖问题,但其他查询数据的线程加入到逻辑中就会出现问题,查询线程在获取数据库数据时,可能其他线程正在提交数据库事务,而此时该查询线程就会获取到提交事务前的旧数据而存入缓存中造成数据不一致问题。
具体的交错顺序是:读线程发现缓存未命中,去数据库查数据,此时查到的是写事务提交前的旧值;写线程随后提交事务并删除缓存;最后读线程才把手里的旧值写回缓存。删除动作发生在旧值回填之前,等于白删了,缓存里又是脏数据,而且同样没有自愈手段。
延时双删策略 (推荐)

通过加入延迟队列,等待一定时间后再次删除该Key的策略,解决掉了单删遗留的问题,也没有进行加锁操作,所以缓存双删策略,我个人觉得更适合允许部分数据短时间不一致的应用场景。
第二次删除的意义就在于兜底:上面那种「旧值在删除之后才被回填」的交错,会被延迟到期的第二次删除清理掉。延迟时间的选取需要覆盖「读线程查库 + 写回缓存」这一整段耗时,业务里一般根据接口的实际耗时估一个偏大的值。代价也很明确——在延迟窗口内,读到的仍然可能是旧数据,所以它保证的是最终一致,而不是强一致。
除开以上的方案,还有很多方案可以选择:
- 读写锁。在写操作时获取写锁,其他线程无法读写缓存,直到写操作完成。这种方案可以保证数据的一致性,但是可能会增加读操作的延迟和锁的竞争问题。
- 版本号控制。在缓存中存储数据版本号,每次更新数据时同时更新版本号。在读取数据时,比较版本号是否一致,如果不一致则重新加载数据。这种方案可以保证数据的一致性,但是需要增加数据的存储空间和读取的开销。
- 带超时的缓存。在缓存中存储数据时,设置缓存的过期时间,超过时间后缓存自动失效,需要重新加载数据。这种方案可以减少缓存数据与数据库数据不一致的可能性,但是可能会增加数据的存储空间和读取的开销。
- 数据库异步更新。在写入数据库时,不直接更新缓存,而是将更新操作异步发送到消息队列中,由消费者负责更新缓存。这种方案可以减少数据库的负载压力,但是可能会增加消息队列的复杂度和延迟。
踩坑与注意
-
无论选哪种删除类方案,都建议给缓存加上过期时间做兜底。即使某次交错产生了脏数据,过期后也能自动回源修正,把不一致的存活时间限制在可控范围内。
-
延时双删的第二次删除如果放在内存里做(比如线程 sleep 后删除),应用重启就丢了。对可靠性有要求的话,用消息队列或延迟队列来投递删除任务,失败还能重试。
-
删除缓存本身也可能失败(网络抖动、Redis 短暂不可用)。删除失败要有重试机制,否则脏数据同样会长期驻留。
-
不要试图在普通业务里追求「缓存与数据库任意时刻完全一致」。缓存和数据库是两套存储,只要不引入锁或事务级的协调,强一致就做不到;先想清楚业务能容忍多久的不一致,再选方案。
小结
缓存一致性没有银弹,本质是在一致性、性能、实现复杂度之间做取舍:直接写缓存会被并发覆盖且无法自愈;单删堵住了覆盖问题,却挡不住旧值回填;延时双删用一次延迟删除兜底,换来最终一致,适合能接受短暂脏读的大多数业务;真正的强一致场景,老老实实上锁,用性能换正确性。选型之前先回答一个问题——这份数据脏几百毫秒,业务到底疼不疼。
评论 / COMMENTS