Redis入门:一致性

旁路缓存策略 (Cache Aside)

假设你要把数据库 💾 里的一个值(比如商品库存)从 10 改成 9。为了防止 Redis 🧠 里存的还是 10(脏数据),我们需要操作 Redis。

这就引出了第一个核心选择题:你觉得我们应该去更新 Redis 里的缓存值,还是直接删除 Redis 里的那个 key?

为了解决这个问题,我们首先需要确定缓存和数据库处理顺序:先修改数据库再修改redis,因为数据库才是数据源头。在先改数据库情况下,后续选择“更新缓存”还是“删除缓存”,区别其实非常大,甚至决定了数据会不会脏掉。

让我们来做一个思维实验,看看双写冲突

假设有两个线程同时修改同一个数据:

  1. 线程 A 把数据库改成 10。
  2. 线程 B 把数据库改成 20。

理应来说,最后的数据应该是 20,对吧?

但是,如果它们都要去更新缓存(而不是删除),且因为网络抖动,线程 A 的缓存更新操作比线程 B 晚到了 1 秒。

这时候:

  • 数据库里最终是 20(因为 B 后写入)。
  • 缓存的操作顺序变成了:B 先更新缓存 -> A 后更新缓存。

现在,Redis 🧠 里的数据变成了10,不一致出现了!所以,我们应该选择删除缓存,让redis自己去重新检查数据库内容,以确保数据始终来自数据库。

延时双删(Delayed Double Delete)

尽管,Cache Aside 模式的标准做法是:先更新数据库 💾,再删除缓存 🧠

这种顺序利用了数据库事务的特性(或者说更新的耗时),通常能避开大部分问题。但是,哪怕是这个“标准答案”,在理论上真的就是完美无缺的吗?

想象这样一个极其巧合的场景:

  1. Redis 里的缓存刚好失效了(空的)。
  2. 线程 A(读) 来读数据,去查数据库,得到了旧值 10。
  3. ...(就在线程 A 拿到数据准备写入 Redis 之前,发生了一件事)...
  4. 线程 B(写) 进来了,它把数据库改成了 20,并且执行了删除缓存(虽然本来就是空的)。
  5. 线程 A(读) 终于醒过来,把它手里的 10 写入了 Redis。

虽然这个概率很低(因为通常写库比写缓存慢得多),但如果发生了,Redis 里是不是又变成了旧值 10?

为了解决这种即使是微乎其微的一致性问题,或者是我们之前提到的“先删缓存”必然遇到的问题,业界发明了一个简单粗暴的补丁方案:“延时双删”

延时双删的逻辑是:

  1. 先删缓存。
  2. 写数据库。
  3. 休眠一会儿(比如 500ms)。
  4. 再次删除缓存。

为什么要休眠? 这个休眠时间必须 >(大于)读线程读取数据 + 写入缓存的时间。目的就是为了把读线程可能写入 Redis 的那个脏数据(旧值),在最后关头再清洗一遍。

但是,这种方法有明显的副作用

  1. 吞吐量降低:写请求还要 sleep,响应变慢。
  2. 不可靠:sleep 多久是个玄学(Magic Number),万一网络抖动,读线程比 500ms 还慢怎么办?

为了彻底解决这个问题,我们就得跳出“在应用代码里同步操作”的思维定势。

MySQL Binlog + 消息队列

我们需要把“保证缓存一致性”这个任务,从主业务逻辑里剥离出来,交给一个专门负责“擦屁股”的机制。这就是异步补偿方案

目前大厂最常用的方案是基于 MySQL Binlog + 消息队列(比如使用 Canal 中间件)。

思路是这样的:

  1. 应用只管改数据库,改完就返回成功,根本不碰 Redis。
  2. 数据库自己会产生 Binlog(操作日志)。
  3. 有一个独立的组件(如 Canal)伪装成 MySQL 的从库,监听这个 Binlog。
  4. 一旦感知到数据变了,它就发消息给 MQ(消息队列)。
  5. 消费者收到消息后,去执行删除缓存的操作。

这就把“强一致性”变成了“最终一致性”。

这里有个关键点:如果这时候消费者去删缓存失败了(比如 Redis 挂了一瞬间),使用消息队列 (MQ) 相比于我们在代码里直接调 redis.del(),有一个巨大的天然优势是消息队列会不停重试请求,直到请求成功。

所以,这样解决延时双删的问题了吗?没有!消息队列(MQ)根本解决不了“读线程 A 回填脏数据”的时序问题。

MQ 保证的是“删除动作必定会执行(Reliability)”,而不是“在读线程写入之后执行(Timing)”。

如果是下面这个地狱级并发场景:

  1. 读线程 A:查数据库得到旧值 10。
  2. 写线程 B:改数据库为 20 -> 产生 Binlog -> MQ 消费者删缓存。
  3. 读线程 A(因为网络卡顿现在才醒):把旧值 10 写入 Redis。

此时,MQ 里的消费者任务已经结束了,Redis 里依然会被线程 A 写入脏数据 10。

面对这个顽固的“回填脏数据”并发死角,既然 MQ 也不行,双删也只是概率防御,那到底有没有兜底的方案?

其实,绝大多数也是最有效的方案,往往是最朴素的两个字:

过期 (TTL)

只要你给 Redis 的 Key 设置了一个过期时间(比如 5 分钟、30 秒,甚至 5 秒):

  • 即使线程 A 把脏数据 10 写进去了,这个错误也只会持续 5 分钟。
  • 5 分钟后,数据自动消失,下一次请求会重新去数据库拉取最新的 20。

所以,真正的架构权衡是这样的:

  1. 基础防线 (Cache Aside):先改库,后删缓存。能挡住 99% 的不一致。
  2. 可靠性防线 (MQ / 重试):防止因 Redis 挂了或网络抖动导致“根本没删掉”的情况。保证最终会删。
  3. 并发防线 (TTL):防止那 1% 的极端并发导致脏数据永久驻留。这是最后的安全网。

如果老板说:“不行!我的业务是涉及金钱的(比如余额),这 5 分钟的脏数据我也不能忍,必须强一致!”

这时候,我们就必须放弃上述所有“软”方案,牺牲性能,祭出重武器了。

你觉得为了保证绝对的一致性,让读和写不能互相插队,我们需要引入什么机制?乐观锁能解决吗?我们思考一下:

  1. 初始状态:数据库是 v1 (值10),Redis 是空的(或者也是 v1)。
  2. 读线程 A:读取数据库,拿到 v1 (值10)。它记住了“我的版本是 v1”。
    • (此时发生网络卡顿,线程 A 暂停)
  3. 写线程 B:更新数据库为 v2 (值20)
  4. 写线程 B删除 Redis 缓存。
    • 注意:现在 Redis 是空的。之前的版本信息也没了。
  5. 读线程 A(醒来了):它拿着 v1 (值10) 准备去写 Redis。

关键问题来了:

线程 A 去检查 Redis 里的时间戳/版本号。 它发现 Redis 是空的(或者说不存在)。 那么,线程 A 该怎么判断自己是“新”还是“旧”呢?

  • 如果是空的,通常意味着“没人占坑”,线程 A 就会觉得自己有责任把数据填进去。
  • 一旦它填进去,Redis 里就变成了 v1 (值10) —— 脏数据又进去了

你看,问题的核心在于:当我们执行“删除缓存”策略时,我们也删除了用来比较的“基准线”

所以,回到那个“涉及金钱、必须强一致”的场景。既然软的不行(版本号被删了,双删不可靠),我们就只能来的。

要保证绝对的一致性,让读写操作像排队一样井井有条,我们需要引入一个分布式锁(Distributed Lock),特别是 读写锁(Read-Write Lock)

你想想看,如果我们把对这个 Key 的操作加上一把锁:

  • 读锁:大家都能读,但不能写。
  • 写锁:只有我能写,其他人都得等着,连读都不行。

这种机制是如何彻底消灭刚才那个并发问题的?

锁升级可以吗?

每个请求先获取读锁,需要修改时升级为写锁。这是否可行?

不可行!在分布式环境(Redis)下,这个方案有一个著名的死锁陷阱,我们需要特别小心。

想象一下这种场景:

  1. 线程 A 发现缓存没了,加了读锁
  2. 线程 B 同时发现缓存没了,也加了读锁(因为读锁是共享的,大家都能拿)。
  3. 现在,线程 A 想要升级成写锁去查库写缓存。它必须等待 线程 B 释放读锁(因为写锁必须是独占的)。
  4. 与此同时,线程 B 也想要升级成写锁,它必须等待 线程 A 释放读锁。

💥 死锁(Deadlock)!两个人都抱着读锁不放,都在等对方松手,程序就卡死了。

为了避开这个坑,同时又能利用“加锁排队”的思路,业界通用的“核武器”方案是 双重检查锁定 (Double-Check Locking)

在这个方案里,我们通常只在缓存缺失(Miss) 的时候才加锁,而且直接加互斥锁(Mutex),不搞复杂的读写锁升级。

流程大概是这样的:

读线程(处理缓存击穿+一致性):

  1. 先查 Redis。有数据?直接返回。(不加锁,保证高性能)。
  2. 没数据?准备重建缓存。这时候去抢一个 分布式锁(比如 Redisson 的 Lock)。
  3. 抢到锁之后,再查一次 Redis!(这就是 Double-Check)。
    • 为什么要再查一次? 因为在你抢锁的过程中,可能别的线程已经抢先一步把数据填进去了。如果这里不查,你又会把(可能的)旧数据覆盖进去。
  4. 如果 Redis 还是空的:查数据库 -> 写 Redis。
  5. 释放锁。

写线程(强一致性保证):

  1. 同一把分布式锁
  2. 更新数据库。
  3. 删除 Redis。
  4. 释放锁。

这样一来,读线程重建缓存写线程更新数据 就变成了互斥关系,谁先抢到锁谁先干活。

  • 如果是写线程先抢到:它改库、删缓存、放锁。读线程随后拿到锁,发现缓存空了,查新库,填新值。-> 一致
  • 如果是读线程先抢到:它查库、填缓存、放锁。写线程随后拿到锁,改库、删缓存。-> 一致

这种方案确实能保证极高的一致性,甚至解决了缓存击穿问题。

但是,凡事都有代价。你觉得给这两个操作加上分布式锁之后,会对系统的性能或者可用性带来什么最直接的负面影响?

如果为了追求强一致性,把所有并发请求都强制变成了串行(Serial),那我们引入 Redis 的初衷高性能就完全丧失了。如果都在排队,那和直接让数据库硬抗有什么区别?甚至因为多了网络开销,比直接查库还要慢。


面试回答:秒杀场景下 Redis 与数据库的配合方案

在秒杀这种高并发、写多读少、严防超卖的极端场景下,传统的“旁路缓存(Cache Aside)”或者“分布式锁”方案因为性能问题都不再适用。

我的设计思路是将高频扣减低频落库解耦,采用Redis 预扣减 + MQ 异步削峰 + 最终一致性兜底的架构。具体分为三步:

1. 准备阶段:数据预热(Cache Pre-warming)

  • 策略:秒杀开始前,必须将商品的库存数据从数据库“预热”加载到 Redis 中。
  • 原因:秒杀瞬间流量巨大,数据库根本扛不住,Redis 必须成为此时流量的唯一入口。

2. 抗压阶段:Redis 原子化预扣减(The Shield)

  • 核心机制:使用 Redis Lua 脚本 实现库存扣减。
  • 逻辑
    1. 检查库存(GET)。
    2. 判断库存 > 0。
    3. 扣减库存(DECR)并记录用户 ID(SADD,作为后续对账的凭证)。
  • 优势
    • 原子性:Lua 脚本保证了“检查+扣减”过程不会被其他线程插队,彻底解决了超卖问题。
    • 高性能:Redis 单线程执行且无网络交互消耗,不仅抗住了流量,还避免了分布式锁带来的串行化性能瓶颈。
  • 关键点:此时 Redis 暂时成为了数据的“绝对权威”

3. 落地阶段:MQ 异步解耦与最终一致性(The Landing)

  • 异步写入:Lua 脚本执行成功后,业务层不直接操作数据库,而是发送一条消息到 消息队列(MQ)
  • 削峰填谷:数据库端的消费者按照数据库能承受的速度,慢慢消费消息,创建订单并扣减数据库库存。
  • 异常兜底(解决少卖问题)
    • 由于 Redis 无法与 MQ 组成强事务,可能会出现“Redis 扣了库存但 MQ 发送失败”的情况。
    • 解决方案:利用 Redis 中记录的“用户 ID 集合”与数据库的“订单表”进行异步对账。通过定时任务发现不一致,进行人工介入或自动补偿。

总结

Q: 我知道“缓存击穿”这个概念,即一个极其热点的数据过期会导致大量查询打到数据库上,为此我们提出了“互斥锁+看门狗”机制。在这种情况下,一个读取了旧值的读线程准备填充缓存,它还没释放锁,怎么会让新数据写入呢?也就是说我们后续讨论的“分布式锁”维持强一致性似乎是必要条件吧?我们会对所有数据采取“互斥锁+看门狗”机制以避免缓存击穿吧?

A:

并不是所有数据都有资格用“互斥锁”

我们在讨论“缓存击穿”时,针对的是热点 Key (Hot Key)。但在实际的生产环境中,大部分数据都不是热点。对于普通数据(比如某个冷门用户的个人资料):

  • 当缓存失效时,我们是裸奔去查数据库的(标准的 Cache Aside)。
  • 不会加锁。因为加分布式锁太重了(网络开销 + Latency)。
  • 隐患:正是这种“普通数据”,如果刚好遇上“一读一写”的巧合,就会发生我们之前说的“脏数据回填”。这时候“延时双删”或者 TTL 就有意义了。

即使加了“防击穿锁”,能挡住“写操作”吗?

假设我们需要防止击穿,确实加了锁。但请注意,通常我们加的是 “针对读线程的互斥锁” (Mutex),而不是 “全局读写锁” (Read-Write Lock)。

回忆一下我们是如何加锁的:SETNX lock:hot_item "1"

明确一点:Redis 的锁就是互斥锁,尽管有些客户端可能会使用 lua 模拟一个读写锁,但 Redis 原生只有互斥锁。

注意到对于“防击穿锁”,只有读线程会尝试加锁并获取锁 SETNX (Set If Not Exit),写线程没有去尝试获取锁!如果我们让写线程也尝试获取锁,那才变成了为强一致性而加锁。

Golang 有一个标准库函数 Singleflight,它的逻辑是:“瞬间合并”,在“防击穿锁”场景下 Singleflight 比 Redis 锁更加轻量,但 DB 会接受等于 Golang Server 进程个数的请求。注意,Singleflight 和 sync.Once 是不同的功能。

Golang 锁防击穿:使用 Singleflight 防击穿,配合 TTL/延迟双删 兜底一致性。这是性能最高的组合,但数据库抗等于 Golang Server 进程数的并发请求。

Redis 锁防击穿:如果 Server 只让读线程加锁(防击穿),系统依然面临一致性问题,依然需要延迟双删/TTL。

Redis 锁强一致:只有让读和写都去抢同一把锁,系统才能彻底解决一致性问题(从而抛弃延迟双删),但这是以牺牲写性能为代价的。

updatedupdated2025-11-212025-11-21