上午十点半,运营把一款降噪耳机推上首页。 GET /products/:sku 平时的 P99 是 42 ms,缓存命中率也长期在 99% 以上,所有仪表盘看起来都很健康。活动开始六分钟后,接口 P99 突然冲到 3.8 s,数据库连接池从绿色变成红色,商品详情页开始间歇性返回 503。
值班同学第一反应是“Redis 变慢了”,但 Redis 的命令延迟并没有明显上涨。真正发生的事情更简单,也更危险:product:airpods-pro-2 恰好在流量峰值到来时过期。几百个请求几乎同时执行 GET,几百次结果都是未命中;每个请求随后都去查 products 表。原本由一个缓存 key 承接的流量,在几十毫秒内全部压到数据库连接池上。

这就是本章要解决的核心问题:缓存不是接到接口前面就会自动生效的“加速开关”。它是一份随时可能消失的副本。只要应用允许未命中请求回源,就必须回答三个问题:同一个 key 未命中时最多放多少请求进数据库;一批 key 同时失效时如何削平回源峰值;Redis 自己不可用时,数据库能承受多大的绕行流量。
我们会一直围绕同一个接口展开:
GET /products/:sku数据库中的 products 表保存商品事实,Redis 的 product:{sku} 只保存可重建的商品快照。价格与库存的一致性会留到下一章,本章只处理读路径的容量、失效和故障边界。
Cache Aside 可以译为旁路缓存。它没有一个独立的缓存代理替应用做决定,读缓存、查数据库和回填缓存都由应用代码显式完成。对商品详情接口,最基本的读路径只有四步:
sku 生成 product:{sku},先从 Redis 读取商品快照。products 表,而且只查询详情页真正需要的列。下面是一个能放进 Express 服务的最小版本。代码刻意把 key、TTL、数据库查询和 JSON 解析写清楚,因为这些细节正是后面加保护时要复用的边界。
import Redis from "ioredis";
import type { Request, Response } from "express";
const redis = new Redis({
host: process.env.REDIS_HOST ?? "127.0.0.1",
port: Number(process.env.REDIS_PORT ??
这段代码能工作,却还不能直接承受热点流量。问题藏在 cached === null 之后:每个请求都拥有查询数据库的资格。平时未命中很少,这个缺口不会暴露;热点 key 一过期,所有并发请求会同时穿过缺口。
Cache Aside 的价值也不能只看“Redis 一次 GET 有多快”。假设接口入口流量为 ,命中率为 ,暂时忽略缓存自身故障,数据库接到的回源流量约为:
当入口是 20,000 QPS 时,99% 命中率仍代表约 200 QPS 回源。如果数据库安全预算只有 150 QPS,这个看起来很漂亮的命中率其实已经不合格。更麻烦的是,平均命中率会把热点 key 的瞬时失效藏起来:全站一百万个 key 中只有一个过期,整体命中率仍然很高,可这一个 key 就可能承接首页的大部分请求。
一个全站命中率很容易制造错觉。九十九万个冷门商品每天各访问一次,和十个首页商品每秒被读几千次,放在同一个分母里没有太大排障价值。更可用的看法是按接口、商品热度和结果类型拆分:热门商品的命中率是否突然下降;正常商品与空值标记分别命中了多少;回填是因为自然过期、内存淘汰,还是发布流程主动删除;同一个 key 在一分钟内触发了多少次数据库查询。
尾延迟也要分来源记录。一次请求可分别记录 redis_get_ms、db_wait_ms、db_query_ms、cache_fill_ms 和最终的 request_ms。若数据库查询只用了 25 ms,但 db_wait_ms 达到 600 ms,说明瓶颈是连接池排队;此时优化 SQL 的收益很有限。若 Redis 命中后仍慢,就要继续检查 value 是否过大、JSON 解析是否占用事件循环、应用与 Redis 是否跨地域,以及一次接口是否串行发了多条命令。
缓存对象也应该控制大小。商品详情页只需要名称、价格展示值、封面与状态,就不要把几十个后台字段和大段富文本一起序列化。value 越大,网络传输、解析、内存与回填成本都越高。缓存不是把数据库整行换个地方存,而是为一条确定的读路径准备一份紧凑快照。
打开下面的实验台,把入口 QPS、命中率和数据库安全 QPS 放在一起调。再打开 Redis 故障开关,你会看到“命中率指标”为什么必须和回源预算、限流策略一起解释。
缓存命中率回答的是“多少次读取拿到了副本”,尾延迟回答的是“最慢的那批用户等了多久”,数据库回源 QPS 回答的是“缓存失效后谁在付账”。三者必须同时看。平均延迟和全局命中率都可能在事故最初几分钟保持正常。
我们把事故时间线拆开。10:36:00,热点商品 key 到期;10:36:00.005,第一批请求发现未命中;10:36:00.020,几百个数据库查询进入连接池。查询本身只有 25 ms,可连接池只有 10 个连接,后面的查询必须排队。第 200 个请求并不是执行得更慢,它只是等了前面十九轮。
如果 200 个请求同时未命中,连接池大小为 10,单次查询占用连接 25 ms,那么只看排队下界,最后一批请求也要接近 500 ms 才能完成。真实链路还包括应用调度、网络、序列化和上游超时,P99 很容易继续放大。请求一旦超时,客户端或网关若立即重试,又会制造一批新的回源查询。
下面的模拟器允许你切换“无保护回源”和“互斥回填”。先把并发请求调到 200、数据库查询耗时调到 25 ms、连接池设为 10,再观察数据库查询数和 P99 的差别。
同样的参数用 Redis 8.2.2 与 ioredis 6.0.0 跑一遍,数据库查询被限制在 10 个并发槽位,结果如下:
无保护回填 请求=200 数据库查询=200 P99=511.4ms
互斥回填 请求=200 数据库查询=1 P99=36.2ms数字不是容量承诺,换一台机器会得到不同的毫秒数。真正稳定的关系是:无保护版本把 200 个缓存未命中放大成 200 次同类查询;互斥版本把这批请求合并成 1 次查询。我们要修的不是某条 SQL,而是未命中到数据库之间没有并发闸门。
最直接的修复叫互斥回填,也常被称为 single flight:第一个未命中的请求取得一个短租约,负责查库和回填;其他请求拿不到租约,不再各自查库,而是短暂等待后重新读取缓存。

实现时有四个容易漏掉的动作:锁必须有 TTL,避免持锁进程崩溃后永远不释放;拿到锁后必须二次读缓存,因为等待获取锁期间可能已有其他实例完成回填;释放锁必须校验唯一 token,不能误删后来者的新锁;未拿锁的请求必须有等待上限,不能无限轮询。
import { randomUUID } from "node:crypto";
const NULL_SENTINEL = "__NULL__";
const releaseLockScript = `
if redis.call("GET", KEYS[1]) == ARGV[1] then
return redis.call("DEL", KEYS[1])
else
return 0
end
`;
const sleep = (ms: number) => new Promise((
productTtl 不应每次完全随机,否则测试、排障和容量预测都很难复现。可以根据 SKU 做稳定抖动,让同一个商品的 TTL 基本固定,不同商品分散到不同时间点:
function productTtl(sku: string) {
let hash = 0;
for (const char of sku) {
hash = (hash * 31 + char.codePointAt(0)!) >>> 0;
}
return 300 +
锁 TTL 要大于数据库查询的坏情况耗时,不能只看平均值。若数据库偶尔要 800 ms,而锁 500 ms 就失效,第一个请求尚未回填,第二个请求已经可以取得新锁,两次查询又会重叠。反过来,锁 TTL 也不是越长越好:持锁实例挂掉后,所有等待者都要等到租约到期。实际项目应从数据库 P99、网络余量和序列化耗时估算,并监控“锁等待超时”和“同 key 并发回源数”。
还有一个常见误区:拿不到锁就直接查数据库。这会把互斥锁变成摆设。等待者应该重读缓存、返回可接受的旧快照,或在达到等待上限后受控失败。具体选哪一种,要由接口的延迟目标和陈旧容忍度决定。
这里的 lock:product:{sku} 只为一次缓存重建服务,生命周期应短,失败后允许下一位请求接手。它不能顺便拿来锁商品编辑、库存扣减或订单提交。这些业务操作的临界区、超时和正确性要求完全不同,共用一个锁会让缓存刷新阻塞真实写入,也会让一次后台慢查询扩大成业务故障。
同一个 Node.js 进程内可以先用 Promise Map 合并请求,但只做进程内合并还不够。服务通常有多个实例,每个实例都可能各放行一次数据库查询;扩到五十个实例时,一次 key 过期仍会产生五十次回源。Redis 的短租约把协调范围扩到所有实例,进程内合并则可以减少本实例的轮询命令。两层可以叠加,职责却要分开。
互斥回填也需要可观测性。至少记录 cache_refill_started_total、cache_refill_wait_total、cache_refill_timeout_total、回填耗时和按哈希聚合的热点 key。不要把完整 SKU 直接做成无限增长的指标标签;可以在日志中保留 SKU,在指标中只保留接口和结果,另用 Top-K 日志分析热点。若“等待者数量”不断增加,可能是锁 TTL 太短、数据库查询变慢、回填写入失败,或者热点 value 已经大到不适合继续走这条路径。
还有一个失败顺序必须想清楚:数据库已经查到商品,Redis 回填却失败。详情读取本身仍可返回数据库结果,因为 Redis 只是副本;但日志和指标必须记录回填失败,后续请求可能继续回源。若此时让接口直接返回 500,客户端重试反而会再次查库。缓存错误不该自动覆盖真相源已经成功的读取结果。
第二类事故发生在凌晨。监控显示 Redis 命中率不断下降,数据库出现大量形如 WHERE sku = $1 的查询,但参数不是正常商品编号,而是随机生成的长字符串。每次请求的流程都完全一样:Redis 没有这个 key,数据库也没有这条商品,接口返回 404;下一次相同请求又从头查一遍。
这不是热点 key 过期,而是缓存从未保存“这个商品不存在”的结论。攻击脚本、爬虫错误、失控客户端和前端拼错参数,都能制造这种流量。我们先在入口做格式校验,例如 SKU 只允许大写字母、数字和连字符,长度限制在 1~40;校验通过却查不到的 SKU,再缓存一个短 TTL 的空值标记。
const SKU_PATTERN = /^[A-Z0-9-]{1,40}$/;
export async function getProductHandler(req: Request, res: Response) {
const sku = req.params.sku.trim().
空值不能和 Redis 的“key 不存在”混成同一个 null,所以示例存储 __NULL__。空值 TTL 也要明显短于正常商品:商品刚刚创建时,旧的空值需要尽快退出;随机抖动能避免大量空值同秒过期。前面的并发实验中,100 个请求首次访问不存在的 NONE 时,通过互斥只查询数据库 1 次;紧接着再来 100 个请求,数据库查询为 0:
空值缓存首轮 请求=100 数据库查询=1 P99=30.4ms
空值缓存次轮 请求=100 数据库查询=0 P99=1.1ms
空值标记=__NULL__ TTL=5s如果商品集合很大、随机 SKU 攻击量也很大,可以在缓存之前增加布隆过滤器。它能确定“肯定不存在”,却只能判断“可能存在”:过滤器说不存在时可直接拒绝,回答可能存在时仍要走 Redis 和数据库。过滤器不是事实表,新商品创建、商品迁移和过滤器重建都要有更新流程;容量与误判率也必须按数据规模预估。对多数普通接口,参数校验、空值缓存和按调用方限流通常是更容易落地的第一道防线。
不要把所有 404 都缓存。带权限条件的查询可能对用户 A 不存在、对用户 B 存在;临时下架与永久不存在的业务语义也不同。空值 key 必须包含真正决定查询结果的维度,并设置短 TTL。
第三类事故看起来像“数据库每隔五分钟抽风一次”。上线脚本在 12:00 预热了 30,000 个商品 key,并统一设置 EX 300。12:05,它们在相近的时间窗口内集体失效。没有任何单个商品达到惊人的 QPS,但大量不同 SKU 同时回源,数据库查询量形成尖峰;12:10 同样的节奏再次出现。
固定 TTL 的问题不在 300 秒太短,而在大量 key 共享同一个失效时刻。把有效 TTL 改成“基础值加抖动”,可以把回源摊到一段时间内:
例如正常商品使用 300~390 秒,空值使用 15~30 秒。TTL 仍然要由业务容忍的陈旧时间决定,抖动只负责分散时刻,不能把五分钟可见性要求随意扩成一小时。

下面的实验台可以改变 key 数量、基础 TTL、抖动区间和请求热度。先将抖动设为 0,再逐步扩大,观察最大单秒过期数和数据库峰值如何变化。
TTL 抖动只能处理“批量到期”这一种雪崩来源。发布时全量删除缓存、Redis 故障、网络分区、集群切换和错误的淘汰策略,也会让大量请求同时失去缓存。应对方案必须分层:热点 key 用互斥回填;批量 key 用抖动和分批预热;Redis 故障用快速失败、回源限流和降级;数据库还要有自己的连接池、查询超时与熔断边界。只做其中一层,事故会从另一个入口回来。
预热同样不能写成一次全量 Promise.all。如果部署刚完成就让每个应用实例同时读取三万件商品,预热本身就是一次人造雪崩。更稳妥的做法是只预热已经确认的热点集合,限制并发,分批查询数据库,并为每个 key 计算不同 TTL。预热任务还要支持停止:数据库等待数或复制延迟超过阈值时立即降速,而不是为了把缓存填满继续挤占在线请求。
内存淘汰造成的未命中也常被误判成 TTL 到期。若 Redis 已接近 maxmemory,热点 key 可能在 TTL 尚未结束时被淘汰,应用看到的仍然只是 GET 返回空。监控中要把 expired_keys 与 evicted_keys 分开看:前者说明生命周期走完,后者说明内存预算或淘汰策略正在主动赶走数据。如果持续淘汰,只调长 TTL 会让问题更严重,因为更多 key 会在内存里竞争。
发布流程也要避免“为了省事清空整个缓存”。字段结构变化时可以在 key 中加入版本,例如从 product:v1:{sku} 切到 product:v2:{sku},让新旧版本在过渡期并存并分别过期。这样会临时增加内存占用,却比一次 FLUSHDB 后让全部请求回源更可控。版本切换前应先估算双份缓存的空间,并准备停止和回滚条件。
互斥回填保护了数据库,但等待者仍要等第一个请求查库。对首页热门商品,业务有时更在意稳定的尾延迟,并且允许名称、封面、展示标签短暂陈旧。此时可以把“数据还能不能返回”和“数据该不该刷新”拆开:Redis key 本身设置较长的物理 TTL,value 内保存较短的逻辑过期时间。

读请求拿到快照后,如果 refreshAfter 还没到,直接返回;如果已经逻辑过期,当前请求仍返回旧快照,同时尝试取得刷新锁。只有取得锁的实例在后台查询数据库并替换快照,其他读请求不等待。
type ProductSnapshot = {
data: Product;
refreshAfter: number;
};
async function getProductStaleWhileRefresh(sku: string) {
const key = productKey(sku);
const raw = await redis.get
逻辑过期不是免费午餐。它明确接受“短暂陈旧”,所以不能照搬到库存确认、优惠资格、权限和支付状态。物理 TTL 仍要保留,避免永久无人访问的快照占满内存;后台刷新失败要有指标和告警,否则一份旧数据可能持续返回很久。进程内直接启动异步任务还会受到发布重启影响,对刷新可靠性要求更高时,应把任务交给有确认与重试的队列。
选择策略时可以用一句话判断:用户能否接受旧值?不能接受,就让并发请求在严格上限内等待互斥回填;可以接受,就用旧快照换稳定尾延迟,并让刷新在后台发生。
最后再复盘一次范围更大的雪崩。Redis 因网络抖动不可达,应用的每个 GET 都开始等待重连;请求线程越积越多,上游超时后重试。有人为了“保证可用”把所有请求直接绕到数据库,结果 Redis 还没恢复,products 表的连接池先被打满。
正确的降级目标不是“Redis 坏了也让所有请求成功”,而是保护系统中更难恢复的部分。数据库的安全回源预算假设是 150 QPS,缓存故障时就只能放行接近这个预算的请求,不应因为入口有 20,000 QPS 就把 20,000 次查询都交给数据库。

面向商品缓存的 ioredis 客户端可以先配置成快速暴露失败。下面的数值只是起点,必须按同机房网络延迟、接口预算与故障演练结果调整:
const redis = new Redis({
host: process.env.REDIS_HOST ?? "127.0.0.1",
port: Number(process.env.REDIS_PORT ?? 6379),
connectTimeout: 200,
commandTimeout: 80,
enableOfflineQueue: false,
enableOfflineQueue: false 避免连接未就绪时把新命令无限堆在进程内;commandTimeout 给缓存读取一个短预算;有限重试避免一次用户请求在缓存层耗尽全部时间;退避加入抖动,避免所有应用实例同时重连。这里的重试只是连接恢复策略,不代表业务请求可以无限等待。
业务层还要做一次明确选择。对商品详情页,可以按下面顺序降级:
products 表;其余请求返回 503、稍后重试,或返回边缘层保存的受控旧快照。回源限流最好靠共享的入口预算或数据库代理统一执行,仅靠每个实例各自设置“最多 20 个并发”会随实例数增长。十个实例就是 200 个并发,自动扩容到五十个实例后会变成 1,000 个,数据库容量并没有跟着自动扩大。如果只能先做进程内信号量,预算应按最大实例数反推,并在扩容配置变化时一起评审。
降级响应也要区分业务层级。商品名称和封面可以返回带明确年龄上限的旧快照;实时库存不能从旧详情快照推断;推荐区可以隐藏;主商品完全无法读取时才返回 503。不要用一个笼统的 catch 把所有 Redis 错误都转换成“查数据库”,也不要把所有错误都转换成 500。超时、连接未就绪、反序列化失败和业务数据缺失需要分别计数,处理策略才有机会被验证。
恢复阶段最容易出现第二次冲击。Redis 连接恢复,只说明缓存服务可访问,不代表热点数据已经回来。若立即关闭熔断并放开全部流量,第一批请求仍会大规模未命中。可以先用小比例流量探测,逐步提高回源许可,同时按热点清单分批预热;数据库连接池等待和 Redis 回填失败一旦回升,就停止扩大流量。恢复是一个受控过程,不是健康检查从红变绿的瞬间动作。
“缓存失败就查数据库”只有在数据库确实有剩余预算时才成立。降级代码若没有全局限流或并发信号量,等于把 Redis 故障转换成数据库故障。高可用部署能缩短缓存不可用时间,却不能替代应用侧的超时、限流和受控回源。
现在回头看 GET /products/:sku,一个可上线的缓存方案至少要把下面这些答案写进设计和监控,而不是散落在几段代码里。
压测也不能只做“缓存已预热”的理想场景。至少要覆盖四组:热 key 正常命中;热 key 在并发峰值中到期;大量不同 key 集中到期;Redis 断连后数据库仍受保护。只测第一组,得到的是 Redis 正常时有多快,得不到系统失去缓存时能不能活下来。
每组压测都应先写通过条件。热 key 到期时,同一 SKU 的数据库查询并发要接近 1,锁等待者不能超过接口超时;大量 key 到期时,数据库 QPS 不越过安全预算,连接池等待保持在阈值内;Redis 断连时,应用内存不能因为离线队列持续增长,非核心模块应按设计关闭;恢复后,放量过程不能产生第二个数据库尖峰。没有通过条件的“打一遍流量”只能得到一张曲线,不能证明方案可上线。
故障演练还要验证日志是否真的能回答问题:这次未命中是正常过期、淘汰还是缓存错误;谁取得了回填锁;等待者多久后命中;空值缓存挡住了多少不存在的 SKU;多少请求获得回源许可,又有多少被降级。事故发生时再临时加日志已经太晚,尤其是问题只持续几十秒、恢复后现场自动消失的击穿。
缓存击穿、穿透和雪崩并不是三道需要背定义的面试题。它们分别对应三种容量漏洞:同一个存在的热点 key 突然失效;不存在的数据反复绕过缓存;大量 key 或整个缓存层同时失效。解决它们的共同方法,是在缓存和数据库之间明确回源资格、回源速率与失败语义。
本章先把读路径守住。商品更新后应该先改数据库还是先动缓存、删除缓存失败怎样补偿、并发读写为什么仍可能产生旧值,这些属于两个数据源之间的一致性问题,下一章会沿用同一个 products 表继续拆解。