保障缓存和数据库尽量一致的策略

双删策略 Cache-Aside

第一次删(把路堵死):

先把 Redis 里的缓存删掉。 目的:让接下来的读请求必须去查数据库,避免读到旧缓存。 更新数据库:把 MySQL 里的数据更新成最新的(比如 9)。

延迟等待(关键一步): 线程在这里睡一会儿(比如 1 秒)。 目的:这一步非常关键!我们要等什么呢?

等那些在"第一次删"之后、"更新数据库"之前的那一瞬间进来的读请求。

让它们有足够的时间去数据库读到旧数据(想想,为什么?????????这里是理解延迟双删的关键),并把这个旧数据重新写回 Redis(变成脏数据)。

故意留出这个时间窗口,就是为了抓出这些"漏网之鱼"。

第二次删(清理战场): 睡醒了,再删一次 Redis。 目的:把刚才那些读请求可能写进去的脏数据(旧数据)再次干掉!

延迟第二次删除,为了防止更新数据库时,有并发读请求把旧数据回填到缓存,造成脏数据。

因为数据库(MySQL)的"提交"和"同步"是需要时间的。

在高并发或者主从分离的架构下,数据库不是"瞬间"就变的。哪怕只有一毫秒的延迟,也会有其他的线程读到旧值。

删-改-立马再删" 为什么不行?

如果删得太快(不等待),第二次删可能根本没用,甚至会把刚写进去的"新数据"给误杀了,相当于白干。

为什么不直接删-写-更新?

第二次为什么不直接更新呢?为什么不更新的时候回填呢?

如果选择更新缓存",必须保证 "读取新数据" 和 "写入缓存" 是一个原子操作(即不可分割的整体)。但这样成本高。

更新(Update): 你需要先把数据从数据库查出来(一次 IO)。 你需要把数据序列化(转成 JSON 等格式)。 你需要把整个大字符串发给 Redis(占网络带宽)。 Redis 需要申请内存空间存这个大对象。

删除(Delete):就是一个简单的 DEL key 命令。时间复杂度是 O(1),非常快,不占带宽。

成本极高!

步骤多了,容易被其他线程干扰,并发不安全了。

更新数据的时候不SET值,只有查询的时候才SET值

因为读请求去 SET 数据是最安全的。因为它拿的数据是它刚刚从数据库查出来的,绝对是最新的,绝对不会出现并发覆盖的问题。

想象一下,你是一个图书管理员(写请求),你要把《Java编程思想》这本书的价格从 50 元改成 60 元。

❌ 错误的做法(写时回填/更新): 你跑去书架(MySQL),把价格改成了 60 元。 你顺手把缓存区(Redis)里的那张卡片也改成了 60 元(回填)。

另一个管理员(线程B)也来改价格,他把价格改成了 70 元,并且他手速快,先更新了缓存卡片。 然后你的网络恢复了,你把卡片又改回了 60 元。

A write X as 60

B write X as 70

B update Cache X as 70

A update Cache X as 60

B update Cache Lost!

C Get X = 60

多个线程交叉改一个值,两个可能被拆分的步骤,多一个步骤,就容易出错。

所以写线程只修改一次

A write X as 60

B write X as 70

C Get X = 70 (速度快)

C update Cache X as 70

不要把两次写(写DB和写Cache)放到一起去。

"读写穿透" (Read/Write Through)

找个管家(缓存层)帮你管数据库。

你只和缓存(Redis)打交道。

读:你问 Redis,Redis 发现没有,它自己去数据库查,查完自己存一份,再给你。

写:你告诉 Redis "改数据",Redis 先把缓存改了,然后它同步去把数据库也改了。

"异步回写" (Write Behind / Write Back)

核心思想:只写缓存,数据库?以后再说!

场景:写操作特别多,特别快,数据库根本扛不住(比如直播间的点赞数、计数器)。

优点:性能极高! 数据库压力极小,因为是批量写的。 缺点:数据不安全! 如果 Redis 挂了,还没来得及同步的数据就丢了。

适用:对数据一致性要求不高,但对性能要求极高的场景(比如点赞、浏览量、日志)。

和读写穿透的对比

  1. Read/Write Through(读写穿透)------ "同步管家" 这个模式下,Redis 就像一个超级负责任的管家。

你(应用程序):把数据交给管家,说"把库存改成 9"。

管家(Redis):"好的主人!"

他先把家里(内存/缓存)的库存改成 9。

然后,他亲自拿着账本,跑一趟银行(数据库),把数据库里的库存也改成 9

直到银行确认改好了,他才回来跟你说:"主人,搞定了!"

特点:同步。你必须等管家从银行回来,你才能继续干下一件事。数据绝对一致,但稍微慢一点点。

  1. Write Behind Caching(异步回写/写回)------ "甩手掌柜" 这个模式下,Redis 就像一个特别懒但是效率高的秘书。

你(应用程序):把数据扔给秘书,说"把库存改成 9"。

秘书(Redis):"收到!"(啪的一下盖个章,立马回复你)

他只把家里(内存/缓存)的库存改成 9。

至于银行(数据库)?他记在小本本上:"回头有空再存吧"。

他根本不去银行,直接跟你说"搞定了",让你去干别的。

他什么时候去银行?等他心情好,或者攒了一大堆小本本,才慢悠悠去更新数据库。

特点:异步。你不用等他,速度飞快。但如果他还没来得及去银行,突然猝死(服务器宕机)了,那这笔钱(数据)就丢了。

和Cache Aside的对比

Read/Write Through(穿透模式),缓存是"门卫",所有读写都必须经过它,它负责同步更新数据库。

Cache-Aside 是:

读的时候:先看一眼旁边的缓存,没有就自己去数据库拿,顺便把结果放回缓存(回填)。 写的时候:直接改数据库,然后顺手把旁边的缓存删掉,让缓存失效。 整个过程,缓存始终是个"外挂",不是必经之路。

一般策略

说转账本来就要慢一点好,为了安全。

点赞啊这些东西不是那么重要,可以通过最终一致性来实现,这些就没有必要要就是要让人等太久。

不同的业务用不同的策略。

在高并发系统中,修改数据还未完成的时候,中间查询到旧值的情况,是无法完全避免的,但我们可以根据业务需求,决定是否容忍它、以及如何控制它的影响范围。

强一致性场景 场景:银行余额、支付状态、库存超卖、订单创建。 用户感受:我转了 100 块,对方说没收到;我买了最后一件商品,结果别人也买到了 ------ 信任崩塌!

策略:必须用强一致性手段:

数据库事务 + 乐观锁/悲观锁 强制读主库(绕过从库)

秒杀修改库存的场景

秒杀商品,修改库存的怎么弄?

分层削峰 + 原子操作 + 异步解耦。

前后端防刷,一个人3秒钟之内只能访问一次,拒绝机器人。

用 Redis 当"前置库存"

预热库存: 秒杀开始前,把商品的库存数量(比如 100)提前写入 Redis。 Key: stock:product_123, Value: 100

原子扣减: 用户点击购买时,你的服务不是去查 MySQL,而是直接向 Redis 发送一个原子操作。 查询并且扣减。 要么成功,要么失败回滚。

如果返回值 >= 0,说明扣减成功! 如果返回值 == -1,说明库存没了,直接告诉用户"手慢了"。

Redis 是内存操作,速度极快(微秒级)。

当 Redis 扣减成功后,立刻构造一条消息,发送到 Kafka 或 RocketMQ,把这个成功的订单信息,最终持久化到 MySQL 里。

消息内容:{ "user_id": 1001, "product_id": 123, "count": 1 }

立刻返回给用户:"恭喜你,抢购成功!"

后台消费者从 MQ 里慢慢消费这条消息:

再次校验一下(防重放攻击)。

在 MySQL 里创建订单、扣减真实库存、扣用户余额等。

这样写的好处:

用户体验极佳:用户感觉"秒回",因为没等数据库。

系统解耦:即使 MySQL 挂了,消息还在 MQ 里,等 DB 恢复了再处理。

削峰填谷:MQ 把瞬间的写压力,变成了平缓的后台任务。

参考

1\] 缓存更新策略与数据一致性保障 [juejin.cn/post/758546...](https://juejin.cn/post/7585463195200946226 "https://juejin.cn/post/7585463195200946226")

相关推荐
海南java第二人2 小时前
Spring Bean作用域深度解析:从单例到自定义作用域的全面指南
java·后端·spring
悟空码字2 小时前
SpringBoot 整合 Nacos,让微服务像外卖点单一样简单
java·spring boot·后端
橘子132 小时前
C++多态
后端
golang学习记2 小时前
🔥 Go Gin 不停机重启指南:让服务在“洗澡搓背”中无缝升级
后端
MX_93593 小时前
Spring的命名空间
java·后端·spring
moyueheng3 小时前
Python 工具生态深度解析:从 Pyright 到 Astral 家族
后端
昭牧De碎碎念3 小时前
AI Agents
后端
李广坤3 小时前
Redisson 实战指南
后端
千寻girling3 小时前
面试官: “ 说一下你对 Cookie 的理解 ? ”
前端·后端