在大多数架构图上,缓存只是服务和数据库之间的一个方框。这个方框掩盖了决定缓存是帮忙还是添乱的那些问题:谁把数据放进去,谁写数据库,读者在两者之间会看到什么。
四种策略对这些问题给出了不同的答案:Cache-Aside、Read-Through、Write-Through 和 Write-Back。本文用同一个例子逐一讲解,对它们做比较,介绍生产环境中会出现的故障,最后给出一张可以复用的图。
每个缓存都要回答的两个问题
每种缓存策略都是一对答案:
- 读取时由谁把数据加载进缓存? 要么是你的应用代码,要么是一个知道如何访问数据库的缓存层。
- 由谁写数据库,什么时候写? 你的应用、在同一个请求中的缓存层,或者稍后由缓存层来写。
| 策略 | 读取:谁填充缓存 | 写入:谁写数据库 |
|---|---|---|
| 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 秒,并逐步预热缓存。
图
四行都是同样的三个参与方,所以区别全在连线上:谁和数据库通信,什么时候通信。
图表内容
- 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
这张图的读法如下:
- 第 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 中构建
- 使用 Google 或 GitHub 登录,并新建一张图。
- 为每种策略添加一个基础节点包中的 文本 作为标题,然后把三个节点排成一行:一个 C4 模型节点包中的 容器 表示商品 API,一个数据节点包中的 Redis 表示缓存,一个数据节点包中的 Postgres 表示数据库。在第 2 到 4 行,把缓存标注为“Cache layer”。
- 用带编号的连线标签连接每一行:Cache-Aside 用“1 GET (miss)”“2 SELECT”和“3 SET, TTL 300 s”,其他行写读取或写入的步骤。让 Cache-Aside 中从 API 到 Postgres 的连线从缓存上方绕过。
- 把 Write-Back 的刷写连线设为虚线。
- 在每行下方添加一个基础节点包中的 文本 注释,写明写入规则以及缓存故障时会发生什么。
这个示例共用 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 安全吗? 只有在可以接受丢失或重放未刷写的写入,或者写缓冲区有副本或由持久化日志支撑时才安全。支付或订单这类数据要避免使用。