题干与适用场景
PostgreSQL 是商品数据的事实源,Redis 保存按 product_id 查询的商品展示快照。系统每秒约有 2 万次读取、500 次写入,活跃商品约 2000 个。名称、图片和展示文案最多允许陈旧 5 秒;库存扣减、价格结算、权限和余额等决定业务正确性的字段不在这份缓存契约内。
请设计 cache-aside 的读取、更新和失效流程,并覆盖以下情况:
- 数据库提交后,进程在删除缓存前崩溃;
- 读请求先读到旧数据库版本,却在写请求失效缓存之后才完成回填;
- 回填从异步副本读到落后数据;
- 失效事件重复、乱序或积压;
- Redis 短暂不可用;
- 产品要求把“最多陈旧 5 秒”写成可以监控和测试的契约。
题设中的吞吐、活跃键数量和 5 秒预算都是面试假设,不是通用配置。当前公开的 2026 后端与高级缓存面试资料都把 cache-aside、写后失效和缓存一致性列为明确考察内容。本题的核心是数据库提交后的后端读写协议与失败语义,因此归为 backend。它与“热点键过期时如何防止缓存击穿”不同:缓存击穿控制并发回源,本题要证明写入后旧值何时、为什么以及最多还能存在多久。
面试官考察点
第一,看候选人能否先定义一致性目标。“数据库和缓存永远一致”没有说明允许什么读结果。强回答会把展示数据的 5 秒有限陈旧、同一用户的读己之写,以及库存和权限的权威读取分开;三种契约需要不同方案。
第二,看是否说清写入顺序。cache-aside 的常用写路径是先提交数据库,再删除缓存。先删缓存再更新数据库会留下一个明确竞态:读请求在两步之间 miss,读出旧数据库值并重新写入缓存。数据库先提交也不是原子双写;提交与删除之间仍有短窗口,删除失败还可能让旧值一直留到 TTL。
第三,看能否画出“旧读晚回填”时间线。读请求可能在数据库更新前读到版本 41,写请求随后提交版本 42 并删除缓存,最后旧读才把版本 41 写回。只在缓存值里加版本号不一定能挡住它:删除后缓存为空,普通的“仅当不小于当前缓存版本才写入”会接受版本 41。需要能在删除后继续存在的版本栅栏、被更新失效的回填租约,或回填前重新验证源版本。
第四,看是否把可靠失效和有限时限区分开。事务型 outbox 或数据库变更捕获能避免“数据库已提交但失效消息未记录”的双写缺口;至少一次消费配合幂等删除能承受重复。然而重试只保证最终会处理,不能自动证明 5 秒内完成。有限陈旧还需要硬 TTL、事件延迟门禁,或在失效链路超预算时绕过缓存。
第五,看是否处理事实源、数据库副本和运维验证。数据库提交成功才产生新事实;从落后副本回填可能在正确失效后再次放回旧值。优秀答案会限定回填数据源,监控事件与缓存版本,并用可控并发时间线和故障注入验证方案,而不是只看命中率。
回答前需要澄清的问题
- 一致性契约是什么? 若允许最多 5 秒陈旧,可以采用异步失效加硬时限;若要求读己之写,写入者后续请求需要短时绕过缓存或携带最低版本;若任何旧读都不可接受,应直接读取权威存储或使用具备相应一致性契约的存储路径。
- 哪些字段会决定不可逆业务动作? 展示名称和图片可以陈旧,库存校验、优惠资格、权限、余额与支付金额不能把缓存快照当作授权依据。字段混在同一对象时,应拆分读取契约或在关键动作中重新读取事实源。
- 5 秒从什么时候开始计算? 本题从数据库事务提交时间计算。若从缓存写入时间计算,副本本来已经落后 4 秒,再缓存 5 秒会让真实数据年龄接近 9 秒。
- 所有写入口都受同一服务控制吗? 若批处理、后台工具和其他服务也能写数据库,只在 API 写路径调用
DEL会漏失效。应把失效记录放进同一数据库事务,或从数据库日志捕获所有受支持的变更。 - 读 miss 从主库还是副本回填? 需要知道副本延迟上界和是否支持会话级读己之写。无法证明副本在预算内时,失效后的首次回填应读主库,或要求结果版本达到调用方的最低版本。
- 数据库能承受多少回填? 5 秒 TTL 下,若 2000 个活跃键均匀过期且都有人读取,平均约有
2000 ÷ 5 = 400次回填每秒;实际还受访问分布、抖动和请求合并影响。数据库预算不足时,不能靠缩短 TTL 假装满足一致性。 - Redis 不可用时优先可用性还是新鲜度? 展示数据可以在明确旧值上界内降级;权威字段必须回源并受限流保护。若数据库容量不足以接住全部 miss,就需要限流、舱壁和明确失败,而不是无界回源。
30 秒回答框架
“我先按数据类型定义契约:商品展示数据允许从数据库提交起最多旧 5 秒,库存和权限始终查权威路径。读请求先查 Redis,miss 时合并同键并发,再从满足新鲜度要求的数据源读取带版本的记录并条件回填。写请求在 PostgreSQL 事务内更新业务行,同时写一条带行版本的 outbox;提交成功后先尝试删除缓存,outbox 或 CDC 再做可重试的失效修复。
我会先提交数据库再删缓存。为防止旧读在删除后晚回填,miss 时取得回填代次,失效时推进版本栅栏并删除值,回填只有在代次未变且源版本不低于栅栏时才成功。5 秒不是靠‘最终重试’证明的:缓存有不超过预算的硬 TTL,失效延迟接近预算时读取绕过缓存。副本延迟无法满足预算时,失效后的回填读主库。最后用提交后崩溃、旧读晚到、事件重复乱序和副本落后的故障注入,断言旧值年龄、版本单调性和数据库负载。”
分步骤深入解答
第一步:把一致性写成读结果契约
先按业务后果分三条路径:
| 路径 | 允许的结果 | 推荐读取方式 | | --- | --- | --- | | 商品展示 | 从数据库提交起最多陈旧 5 秒 | Redis cache-aside,硬 TTL 与失效延迟门禁 | | 写入者随后读取 | 至少看到自己刚提交的版本 | 携带 min_version,缓存不足时读主库 | | 库存、权限、余额、结算 | 业务决定必须基于当前权威状态 | 绕过展示缓存,在事务或权威服务中校验 |
这一步决定后续设计。缓存适合加速可重建副本,不能让库存扣减依赖一个可能丢失、延迟或被淘汰的值。即使缓存命中率为 99.9%,也没有改变剩余 0.1% 是否会造成超卖或越权。
缓存条目至少保存源版本和生成时间。下面是伪结构,不是特定语言的可执行类型:
ProductCacheEntry {
value
source_version
source_committed_at
cached_at
}sourcecommittedat 用于计算真实陈旧年龄;cached_at 只反映缓存何时写入。源版本可以是每行单调递增版本、提交序号,或能比较新旧的领域版本。墙上时钟时间戳若可能相同或漂移,不适合单独承担顺序证明。
第二步:建立基础 cache-aside 读写路径
读取流程先取缓存,命中且未违反调用方的最低版本与陈旧预算就返回。miss 时对同一 product_id 合并并发回填,避免 2 万次读取同时打到数据库。回填读取带版本的数据,再尝试写 Redis。下面是流程伪代码:
read(product_id, min_version = none):
entry = cache.get(product_id)
if entry satisfies age_budget and min_version:
return entry.value
return singleflight(product_id):
recheck cache
row = read_authoritative_version(product_id)
conditional_fill(product_id, row)
return row.value写入流程把业务行和 outbox 放进同一 PostgreSQL 事务。COMMIT 成功后,业务变更才对其他事务可见并持久化。应用随后执行一次快速失效;独立 relay 或 CDC 消费者处理持久失效事件,负责重试和修复。写路径伪代码如下:
transaction:
row = update product and increment source_version
insert outbox(product_id, source_version, committed_at)
commit
best_effort_invalidate(product_id, source_version)
return committed source_version直接失效缩短通常窗口,outbox 封闭进程在提交后崩溃造成的消息丢失。二者处理同一事件时,删除必须幂等;同一版本重复到达不会产生新的业务副作用。
第三步:逐条分析三个竞态窗口
窗口一:数据库提交与缓存删除之间。
W: COMMIT version 42
R: read cached version 41
W: DEL cache key读请求短暂看到版本 41,符合有限陈旧时可以接受。若完全不能接受,写入响应前等待缓存操作成功仍不能解决所有网络不确定性;更直接的契约是关键读取走事实源,或让调用方携带 min_version=42。
窗口二:数据库已提交,但失效未执行。
W: COMMIT version 42 plus outbox record
W: process crashes before DEL
R: cache still contains version 41
relay: retries invalidation for version 42只写 COMMIT 后的内存任务会丢失。事务型 outbox 让业务变更与“必须失效”的意图一起提交;relay 可重复投递,消费者按键删除并记录已处理版本。若 relay 停滞,硬 TTL 或读取门禁负责守住 5 秒上界。
窗口三:旧读在失效后晚回填。
R: cache miss; captures generation 7
R: reads database version 41
W: commits version 42
W: advances generation to 8 and deletes cache value
R: tries to fill version 41 with generation 7; rejected这是最容易漏掉的竞态。若只删除值,缓存为空时 version 41 >= no version 会成立,旧值仍会复活。解决方法之一是把值与栅栏分开:失效推进一个短期保留的 generation 或最低源版本,回填通过 Redis 脚本原子检查“回填代次仍等于 miss 时代次,且源版本不低于最低版本”,再写入值。在 Redis Cluster 中,值键与栅栏键必须设计到同一 hash slot,脚本才能原子访问两者。另一种实现是缓存服务发放 miss lease,数据库写入会使 lease 失效。也可以在回填前重新读主库版本,但多一次数据库读取且仍需定义检查与写入之间的原子边界。
版本栅栏需要有限保留时间,至少覆盖最大回填执行时间、重试和网络暂停;清理过早会重新打开晚回填窗口。它只保护缓存写入顺序,不替代数据库并发控制,也不告诉普通读者数据库是否已经出现了尚未传播的新版本。
第四步:让失效事件可恢复,但不夸大保证
outbox 行与业务更新同事务写入,relay 读取后把失效事件送到持久通道。消费者对每个 product_id 维护已观察的最高版本,处理规则可以是:
- 版本低于已观察最高值的乱序旧事件,只做幂等确认;
- 新版本推进最低版本栅栏并删除缓存值;
- 缓存命令失败时重试,超过限制进入可见的隔离队列;
- 定期对账数据库最新版本、outbox 进度和缓存样本版本。
至少一次消费适合删除,因为重复 DEL 没有额外业务含义。不要宣称“重试等于恰好一次”;消费者可能在 Redis 已删除后、确认消息前崩溃,随后再次删除。设计目标是可安全重复和可查缺口。
还要监控从数据库提交到失效成功的延迟,而不只监控消息队列年龄。必要指标包括 invalidationlagseconds、失效失败与重试数、隔离队列最老年龄、缓存源版本落后量、超预算缓存绕过数、数据库回填 QPS,以及同键 singleflight 共享数。
第五步:用 TTL 和门禁证明 5 秒上界
可靠事件最终会到达,但“最终”没有时间单位。若产品承诺从提交起最多陈旧 5 秒,至少要有一条独立的有限时限防线:
- 缓存物理 TTL 不超过 5 秒减去时钟、调度和检测余量,并从权威版本的提交时间计算陈旧度;
- 或者失效链路延迟接近预算时,全局或按分区停止信任缓存,改读满足新鲜度要求的数据源;
- 若两者都做不到,就只能把契约改为最终一致,不能继续承诺 5 秒。
TTL 是上界后盾,不是主要失效机制。给过期时间加抖动,避免 2000 个键同一时刻过期;对同键 miss 做请求合并。若所有 2000 个活跃键在 5 秒窗口内至少被读一次,均匀情况下平均约 400 次回填每秒。这个估算不是容量保证:热点分布、批量过期、Redis 故障和慢查询都可能产生峰值,必须用数据库安全 QPS 与并发舱壁验证。
如果 5 秒 TTL 让数据库无法承受,选择只有三个:增加可安全承载的回填能力、减少需要该契约的活跃数据,或放宽陈旧预算。把 TTL 偷偷调长会直接违反题意。
第六步:处理副本延迟与读己之写
正确删除缓存后,从落后副本回填仍可能复活旧版本。对有 5 秒硬契约的 miss,可以采用以下顺序:
- 失效后的首次回填读主库;
- 只有当副本公开可观测的 replay position 已达到所需提交位置,才允许从副本读取;
- 写入响应返回
sourceversion,调用方随后携带minversion;缓存或副本达不到时路由主库; - 副本延迟或失效延迟超预算时,打开新鲜度断路器,停止返回不合格缓存值。
把 TTL 设置为 5 秒却从可能落后 4 秒的副本回填,不能证明数据只旧 5 秒。陈旧年龄必须从事实源提交开始计算,读副本、事件通道和缓存三段延迟都要计入。
第七步:比较替代方案
同步更新缓存。 数据库成功后立即写入缓存,可能改善读己之写,但两个独立系统仍有部分失败:数据库已提交而缓存更新失败,或两个并发写按不同顺序到达缓存。它需要源版本条件写和失败修复,不能因命名为 write-through 就视为原子事务。
先删缓存再写数据库。 实现简单,却允许读者在数据库提交前回填旧值;“睡眠后再删一次”可以降低特定时序概率,但固定延迟无法覆盖无上界的副本延迟、进程暂停和网络故障,进程也可能在第二次删除前崩溃。它可以是辅助措施,不能单独证明有限陈旧。
只依赖短 TTL。 在写入很少、陈旧预算宽且数据库能承受回填时足够简单。代价是每次写入后仍可能在整个 TTL 内读旧值,且大量键同时过期会放大数据库负载。
所有读都查数据库。 对低吞吐或强正确性数据,这往往是最清楚的方案。缓存不是架构必选项;当一致性协调成本高于节省的数据库读取时,删除缓存更合理。
第八步:验证不变量和故障路径
不要只做“更新后刷新页面”的手工测试。用 barrier 控制并发顺序,复现旧读晚回填:读请求在拿到版本 41 后暂停;写请求提交版本 42、推进栅栏并删除;再放行旧读,断言条件回填失败。然后覆盖:
- 在 PostgreSQL 提交成功后立即终止写进程,确认 outbox 最终触发失效;
- 让同一失效事件重复并乱序到达,确认最高版本不回退;
- 让 Redis 删除命令失败,确认重试、隔离与 TTL 后盾生效;
- 人为暂停副本回放,确认回填改读主库或拒绝不合格结果;
- 让失效消费延迟超过门槛,确认缓存读取被门禁绕过;
- 让 2000 个键同时接近过期,确认抖动、singleflight 与数据库舱壁控制峰值;
- 让 Redis 整体不可用,确认展示流量按预算降级,权威读取仍按业务规则执行。
核心不变量包括:缓存返回版本不得低于调用方 min_version;失效后旧代次不能写回;权威业务动作不从展示缓存授权;缓存陈旧年龄不超过 5 秒;数据库回填不超过已测安全并发和 QPS。
高质量示范回答
“我不会先承诺数据库和 Redis 每一瞬间相同,而会把读契约分开。商品名称和图片允许从 PostgreSQL 提交起最多旧 5 秒;库存扣减、权限、余额和结算继续在权威存储中判断。写入者需要读己之写时,写接口返回源版本,后续读携带最低版本,缓存达不到就读主库。
基础模式是 cache-aside。读命中且满足年龄与最低版本就返回;miss 对 product_id 做 singleflight,再读取带单调版本的记录并条件回填。写入在同一 PostgreSQL 事务里更新商品、递增版本并写 outbox。提交后应用先尝试删 Redis;relay 或 CDC 依据持久事件重试失效,所以进程在提交后崩溃也不会永久丢失失效意图。
写顺序是先数据库、后删缓存。先删会让读请求在数据库提交前把旧值放回。即便顺序正确,仍有晚回填:读者先拿到版本 41,写者提交 42 并删除,旧读最后才 set 41。缓存值带版本还不够,因为删除后没有 42 可比较。我会让 miss 取得 generation,失效时推进 generation 和最低版本,再删除值;回填用原子脚本验证 generation 没变且源版本不低于栅栏,否则拒绝。副本无法证明已追到所需提交位置时,失效后的回填读主库。
outbox 只能证明可恢复的最终失效,不能单独证明 5 秒。我会设置不超过预算并留出余量的硬 TTL,监控提交到失效成功的延迟;延迟接近预算时绕过缓存。2000 个活跃键在 5 秒内都被访问,均匀回填约 400 次每秒,所以还要做 TTL 抖动、同键请求合并和数据库并发舱壁。
验证时我会精确控制线程顺序,注入提交后崩溃、旧读晚到、重复乱序事件、Redis 删除失败和副本落后。验收不是只看命中率,而是断言旧代次无法回填、源版本不回退、陈旧年龄不超过 5 秒、权威动作绕过展示缓存,而且数据库负载保持在已测预算内。”
常见错误
- 声称“数据库与缓存强一致” → 没有定义读结果、失败时限和两个系统的事务边界 → 按有限陈旧、读己之写和权威读取分别给出契约。
- 先删缓存再更新数据库 → 两步之间的 miss 会读旧数据库并重新缓存 → 先提交数据库,再失效缓存,并补可靠修复。
- 数据库提交后只发一条内存消息 → 进程在发送前崩溃会永久漏掉失效 → 同事务写 outbox,或从数据库日志捕获变更。
- 认为
DEL后一切安全 → 早先开始的旧读可能在删除之后才完成 set → 用回填租约或删除后仍存在的版本栅栏拒绝晚到旧值。 - 只给缓存值加版本号 → 缓存为空时没有较新版本可比较,旧版本可能被接受 → 把最低版本或 generation 保存在独立栅栏中,并原子检查回填。
- 把重复消费当错误 → 消费者确认前崩溃会自然产生重复 → 让删除和最高版本推进幂等,按至少一次语义设计。
- 用 outbox 就承诺 5 秒 → 可重试只保证最终处理,没有有限时延 → 增加硬 TTL 或超预算绕过门禁,并监控提交到失效的端到端延迟。
- 从任意只读副本回填 → 副本落后会在正确失效后重新缓存旧数据 → 检查 replay position、读主库,或要求达到最低源版本。
- 所有字段共用一份缓存快照 → 展示数据的陈旧容忍会泄漏到库存、权限和结算 → 拆分数据契约,在不可逆动作中重新读取权威状态。
- Redis 故障就让全部流量回源 → 2 万次读取可能压垮数据库 → 用舱壁、限流、明确降级和受控恢复保护事实源。
- 固定睡眠后双删 → 睡眠无法覆盖无上界延迟,第二次删除也可能丢失 → 把它视为概率优化,保留持久失效、栅栏和有限时限后盾。
追问及应对
追问一:如果商品价格也要求读己之写怎么办?
写接口返回提交后的 sourceversion。同一会话随后读取时携带 minversion;缓存版本不足就读主库,成功后只条件写入新版本。若只是向写入者展示结果,也可以直接返回刚提交的记录并短时绕过缓存。结算时仍应在权威事务中重新确认价格,不能把读己之写等同于支付授权。
追问二:版本栅栏为什么不能只放在缓存值里?
失效会删除缓存值,随后普通比较看不到版本 42。一个更早读到版本 41 的请求此时可能把 41 当作空键的新值写入。独立栅栏或 miss lease 在值删除后继续保存“版本 42 已发生”或“原回填资格已失效”,才能拒绝晚到 set。
追问三:事务型 outbox 会不会重复发送?
会。relay 可能在下游接受事件后、标记 outbox 完成前崩溃。消费者按 product_id 和源版本幂等处理:旧版本不推进状态,新版本推进最高版本并删除值,重复删除安全。需要承诺的是不静默漏失效和可对账,不是恰好一次执行。
追问四:能否把 TTL 设为 30 分钟,只靠 CDC 失效?
如果业务只要求最终一致且能接受失效链路长时间故障,可以这样权衡。题设承诺最多陈旧 5 秒,30 分钟 TTL 会让 CDC 停滞时违反上界。除非系统在失效延迟接近 5 秒时自动绕过缓存,否则必须保留不超过预算的硬时限。
追问五:副本通常只延迟几十毫秒,为什么还要读主库?
“通常”不是上界。部署、网络分区、长事务或恢复都可能扩大延迟。可以继续使用副本,但要证明它的 replay position 已达到所需提交位置,或让读取携带最低版本;无法证明时,关键 miss 路由主库。选择取决于 5 秒契约和数据库容量。
追问六:为什么不在一个分布式锁里同时更新 PostgreSQL 和 Redis?
锁只能约束遵守同一协议的参与者,不能让两个系统获得原子提交,也不能消除持锁进程崩溃、锁过期、网络分区和外部写入口。数据库事务先确定事实,持久失效修复处理第二个系统;锁可以减少同键并发,却不是一致性证明。
追问七:Redis 完全不可用时怎样守住 5 秒?
展示读取绕过 Redis,但所有回源经过数据库舱壁和限流;容量不足时返回明确降级或失败。若进程内旧副本仍在 5 秒预算内,可以短暂服务并标记降级,超过预算就不能继续返回。恢复时限速预热并继续处理积压失效,避免冷缓存再次压垮数据库。
追问八:如何证明线上真的没有长期旧值?
对抽样键同时读取数据库源版本和缓存版本,记录版本差与从源提交起的年龄;对账数据库变更、outbox 和消费者最高位点;告警失效端到端延迟、隔离队列最老年龄和超预算绕过。再定期注入提交后崩溃、Redis 命令失败与副本暂停,验证门禁和 TTL 在真实故障下仍守住不变量。