Redis缓存与数据库一致性解决方案

本文涉及的产品
RDS Agent(兼容Hermes Agent),2核4GB
云数据库 PolarDB MySQL 版,列存表分析加速 8核16GB
RDS MySQL DuckDB 分析主实例,集群系列 4核8GB
简介: 本文深入探讨了缓存与数据库数据一致性问题,分析了读写缓存和只读缓存的不同场景及解决方案。重点介绍了无并发和高并发下的数据不一致现象及其应对策略,如重试机制、延迟双删、设置缓存TTL等。同时对比了先更新数据库再删除缓存与直接更新缓存的优缺点,并推荐使用分布式锁解决写+写并发导致的不一致问题。最后总结了缓存同步策略,建议优先采用“先更新数据库,再删缓存”的方式,配合合适TTL确保最终一致性。内容实用,适合关注系统架构优化的开发者学习。

本文已收录在Github关注我,紧跟本系列专栏文章,咱们下篇再续!

  • 🚀 魔都架构师 | 全网30W技术追随者
  • 🔧 大厂分布式系统/数据中台实战专家
  • 🏆 主导交易系统百万级流量调优 & 车联网平台架构
  • 🧠 AIGC应用开发先行者 | 区块链落地实践者
  • 🌍 以技术驱动创新,我们的征途是改变世界!
  • 👉 实战干货:编程严选网

0 前言

用Redis缓存,就存在缓存和DB数据一致性问题。若数据不一致,则业务应用从缓存读取的数据就不是最新数据,可能导致严重错误。如将商品库存缓存在Redis,若库存数量不对,则下单时就可能出错,这是难以接受的。

1 什么是缓存和DB的数据一致性?

一致性包含如下情况:

  • 缓存有数据:缓存的数据值需和DB相同
  • 缓存无数据:DB是最新值

不符合这两种情况的,都属缓存和DB数据不一致

2 缓存的读写模式

根据是否接收写请求,可将缓存分成读写缓存和只读缓存。

2.1 读写缓存

若要对数据进行增删改,需在Cache进行。 同时根据采取的写回策略,决定是否同步写回DB:

2.1.1 同步直写

写缓存时,也同步写数据库,缓存和数据库中的数据一致。

2.1.2 异步写回

写缓存时不同步写DB,等数据从缓存中淘汰,再写回DB。使用这种策略时,若数据还没有写回DB,缓存就发生故障,则此时,DB就没有最新数据了。

所以,对于读写缓存,要想保证缓存和DB数据一致,就要采用同步直写。若采用这种策略,就需同时更新缓存和DB。所以,要在业务代码中使用事务,保证缓存和DB更新的原子性,即两者:

  • 要么一起更新
  • 要么都不更新,返回错误信息,进行重试

否则,我们无法实现同步直写。

有些场景下,我们对数据一致性要求不高,比如缓存的是电商商品的非关键属性或短视频的创建或修改时间等,则可以使用异步写回

2.2 只读缓存

  • 新增数据 直接写DB
  • 删改数据 删改DB,删除只读缓存中的数据

这样应用后续再访问这些增删改的数据时,由于Cache无数据 =》缓存缺失。 此时,再从DB把数据读入Cache,这样后续再访问数据时,直接读Cache。

下面我们针对只读缓存,看看具体会遇到哪些问题,又该如何解决。

3 新增数据

数据直接写到DB,不操作Cache。此时,Cache本身无新增数据,而DB是最新值,所以,此时缓存和DB数据一致。

4 删改数据

此时应用既要更新DB,也要删除Cache。这俩操作若无法保证原子性,就可能出现数据不一致。

4.1 先删Cache,再更新DB

image.png

4.2 先更新DB,再删除Cache

image.png

综上,更新DB和删除Cache时,无论谁先执行,只要有一个操作失败,就会导致客户端读到旧值。

咋办?好像咋都会导致数据不一致?

5 数据不一致的解决方案

5.1 无并发

5.1.1 重试

  • 要删除的Cache值
  • 或要更新的DB值

暂存到MQ。

当应用删除Cache或更新DB:

  • 成功:把这些值从MQ去除,避免重复操作,这时即可保证DB、Cache数据一致性
  • 失败:重试。从MQ重读这些值,再进行删除或更新。若重试超过一定次数,还没成功,就向业务层发送报错信息

在更新数据库和删除缓存值的过程,若任一操作失败:

5.1.2 先更新DB,再删除缓存

若删除缓存失败,再次重试后删除成功

image.png

其它情况不赘述。

5.2 高并发

即使这俩操作第一次执行时都没失败,在高并发请求时,应用还可能读到不一致数据。按不同的删除和更新顺序,分成:

5.2.1 先删除Cache,再更新DB

  • 时刻t1< t2 < t3
  • 线程T1、T2
T1 更新操作 T2 查询操作 影响
t1 删除缓存X的缓存值 缓存X为空
t2 1. 读缓存,未命中
于是从DB读X,读到旧值
2.把读到的数据X的旧值写入Cache
1.T1尚未更新 DB,导致 T2 读到旧值
2.T2把旧值写入Cache,导致其它线程可能读到旧值
t3 更新DB中的X Cache中是旧值,DB 是新值,最终二者不一致

这咋办?考虑:

① 延迟双删

T1更新完DB后,让它sleep一段时间,再删除Cache。

Q:为何sleep一段时间?

让T2能先从DB读数据,再把缺失数据写入Cache,然后,T1再执行删除。所以,须满足:

的时间>【读取数据,再写入的时间】

Q:sleep时间咋确定?

业务程序运行时,统计线程读数据和写缓存的操作时间,以此估算。确保读请求结束,写请求可删除读请求造成的缓存脏数据。

该策略还要考虑 Cache 和 DB 主从同步耗时。最后写数据的休眠时间:则在读数据业务逻辑的耗时的基础上,加上几百ms。

这样,当其它线程读数据时,会发现Cache未命中,所以从DB读最新值。因为该方案会在第一次删除Cache后,延迟一段时间再删除,所以叫“延迟双删”。

cache.delKey(X)
db.update(X)
Thread.sleep(N)
cache.delKey(X)
② 设置缓存TTL

设置缓存TTL,是保证最终一致性的解决方案。所有写操作以DB为准,只要到达缓存TTL,则后面的读请求自然都会从DB读最新值,然后回填缓存。

结合【双删策略】+【缓存TTL设置】,最差就是在TTL时间内数据存在不一致,而且又增加写请求耗时。

③ 该方案缺点

操作完DB后,由于某原因删除Cache失败,此时可能出现数据不一致,需提供

④ 重试补偿方案
方案一
  1. 更新DB
  2. Cache因某异常,删除失败(问题点)
  3. 将待删除的K发至MQ
  4. 自己消费消息,获得待删除K
  5. 重试删除操作,直到成功(解决问题)

缺点:对业务代码侵入性太强,于是有方案二。

方案二

启动一个订阅程序去订阅DB binlog,获得待操作数据。在应用程序中,另起一段程序,获得这个订阅程序传来的信息,执行删除Cache操作。

  1. 更新DB数据
  2. DB将操作信息写入binlog日志
  3. 订阅程序提取出所需要的数据及K
  4. 另起一段非业务代码,获得该信息
  5. 尝试删除Cache操作,发现删除失败
  6. 将这些信息发送至MQ
  7. 重新从MQ获得该数据,重试删除操作

5.2.2 先更新DB,再删除Cache

T1 T2 问题
t1 删除 DB 的数据 X
t2 读数据X,Cache命中,
从Cache读X,读到旧值
T1 尚未删除 Cache
导致 T2 读到 Cache 旧值
t3 删除 Cache的数据 X

此时,若其他线程并发读缓存的请求不多,就不会有很多请求读到旧值。

线程一般会很快删除缓存值,当其他线程再次读取,就会发生缓存缺失,进而从数据库读最新值。所以,这种情况对业务影响较小。

5.3 小结

至此,Cache和DB数据不一致场景也都有对应解决方案。

  • 删除Cache或更新DB失败而导致数据不一致:重试,确保删除或更新成功
  • 删除Cache、更新DB这俩操作中,有其他线程的并发读操作,导致其他线程读取到旧值:延迟双删

大多场景都将Redis作为只读缓存:

  • 既可先删除缓存值,再更新数据库
  • 也可先更新数据库,再删除缓存

推荐

优先使用先更新数据库再删除缓存:

  • 先删除缓存值再更新数据库,有可能导致请求因缓存缺失而访问数据库,给数据库带来压力
  • 如果业务应用中读取数据库和写缓存的时间不好估算,那么,延迟双删中的等待时间就不好设置

不过,当使用先更新数据库再删除缓存时,也有个地方需要注意,如果业务层要求必须读取一致的数据,那么,我们就需要在更新数据库时,先在Redis缓存客户端暂存并发读请求,等数据库更新完、缓存值删除后,再读取数据,从而保证数据一致性。

6 直接更新Cache

在只读缓存中进行数据的删改操作时,需要在缓存中删除相应的缓存值。若此过程不是删除缓存,而是直接更新缓存,效果如何?

这种情况相当于把Redis当做读写缓存使用,删改操作同时操作DB、Cache。

6.1 无并发

先更新DB,再更新Cache

若更新DB成功,但Cache更新失败,此时DB最新值,但缓存旧值,后续读请求会直接命中缓存,得到旧值。

先更新Cache,再更新DB

如果更新缓存成功,但数据库更新失败:

  • 缓存中是最新值
  • 数据库中是旧值

后续读请求会直接命中缓存,但得到的是最新值,短期对业务影响不大。但一旦缓存过期或满容后被淘汰,读请求就会从数据库中重新加载旧值到缓存中,之后的读请求会从缓存中得到旧值,对业务产生影响。

针对这种其中一个操作可能失败的情况,类似只读缓存方案,也可使用重试。把第二步操作放入到MQ中,消费者从MQ取出消息,再更新缓存或数据库,成功后把消息从消息队列删除,否则进行重试,以此达到数据库和缓存的最终一致。

6.2 并发读写

也会产生不一致,分为以下4种双写场景。

双写模式下,更新DB有返回值,更新Redis的操作可放到更新DB返回后进行,通过数据库的行锁机制,可以避免更新DB是线程A,B,但更新Redis是线程B,A的情况。

先更新数据库,再更新缓存

写+读并发。 线程A先更新数据库,之后线程B读取数据,此时线程B会命中缓存,读取到旧值,之后线程A更新缓存成功,后续的读请求会命中缓存得到最新值。

这时,线程A未更新完缓存之前,在这期间的读请求会短暂读到旧值,对业务短暂影响。

先更新缓存,再更新数据库

写+读并发。 线程A先更新缓存成功,之后线程B读取数据,此时线程B命中缓存,读取到最新值后返回,之后线程A更新数据库成功。这种场景下,虽然线程A还未更新完数据库,数据库会与缓存存在短暂不一致,但在这之前进来的读请求都能直接命中缓存,获取到最新值,所以对业务没影响。

先更新数据库,再更新缓存

写+写并发。 线程A和线程B同时更新同一条数据,更新数据库的顺序是先A后B,但更新缓存时顺序是先B后A,这会导致数据库和缓存的不一致。

先更新缓存,再更新数据库

写+写并发。 与场景3类似,线程A和线程B同时更新同一条数据,更新缓存的顺序是先A后B,但是更新数据库的顺序是先B后A,这也会导致数据库和缓存的不一致。

场景1和2对业务影响较小,场景3和4会造成数据库和缓存不一致,影响较大。即读写缓存下,写+读并发对业务的影响较小,而写+写并发时,会造成数据库和缓存的不一致。

针对场景3、4解决方案:对于写请求,配合分布式锁。写请求进来时,针对同一资源的修改操作,先加分布式锁,这样同一时间只允许一个线程去更新DB和Cache,没有拿到锁的线程把操作放入到MQ,延时处理。 这样保证多个线程操作同一资源的顺序性,以此保证一致性。

综上,使用读写缓存同时操作数据库和缓存时,因为其中一个操作失败导致不一致的问题,同样可以通过MQ重试解决。 而在并发的场景下,读+写并发对业务没有影响或者影响较小,而写+写并发时需要配合分布式锁的使用,才能保证缓存和数据库的一致性。

另外,读写缓存模式由于会同时更新数据库和缓存:

  • 优点:缓存一直会有数据。若更新后立即访问,可直接命中缓存,能降低读请求对DB的压力(没有只读缓存的删除缓存导致缓存缺失和再加载的过程)
  • 缺点:若更新后的数据,之后很少再被访问到,会导致缓存中保留的不是最热数据,缓存利用率不高(只读缓存中保留的都是热数据)

所以读写缓存比较适合用于读写相当的业务场景。

7 缓存数据同步策略

若修改原始数据,考虑缓存数据更新及时性,可采用主动更新缓存:

7.1 先更新Cache,再更新DB

不可行。数据库设计复杂,压力集中,数据库因超时等原因更新操作失败可能性大,还涉及事务,很可能因数据库更新失败,导致缓存和数据库数据不一致。

7.2 先更新DB,再更新Cache

不可行:

  • 若线程A、B先后完成DB更新,但更新Cache时却是B、A顺序,很可能把旧数据更新到缓存 -> 数据不一致
  • 不确定Cache中的数据是否会被访问,不一定要把所有数据都更新到Cache

7.3 先删除Cache,再更新DB,访问时按需加载数据到Cache

不可行。高并发时,可能删除Cache后还没来得及更新DB,就有另一线程先读取旧值到缓存去。并发越高,概率越大。

7.4 先更新DB,再删Cache,访问时按需加载数据至Cache

最好。虽极端时,这种策略也可能数据不一致,但概率很低。

一个极端案例,更新数据的时间点恰好缓存失效:

  • 【查询线程A】,先读到DB旧值
  • 随后【更新线程B】操作DB完成更新,并删除Cache后
  • 【查询线程A】再把旧值加入Cache

更新DB后,删除缓存的操作可能失败,若失败,考虑把任务加入延迟队列进行延迟重试,确保数据可删除,缓存可及时更新。因为删除操作幂等,即使重复删,问题不大,这也是删除比更新缓存好的一点。

所以对于缓存更新,推荐缓存中的数据不由数据更新操作主动触发,统一按需加载,数据更新后及时删除缓存中的数据。

尽量设置合适的缓存TTL,这样即便真发生不一致,也可在过期后数据得到及时同步。

8 总结

操作顺序 是否有并发请求 潜在问题 现象 应对方案
先删除缓存值,再更新数据库 缓存删除成功,但数据库更新失败 应用从数据库读到旧数据 重试数据库更新
同上 缓存删除后,尚未更新数据库,有并发读请求 并发请求从数据库读到旧值,并且更新到缓存,导致后续请求都读取旧值 延迟双删
先更新数据库,再删除缓存 数据库更新成功,但缓存删除失败 应用从缓存读到旧数据 重试缓存删除
同上 数据库更新成功后,尚未删除缓存,有并发读请求 并发请求从缓存中读到旧值 等待缓存删除完成,期间会有不一致数据短暂存在
目录
相关文章
|
缓存 NoSQL 关系型数据库
美团面试:MySQL有1000w数据,redis只存20w的数据,如何做 缓存 设计?
美团面试:MySQL有1000w数据,redis只存20w的数据,如何做 缓存 设计?
美团面试:MySQL有1000w数据,redis只存20w的数据,如何做 缓存 设计?
|
8月前
|
缓存 负载均衡 监控
135_负载均衡:Redis缓存 - 提高缓存命中率的配置与最佳实践
在现代大型语言模型(LLM)部署架构中,缓存系统扮演着至关重要的角色。随着LLM应用规模的不断扩大和用户需求的持续增长,如何构建高效、可靠的缓存架构成为系统性能优化的核心挑战。Redis作为业界领先的内存数据库,因其高性能、丰富的数据结构和灵活的配置选项,已成为LLM部署中首选的缓存解决方案。
862 25
|
9月前
|
存储 缓存 NoSQL
Redis专题-实战篇二-商户查询缓存
本文介绍了缓存的基本概念、应用场景及实现方式,涵盖Redis缓存设计、缓存更新策略、缓存穿透问题及其解决方案。重点讲解了缓存空对象与布隆过滤器的使用,并通过代码示例演示了商铺查询的缓存优化实践。
383 1
Redis专题-实战篇二-商户查询缓存
|
8月前
|
缓存 运维 监控
Redis 7.0 高性能缓存架构设计与优化
🌟蒋星熠Jaxonic,技术宇宙中的星际旅人。深耕Redis 7.0高性能缓存架构,探索函数化编程、多层缓存、集群优化与分片消息系统,用代码在二进制星河中谱写极客诗篇。
1649 3
|
9月前
|
缓存 Java 应用服务中间件
Spring Boot配置优化:Tomcat+数据库+缓存+日志,全场景教程
本文详解Spring Boot十大核心配置优化技巧,涵盖Tomcat连接池、数据库连接池、Jackson时区、日志管理、缓存策略、异步线程池等关键配置,结合代码示例与通俗解释,助你轻松掌握高并发场景下的性能调优方法,适用于实际项目落地。
1679 5
|
9月前
|
缓存 NoSQL 关系型数据库
Redis缓存和分布式锁
Redis 是一种高性能的键值存储系统,广泛用于缓存、消息队列和内存数据库。其典型应用包括缓解关系型数据库压力,通过缓存热点数据提高查询效率,支持高并发访问。此外,Redis 还可用于实现分布式锁,解决分布式系统中的资源竞争问题。文章还探讨了缓存的更新策略、缓存穿透与雪崩的解决方案,以及 Redlock 算法等关键技术。
|
缓存 NoSQL Java
Redis+Caffeine构建高性能二级缓存
大家好,我是摘星。今天为大家带来的是Redis+Caffeine构建高性能二级缓存,废话不多说直接开始~
1664 0
|
消息中间件 缓存 NoSQL
基于Spring Data Redis与RabbitMQ实现字符串缓存和计数功能(数据同步)
总的来说,借助Spring Data Redis和RabbitMQ,我们可以轻松实现字符串缓存和计数的功能。而关键的部分不过是一些"厨房的套路",一旦你掌握了这些套路,那么你就像厨师一样可以准备出一道道饕餮美食了。通过这种方式促进数据处理效率无疑将大大提高我们的生产力。
390 32
|
缓存 NoSQL Java
Redis:现代服务端开发的缓存基石与电商实践-优雅草卓伊凡
Redis:现代服务端开发的缓存基石与电商实践-优雅草卓伊凡
335 5
Redis:现代服务端开发的缓存基石与电商实践-优雅草卓伊凡
|
存储 缓存 NoSQL
解决Redis缓存数据类型丢失问题
解决Redis缓存数据类型丢失问题
628 85

相关产品

  • 云数据库 Tair(兼容 Redis)