一道我曾经答不好的架构问题,今天我来重答

一道我曾经答不好的架构问题,今天我来重答

一、那道题是什么,当时的背景

2019 年参加一次技术面试,面试官问了这么一道题:

「你们系统的缓存和数据库是怎么保持一致的?」

当时我的回答大概是:「用 Redis 缓存,查询先查缓存,没有再查库,更新时先更新库再更新缓存,或者让缓存过期。」

面试官追问:「如果先更新了数据库,还没来得及更新缓存,另一个线程来读,读到了脏数据,怎么处理?」

我有点慌,说「设置缓存过期时间,等它自动失效」。面试官没有继续追问,面试最后也没有通过。

那道题我当时就觉得答浅了,但不知道浅在哪。

二、当时我的回答,为什么没答好

回头看,当时的答案有两个问题:

第一,没有意识到「先更新库还是先更新缓存」是一个有具体权衡的选择。 我把它当成了一个无所谓的实现细节,其实两种顺序各有不同的风险,需要根据业务容忍程度来选。

第二,「过期时间兜底」是一个工程策略,不是一个解答。面试官在问「并发情况下的数据不一致窗口怎么控制」,我用一个「最终一致性兜底」直接跳过了这个问题。

三、后来哪些经历让我真正理解了这个问题

两次经历。

第一次是 2021 年,我们的商品详情页缓存出了一个问题:商品价格改了,但部分用户看到的价格还是旧的,持续了大概 3 分钟。追查下来,是更新代码里先删了缓存、再更新数据库,但这两步之间有一个请求进来了,读到库里的旧价格之后写回了缓存,导致缓存里又是旧数据。

当时为了修这个问题,我第一次系统地搜索了「缓存更新策略」,看到了 Cache Aside、Read Through、Write Through 这几种模式,也第一次搞清楚了「删缓存还是更新缓存」、「先操作库还是先操作缓存」背后的推理逻辑。

第二次是 2023 年,在一次内部的方案评审里,同事提出了「延迟双删」的方案,我被追问:「延迟多久?这个时间怎么确定?如果延迟时间内还有写入怎么办?」那几个问题让我意识到,延迟双删也不是银弹,只是把不一致窗口缩小了,没有消除。

四、现在我的完整回答

核心原则:优先删缓存,不要更新缓存。

删缓存后,下一次请求会回源到数据库,重新写入一份最新的缓存。如果直接更新缓存,在并发写的场景下,两个线程的更新顺序可能和数据库的写入顺序不一致,导致缓存里留下过期值。


策略一:Cache Aside(最常用)

读:先读缓存 → 缓存 miss 则读库 → 将结果写入缓存

写:先写库,再删缓存

为什么是「先写库,再删缓存」,而不是「先删缓存,再写库」?

  • 如果先删缓存再写库:删缓存后、写库之前,另一个线程来读,回源读到旧的库数据,写回缓存,此后库更新成功,但缓存里是旧值。不一致。
  • 如果先写库再删缓存:写库后、删缓存之前,另一个线程来读,读到旧缓存,可接受(读到删除前的一致快照);删缓存后,下次请求回源重新写入最新值。

先写库再删缓存的方案仍有一个极小概率的问题:写库成功但删缓存失败(如网络抖动)。缓存里会留下旧值,直到过期。

对策:删缓存操作走消息队列做异步重试,确保最终删除成功;同时设置合理的缓存过期时间作为兜底。


策略二:延迟双删(在读多写多场景的补充手段)

// 更新前先删一次缓存
redisClient.del(cacheKey);
// 更新数据库
db.update(entity);
// 延迟一段时间(略大于一次完整读操作的耗时)后再删一次
scheduler.delay(500ms, () -> redisClient.del(cacheKey));

延迟双删的目的:消灭写库前已经读到旧值、正在构建缓存的那个请求带来的「回写旧值」问题。延迟时间取「数据库主从延迟 + 读操作最长耗时 + buffer」的经验值,不是一个精确数字。

延迟双删不是银弹:它把不一致窗口从「两步操作之间」缩小到「延迟时间内」,但没有消除,适合「能容忍秒级不一致、不希望引入消息队列复杂度」的业务场景。


一致性边界总结:

方案不一致窗口实现复杂度适用场景
先写库再删缓存 + 过期时间兜底较小(删缓存失败时到过期)大多数场景
先写库再删缓存 + 消息队列重试极小对一致性要求较高
延迟双删延迟时间内(秒级)读多写多、能容忍秒级不一致
分布式事务(如 Seata)无(强一致)金融级别,跨服务写操作

五、这类问题考察的本质是什么

不是「你背了多少种缓存更新策略」,而是:你能不能在约束条件下做合理的权衡

面试官真正想看到的回答结构是:「这个问题的本质是一致性和可用性的权衡 → 不同业务场景对不一致的容忍度不同 → 在我们的业务里,选了 X 方案,原因是 Y,边界条件是 Z。」

一个「标准答案」背出来,和「在真实项目里踩过坑、想清楚了每个方案的适用边界」,面试官是能分辨出来的。