有位同学去面试字节前端实习岗,然后面试官问了这个问题:"**BFF 层有 Redis 缓存,怎么保证缓存和数据库一致性?**"作为从前端转全栈的同学,咱们就来聊聊这个问题。
为什么需要缓存?又为什么不一致?
普通情况下,请求都直接打到数据库,并发量大时 DB 压力剧增。我们在 DB 前面加一层缓存挡请求,这样 DB 压力就会大幅下降。
但是,缓存和数据库是两套独立的存储,只要有数据更新,就不可能同时更新这两套存储系统,所以就会出现数据不一致的问题。
常见思路
先说实际情况:
实际业务里一般不追求强一致,而是追求最终一致。最常见方案是:读的时候先查 Redis,未命中查 DB 再回填;写的时候先更新 DB,再删除 Redis 缓存。
可能有同学就要问了:为什么是"更新数据库,再删除缓存"?
因为如果先删缓存,再更新数据库,在并发场景下可能出现:
Plain
线程A:删除 Redis
线程B:Redis 未命中 → 查询 DB,拿到旧数据
线程A:更新 DB
线程B:把旧数据重新写入 Redis
最终 Redis 里反而又变成了旧数据。
所以通常采用:写请求更新 DB,然后删除 Redis
读请求:先查询 Redis,如果命中直接返回;如果未命中,去查询 DB,然后写入 Redis 并返回结果。
但是这样其实并不能够达到百分百一致!
还有一个经典并发场景:
Plain
A:更新 DB = 新值
B:查询 DB = 旧值
A:删除 Redis
B:把旧值写入 Redis
因此工程上还会进一步增强。延迟双删!
延迟双删
我们先来回答一个问题,什么是延迟双删。
比如我们在更新 DB 的时候先删除 Redis,然后等待了几十或者几百毫秒。再次删除 Redis。
这样做的目的就是在 DB 更新的时候,第一次删除了缓存,并发读请求查询到旧数据如果旧数据回填到了 Redis,这样就可以通过第二次来删除 Redis 缓存。
不过需要注意:延迟双删不是严格意义上的强一致方案,只是在降低脏缓存概率。
如果想要保证最终一定一致,我们还可以引入 MQ 或者 Binlog CDC。
数据库提交成功后产生变更事件,由异步消费者删除或更新 Redis;同时配合重试机制和幂等处理,即使第一次操作失败,也能够最终把缓存修正过来。
MQ 和 Binlog CDC 它们本质上都是在解决一个问题:数据库已经变了,怎么可靠地通知 Redis "你也赶紧更新/删除"。
这里我们用一个例子来说明。
假设用户修改昵称,旧昵称是"张三",新昵称是"李四"。
正常的做法是先更新 MySQL,然后删除 Redis。比如,我们先把 MySQL 更新为"李四",然后删除掉 Redis。这样下一次查询时,Redis 未命中,就会去查询 MySQL,查到的是我们更改后的结果,最后再把 MySQL 的真实结果缓存到 Redis 中。这样看是没问题的。
但是,如果我们在代码执行中 MySQL 更新成功,然后更新后服务突然宕机,导致下一步 Redis 删除失败,那么最终的结果就是:MySQL 是"李四",而 Redis 还是旧数据"张三"。这样就会导致缓存和数据库不一致。
MQ 怎么解决缓存不一致?
MQ 就是:Message Queue,消息队列。 可以把它理解成一个中间的传话筒。
原来是我们的业务服务到 MySQL 更新新数据,再到 Redis 删除旧数据。
有了 MQ 之后,流程就变成了:业务服务更新 MySQL 新数据,然后发送到 MQ 消息队列,消息队列再通知缓存服务,最后到 Redis 删除旧数据。
比如用户在 BFF 层将昵称改为"李四",就会先去 MySQL 更新昵称为"李四",然后发送消息告诉 MQ:"用户某某某的信息变了"。接着,再经过消费者消费,删除 Redis 中该用户缓存。后续 Redis 查询不到,会在 MySQL 中查询,并将 MySQL 数据回填到 Redis 缓存,这样实现了数据一致。
所以 MQ 的核心不是 Redis。
它解决的是:"我现在没办法直接可靠地通知另一个系统,我先把这个事情放进一个消息队列里。"
这样即使业务服务不能马上操作 Redis,也可以由 MQ 后面的消费者继续处理。
我们再回过头看之前 Redis 失败的例子。如果中间加了 MQ 之后,那就变成由消费者删除 Redis。删除失败之后继续重试,如果还是失败,可以再次重试。所以,MQ 最大的价值之一,就是把数据库变更变成一个可以异步重试的事件。
Binlog CDC
其实聪明的同学已经想到了:如果 MySQL 更新成功但是 MQ 发送失败呢?这还不是 DB 中是新数据,而 Redis 中是旧数据,依旧不能做到百分百可靠。所以此时,我们就需要引入 binlog CDC。
MySQL 有一个东西叫:Binlog(Binary Log), 你可以把它理解成:MySQL 自己记录的一份"数据库变更日志"。
比如你执行:
SQL
UPDATE user
SET name = '李四'
WHERE id = 123;
MySQL 内部会记录:"用户 123 的 name 由张三变成李四"。
然后来说 CDC:Change Data Capture,也就是 捕获数据库变化。
MySQL 记录的 binlog 通过 CDC 程序监听,发现用户 123 的 name 改变了,就可以通知下游系统。
所以 Binlog CDC = 监听 MySQL 的 Binlog,把数据库变化捕获出来,再发送给其他系统。
只要 MySQL 的事务提交成功,Binlog 就能记录这次变化,CDC 后面仍然可以把它捕获出来。
总结一下
为了方便大家理解,上面的情况中我们并没有特别强调边界情况,防止干扰大家的思路。如果你已经看懂了上面的所有内容,那么接下来我把这些边界情况逐一分析一下,就可以真正学会为什么上面的策略是我们工程上相对的最优解了。
方案 1:先更新数据库,再更新缓存 (不行)
场景:并发写,请求一改 100、请求二改 200。
- 请求一更新库 → 100(还没更新缓存)
- 请求二更新库 → 200,紧接着更新缓存 → 200
- 请求一才更新缓存 → 100
结果 :缓存 = 100,数据库 = 200,不一致 。根因:两个写请求对"库"和"缓存"的更新顺序交错,后更新缓存的请求一反而把值写回了旧的 100。
方案 2:先更新缓存,再更新数据库 (不行)
场景:同样并发写,请求一改 100、请求二改 200。
- 请求一更新缓存 → 100,紧接着请求二更新缓存 → 200
- 请求二更新库 → 200,请求一才更新库 → 100
结果 :缓存 = 200,数据库 = 100,不一致 。结论 :"更新这种搞不定。" 两种"更新缓存"的顺序都不行,直接排除。
方案 3:先删除缓存,再更新数据库 (不行)
场景:读写并发,缓存与库初始值都是 100。
- 请求二(写)先删除缓存(100 被删)
- 请求一(读)读缓存未命中 → 读库拿到 100
- 请求二更新库 → 200(写完成)
- 请求一把读到的 100 回填进缓存
结果 :缓存 = 100,数据库 = 200,不一致 。根因:删缓存后、写库完成前,读请求把"旧 DB 值"回填进了缓存。
方案 4:先更新数据库,再删除缓存 (主方案)
场景 :读写并发,且缓存刚好过期不存在,库值 100。
- 请求一(读)读缓存未命中 → 读库拿到 100
- 请求二(写)更新库 → 200,并删除缓存(写完成)
- 请求一把读到的 100 回填进缓存
表面看也不一致 ,但关键点:要同时满⾜两个条件才会导致不一致:
- 条件 ①:缓存中的数据刚好过期不存在;
- 条件 ②:修改数据库 的执行时间,比读取数据库的执行时间短(即写比读快)。
为什么概率极低 :修改库要加锁,而读可以用 MVCC 实现、不需要锁,所以绝大多数情况下修改时间比读时间长 ,条件 ② 很难成立。结论:先更新库再删除缓存这种操作发生不一致的概率是比较低的,所以可以采用这种方式。
MVCC 是一种数据库并发控制机制,它通过维护数据的多个历史版本,让读操作和写操作可以同时进行,不需要相互等待,可以大幅提高数据库的并发性能。
它核心解决的问题是:
- 避免读写冲突:防止查询操作被正在修改数据的事务阻塞。
- 事务隔离:支持不同事务隔离级别(如可重复读、可提交读),保证数据读取的一致性。
方案 5:延迟双删(方案 4 的增强)(最终推荐)
延迟双删: 在"先更新库再删缓存"基础上,当读请求把旧值回填缓存后,让写请求再删一次缓存,把回填的旧值清掉。
实现策略:用 MQ 实现延迟删除,写请求更新库后,投递一条"删除缓存"消息到 MQ,延迟消费。
- 延迟时间 :根据读操作的时间来设。
删除消息原本由写请求主动投递 到 MQ;可借助 Canal 自动化(见方案 6)。
方案 6:Canal + binlog 自动投递 MQ(方案 5 的工程化升级)
Canal 利用数据库的主从同步机制,模拟成数据库的从节点。 数据库有数据变化时,通过 binlog 发给 Canal;Canal 接收后自动把消息投递到 MQ ;消费者收到消息后执行删除缓存。
好处:写请求不必自己主动投递删除消息,业务无侵入、顺序可靠。
面试回答
面试官问:
"Redis 和数据库不一致怎么解决?"
你可以回答:
我一般采用 Cache Aside 模式,写请求先更新数据库,成功后删除 Redis。只有在"缓存刚好过期 + 写库比读库还快"这两个苛刻条件同时满足时才会不一致,又因为改库要加锁、读可用 MVCC 无锁,写通常比读慢,