缓存一致性:三个差点让我被开除的生产事故

2026-08-15 10 0

缓存一致性:三个差点让我被开除的生产事故

凌晨三点,手机响了。PagerDuty的红色警报在黑暗中格外刺眼——订单服务报错,500错误率飙升到40%。我一边从床上爬起来一边骂自己:上次"优化"缓存的时候,是不是哪里想当然了?

结果还真他妈是。

这篇文章不讲什么"缓存三兄弟"的定义,那玩意儿百度一搜一大堆。我讲点真正在生产环境里会让你菊花一紧的东西——以及我是怎么在踩了三次坑之后,才TM终于学会正确地处理缓存一致性的。


事故一:库存超卖,全赖我删了那个"多余"的写锁

先交代背景。电商系统,Redis缓存库存,每次下单前先查缓存,库存充足就扣减缓存,然后异步落库。

看起来很标准对吧?经典方案。但问题是:当缓存里的库存和数据库里的库存不一致的时候,TM的以谁为准?

当时的实现是这样的:

// 下单扣库存
stock = redis.decr(key, 1)
if stock < 0:
    # 库存不足,回滚缓存
    redis.incr(key, 1)
    return error
# 异步写DB
async_write_to_db(stock)

这段代码的问题在于:并发情况下,两个请求同时读到stock=1,都觉得自己能扣。两个人同时decr,得到stock=0和stock=-1,都觉得自己抢到了。

你可能会说,这TM不是显而易见的并发问题吗?上分布式锁啊!

没错。但当时的我,认为"这个接口QPS不高,不会有人并发下单的"。我加上了一个应用层锁,以为这就够了。问题是,应用层锁在单实例下有效,你集群部署三个节点,锁了个寂寞。

更蠢的是,我后面为了"优化性能",把那个写锁删了——因为压测的时候单实例锁竞争导致TPS上不去。压测 traffic 是 100 concurrent,生产 traffic 是 1000 concurrent,我用压测数据做了生产决策。

教训:缓存扣减必须原子操作,要么用Lua脚本,要么用Redis的DECR原子操作配合CAS机制,别用应用层锁自己骗自己。

# 正确的Lua扣库存脚本(原子操作)
local stock = redis.call("GET", KEYS[1])
if not stock then
    return -1  # 缓存未命中,需要查DB
end
if tonumber(stock) < tonumber(ARGV[1]) then
    return -2  # 库存不足
end
return redis.call("DECRBY", KEYS[1], ARGV[1])

事故二:缓存穿透把我数据库打挂了

缓存穿透这个概念大家应该都知道:大量请求去查一个根本不存在的数据,缓存里没有,数据库里也没有,每个请求都直接打到DB。

标准的解决方案是:布隆过滤器(Bloom Filter)或者缓存空值。

我当时用的是"缓存空值"方案——查不到的数据,在缓存里放一个空值,TTL设短一点,比如60秒。这样下次同样的请求来了,直接从缓存返回空,不用打DB。

听起来很完美对吧?

问题是:我设的TTL是60秒。然后,业务同学上线了一个新功能,根据商品ID查商品信息。新商品还没入库,ID是递增的,但业务逻辑里某些边界情况会产生一些"幽灵ID"。

这些幽灵ID每个只会来一次请求,但由于我缓存空值只缓存60秒,60秒后同样的幽灵ID又来了,继续打DB。而且因为是凌晨流量低谷,没人发现,等白天流量一起来——得,DB被打成了孙子。

教训:缓存空值的TTL要设长一点,比如5-10分钟。而且更重要的是——幽灵ID打DB的问题,说明你的数据层设计本身就有漏洞,缓存只是放大了这个问题。查数据来源比加缓存重要得多。

最后我是怎么解决的?加了一个简单的布隆过滤器,把所有合法ID扔进去。请求来了先过布隆过滤器,不在里面的直接返回不存在,连DB的门都不用敲。内存占用几乎可以忽略,但防护效果拔群。

# Python布隆过滤器实现(简化版)
from bitarray import bitarray
import hashlib

class BloomFilter:
    def __init__(self, size, hash_count):
        self.size = size
        self.hash_count = hash_count
        self.bit_array = bitarray(size)
        self.bit_array.setall(0)
    
    def _hashes(self, item):
        result = []
        for i in range(self.hash_count):
            hash_val = int(hashlib.md5(f"{item}{i}".encode()).hexdigest(), 16)
            result.append(hash_val % self.size)
        return result
    
    def add(self, item):
        for i in self._hashes(item):
            self.bit_array[i] = 1
    
    def __contains__(self, item):
        return all(self.bit_array[i] for i in self._hashes(item))

事故三:缓存雪崩——最贵的那一次

缓存雪崩,就是在某一时刻,缓存里大量的key同时过期,或者缓存服务直接挂了,导致所有请求都打到后端数据库。

我这次不是被雪崩打死的——我是那个制造雪崩的人。

当时系统要从单机Redis迁移到集群Redis。迁移方案是:双写,新数据写集群,旧数据还在单机。切流量的时候,需要把单机里的数据一次性迁移到集群。

迁移脚本是这样的:

keys = redis.scan_iter(match="product:*")
for key in keys:
    value = redis.get(key)
    cluster.set(key, value)
    ttl = redis.ttl(key)
    if ttl > 0:
        cluster.expire(key, ttl)

问题在哪?scan_iter每次返回一批key,这没问题。但问题是,在遍历的过程中,如果有新的商品上架,那个新key不会被扫描到——结果就是数据不一致。

不,这不是最致命的问题。

最致命的问题是:我用了一个叫做"分批删除旧缓存"的策略来"确保平滑切换"。每迁移完一批数据,就删除旧缓存里对应的key。这样新请求就会直接读集群。

结果:在迁移的30分钟窗口内,旧缓存被分批删除,但新集群里的数据还没有完全同步完。而且因为有DEL操作,刚好撞上了某个热点商品的缓存过期时间——大量请求同时发现缓存没了,全部去打DB。

DB的CPU在那一刻飙到了98%,持续了整整7分钟。SRE群里发了7条告警。我被call了3次。

教训:缓存迁移不要用"双写+删除旧缓存"的策略,用"读旧写新+渐进迁移"会比这个靠谱一万倍。而且永远不要在高峰期做批量删除操作——你永远不知道哪个热点key会在那一刻巧合地集体消失。

正确的做法是:

# 正确的缓存迁移策略
# 1. 新集群写入,老集群继续服务读请求
# 2. 渐进式切读:先切10%流量,观察没问题再逐步放大
# 3. 切完全部流量后,再下掉老集群
# 4. 永远不要主动删除旧缓存,让它自己过期

三个事故教会我的三件事

回过头来看这三次事故,我发现一个共同点:我每次都是在做"优化"的时候引入的问题,而不是在写新功能的时候。加缓存是为了优化,删锁是为了优化,迁移缓存也是为了优化——每一次"优化"都让我离生产事故更近了一步。

为什么?因为新功能写的时候你会小心翼翼,会review,会想着"这个有没有并发问题"。但"优化"的时候,你会默认之前的决策是对的,然后在上面叠加新的复杂度。

第一:缓存不是性能优化工具,是一致性挑战工具。加上缓存,你就需要处理缓存和DB之间的一致性问题。别TM在脑子里把缓存当成免费的午餐。

第二:所有缓存方案都要过一遍"失效路径"。你写缓存读写逻辑的时候,花一半的时间想想:缓存不命中的时候怎么办?缓存过期的时候怎么办?缓存被删除的时候怎么办?并发的时候怎么办?

第三:如果一个优化让你删掉了"看起来多余"的代码,这个优化大概率是错的。代码冗余有时候是必要的——它是在给未来的你留后路。

缓存一致性没有银弹。CDN、本地缓存、分布式缓存,每一层都有它的一致性挑战。理解你用的每一层缓存的失效机制,比背下"缓存三兄弟"的概念重要得多。

好了,文章写完了。希望下次凌晨三点我的手机别响。如果响了——大概率又是缓存的问题。

(别问我怎么知道的)

相关文章

追剧吐槽大会:我是如何从”就看一集”看到凌晨三点的
为什么你的RESTful API总被吐槽?聊聊那些年我们踩过的坑
月薪5000,花呗欠8000:我是如何成为月光族天花板的
那些AI厂商不会告诉你的事:我花了三个月把主流AI工具测了个遍,发现了一些扎心的真相
🦞 手机依赖症:我和手机的双向奔赴,比谈恋爱还黏糊
忘带钥匙、忘关火、丢手机:我的人生就是一部丢三落四史诗

发布评论