核心问题

当系统里有多个答案时,哪个答案还有效?

软件工程里那句经典玩笑:

There are only two hard things in Computer Science: cache invalidation and naming things.

前面 Dimension 1 已经讲了命名。

现在回到缓存失效。

缓存失效为什么难?

不是因为 redis.del(key) 难写,而是因为缓存让系统里出现了多个版本的“真相”:

数据库里一份
Redis 里一份
CDN 里一份
浏览器里一份
前端状态里一份
搜索索引里一份
分析报表里一份

当它们不一致时,系统必须回答:

哪个可以信?哪个可能过期?过期多久可以接受?

这就是认识论问题。

缓存不是数据结构,是承诺

很多人把缓存理解成:

为了快,存一份副本。

但工程上更准确的理解是:

缓存是一份带有时效承诺的副本。

设计缓存时,不要先问:

用 Redis 还是 CDN?

先问:

  1. 缓存什么?
  2. 为什么可以缓存?
  3. 最多允许过期多久?
  4. 哪些事件会让它失效?
  5. 如果失效失败,后果是什么?
  6. 谁负责重建?
  7. 用户是否能接受短暂不一致?

缓存失效的本质

缓存失效本质上是在回答:

旧答案什么时候不再合法?

例如课程详情页可以缓存:

title
description
instructor
lesson count
rating summary

但课程访问权不一定适合长缓存:

canAccessCourse(accountId, courseId)

因为访问权可能被很多事件改变:

  • 支付成功
  • 退款完成
  • 订阅过期
  • 企业 membership 移除
  • 账号封禁
  • 课程下架
  • 管理员手动撤销

它的失效条件多,业务风险高。

所以缓存前要问:

这个答案的变化原因有多少?

变化原因越多,缓存越危险。

TTL:承认自己不知道

最简单的缓存策略是 TTL:

缓存 5 分钟后自动过期

TTL 的本质是:

我不知道它什么时候会变,但我承诺最多错这么久。

所以 TTL 不是随便拍脑袋的数字。

它应该来自业务容忍度。

例如:

数据可接受过期时间
课程简介几分钟到几小时
课程评分汇总几分钟
课程访问权通常很短,甚至不缓存
账号封禁状态很短
权限判断很短或事件驱动失效
分析报表几分钟到一天

TTL 越长,性能越好,但错误答案存在越久。

事件驱动失效

另一种策略是事件驱动:

course.updated -> invalidate course detail cache
payment.refunded -> invalidate course access cache
account.suspended -> invalidate account permission cache

这比纯 TTL 更及时,但也更复杂。

因为你必须保证:

  1. 事件真的发出。
  2. 消费者真的处理。
  3. key 计算正确。
  4. 删除失败能重试。
  5. 漏删时有兜底。

事件驱动失效不是免费午餐。

它把问题从“最多错多久”变成:

我能否可靠地知道所有让答案失效的事件?

如果不能,仍然需要 TTL 兜底。

Write-through, Write-around, Write-back

缓存更新有几种常见模式。

Write-through

写数据库时同步写缓存。

write db -> write cache

优点:

  • 读缓存更容易命中新值。

缺点:

  • 写路径更慢。
  • 数据库写成功但缓存写失败时要处理一致性。

Write-around

写数据库,不写缓存。下次读时再加载。

write db -> invalidate cache -> next read rebuild

优点:

  • 写路径简单。
  • 避免缓存不常读的数据。

缺点:

  • 下次读会慢。
  • 需要处理 cache miss。

Write-back

先写缓存,之后异步写数据库。

write cache -> async write db

优点:

  • 写入很快。

缺点:

  • 数据丢失风险高。
  • 一致性复杂。

业务系统里,涉及钱、权限、访问权时,通常要非常谨慎使用 write-back。

Cache Aside:最常见的模式

常见读路径:

async function getCourseDetail(courseId: CourseId) {
  const cached = await cache.get(`course:${courseId}`)
  if (cached) return cached

  const course = await db.courses.findById(courseId)
  await cache.set(`course:${courseId}`, course, { ttl: 300 })
  return course
}

写路径:

async function updateCourse(courseId: CourseId, input: UpdateCourseInput) {
  await db.courses.update(courseId, input)
  await cache.del(`course:${courseId}`)
}

这个模式简单有效,但要注意:

  • cache.del 失败怎么办?
  • 并发读写时会不会回填旧值?
  • key 是否覆盖所有查询维度?
  • 是否有 TTL 兜底?

缓存代码看起来很短,但边界条件很多。

缓存 Key 是契约

缓存 key 不是随便拼字符串。

它定义了:

这个缓存答案适用于什么上下文。

例如:

course-detail:{courseId}

如果课程详情对所有用户一样,这可以。

但如果返回结果和用户身份有关:

course-page:{courseId}

就可能危险。

因为不同用户看到的课程页可能不同:

  • 是否有访问权
  • 是否显示购买按钮
  • 是否显示企业分配状态
  • 是否显示学习进度
  • 是否显示管理入口

这时 key 必须包含影响答案的维度,或者把用户相关部分拆出去。

例如:

public-course-detail:{courseId}
course-access-decision:{accountId}:{courseId}
course-progress:{accountId}:{courseId}

原则:

缓存 key 必须包含所有会影响答案的输入。

缓存和权限是危险组合

权限和访问权判断很容易被缓存诱惑。

因为它们可能调用多张表,比较慢。

但缓存权限判断要格外小心。

例如:

canAccessCourse(accountId, courseId) = true

如果用户退款、账号封禁、组织移除后缓存没失效,就会造成越权访问。

所以权限缓存必须明确:

  1. TTL 很短。
  2. 关键事件会主动失效。
  3. 拒绝和允许是否都缓存。
  4. 高风险动作是否绕过缓存。
  5. 日志能看到命中缓存还是实时计算。

很多时候更好的策略是:

缓存规则所需的数据,而不是缓存最终授权结论。

例如缓存课程公开信息、组织 membership 列表,但最终 canAccessCourse 仍然实时组合判断。

一致性模型

缓存设计必须承认一致性模型。

Strong Consistency

读到的一定是最新值。

成本高,性能通常差一些。

适合:

  • 支付
  • 余额
  • 权限
  • 库存
  • 安全关键状态

Eventual Consistency

短时间内可能读到旧值,但最终会一致。

适合:

  • 报表
  • 搜索索引
  • 推荐结果
  • 课程评分汇总
  • 非关键展示信息

Bounded Staleness

允许旧值,但有明确上限。

例如:

课程评分最多延迟 5 分钟。
学习进度 dashboard 最多延迟 30 秒。

这通常是业务系统里最实用的模型。

关键是要说清楚:

这个数据最多可以旧多久?

缓存失效检查清单

设计缓存时,问:

  1. 这个缓存回答什么问题?
  2. Source of truth 是什么?
  3. 所有影响答案的输入有哪些?
  4. 缓存 key 是否包含这些输入?
  5. 哪些事件会让答案失效?
  6. 失效失败的后果是什么?
  7. 是否有 TTL 兜底?
  8. 是否允许短暂不一致?
  9. 最大可接受 staleness 是多少?
  10. 能否观测 cache hit、miss、stale、invalidation failure?
  11. 是否涉及权限、安全、钱?
  12. 出错时能否绕过缓存回源?

小结

  1. 缓存难,是因为系统出现多个可能过期的答案。
  2. 缓存是带时效承诺的副本,不是真相来源。
  3. TTL 的本质是承诺“最多错多久”。
  4. 事件驱动失效更及时,但需要可靠事件和兜底机制。
  5. 缓存 key 必须包含所有影响答案的输入。
  6. 权限和访问权缓存要格外谨慎。
  7. 缓存设计必须明确一致性模型和最大可接受过期时间。