题干与适用场景
一个热点商品配置键承受每秒 5 万次读取,源数据库安全上限为每秒 200 次查询,重建缓存的 p95 为 800 毫秒。缓存新鲜期为 10 分钟,业务最多容忍 30 秒旧数据。服务运行在 100 个无状态应用实例上,共享一个远程缓存和同一源数据库。
请设计完整的读取与刷新流程,并解释软过期、硬过期、首次冷启动、刷新实例崩溃、缓存不可用和源数据在刷新期间发生变更时如何处理。还要说明怎样证明方案不会把并发压力转移给数据库。本题中的吞吐、延迟和过期时间都是设计输入,不代表某个产品的性能承诺。
这是一道后端可靠性题。当前公开的 2026 SRE 面试题资料直接给出了“关键键在每秒 10 万请求下过期”的缓存踩踏场景;本题把它改写成可计算、可验证的工程约束。它与单进程 LRU 的淘汰算法不同,核心是跨实例并发控制与故障语义。
面试官考察点
第一,看候选人是否先算失效瞬间的量级。若 800 毫秒内到达的请求都绕过缓存,理论上有 50,000 × 0.8 = 40,000 个请求试图访问源站,远超每秒 200 次的安全上限。“加缓存”本身没有解决缓存失效时的同步放大。
第二,看能否区分三层保护。本地请求合并只约束一个进程;100 个实例各自选出一个刷新者,仍可能同时产生 100 次重建。分布式租约把正常情况下的跨实例刷新收敛到一个,但租约过期、进程暂停或网络分区仍可能出现重叠刷新。因此,源站并发舱壁或限流必须作为独立的最后防线。
第三,看过期语义是否明确。新鲜期内直接返回;软过期后、30 秒可陈旧窗口内立即返回旧值,并在后台竞争刷新;超过硬过期后不能继续无限返回旧值,未获刷新资格的请求只能有界等待、降级或失败,不能全部穿透源站。
最后,看候选人是否处理“旧刷新者覆盖新值”。租约只在有效期内提供互斥,不等于恰好一次执行。缓存项要带源版本或生成代次,写回必须做版本比较,避免暂停过久的旧刷新者恢复后覆盖更新结果。
回答前需要澄清的问题
- 旧数据是否真的可用? 本题允许最多 30 秒旧数据;余额、权限、库存扣减等强敏感数据可能不允许采用同样策略。
- 源站安全上限的口径是什么? 本题按整个源数据库每秒 200 次查询处理,还应确认单键查询的最大并发与超时。
- 首次冷启动能否返回默认值? 默认没有可用旧值,只允许一个刷新者访问源站,其他请求有界等待;若业务有静态默认值,可作为显式降级。
- 缓存条目如何失效? 采用逻辑新鲜期和硬过期,不依赖所有副本在同一秒物理删除键。源数据变更事件可主动刷新或失效。
- 允许跨地域吗? 本题先设计单地域的 100 个实例;多地域需要分别分配源站预算,不能默认用一个跨地域锁解决所有问题。
- 返回旧值时是否要告诉调用方? 内部响应至少记录
stale_age和降级原因;是否暴露给终端用户由业务决定。 - 缓存本身故障时怎么办? 必须预先约定降级策略和源站预算,不能把“缓存失败”直接等同于“所有请求查数据库”。
30 秒回答框架
“我会在缓存值中保存 freshuntil、staleuntil 和源版本。新鲜命中直接返回;软过期后在 30 秒窗口内返回旧值,并用进程内 singleflight 加一个带所有权令牌和 TTL 的分布式租约选刷新者;硬过期或首次加载时,跟随者只做有界等待和带抖动的重读。刷新写回按源版本条件更新,释放租约时校验令牌。租约失效不代表源站可以无限并发,所以数据库前还要有每键与全局舱壁。最后用失效压测、刷新者崩溃、租约超时和缓存故障演练验证源站 QPS、并发与旧数据年龄上界。”
分步骤深入解答
先定义缓存项,而不是只存业务值:
CacheEntry {
value
source_version
generated_at
fresh_until
stale_until
}freshuntil 是 10 分钟新鲜期的结束时间,staleuntil 再延后最多 30 秒。远程缓存键的物理 TTL 应至少覆盖 stale_until 和少量清理余量,否则缓存服务会在软过期时先删掉仍可安全返回的旧值。旧值窗口是明确的业务预算,不应在刷新失败时悄悄延长。
读取路径可以写成下面的伪代码:
entry = cache.get(key)
now = clock.now()
if entry exists and now < entry.fresh_until:
return entry.value
if entry exists and now < entry.stale_until:
try_refresh_async(key)
return entry.value
return rebuild_or_wait(key, request_deadline)软过期路径优先保护请求延迟。第一个观察到软过期的请求尝试后台刷新,其他请求继续使用旧值。进程内 singleflight 以键为粒度,让同一实例只运行一个刷新函数;Go 的公开实现把该语义定义为“同一键同一时刻只有一个调用执行,重复调用者共享结果”。但它不跨进程,因此不能单独用于 100 个实例。
跨实例刷新使用有期限的租约。例如刷新者生成随机且不可复用的 token,执行:
SET refresh:{key} {token} NX PX {lease_ms}获得租约后再读一次缓存,防止另一个刷新者刚刚完成;仍需刷新时才调用源站。释放时必须“仅当当前值仍等于自己的 token 才删除”。直接 DEL 有竞态:旧刷新者暂停到租约过期,新刷新者已获得租约后,旧刷新者恢复并删除键,会误删新租约。Redis 的分布式锁文档也把唯一值与所有权校验作为安全释放条件。
lease_ms 应高于实测刷新 p99 加网络与调度余量,而不是机械使用 800 毫秒的 p95。租约太短会增加重叠刷新,太长会在刷新者崩溃后延迟接管。租约到期只允许其他实例重试竞争,不能证明旧刷新者已经停止执行。因此源站读取要受每键 singleflight 服务或数据库舱壁保护,刷新写回也必须防旧结果覆盖。
写回时读取源数据的单调版本,例如数据库行版本或事件序号,并做条件更新:只有 new.sourceversion >= cached.sourceversion 才替换缓存。若源数据没有可靠版本,可在共享协调存储中用原子递增值为刷新分配生成代次,并在缓存端原子比较。缓存仍不是事实真源;延迟完成的刷新不能覆盖已经由变更事件写入的更新版本。
硬过期与首次加载没有可返回的合格旧值。取得租约的请求在源站舱壁允许时重建;跟随者在请求截止时间内按短间隔加抖动重读缓存,而不是轮询固定节拍。等待超时后返回明确的降级响应或错误。若产品允许静态默认值,可返回默认值并标记降级;不能为了“高可用”把 5 万请求直接放向源站。
源站最后防线至少包含每键并发上限、全局刷新并发上限和每秒查询预算。正常情况下某键只有一个重建;租约故障时,舱壁仍保证总查询不超过安全边界。舱壁满时刷新任务快速失败或排队到有界队列,软过期请求继续使用未超过 30 秒的旧值,硬过期请求按既定降级处理。
缓存不可用时,应用不能把所有读流量切到数据库。可在每个实例保留短时只读近端副本,但全局仍受源站舱壁约束;没有近端副本的请求应降级或失败。恢复后通过限速预热热点键,避免所有实例同时回填。对大量不同键同时过期,应给物理或逻辑新鲜期加入随机抖动;抖动缓解缓存雪崩,却不能解决单个热点键的并发重建。
还要区分相邻问题:缓存击穿或踩踏是一个已有热点键失效后被并发重建;缓存雪崩是大量键同时失效或缓存整体故障;缓存穿透是反复查询本来就不存在的数据。TTL 抖动主要处理雪崩,短期负缓存或布隆过滤器主要处理穿透,它们不能替代本题的请求合并。
对于可预测热点,可在 fresh_until 前主动刷新。Cloudflare 公开的概率式提前刷新方案会让刷新概率随着到期临近而变化,从而在高并发下减少锁竞争;固定写成“每次有 1% 概率刷新”会随请求率变化而失控。可陈旧窗口和后台再验证的语义也与 HTTP 的 stale-while-revalidate 一致:旧响应只能在明确窗口内返回,并由后台完成再验证。
多地域通常按地域保存缓存并独立刷新,再给每个地域分配源站 QPS 和并发预算。一个全球租约会把跨地域延迟和网络分区放进读取链路。若所有地域共享同一源站,可以由中央控制面分配刷新预算,或让源站提供统一的重建服务;关键是所有地域预算之和仍不超过每秒 200 次。
可观测性至少包含新鲜命中率、旧值返回率、硬未命中率、旧值年龄、刷新尝试与成功率、singleflight 共享请求数、租约获取失败与过期数、刷新延迟、源站 QPS/并发/拒绝数,以及缓存错误和延迟。告警应关注源站预算、stale_until 即将耗尽和持续刷新失败,不能只看缓存命中率。
验证围绕不变量进行:在每秒 5 万请求下让键同时软过期,确认正常情况下每键只有一次源站重建;刷新者在写回前崩溃,确认旧值继续服务且租约到期后有人接管;让旧刷新者暂停超过租约,确认它不能覆盖新版本;让缓存不可用,确认源站仍受每秒 200 次与并发舱壁保护;让多个键同时过期,确认 TTL 抖动与全局预算生效。
高质量示范回答
“先算最坏情况:5 万请求每秒乘以 800 毫秒重建时间,失效窗口会出现约 4 万个到达请求,数据库每秒只能安全处理 200 次,所以任何跟随者都不能直接回源。
我会把值、源版本、freshuntil 和 staleuntil 一起缓存。10 分钟内直接返回;接下来的 30 秒返回旧值,同时尝试刷新。每个进程先用 singleflight 合并本地请求,再用 SET lock token NX PX lease 竞争跨实例刷新租约。获得租约后复查缓存,只让仍有必要的刷新者查询数据库。释放租约时比较 token,写回时比较源版本,避免旧刷新者误删新租约或覆盖新值。
首次加载或超过 30 秒时,没有合格旧值。一个请求负责重建,其他请求只在截止时间内带抖动重读缓存,超时就使用明确的默认降级或返回错误。数据库前再放每键和全局舱壁,因为租约过期可能产生重叠刷新,锁不能替代容量保护。
我会压测键过期瞬间,并注入刷新者崩溃、超长暂停、缓存不可用和源数据并发更新。验收条件包括源站 QPS 不超过 200、正常每键重建并发为 1、旧数据不超过 30 秒、旧刷新者不能覆盖新版本。这样延迟、数据新鲜度和源站安全都有可测上界。”
常见错误
- 只给 TTL 加随机数 → 只能把多个键的过期时间打散,单个热点键到期时仍会并发回源 → 对同一键做请求合并,并保留源站舱壁。
- 只用进程内 singleflight → 100 个实例仍可能产生 100 个刷新者 → 本地合并后再做跨实例租约。
- 获得锁前就查数据库 → 并发压力已经到达源站 → 先竞争刷新资格,获胜后复查缓存,再进入源站预算。
- 用
SETNX锁却不设 TTL → 刷新者崩溃后键可能永久不能更新 → 使用有期限租约并设计接管。 - 释放时直接
DEL→ 旧刷新者可能删除后来者的新租约 → 用唯一 token 做所有权校验后原子释放。 - 把租约当成恰好一次保证 → 进程暂停超过 TTL 后可能有两个刷新者同时运行 → 用源站舱壁和版本化写回承受重叠执行。
- 刷新失败就无限返回旧值 → 数据陈旧度失去上界 → 只在
stale_until前返回旧值,之后显式降级或失败。 - 缓存故障时全量回源 → 5 万次读取会立刻压垮每秒 200 次的源站 → 保留近端副本,并让所有回源经过总预算。
- 混淆击穿、雪崩与穿透 → 解决手段与失败条件对不上 → 分别使用请求合并、TTL 抖动和负缓存等针对性手段。
- 只监控命中率 → 高命中率也可能隐藏刷新失败和源站尖峰 → 同时监控旧值年龄、刷新并发、租约和源站预算。
追问及应对
追问一:如果业务完全不能返回旧值呢?
取消软过期返回,跟随者只能有界等待;需要为源站重建服务提供足够容量和明确的失败响应,不能牺牲源站安全换取表面可用性。
追问二:租约 TTL 应设多少?
从真实刷新 p99、网络超时和调度暂停出发加余量,并监控租约到期时仍在执行的比例;p95 的 800 毫秒不足以直接作为 TTL。
追问三:刷新期间源数据又更新了怎么办?
读取并携带源版本,缓存端条件写入;变更事件写入的新版本不能被延迟刷新覆盖。
追问四:缓存集群整体不可用怎么办?
近端只读副本承担可接受的旧读,所有必要回源仍经过全局舱壁;无副本时按业务降级或失败,并限速预热恢复。
追问五:如何处理负缓存?
对确认不存在的数据缓存短 TTL 的“未找到”,防止穿透;它与已有热点键过期的请求合并是两条独立路径。
追问六:概率式提前刷新什么时候合适?
适合可重算、允许旧读且请求率较高的数据;概率应随剩余新鲜时间和观测请求率调整,并保留刷新失败与源站预算保护。
追问七:多地域是否共用一个锁?
通常不共用。按地域缓存与刷新,并分配源站预算;只有确需全球单刷新且能接受跨地域延迟与分区影响时才考虑中央协调。
追问八:怎样证明方案有效?
用每秒 5 万请求跨越软过期与硬过期,注入崩溃、暂停、缓存故障和版本竞争,断言正常每键单刷新、源站不超过预算、旧值不超过 30 秒且新版本不会回退。