缓存失效问题之所以难,从来不是因为概念复杂,而是因为线上系统的写路径没有想象中那么简单。
开端
这同样是我们一位客户的经验,他们近期上线了新功能,系统的响应时间从 800ms 降到 40ms。指标本身得到了 Leader 的认可。
而在三天后,一个用户提了工单:他的个人主页还在展示旧邮箱。他们查了数据库,更新是正确被写入的。但是查了 Redis,返回了一个 72 小时前的值。
缓存运行得很正常,不过这正是问题所在。
经验
所有教程都会教你怎么写缓存,但很少有教程告诉你缓存 Key 在什么时候会变成一个负担。
标准写法大概是这样:
def get_user(uid):
val = cache.get(f"user:{uid}")if val:return val
user = db.query("SELECT * FROM users WHERE id = %s", uid)
cache.set(f"user:{uid}", user, ttl=3600)return user
这段代码本身没有错。它同时也是一类 Bug 的起点,这类 Bug 通常在生产环境出现。问题不在于缓存读取这一侧。问题在于:当底层数据发生变更时,如何通知到 Redis。
生产系统里的失效设计
缓存失效出了名地难处理,但难点不在于概念本身。它失控,是因为真实应用中的写路径从来没有你想象的那么规整。
例如说用户记录可以从以下几个地方被修改:
用户设置 API → 正常更新
Billing Webhook → 支付后更新账户状态
Admin 后台 → 运营人工修改
Stripe 同步任务 → 定时对账写入
这四条写路径,由四个人在四个不同的时间点、在各自的排期压力下分别实现。没有人在 Billing Webhook 里加上缓存驱逐。这是架构上的问题。
写穿透 + 明确 Ownership
通常而言在快速迭代团队中最持久的方案,叫做 Write-Through with Explicit Ownership(写穿透 + 明确所有权)。规则只有一条:一个服务拥有一个缓存命名空间,且只有这个服务向其写入。
class UserCache:
KEY = "user:{}"def get(self, uid):return cache.get(self.KEY.format(uid))def set(self, uid, data):
cache.set(self.KEY.format(uid), data, ttl=1800)def evict(self, uid):
cache.delete(self.KEY.format(uid))
现在,所有对用户记录的写操作都经过同一个入口。Billing Webhook 在更新完数据之后调用 UserCache().evict(uid),Admin 后台也调用它,Stripe 同步任务也调用它。
把一个分布式内存一致性问题,收敛成了一个方法调用。
架构在结构上让问题变得显而易见
┌───────────┐ ┌────────────┐
│ API GW │────▶│ User Svc │
└───────────┘ └────────────┘
│
┌───────────┴───────────┐
▼ ▼
┌─────────┐ ┌─────────┐
│ Redis │ │ DB │
└─────────┘ └─────────┘
▲
│
所有写操作(Webhook / 定时任务 /
Admin 工具)统一经由 UserCache 驱逐,
而非各自直接操作 Redis Key
每一个调用方——Webhook、后台任务、管理工具,都通过同一个有所有权的接口操作缓存。Redis 的一致性由结构契约来保证,而不是靠约定俗成或代码 Review。
缓存任何内容之前,跑个 Benchmark
测试环境:单台 EC2 t3.medium,Postgres 15,Redis 7,10,000 并发读请求
| 场景 | P99 延迟 | 5 分钟窗口内的脏读率 | | ------------------------- | ---------- | ---------------------- | | 无缓存 | 620 ms | 0% | | 有缓存,无失效逻辑 | 38 ms | 17% | | 有缓存 + Ownership 驱逐 | 41 ms | 0% |
加上正确的失效逻辑之后,比"有缓存但数据错误"的方案只慢了 3 毫秒。性能差距可以忽略不计。正确性差距不能。
小结
| 维度 | 常见做法 | 推荐做法 |
|---|---|---|
| 缓存写入 | 各写路径各自操作 Redis Key | 统一通过 Cache 类操作 |
| 缓存失效 | 依赖 TTL 自然过期 | 每次写操作触发显式驱逐 |
| Ownership | 无明确归属,约定维护 | 一个服务拥有一个命名空间 |
| 一致性保证机制 | 人工纪律 + Code Review | 结构契约,不可绕过 |
为性能而缓存很容易。为正确性而缓存,才是真正的技术。