目录

缓存与数据库--一致性的取舍

今天讲讲旁路缓存策略。

概念介绍

一般来说我们在业务中引入缓存,涉及到三种操作数据库和缓存的方式:

  • 读穿:
    1. 应用读数据。
    2. 查 Redis。命中则返回。
    3. 未命中则查 MySQL。
    4. 从 MySQL 取到数据后,写入 Redis。
    5. 返回数据。
  • 写穿:
    1. 应用更新数据。
    2. 代码同时(或在同一个事务中)执行:
      • 更新 MySQL 数据库。
      • 更新(或失效)Redis 中的对应缓存。
  • 旁路缓存:
    • 读操作:采用 “读穿” 策略。
      • Cache Hit -> 返回缓存数据。
      • Cache Miss -> 从 DB 读,回填 Cache,再返回。
    • 写操作:先更新数据库,然后删除(失效)缓存。
      • 这是“旁路缓存”策略的精髓——“先更新数据库,再删缓存”。

对于读穿从上面的介绍也可以看出来它不是单独使用的,而写穿,目前没有一个中间件支持同时操作数据库,所以一般不使用,所以日常使用最多的也就说第三种方式:旁路缓存

旁路缓存

具体为什么要引入旁路缓存,可以去看另一篇文章 DB-Cache 一致性问题

本文主要讲解经典旁路缓存策略的优化思路。

第二步操作失败了怎么办?

再次拿出 DB-Cache 一致性中讨论的问题。

该文章在探讨各种写入策略的时候,都遵循了一个前提,就是 写数据库、操作缓存 都是在同一个线程中,那么为了解决传统的 先写数据库再写缓存 带来的并发问题, 于是引出了删除缓存而不是更新缓存,然后接着讨论了如果第二步失败了怎么办,在该文章,我们引入的方式是将删除缓存采用消息队列异步化,消息队列能够保证操作的可靠性

那这样的方式实际是把 写数据库、操作缓存 进行了解耦,前者同步,后者异步,这种设计已经可以达到很强的一致性了,但吞吐量还是没达到极致,原因是我们的写 DB 仍然是同步的。

那是否还有别的方式呢?

有的,我们来看另外一种设计

写 DB 也异步化

以一个评论系统为例:

我们只看写操作。在这种设计下,每当有写请求,comment service 会写入一条消息到 kafka,由 comment-job 进行异步的消费,在过程中,它会先写入数据库,然后更新缓存。

发现问题了没有,它这里为什么没有删除缓存而是选择了最原始的更新缓存呢?回想一下,我们当时为什么要引入删除缓存?是不是为了解决并发问题?

而本架构中的 kafka,它的 partition 是天然支持顺序消费的。这是什么意思?

具体来说,当多个请求对同一条数据进行操作时,全部发送到了 kafka, 而同一条数据它们是可以通过 kafka 的 hash 策略发送到同一个分区内,而同一个分区内的策略是顺序消费的, 也就是说它们的操作是原子的、逐个进行的,那么天然的保证了不会出现并发问题。

正是因为它的写数据库、操作缓存在同一个线程内,结合 kafka 的顺序消费,保证了并发问题不会出现。

对比一下两种写操作的设计方式

  1. 数据一致性
    • 方案A:
      • DB 是同步更新的,所以只要 DB 成功,数据就不会丢失。
      • 缓存是异步删除的,可能会有短暂旧数据,但下次读请求会触发回源,最终一致。
      • 适合:对 DB 数据强一致要求高的场景(如订单、支付)。
    • 方案B:
      • DB 和缓存都是异步更新的,在 Kafka 消息积压或消费失败时,两者都可能短暂不一致。
      • 适合:允许最终一致性的场景(如社交评论、动态)。
  2. 性能与吞吐量
    • 方案A:
      • 服务层仍需同步写 DB,DB 仍然是瓶颈。
      • 但缓存操作异步化,减轻了部分压力。
    • 方案B:
      • 全异步,服务层只需写 Kafka,吞吐量极高。
      • 适合高并发写入场景(如微博评论、聊天消息)。
  3. 缓存策略差异
    • 方案A(删除缓存):
      • 优势:避免缓存污染,确保下次读请求回源最新数据。
      • 劣势:可能引发缓存击穿(大量请求同时回源 DB)。
    • 方案B(直接更新缓存):
      • 优势:缓存始终有数据,减少回源 DB 的压力。
      • 劣势:如果 Kafka 消费延迟,缓存可能短暂存旧数据。
场景推荐方案原因
读多写少,强一致要求(如订单、账户)方案A(同步写DB + 异步删缓存)确保 DB 强一致,缓存最终一致
写多读少,高吞吐(如评论、动态)方案B(全异步 Kafka 写DB+缓存)最大化写入性能,允许短暂不一致
超高并发,可接受延迟(如热搜榜)方案B + 本地缓存进一步降低 DB 压力

读操作可以异步化吗?

按照经典的 Cache-Aside 模式,读回源(Loading Data on Cache Miss)必须是同步的。

它追求的是在单个请求的生命周期内完成数据获取,确保本次请求能返回尽可能准确的数据。

为什么要求同步回源?

目的就是为了解决一致性问题。同步回源可以确保:

  1. 本次请求返回数据的一致性:当缓存未命中时,客户端需要等待该次同步操作完成,以确保获取到的是当前数据库中最新的数据(假设没有其他并发写操作干扰)。如果回源是异步的,本次请求要么无法返回数据,要么返回一个空或错误,这通常不可接受。
  2. 避免“回源风暴”:同步回源的一个关键技巧是使用互斥锁(Mutex Lock)。当多个并发请求同时发现同一个 key 缓存失效时,只有一个请求会获得锁去同步查询数据库和回源缓存,其他请求则等待锁释放后直接从新填充的缓存中读取数据。这避免了多个请求同时去击穿数据库。

但图中的设计你其实可以看到,我们的回源也是异步的。

通过 Kafka 进行异步回源,是对经典模式的一种优化和折衷。它解决了经典模式的一些痛点,但也引入了新的特性。

解决了什么?

  • 降低读请求延迟:服务端无需等待缓存写入完成。只要从数据库查到数据,就可以立即返回给客户端,缓存更新交由后台异步处理,显著提升了接口响应速度。
  • 简化服务端逻辑:服务端不需要处理复杂的缓存写入逻辑和加锁机制,代码更简洁。
  • 合并写入:对同一条数据的多次更新,在异步消息队列中可以合并操作,减少对缓存的写入压力。

引入了什么?(一致性问题)

  • 短暂的数据不一致:在异步消息被处理之前,缓存中仍然是旧数据(或没有数据)。后续的读请求在缓存失效前,会一直读到旧数据。
  • 数据可能丢失:如果 Kafka 消息丢失或消费失败,缓存就会一直处于未回源的状态,直到下一次写操作触发更新或缓存自然过期。

但这种设计是一种合理的权衡,对于评论系统来说,用户看到自己或他人的评论晚几毫秒或几秒更新,通常是可以接受的业务场景。 用这个小小的代价换来系统吞吐量和响应速度的大幅提升,是一个非常值得的架构决策。