Chapter 07

缓存与异步消息

前六章解决了状态怎么存、怎么切分(03)、怎么复制(04)、复制后还能拿到什么一致性保证(05),以及一个操作横跨多个节点时怎么保正确(06)。那些都是把状态摆放对。本章换一个视角:当状态已经摆好、流量却把它压垮时,用两个横切手段把读写流量"扛"下来——缓存(把热数据搬到离请求更近的地方)和异步消息(把同步调用拆成"先收下、后处理"以削峰解耦)。

本章建立的 schema

  • 缓存读写有四种模式(cache-aside / read-through / write-through / write-behind),每种在一致性、延迟、谁负责写 DB 三者间换位置。
  • 缓存的三种崩法——穿透、击穿、雪崩——触发条件不同,修复手段也各不相同,不能用一招通杀。
  • 缓存与数据库的一致性只能做到 best-effort:双删、TTL、binlog 失效都在"尽量"层面,没有传输层意义上的强保证。
  • 投递语义只有 at-most-once / at-least-once 两种基石;"exactly-once"在传输层不存在,工程上靠 at-least-once + 幂等去重凑出 effectively-once,再配背压防止消费者被压垮。

7.1缓存读写模式:四种摆位

缓存的本质是把一份数据的副本放在离请求更近、读取更快的地方。第 01 章的延迟阶梯给了它存在的理由:内存读取约 100ns、同机房一次 RTT 约 0.5ms、缓存命中比一次回源数据库调用快约三个数量级。差距越大,缓存的杠杆越大。但缓存是数据的第二份拷贝——按本教程主线那把尺子说,凡是复制状态就要付一致性的账:源数据一变,缓存里的旧值就成了谎言。四种模式的差别,正是这笔账记在谁头上、什么时候记。

cache-aside(旁路缓存)

应用代码直接编排缓存与数据库,缓存库自己不碰数据库。读:先查缓存,命中就返回;未命中(cache miss)就回源数据库、把结果写回缓存、再返回。写:更新数据库,然后删除缓存里对应的键(不是更新它)。这是最常见的模式,Redis + 关系库的组合默认就是它。

它真正的难点藏在"读未命中回填"和"写后删"是两条独立、各自非原子的序列里。设想一个慢读:线程 A 读 miss,从数据库取到旧值 v1,正准备写回缓存,此刻被调度器挂起;线程 B 完成一次写,把数据库更新成 v2 并删掉缓存(此刻缓存是空的,正确);然后 A 醒来,把手里早已过期的 v1 写进缓存。结果缓存里稳定地躺着 v1,而数据库是 v2——这个错值会一直存在,直到 TTL 到期或下一次写再次删它。这就是 cache-aside 的不一致窗口:它来自两条非原子序列的交错,不是"网络抖动"。

先想一下,再展开

cache-aside 的写操作,为什么是"删缓存"而不是"更新缓存"?直觉上更新缓存岂不是省了下次 miss 的回源?

展开答案

两个并发写如果都去更新缓存,可能产生乱序:写 v2 先更新数据库、写 v3 后更新数据库,但两者更新缓存的顺序被网络/调度打乱,缓存最终停在 v2——一个稳定的错值,且不会自愈。删除是幂等的、与顺序无关:无论几个并发写、按什么顺序删,结果都是"缓存为空",下次读自然回源拿到数据库当前值。删把"维护一致性"的责任推给了"下次读回填",代价只是多一次 miss——用一次回源换掉一类难以排查的脏数据。所以删优于更新。

read-through / write-through(读穿 / 写穿)

把缓存与数据库的 I/O 编排下沉到缓存层自己(如本地 Caffeine + CacheLoader,或带 loader 的缓存服务),应用只跟缓存对话。read-through:miss 时缓存自己去加载数据库并回填,应用感知不到回源。write-through:写时缓存同步地把值写进自己和数据库,两者都成功才返回。好处是一致性比 cache-aside 强(写时缓存与库同步刷新,不靠"删后回填")、应用代码更干净;代价是每一次写都要付一次数据库写延迟——它把缓存的"加速读"优势让出一部分去买写一致性,因此适合读远多于写的场景。

write-behind(写回 / write-back)

写命中缓存就立即返回,缓存把脏数据先攒在内存里,由后台线程批量、异步地刷回数据库。这是四种里写延迟最低的——写操作完全不等数据库。代价也最重:缓存节点在刷盘前崩溃,那些"已经向客户端确认成功"的写就真的丢了。这与 第 04 章异步复制的失效模式同构:返回得越早,崩溃窗口里丢的就是已确认数据。要让 write-behind 不丢,必须给它配 WAL(write-ahead log,预写日志,先把变更落到顺序日志再异步应用)——这正是数据库自己用来兼顾"快"和"不丢"的老办法。

cache-aside 读路径与写路径,标出不一致窗口 读路径 应用 缓存 数据库 ① 查缓存 ② miss 回源 ③ 回填 写路径 应用 数据库 缓存 ① 更新数据库 → v2 ② 删缓存(幂等) 不一致窗口 慢读在「②删」之后把 旧值 v1 写回 → 缓存停在 v1,DB 是 v2,直到 TTL
图 7.1cache-aside 把"维护一致性"的责任拆成两条独立的非原子序列,错值正诞生于它们的交错处。注意:写路径选"删"而非"更新",是因为删是幂等的、与并发顺序无关;TTL 是这个窗口最终能自愈的唯一兜底。
四种缓存模式对照
模式一致性写延迟谁写数据库何时选 / 为什么不选
cache-aside 弱(有不一致窗口,靠 TTL 兜底) 低(写库 + 删缓存) 应用代码 通用默认:读写比例不极端、能容忍秒级陈旧。绝大多数业务起步选它。
read-through 同 cache-aside 读侧 — 缓存层(loader) 想让应用代码不写回源逻辑;与 write-through 配套。
write-through 较强(写时同步刷库+缓存) 高(每写付一次库延迟) 缓存层 读远多于写、要求缓存不轻易陈旧时选;写密集场景不选,写延迟扛不住。
write-behind 弱(异步刷库,崩溃丢已确认写) 最低(不等库) 缓存层(后台批量) 写吞吐极高、可容忍少量丢失或已配 WAL 时选;金额类强持久数据不选。

陷阱

write-through 之后缓存节点崩溃、缓存与数据库分叉,若这份缓存被当成"权威只读源"且没设 TTL,分叉会永久存在、无法自愈。规则很硬:哪怕是"权威"缓存,也要给它一个 TTL 作为最后的一致性兜底——TTL 不是性能旋钮,它是缓存唯一不依赖外部修复就能纠错的机制。

7.2缓存的三种崩法:穿透 / 击穿 / 雪崩

这三个中文术语在社区里固定下来、含义彼此独立,混用是排障时的常见错误。它们的区别不在"现象都是数据库被打爆",而在触发条件不同——所以修复手段也完全不同,没有一招通杀。

穿透(cache penetration):查一个根本不存在的键

请求的键既不在缓存、也不在数据库。cache-aside 的回填逻辑只在数据库查到结果时才写缓存,于是这类"查不到"的请求每次都 miss、每次都打到数据库——缓存形同虚设。攻击者用海量随机不存在的 ID 刷接口,就是冲着这条路来的。修复有两条:① 把"不存在"这个结果本身也缓存起来(缓存空值,配一个较短的 TTL,避免占满内存);② 布隆过滤器(Bloom filter,一种概率型集合,能在 O(1) 内回答"一定不存在"或"可能存在"),把所有合法键预先放进去,请求先问它,答"一定不存在"就直接拒绝、连数据库都不碰。

陷阱

标准布隆过滤器不支持删除——一旦某个键被删除(从数据库下线),布隆过滤器仍会对它回答"可能存在",于是这个已删除的键会永远漏过滤、继续打数据库。要支持删除得换计数布隆过滤器(counting Bloom filter)或周期性重建。选布隆过滤器前先确认键集合是否会频繁删除。

击穿(hotspot invalidation):单个热键瞬间过期

某个极热的单键(如明星主页、爆款商品)TTL 到期的那一瞬间,成百上千个并发请求同时 miss、同时回源重建同一份数据——数据库在一刹那被同一个键的重复查询砸穿。这与穿透的根本区别是:击穿的键在数据库里是存在的,问题出在"过期瞬间的并发回源",而非"查不到"。修复:① 互斥重建(singleflight,让同一键的并发回源只放一个请求去重建、其余等待结果),把 N 次回源压成 1 次;② 逻辑过期——值永不真正从缓存删除,而是携带一个逻辑过期时间戳,读到"逻辑已过期"时由后台异步刷新、前台先返回旧值,用一点陈旧换"永不击穿"。

雪崩(cache avalanche):大批键同时失效或缓存层整体宕

两种成因:① 大量键被设置了相同的 TTL(比如启动时批量预热),到点同时过期,瞬间把所有读压力一起推给数据库;② 缓存节点/集群整体宕机,所有读直接穿到数据库。它与击穿的区别是"规模"——击穿是一个热键,雪崩是成片的键或整层。修复:① 给 TTL 加随机抖动(如基础 TTL ± 一个随机量),把过期时刻打散、避免同时到点;② 缓存层做高可用(多副本、主从),别让整层成为单一故障域;③ 配熔断/降级,缓存挂了就限流保护数据库,而不是让它被流量冲垮(熔断与降级的完整机制在 第 08 章)。

穿透、击穿、雪崩的触发条件、后果与修复对照 触发条件 后果 修复 穿透 键在缓存和库里 都不存在 每次都 miss 回源, 缓存形同虚设 缓存空值(短 TTL) 或布隆过滤器 击穿 单个热键过期, 并发同时回源 同一键被重复查询 瞬间砸穿数据库 互斥重建 或逻辑过期异步刷 雪崩 大批键同时过期 或缓存层整体宕 成片流量一起推给 数据库,可能压垮 TTL 加随机抖动 + 缓存高可用 + 熔断降级 规模轴:穿透=不存在的键 · 击穿=一个热键 · 雪崩=成片键 / 整层
图 7.2三种崩法按"触发条件 → 后果 → 修复"逐行分类,关键差异在第一列。注意:击穿和雪崩都是"键存在但失效",区别只在规模(一个热键 vs 成片);穿透则根本是"键不存在",所以唯有它需要布隆过滤器/空值缓存这类"提前否定"的手段。

7.3缓存与数据库一致性:只能做到"尽量"

一个被反复追问的问题:缓存和数据库怎么保证强一致?诚实的回答是——做不到,只能 best-effort。原因回到 第 05 章:缓存和数据库是两个独立的存储系统,跨两个系统的写无法原子完成(这正是 第 06 章双写问题的同一面)。一旦接受这一点,"一致性方案"就都退化成"把不一致窗口压到多小、多快自愈"。

延迟双删:面试常考,但不是正确性保证

流程是:先删缓存 → 更新数据库 → 睡几百毫秒 → 再删一次缓存。第二次删是为了清掉"第一次删之后、数据库更新生效之前"被并发读回填进来的旧值。问题在那个"睡几百毫秒":睡多久才够,取决于从库复制延迟、慢查询、GC 暂停等无法预知的量。睡短了清不干净,睡长了白白增加写延迟,且无论睡多久,总存在边缘情况让旧值在第二次删之后才被回填。

辨清

延迟双删是一个 best-effort 的工程 hack,它把不一致窗口变小,但不消除。它常出现在面试题里,却不是数据库厂商或缓存最佳实践推荐的正确性手段。把它当成"强一致方案"写进设计文档,是对自己和评审的误导。

binlog 驱动失效:现代生产更倾向的做法

与其让应用代码同步地协调"删缓存 + 写库"(双写,天然有原子性漏洞),不如让缓存失效由数据库的变更日志单向驱动:订阅 MySQL 的 binlog(Canal、Debezium 这类 CDC 工具,CDC = change data capture,变更数据捕获),数据库一旦提交变更,就由订阅方去删/更新对应缓存。它的优势是:失效事件的源头就是数据库已提交的事实,不再依赖应用代码记得删、也不怕应用删缓存时崩在半路;缓存失效从"双写的一侧"变成了"对已提交事实的单向投影"。代价是引入一条 CDC 链路(多一个组件、有自己的延迟和投递语义)——而这条链路的投递语义,正是下一节要讲的 at-least-once,所以消费端删缓存的动作必须幂等(删本来就幂等,天然契合)。

7.4投递语义:为什么 exactly-once 是神话

从缓存切到异步消息。消息系统给出三种"听起来"的投递语义,但只有两种是真正的基石:

  • at-most-once(至多一次):发出去就不管、不重试。可能丢,但绝不重复。适合能容忍丢失的数据(如可降采样的指标、日志)。
  • at-least-once(至少一次):重试直到收到 ack(acknowledgement,确认回执)。绝不丢,但可能重复。是绝大多数业务消息的默认选择。
  • "exactly-once(恰好一次)":每条消息不丢不重——在传输层这是不可能的。

为什么不可能?这是"两将军问题"的直接后果。发送方发出消息后在等 ack,结果 ack 一直没回来。此刻发送方无法区分两种情形:是消息根本没送达(该重发),还是消息送达了、但回来的 ack 在网络里丢了(重发就会重复)。两种情形在发送方看来完全一样,而它必须做出选择:要么不重发(接受可能丢——退回 at-most-once),要么重发(接受可能重——退回 at-least-once)。没有第三条路。所以传输层的真实选项永远只有"可能丢"和"可能重"两种,"恰好一次"在传输层不存在。

真正可行的目标

工程上能做到的不是 exactly-once,而是 effectively-once(效果上恰好一次)= at-least-once + 幂等/去重。底层照样可能重复投递,但消费端用幂等设计让"重复处理"不产生重复的副作用——效果上等价于只处理了一次。这是一个关于效果而非传输的保证,区别至关重要。

那 Kafka 宣传的 exactly-once 是怎么回事?它是真的,但有严格边界:Kafka 的 exactly-once 只在 Kafka 内部成立——"读一个 topic、处理、写另一个 topic 并提交 offset"这一串若全在 Kafka 事务里,能做到对 Kafka 自己恰好一次。可一旦处理过程里有外部副作用(调用一次 RPC、往关系库写一行、发一封邮件),那个副作用就不在 Kafka 的事务边界内,Kafka 管不了它的重复。所以只要消费逻辑触碰外部系统,就仍然必须自己实现幂等键——Kafka 的事务不是"端到端 exactly-once"的免罪符。

at-least-once 叠加幂等去重得到 effectively-once 生产者 at-least-once broker 消费者 幂等键去重 投递 丢 ack → 重发(可能重复) 可能重复 同一幂等键只生效一次 = effectively-once 传输层只有「可能丢 / 可能重」两种基石 选 at-least-once(可能重)+ 消费端幂等,把「重」吸收掉
图 7.3effectively-once 不是一种投递语义,而是"at-least-once 的重复"被"消费端幂等"在下游吸收后的效果。注意:去重逻辑必须落在消费者一侧的外部副作用处——broker 内部的 exactly-once 管不到你写关系库或调 RPC 的那一步。

7.5幂等消费与背压:把重复和过载都吸收掉

幂等消费:去重表必须和副作用同事务

幂等(idempotent,同一操作执行一次和执行多次,对系统状态的影响相同)是 effectively-once 的落脚点。给每条消息一个全局唯一的幂等键(业务上的订单号、或生产端生成的消息 ID),消费端处理前先查去重表:见过这个键就跳过,没见过才执行并记录该键。这套话术里藏着一个最容易被忽略、却最致命的细节——

陷阱(重复扣款的真凶)

"记录幂等键"和"执行副作用(扣款)"如果不在同一个本地事务里,重复就会从缝隙里漏出来:消费者扣了款,正要写去重表时崩溃;重启后重新消费同一条消息,去重表里查不到这个键,于是又扣一次——静默的重复扣款。正确做法是把"写去重表"和"业务副作用"放进一个数据库事务,要么一起成功、要么一起回滚。这与 第 06 章发件箱(outbox)把业务行和事件行塞进同一事务的思路完全同源:原子性来自共用一个本地事务。

Kafka 自 3.0 起默认开启的幂等生产者解决的是另一段路:它用 PID(producer ID,生产者 ID)加每个分区的递增序列号,让 broker 能识别并丢弃"因网络重试而重发的同一条消息",避免生产侧重发导致的重复写入 broker。但它管的只是生产者到 broker 这一跳的重发去重——消费者的重复处理和外部副作用,它一概管不了。这两段去重职责必须分清,否则又会陷入"以为 Kafka 帮我兜底了"的错觉。

背压:跟不上时向上游说"慢点",而不是无界缓冲

背压(backpressure,下游处理不过来时主动向上游施加的反向压力)回答的是一个生死问题:消费者的处理速度跟不上生产速度时怎么办。错误答案是把积压的消息无界地缓冲在内存里——队列只会越堆越长,最终 OOM(out of memory,内存耗尽)让消费者崩溃,问题没解决还多了一次宕机。正确答案是让消费者把"我跟不上"这个信号传回上游,迫使上游放慢或暂停发送。

不同 broker 实现背压的机制不同:Kafka 是 pull 模型——消费者主动按自己的节奏拉取,拉得慢了上游自然就压着没人取,背压是天然的、不需要额外信令。RabbitMQ 是 push 模型——broker 主动把消息推给消费者,所以要靠 prefetch(预取上限,限制 broker 向单个消费者"在途未 ack"的消息数)来卡住推送速度,叠加 TCP 流控做底层兜底。理解这点能解释一个常见困惑:为什么 Kafka 很少听到"消费者被推爆",而 RabbitMQ 不设 prefetch 就容易把慢消费者冲垮。

7.6日志型 vs 队列型 broker

消息中间件分成两个谱系,差别不在"快慢",而在消息读完之后还在不在、以及消费进度由谁记。这一个设计选择,决定了它们各自擅长和不擅长什么。

队列型(RabbitMQ 为代表):ack 即删,broker 记账

消息被消费者 ack 之后就从队列里删除,broker 逐条跟踪每条消息投递给了谁、是否已确认。这是"聪明的 broker + 简单的消费者"模型:路由(exchange/binding)、重试、死信、延迟投递这些复杂逻辑都由 broker 承担,消费者只管收和 ack。它擅长任务分发型场景(一个任务被某一个 worker 处理掉就行),灵活的路由是它的强项。代价:消息一旦被消费就没了,无法重放;broker 要为每条消息记账,海量堆积时压力大。

日志型(Kafka 为代表):append-only,消费者记 offset

消息以 append-only(只追加)的方式写进分区日志,按时间或大小保留(比如保留 7 天),消费完不删除。消费进度不由 broker 记,而是每个消费者组自己记一个 offset(偏移量,标记"我读到日志的哪个位置了")。这是"简单的 broker + 聪明的消费者"模型,带来两个队列型给不了的能力:① 多个消费者组各记各的 offset,能独立、互不干扰地重放同一份日志(一组做实时计算、另一组重跑历史,互不影响);② broker 不为每条消息记投递状态,吞吐极高。

代价同样清晰:顺序和并行度被分区数钉死——Kafka 只在单个分区内保证顺序,消费并行度上限就是分区数;而且同一分区内,一条处理很慢的消息会阻塞它后面所有消息(head-of-line blocking,队头阻塞),因为 offset 是按顺序推进的,绕不过去。所以"一个慢 key 拖慢整条分区"是 Kafka 的固有失效模式,不是配置问题。

陷阱

Kafka 分区数设得太少,消费并行度就被锁死——无论加多少消费者实例,超过分区数的那些都只能空闲(一个分区同一时刻只能被组内一个消费者持有)。分区数是要预先规划的容量参数,临时加分区还会打乱按 key 的顺序保证。上线前按目标吞吐和并行度算好分区数,是日志型 broker 的必修课。

队列型 vs 日志型 broker
维度队列型(RabbitMQ)日志型(Kafka)
消费后 ack 即删,不可重放 保留期内不删,可多组重放
进度由谁记 broker 逐条记账 消费者组各记 offset
路由能力 强(exchange/binding 灵活) 弱(按分区,靠下游处理)
顺序 / 并行 无强顺序,并行灵活 分区内有序,并行≤分区数;慢消息队头阻塞
适合 任务分发、复杂路由、即时处理 高吞吐事件流、多消费方、需重放/回溯
队列型 ack 删除 vs 日志型 offset 回放对照 队列型:ack 即删 已删 m5 m4 消费者 ack → broker 删除该消息 消息读完即消失,无法二次消费 日志型:append-only + offset 0 1 2 3 4 保留期内消息不删除 → 消费组 A · offset=4 消费组 B · offset=1 两组各记 offset,独立回放同一份日志
图 7.4同一份消息在两个谱系里的命运截然不同:队列型 ack 后即删、读完即逝;日志型保留原样、由各消费组自己的 offset 决定读到哪。注意:"能否重放"不是性能差异而是模型差异——它源于"进度由 broker 记"还是"由消费者记 offset"这一个根本选择。

自测

先合上教程、用自己的话写下答案,再展开对照。能复述定义不算会,能说清"代价/何时失效"才算。

  1. cache-aside 的不一致窗口具体是怎么产生的?为什么写操作要"删缓存"而不是"更新缓存"?
  2. 穿透、击穿、雪崩各自的触发条件是什么?为什么布隆过滤器只对其中一种有效,对另外两种无效?
  3. 为什么 exactly-once 在传输层根本不存在?工程上"effectively-once"是靠哪两件事拼出来的,缺一会怎样?
  4. at-least-once 配一个非幂等的消费者,会出现什么具体故障?把"写去重表"和"扣款"分到两个事务里又会怎样?
展开参考答案

1. 窗口来自"读 miss 回填"和"写后删"两条独立非原子序列的交错:慢读取到旧值 v1 后被挂起,期间一个写更新库到 v2 并删了缓存,慢读再醒来把 v1 写回缓存——缓存稳定停在 v1、库是 v2,直到 TTL 或下次写。用"删"是因为删幂等、与并发顺序无关;"更新"在并发下可能乱序产生稳定错值且不自愈。

2. 穿透=键在缓存和库里都不存在(修复:缓存空值 / 布隆过滤器);击穿=单个热键过期瞬间并发回源(修复:互斥重建 / 逻辑过期);雪崩=大批键同时过期或缓存层整体宕(修复:TTL 加随机抖动 + 缓存高可用 + 熔断降级)。布隆过滤器只对穿透有效,因为它解决的是"提前判定键不存在";击穿和雪崩的键是存在的,布隆过滤器一律答"可能存在",帮不上忙。

3. 因为两将军问题:发送方收不到 ack 时无法区分"消息没送达"和"消息送达但 ack 丢了",只能在"不重发(可能丢)"和"重发(可能重)"之间选,没有第三种。工程上靠 at-least-once + 消费端幂等去重 拼出 effectively-once:前者保证不丢,后者把重复在下游吸收掉。缺幂等就会重复处理产生重复副作用;缺重试(退回 at-most-once)就会丢消息。

4. 非幂等消费者遇到重复投递会重复执行副作用——典型就是静默重复扣款。即便加了去重表,若"写去重表"和"扣款"分在两个事务:扣款成功、写去重表前崩溃,重启后查不到幂等键,于是再扣一次。必须把去重记录和业务副作用放进同一个本地事务(同 06 章 outbox 的原子性思路),重复才被真正堵住。

进阶挑战

明星发微博击穿主页缓存,设计三层防御

某明星刚发了一条微博,主页缓存恰在此刻 TTL 到期,瞬间数百万请求并发回源、把数据库砸穿。给出三层互补的防御,并说清每一层挡住的是哪一段、各自的代价:

展开思路(先自己写满三层再看)

第一层 · 互斥重建(singleflight):同一热键的并发回源只放一个请求去重建、其余短暂等待它的结果,把 N 次回源压成 1 次。代价:等待者多了一点延迟。挡住的是"过期瞬间的并发回源"本身。

第二层 · 逻辑过期 / 永不物理删除:热键的值携带逻辑过期时间戳、不真正从缓存删除,读到"逻辑已过期"时前台先返回旧值、后台异步刷新。代价:短时间内读到略陈旧的数据。它让"过期瞬间"根本不存在硬 miss。

第三层 · TTL 随机抖动 + 缓存高可用 + 熔断降级:给一批热键的 TTL 加随机量避免同时到点(防止单点击穿升级成片雪崩);缓存做主从避免整层宕;万一仍打到库就限流/降级保护数据库(机制见 08 章)。代价:额外组件与少量降级体验。它是兜底,确保即使前两层失手,数据库也不被冲垮。

三层覆盖了"并发回源 → 过期瞬间 → 整体过载"三个不同层级,缺任何一层都会在对应层级留缺口。