核心问题
当系统里有多个答案时,哪个答案还有效?
软件工程里那句经典玩笑:
There are only two hard things in Computer Science: cache invalidation and naming things.
前面 Dimension 1 已经讲了命名。
现在回到缓存失效。
缓存失效为什么难?
不是因为 redis.del(key) 难写,而是因为缓存让系统里出现了多个版本的“真相”:
数据库里一份
Redis 里一份
CDN 里一份
浏览器里一份
前端状态里一份
搜索索引里一份
分析报表里一份
当它们不一致时,系统必须回答:
哪个可以信?哪个可能过期?过期多久可以接受?
这就是认识论问题。
缓存不是数据结构,是承诺
很多人把缓存理解成:
为了快,存一份副本。
但工程上更准确的理解是:
缓存是一份带有时效承诺的副本。
设计缓存时,不要先问:
用 Redis 还是 CDN?
先问:
- 缓存什么?
- 为什么可以缓存?
- 最多允许过期多久?
- 哪些事件会让它失效?
- 如果失效失败,后果是什么?
- 谁负责重建?
- 用户是否能接受短暂不一致?
缓存失效的本质
缓存失效本质上是在回答:
旧答案什么时候不再合法?
例如课程详情页可以缓存:
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 更及时,但也更复杂。
因为你必须保证:
- 事件真的发出。
- 消费者真的处理。
- key 计算正确。
- 删除失败能重试。
- 漏删时有兜底。
事件驱动失效不是免费午餐。
它把问题从“最多错多久”变成:
我能否可靠地知道所有让答案失效的事件?
如果不能,仍然需要 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
如果用户退款、账号封禁、组织移除后缓存没失效,就会造成越权访问。
所以权限缓存必须明确:
- TTL 很短。
- 关键事件会主动失效。
- 拒绝和允许是否都缓存。
- 高风险动作是否绕过缓存。
- 日志能看到命中缓存还是实时计算。
很多时候更好的策略是:
缓存规则所需的数据,而不是缓存最终授权结论。
例如缓存课程公开信息、组织 membership 列表,但最终 canAccessCourse 仍然实时组合判断。
一致性模型
缓存设计必须承认一致性模型。
Strong Consistency
读到的一定是最新值。
成本高,性能通常差一些。
适合:
- 支付
- 余额
- 权限
- 库存
- 安全关键状态
Eventual Consistency
短时间内可能读到旧值,但最终会一致。
适合:
- 报表
- 搜索索引
- 推荐结果
- 课程评分汇总
- 非关键展示信息
Bounded Staleness
允许旧值,但有明确上限。
例如:
课程评分最多延迟 5 分钟。
学习进度 dashboard 最多延迟 30 秒。
这通常是业务系统里最实用的模型。
关键是要说清楚:
这个数据最多可以旧多久?
缓存失效检查清单
设计缓存时,问:
- 这个缓存回答什么问题?
- Source of truth 是什么?
- 所有影响答案的输入有哪些?
- 缓存 key 是否包含这些输入?
- 哪些事件会让答案失效?
- 失效失败的后果是什么?
- 是否有 TTL 兜底?
- 是否允许短暂不一致?
- 最大可接受 staleness 是多少?
- 能否观测 cache hit、miss、stale、invalidation failure?
- 是否涉及权限、安全、钱?
- 出错时能否绕过缓存回源?
小结
- 缓存难,是因为系统出现多个可能过期的答案。
- 缓存是带时效承诺的副本,不是真相来源。
- TTL 的本质是承诺“最多错多久”。
- 事件驱动失效更及时,但需要可靠事件和兜底机制。
- 缓存 key 必须包含所有影响答案的输入。
- 权限和访问权缓存要格外谨慎。
- 缓存设计必须明确一致性模型和最大可接受过期时间。