小Cの已经记不起来的博客

Redis 缓存穿透、雪崩与 热更新

最近线上一个商品详情接口又被值班电话吵醒,结果看了一眼监控,Redis 命中率掉到 60%。一开始以为是缓存雪崩,后来发现请求里混着一堆 goods_id=-1goods_id=88888888 这种数据库里根本不存在的东西。Redis 没命中,应用老老实实去查 MySQL,查完还写不进去,因为压根没这行数据。然后 QPS 一高,DB 连接池先顶不住了。这类问题其实就是缓存穿透,只是名字听着像玄学,实际特别朴素:缓存里没有的东西,每次都会变成 DB 的新活。

缓存穿透:最难防的是“确定没有”

最开始的修复很简单,入参校验:ID 大于 0,格式合法,签名正确。能挡掉一部分,但挡不住所有。比如第三方回调传了一个历史数据,或者用户拿着老链接来,甚至就是随机数。总不能把第三方也一起改了。

我后来加了一个空值缓存:

redis-cli SET goods:null:88888888 1 EX 60

查到 DB 没有,就在 Redis 里写一个 null,过期 60 秒。注意,这个 TTL 不能太长,不然真有个商品刚创建出来,用户还查不到;也不能太短,短了又变成每次穿透。60 到 300 秒,看业务。

然后还有布隆过滤器。Redis 如果用了 RedisBloom,命令很直接:

redis-cli BF.ADD bloom:goods 1001
redis-cli BF.EXISTS bloom:goods 88888888

应用先查布隆,如果返回不存在,就直接返回空,别碰 DB。如果返回存在,再去 Redis/DB 查。布隆有个恶心点:可能误判存在,但不能误判不存在。也就是说,它适合挡掉大量随机不存在请求,不适合当唯一正确性保证。删除也不友好,商品下架了,布隆里还在。要么重建,要么用计数布隆,但那个更占内存。没装模块就别硬写 Redis 位图了,容易把简单问题写复杂。

还有一种穿透更脏:每个请求都不同,goods_id=random_int()。这时候空值缓存也会把 Redis 撑爆,因为 key 是无限的。这种只能靠前置限流、风控、WAF、签名、频率限制。缓存救不了无脑随机流量。

缓存雪崩:过期时间别排成一排

雪崩这个词有点吓人,听起来像 Redis 整个挂了。其实更多时候是 Redis 还好好的,但一大片 key 在同一秒过期。

以前我见过一个定时任务,凌晨 0 点全量刷新用户缓存,统一 EXPIRE user:1001 86400。结果第二天零点零五,缓存集体失效,数据库开始冒烟。看着整齐,其实全是坑。

最简单是过期时间打散:

EXPIRE user:1001 86400

换成基础时间加随机时间,比如:

ttl = random.randint(600, 900)
redis.setex("user:1001", ttl, json.dumps(user))

如果必须用 Redis CLI 模拟:

redis-cli SET user:1001 '{"id":1}' EX $((1800 + RANDOM % 300))

然后热点 key 不要同时全挂。失效时最好只让一个请求回源,其余请求等一等,或者返回旧值。这个可以用 Redis 锁:

redis-cli SET lock:user:1001 1 NX EX 3

抢到锁的查 DB 并回写 Redis,没抢到的可以短暂重试,或者直接返回上次有效数据。别用 GETDEL 解锁,可能删掉别人的锁。真要稳一点,用 Lua 判断 value:

redis-cli EVAL "if redis.call('GET', KEYS[1]) == ARGV[1] then return redis.call('DEL', KEYS[1]) else return 0 end" 1 lock:user:1001 1

这个例子写死 1,实际 value 要用唯一 token,比如 UUID。

Redis 高可用能兜底,但主从切换、哨兵故障恢复、Cluster 槽迁移都会有短暂影响。架构高可用不等于业务无感。限流、熔断、降级还是得做。本地缓存也能挡掉一部分雪崩,比如热点商品在应用进程里缓存 1 到 5 秒。但本地缓存也有坑:每台机器过期时间不同,数据会有短暂不一致;热 key 更新时,本地缓存可能还是旧值。

热更新:直接 DEL key 很容易把自己坑了

配置热更新是 Redis 最常用的场景之一:活动开关、限流阈值、字典、规则 JSON。最朴素的做法是更新时:

redis-cli SET rules:active '{"rate":100,"open":true}' EX 86400

应用里缓存一下,每隔几秒读 Redis。单机没问题,多实例开始出问题:更新到一半,有的机器读到旧规则,有的读到新规则;或者一次业务规则涉及多个 key,先更新 A,后更新 B,中间被人读到半套配置。

后来我改成双缓冲加版本指针:

redis-cli SET rules:v1 '{"rate":80,"open":true}' EX 86400
redis-cli SET rules:v2 '{"rate":100,"open":true}' EX 86400
redis-cli GETSET rules:active rules:v2

rules:active 只是个指针,指向哪个版本 key,应用就按指针读。GETSET 是原子的,切换的一瞬间不会读到中间态。旧版本 key 还在那,正在处理长请求的实例可以按自己拿到的版本继续读,等新请求过来自然切到新指针。等 TTL 到了,旧版本自己消失。

如果更想严谨一点,把数据和版本放一起,用 Lua 原子写入:

redis-cli EVAL "
local version = redis.call('HINCRBY', KEYS[1], 'version', 1)
redis.call('HSET', KEYS[1], 'payload', ARGV[1])
return version
" 1 rules:meta '{"rate":100,"open":true}'

应用每次读取后记一下 version,本地如果 version 没变就不用反序列化 JSON。通知可以用 Redis 发布订阅:

redis-cli PUBLISH rules:update '{"version":42}'

但我现在不太把 pubsub 当可靠机制。消费者掉线、重连、网络抖动,消息就没了。pubsub 只能当“快点来查”,不能当“我已经通知你”。所以最终还是会比对 version,或者轮询 HGET rules:meta version。可能是我踩过 pubsub 丢消息的坑,也可能是我对可靠性有强迫症,反正线上我更信“版本号 + 兜底轮询”。

热更新还容易出大 key。一个配置 JSON 几百字节没事,塞成几百 KB、几 MB 就有问题了。GET 一个热大 key,Redis 主线程会卡,网络带宽也容易被吃满。更新前看一眼 MEMORY USAGE rules:active

redis-cli MEMORY USAGE rules:active

太大就拆结构,或者走对象存储,Redis 里只放版本号。

回滚也别忘了。别等线上出问题了才发现旧版本没留。我一般会保留 rules:prev,更新前先备份,回滚时一条 SET 切回去。不过要注意,切指针和切数据不能混着来,最好也保持原子性。

最后落到代码里

这三个问题放在一起聊,其实都是因为 Redis 被当成“又便宜又万能”的层。缓存穿透是 Redis 不背 DB 的锅;雪崩是过期策略太天真;热更新是分布式更新没有原子性。实际项目里,我会把入口校验、空值缓存、布隆过滤器、过期打散、本地缓存、双缓冲、版本号监控这些一起配齐。没有哪个单独方案特别优雅,全是边界和取舍。

评论

还没有评论。

发表评论

提交后评论将经过自动审核,审核通过后公开展示。

未在播放