缓存说旧了,数据库说新了:别再让这两个家伙互相甩锅

2026-10-25 10 0

先说一个我见过太多次的场景。

某个电商系统改价,运营在后台把一件 T 恤从 79 块改成 59 块。数据库改完了,前台刷新——还是 79。再刷,还是 79。直到运维手动清了一把 Redis,页面才变成 59。

然后另一个更刺激的:一件限量球鞋,库存 10 双,有人下单支付成功,库存扣到 9,缓存里却还写着 10。又有人下单,超卖。

这两个 bug 的根因其实是同一个:缓存和数据库是两个独立的事实来源,你却把它们当成一个。

今天咱们把这颗定时炸弹拆开看,顺便讲清楚为什么那句流传最广的「先更新数据库,再删除缓存」,其实是个有前提条件的建议,而不是圣旨。

一、先搞清楚一件事:缓存不是数据,缓存是数据的副本

数据库才是唯一事实来源(single source of truth)。缓存只是一个「加速读取的副本」,它可以脏、可以旧、可以短暂不存在,只要你能容忍这个窗口并且知道它存在。

很多人栽在同一个认知误区上:把缓存当成了数据库的替身,然后在业务代码里同时写两处。写两处,就意味着迟早会写不一致——因为两次写入之间没有事务。

凡是「更新 A 然后更新 B」的代码,都必须回答一个问题:B 更新失败了怎么办?A 怎么回滚?答不上来,这就是一个定时炸弹。

二、Cache-Aside:最常见,也最常被写错

Cache-Aside(旁路缓存)是绝大多数系统的默认选择,逻辑长这样:

// 读
val, err := redis.Get(key)
if err == nil {
    return val
}
val, err = db.Query(...)
if err != nil {
    return nil, err
}
redis.Set(key, val, ttl)   // 写缓存
return val

// 写
db.Update(...)              // 先写库
redis.Del(key)             // 再删缓存

注意:是 Delete,不是 Update。

为什么是删而不是改?因为你没法保证「你算出来的值」和「数据库里的值」一致。缓存里的对象可能已经被别的请求改过、可能经过了别的加工逻辑、可能压根就是别的格式。删掉它,让下次读重新从数据库构建,这是唯一「逻辑上一定收敛」的操作。

而「先写库、再删缓存」这个顺序也不是随便定的。设想反过来的顺序:

redis.Del(key)              // 先删
db.Update(...)              // 再写库

在两步之间如果有个读请求进来,它发现缓存没了,去读数据库,读到了旧值,然后把旧值写回缓存。接着你的写库完成。结果:缓存里躺着旧数据,而且它有 TTL,会一直旧到过期。这就是经典的「缓存旧值回填」竞态。

而先写库再删缓存,这个竞态同样存在,只是窗口小得多:写库完成后、删缓存前的那一瞬间,有读请求可能读到缓存里的旧值——但这没关系,它只是短暂读到旧数据,缓存并不会被污染,等你删掉缓存,下一次读就是新的。

一句话总结:顺序决定了「脏数据会不会被写回缓存并赖着不走」。

三、那个经典的「删除缓存失败」怎么办?

删缓存失败了,数据库已经是新值,缓存还是旧值。这时候有三个选择:

方案 A:重试。** 简单直接,但要考虑重试风暴——大量删除失败往往是 Redis 已经挂了,这时候重试只会加剧压力。实践中通常配合指数退避 + 有限次数。

方案 B:延迟双删。 写库 → 删缓存 → 延迟(比如 500ms)→ 再删一次。第二次删除专门用来清理「第一次删完之后、DB 写之前那批并发读写回的旧值」。

db.Update(...)
redis.Del(key)
time.Sleep(500 * time.Millisecond)   // 别真 sleep,用延迟队列
redis.Del(key)

延迟双删能覆盖绝大多数竞态,代价是多一次删除 + 一个延迟任务,而且延迟时长是个玄学值——网络慢一点,500ms 可能就不够。

方案 C:订阅 binlog(CDC)。 用 Canal、Debezium、或者云厂商的 binlog 订阅服务,监听数据库的变更事件,然后失效对应缓存。

这个方案的最大优点是:缓存失效逻辑和业务代码彻底解耦了。不管是谁改了数据库、是用什么方式改的、是哪个服务改的,binlog 都忠实地把变更事件推出来,订阅方统一去删缓存。

它还顺手解决了另一个经典难题:「这个缓存 key 到底该由谁负责删除?」在多服务共用一个 Redis 的架构里,这个问题简直能把人逼疯——A 服务删了自己的 key,B 服务写缓存时顺手覆盖了 A 的 key,然后 A 再删一次,就把 B 的新值删了(旧值回填)。

如果你正在做一个多服务共享缓存的系统,我强烈建议:缓存失效走 binlog 订阅,业务代码里不要写 redis.Del。

四、缓存三兄弟:穿透、击穿、雪崩

说完一致性,说说另一类高频故障。这三个词经常被混着说,但病因完全不同。

穿透(Cache Penetration):查一个根本不存在的 ID。缓存没有→数据库没有→缓存还是没有→下次还查库。恶意刷的话,数据库会被大量无效查询打穿。

解法:① 空值也缓存(设个短 TTL,比如 60 秒);② 布隆过滤器,提前把不存在的 key 挡在缓存之前;③ 入参校验,明显的非法 ID 直接拒。

击穿(Cache Breakout):某个超级热点的 key 过期了,一瞬间成千上万请求同时发现缓存没了,全部涌向数据库。数据库瞬间被打穿,重启后缓存再次被击穿,形成二次、三次雪崩。

解法:① 互斥重建(single flight),只有一个请求去查库,其余等待;② 逻辑过期,缓存永不过期,过期的是数据里的时间戳,过期后返回旧数据并异步刷新;③ 热点 key 永不过期 + 后台主动刷新。

// Go 里的 single flight 雏形
var mu sync.Mutex
var inflight = map[string]*sync.WaitGroup{}

func Get(key string) (string, error) {
    v, _ := redis.Get(key)
    if v != "" {
        return v, nil
    }
    mu.Lock()
    if wg, ok := inflight[key]; ok {
        mu.Unlock()
        wg.Wait()
        v, _ = redis.Get(key)
        return v, nil
    }
    wg := &sync.WaitGroup{}
    wg.Add(1)
    inflight[key] = wg
    mu.Unlock()

    v, err = loadFromDB(key)
    if err == nil {
        redis.Set(key, v, ttl)
    }
    wg.Done()
    mu.Lock()
    delete(inflight, key)
    mu.Unlock()
    return v, err
}

(示例代码为了讲清楚原理牺牲了优雅度,生产环境直接用 golang.org/x/sync/singleflight。)

雪崩(Cache Avalanche):大量 key 同一时刻集中过期,或者 Redis 集群直接挂了。流量全部穿透到数据库。

解法:① TTL 加随机抖动,比如 ttl + rand(0, 300s);② 多级缓存,本地缓存挡一层;③ 熔断降级,数据库压力超阈值时直接返回兜底数据;④ Redis 高可用 + 集群,别把命押在一台机器上。

五、TTL 这个数字,别拍脑袋

有人问:缓存到底该设多长?

我的答案是:先设一个相对短的值,然后靠业务量把缓存顶热。热 key 的命中率会自然稳定在很高水平,因为访问足够频繁,每次过期后立刻被重建、被命中。真正冷门的 key 多存一会儿也无所谓,反正它也没人访问。

反过来说,如果你给了一个很长的 TTL(比如一小时),那么一旦这个 key 对应的数据发生变更,你就要等一小时才能看到更新——除非你显式去删。

TTL 是兜底,删除才是主力。这两者的关系想明白了,很多缓存设计上的纠结就自然消失了。

六、最后说说观测

缓存系统出问题的时候,最缺的不是方案,是数据。建议至少埋这几个指标:

  • 命中率:按 key 前缀分组看,而不是看全局。整体命中率 95% 听起来不错,某个模块可能只有 20%。
  • 回源 QPS:直接打到数据库的查询量。这个数字的突增,比命中率下降更早发现问题。
  • 缓存命令延迟 P99:Redis 变慢时,故障会先出现在延迟上,而不是错误率上。
  • 空值回源比例:突然飙高往往意味着有人在扫不存在的 key。

缓存出故障的时候,最值钱的信息是「哪个 key 的回源量突然涨了」。没有这个,你只能瞎猜。

结语

缓存这东西,好用的时候是真爽,不用的时候是真疼。总结成三句话:

第一,数据库是唯一事实来源,缓存只是副本。别把缓存当数据库用,也别在两个地方同时写。

第二,先写库再删缓存,删永远比改安全。删失败要重试、要做延迟双删,或者干脆上 binlog 订阅。

第三,穿透、击穿、雪崩三兄弟要分开治:空值+布隆过滤、single flight、TTL 抖动+熔断降级,各有各的药方。

最后送一句:你以为缓存问题是性能问题,其实大多数时候,它是一致性问题。而一致性问题的解法,从来不在缓存本身,在「谁说了算」这四个字上。

祝各位的缓存,永远和数据库说同一种语言。

相关文章

你的服务雪崩,不是因为流量大:超时与重试写成的死亡循环
你的 goroutine 明明很轻,为什么服务还是被压垮了?
别再用 REST 硬扛了:gRPC 与 Protobuf 的六个真实翻车现场
你的 Redis 分布式锁,可能压根没锁住:一个靠运气续命的并发原语
你的服务半夜自己崩了,但你什么都没改:TCP 长连接的生命周期管理
你的支付接口被扣了两次钱:一次幂等性设计翻车实录

发布评论