缓存策略详解:Cache-Aside、Read-Through、Write-Through 与 Write-Back

并排比较四种缓存策略:谁来填充缓存,谁来写数据库,什么数据会过时,以及如何在架构图上表示每一种策略。

· 创始人兼开发者

发布于

缓存策略详解:Cache-Aside、Read-Through、Write-Through 与 Write-Back

在大多数架构图上,缓存只是服务和数据库之间的一个方框。这个方框掩盖了决定缓存是帮忙还是添乱的那些问题:谁把数据放进去,谁写数据库,读者在两者之间会看到什么。

四种策略对这些问题给出了不同的答案:Cache-Aside、Read-Through、Write-Through 和 Write-Back。本文用同一个例子逐一讲解,对它们做比较,介绍生产环境中会出现的故障,最后给出一张可以复用的图。

每个缓存都要回答的两个问题

每种缓存策略都是一对答案:

  1. 读取时由谁把数据加载进缓存? 要么是你的应用代码,要么是一个知道如何访问数据库的缓存层。
  2. 由谁写数据库,什么时候写? 你的应用、在同一个请求中的缓存层,或者稍后由缓存层来写。
策略 读取:谁填充缓存 写入:谁写数据库
Cache-Aside 应用,在未命中之后 应用,然后让键失效
Read-Through 缓存层,在未命中之后 策略本身不规定;通常与下面两种之一搭配
Write-Through 通常与 Read-Through 或 Cache-Aside 结合 缓存层,在同一个请求中
Write-Back(Write-Behind) 通常与 Read-Through 结合 缓存层,稍后进行,而且往往是批量的

贯穿全文的例子是一个商品 API,它有一张 Postgres 的 products 表和一个 Redis 缓存。商品页面读取 product:42,后台管理工具负责更新价格。

Cache-Aside(懒加载)

应用同时与两个存储打交道。它先查缓存;未命中时读数据库,再把结果带着过期时间(TTL)放进缓存。AWS 的 ElastiCache 文档把同一模式称为懒加载(lazy loading)。

def get_product(product_id):
    key = f"product:{product_id}"
    cached = redis.get(key)
    if cached is not None:
        return json.loads(cached)                  # hit
    row = db.fetch_one("SELECT * FROM products WHERE id = %s", product_id)
    if row is not None:
        redis.set(key, json.dumps(row), ex=300)    # fill, with a 300 s TTL
    return row

def update_price(product_id, price):
    db.execute("UPDATE products SET price = %s WHERE id = %s", price, product_id)
    redis.delete(f"product:{product_id}")          # invalidate, don't overwrite

写入时,删除键,而不是把新值写进缓存。下一次读取会加载最新的行。Microsoft 的 Cache-Aside 模式也描述了同样的写入规则。

你得到的:只有被请求过的数据才会进入缓存;缓存宕机时,读取会转向数据库继续工作,只是更慢。你付出的:每次未命中都要三次往返(缓存、数据库、缓存),而且存在一个竞态条件。一个未命中的读者可能读到旧行,写者更新了这一行并删除了键,随后读者又把旧值写回缓存。这个值会一直过时,直到 TTL 到期;这也是 Cache-Aside 总是需要 TTL 的原因之一。

Read-Through

应用只调用缓存。未命中时,缓存层通过你配置的加载器从数据库加载数据,存起来再返回。读取结果与 Cache-Aside 相同,但加载代码集中在一个地方,而不是散落在每个调用方中。

普通的 Redis 服务器不会自己去读你的数据库。Read-Through 要靠有这种能力的一层来实现:带加载函数的缓存库、代理,或者专为某种数据库打造的托管缓存。Amazon DynamoDB Accelerator(DAX)就是一个例子:缓存未命中时,它把读请求转给 DynamoDB,返回结果并缓存起来。

代价是:缓存层现在位于读取路径上。如果它出故障,除非客户端有回退到数据库的路径,否则读取也会失败。

Write-Through

应用写入缓存层,缓存层在同一个操作中写数据库。只有两次写入都成功,应用才会收到成功的结果。DAX 的文档正是这样描述的:数据先写入 DynamoDB 表,再写入缓存,只有两次写入都成功,调用才算成功。

你得到的:通过缓存写入的数据在缓存中是最新的,所以写完立刻读也能看到。你付出的:每次写入都要等待两个存储,而且缓存会被写了却从不被读的数据填满。给这些数据加上 TTL,让它们过期。一个新的空缓存节点在数据被重新写入之前什么都没有,这就是 Write-Through 通常要和 Read-Through 或懒加载搭配使用的原因。

Write-Back(Write-Behind)

应用写入缓存后立即收到成功的结果。缓存层稍后再写数据库,往往是批量写入,对同一个键的多次更新会合并成一次数据库写入。

你得到的:最低的写入延迟,以及高峰期大幅减少的数据库写入。你付出的:如果缓存节点宕机,所有尚未刷写的数据都会丢失,除非缓冲区有副本或由持久化日志支撑。数据库也会落后于缓存,因此报表、其他服务和备份看到的都是较旧的数据。Write-Back 适合浏览计数这类丢失几秒数据也能接受的场景。

并排比较

Cache-Aside Read-Through Write-Through Write-Back
应用代码知道数据库 是 否 否 否
写入延迟 一次数据库写入加一次缓存删除 取决于写入策略 缓存和数据库 只有缓存
读到过时数据 直到失效或 TTL 到期 直到失效或 TTL 到期 经由缓存的写入很少出现 数据库落后于缓存
丢失一个缓存节点 读取变慢 没有回退路径时读取失败 数据缺失,直到重新写入 未刷写的写入丢失
典型用途 使用 Redis 时的默认选择 缓存库或托管缓存 写后立即读到新值 写多的计数器和流量高峰

TTL、淘汰与三种经典故障

TTL 限定了一个值最多能过时多久。按数据类型、根据产品能接受的过时程度来设置:商品描述可以保留几分钟,库存数量也许只有几秒。读取已过期的键时,它的表现就像不存在一样。

淘汰(eviction)是另一套机制:内存不足时,缓存要移除什么。在 Redis 中,maxmemory 设定上限,maxmemory-policy 决定移除什么。redis.conf 中的默认值是 noeviction,内存写满后,会对添加数据的命令返回错误。对于纯缓存,Redis 文档建议把 allkeys-lru 作为不错的默认值。托管服务有自己的默认值,请检查你所用的服务。命中率可以从 INFO stats 中按 keyspace_hits / (keyspace_hits + keyspace_misses) 计算。

几乎每个缓存都会遇到三种故障:

  • 缓存击穿(stampede)。 一个热点键过期,数百个请求同时未命中,全部去查数据库。只让一个请求重建这个值,其他请求短暂等待或返回旧值。Redis 锁可以做到这一点:SET lock:product:42 <token> NX PX 5000 只会对一个调用方成功。Facebook 的 memcache 论文用租约(lease)解决了同样的问题,并报告说,对于容易出现这种情况的键,数据库查询的峰值从每秒 1.7 万次降到了 1300 次。
  • 缓存穿透(penetration)。 查询不存在的 ID 的请求总是未命中,总会打到数据库。把“未找到”的结果用较短的 TTL 缓存起来,或者先查一个由有效 ID 构成的布隆过滤器。
  • 缓存雪崩(avalanche)。 大量键同时过期,例如在批量加载或缓存重启之后,数据库一下子承受全部负载。给 TTL 加上随机抖动,例如 300 秒再加最多 60 秒,并逐步预热缓存。

图

四行都是同样的三个参与方,所以区别全在连线上:谁和数据库通信,什么时候通信。

四行,每行都有一个商品 API、一个缓存和一个 Postgres 数据库。Cache-Aside 中,API 先读缓存,再读数据库,然后回填缓存。Read-Through 和 Write-Through 中,缓存层与数据库通信。Write-Back 中,缓存稍后再把写入刷到数据库。

20 个组件 · 9 个连接

在 Archboard 中打开
图表内容
  • Product API
  • Redis
  • Postgres
  • Cache layer
  • 1 GET (miss)
  • 2 SELECT
  • 3 SET, TTL 300 s
  • 1 read
  • 2 on miss: SELECT
  • 1 write
  • 2 UPDATE, same request
  • 1 write, OK at once
  • 2 flush later, batched
本文的四种策略,作用于同一个商品 API:每种策略下谁读写数据库,应用要等待什么,以及缓存宕机或落后时会出什么问题。

这张图的读法如下:

  • 第 1 行,Cache-Aside。 商品 API 先读 Redis(1),未命中时自己读 Postgres(2),再带着 TTL 回填 Redis(3)。通往 Postgres 的连线绕过了缓存:两次调用都由应用负责。
  • 第 2 行,Read-Through。 API 只与缓存层通信,缓存层在未命中时从 Postgres 加载。
  • 第 3 行,Write-Through。 写入在同一个请求中经过缓存层到达 Postgres。
  • 第 4 行,Write-Back。 API 立即得到成功的结果。虚线连线表示之后的批量刷写。

如何在架构图上画缓存

  • 写明策略和 TTL。 在缓存节点或它的连线上标注“Cache-aside, TTL 300 s”,就回答了评审者的第一个问题。
  • 画出谁与数据库通信。 在 Cache-Aside 中,服务除了连向缓存,还有一条连向数据库的连线。把缓存串联画在数据库前面,会让人以为是 Read-Through,读者也会这样理解。
  • 画出失效路径。 标明谁会删除键,包括直接写数据库的批处理任务和管理工具。没有通往缓存的路径的写入方,正是过时数据的来源。
  • 写下故障时的行为。 “缓存宕机:读取改走 Postgres”比再多画一个缓存图标更能帮助值班工程师。
  • 把数字放到画布上。 目标命中率、内存上限和淘汰策略,用缓存旁的一条小注释就能写下。
  • 所有异步的东西都画成虚线。 Write-Back 的刷写或失效事件不属于请求本身。

如何选择

先从 Cache-Aside 加 TTL 开始。它适用于任何缓存和任何数据库,缓存故障时只是读取变慢。当缓存库或托管缓存提供了加载器时,改用 Read-Through。如果写完立刻读必须看到新值,而且写入能承受额外的延迟,就加上 Write-Through。只有在能够接受丢失或重放未刷写的写入时,才使用 Write-Back。

关于缓存周边的整体情况,请参阅系统设计图示例,其中 Redis 承担短链接服务的读取路径;关于如何展示哪个服务拥有哪些数据,请参阅微服务架构图的画法规则。

在 Archboard 中构建

  1. 使用 Google 或 GitHub 登录,并新建一张图。
  2. 为每种策略添加一个基础节点包中的 文本 作为标题,然后把三个节点排成一行:一个 C4 模型节点包中的 容器 表示商品 API,一个数据节点包中的 Redis 表示缓存,一个数据节点包中的 Postgres 表示数据库。在第 2 到 4 行,把缓存标注为“Cache layer”。
  3. 用带编号的连线标签连接每一行:Cache-Aside 用“1 GET (miss)”“2 SELECT”和“3 SET, TTL 300 s”,其他行写读取或写入的步骤。让 Cache-Aside 中从 API 到 Postgres 的连线从缓存上方绕过。
  4. 把 Write-Back 的刷写连线设为虚线。
  5. 在每行下方添加一个基础节点包中的 文本 注释,写明写入规则以及缓存故障时会发生什么。

这个示例共用 12 个图表节点,在 Free 套餐的限制之内。适用于任何架构图的通用方法,请参阅如何绘制软件架构图。

常见问题

Cache-Aside 和懒加载是一回事吗? 是的。两个名字描述的都是应用只在未命中之后才把数据加载进缓存。AWS 文档称之为“lazy loading”,许多其他资料称之为“cache-aside”。

数据变化时,应该更新缓存键还是删除它? 在 Cache-Aside 中删除它。两个写入方各自把新值写进缓存时,可能以错误的顺序完成,留下一个旧值。先删除再重新加载可以避免这个问题,而 TTL 则限制了前面描述的那种更少见的竞态条件。

Redis 自己能实现 Read-Through 或 Write-Through 吗? 不能。Redis 服务器只保存客户端发给它的内容。Read-Through 和 Write-Through 需要一个了解你数据库的层:缓存库、代理,或者专为某种数据库打造的托管缓存。

TTL 应该设多少? 设为产品对这类数据能接受的最长过时时间。然后加上抖动,避免一起加载的键一起过期。

Write-Back 安全吗? 只有在可以接受丢失或重放未刷写的写入,或者写缓冲区有副本或由持久化日志支撑时才安全。支付或订单这类数据要避免使用。

免费开始 — 1 个席位,无需信用卡。

相关文章

← 返回博客

一分钟打开画布

使用 Google 或 GitHub 登录。单席位免费,团队加入后再升级。