艾体宝干货|【Redis实用技巧#20】Redis 很快,但怎么避免数据是错的

4 阅读3分钟

缓存失效问题之所以难,从来不是因为概念复杂,而是因为线上系统的写路径没有想象中那么简单。

开端

这同样是我们一位客户的经验,他们近期上线了新功能,系统的响应时间从 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结构契约,不可绕过

为性能而缓存很容易。为正确性而缓存,才是真正的技术。