缓存命中率跌到多少该告警?我们反过来用空值率和穿透量推了一把阈值
命中率这个指标,看久了反而会麻痹。在几次深夜排查之后,我把告警逻辑从「命中率低于多少」改成了「空值率和穿透量高于多少」——异常捕捉的延迟直接从 3 分钟压到了 15 秒。
下面聊聊这个阈值推导过程和落地方式。
为什么命中率不适合做第一告警指标
命中率是个滞后且模糊的指标。一个 Redis 集群整体命中率从 98% 掉到 95%,看起来只降了 3 个百分点,但背后可能已经有某个 key 的请求全部砸到了数据库上。问题在于:命中率是聚合值,会把热点 key 的异常淹没在全量请求里。
2023 年 11 月我们线上遇到过一次典型案例:秒杀活动开始后,一个商品详情缓存的 key 因为逻辑 bug 没有被加载进 Redis,每秒 8000 次请求全部穿透到 MySQL。但当时集群整体 QPS 在 12 万左右,命中率只从 97.6% 降到 96.1%,没有触发告警。等 DBA 发现连接池被打满,已经是 3 分钟之后的事了。
核心矛盾就两个:一是命中率对热点 key 的穿透不敏感,二是正常业务波动(比如缓存预热、定时任务重建)会让命中率上下跳 2-3 个百分点,阈值很难设死。
用空值率替代命中率做一级告警
换个角度想,缓存穿透的本质不是「没命中」,而是「没命中且数据库也没有」。所以我们应该直接监控空值率:缓存未命中且数据库返回空结果的请求数 / 总请求数。
这个指标的优势在于:正常业务下,空值率是极低的。用户不会频繁请求不存在的商品 ID、不存在的用户 ID。我们统计了线上 30 天的数据,正常时段的空值率中位数在 0.02%,P99 在 0.15%。一旦出现恶意扫描或爬虫遍历 ID,空值率会瞬间飙到 5% 甚至 20%,信号极其清晰。
具体落地方式:在缓存层做埋点,每次 cache.Get 返回 miss 后查询数据库,如果数据库也返回空,就递增一个 cache_null_hit 计数器。Prometheus 里配一个 Counter,每 10 秒计算一次空值率。
告警规则我用的是 PromQL:
rate(cache_null_hit_total[30s]) / rate(cache_request_total[30s]) > 0.01
阈值设在 1%,基于历史数据的 P99(0.15%)向上取了约 6 倍的安全系数。实际跑下来,正常流量尖峰时空值率最高到过 0.4%,从来没有误触发过,而真实穿透攻击出现时,这个指标 15 秒内就能拉响告警。
空值率还有一个好处:它能区分「缓存失效导致的回源」和「恶意穿透」。缓存失效时,数据库能返回数据,空值率不会涨;只有真正的穿透(数据库也查不到)才会触发。这样就把雪崩和穿透的告警路径分开了。
穿透量作为二级确认:避免空值率误报
空值率虽然灵敏,但有一个边界问题:低流量时段,分母太小,一个偶然的非法请求就能把比率拉高。凌晨 3 点总请求只有每秒 10 次,偶尔一个不存在的 ID 请求,空值率直接 10%,但这对数据库毫无威胁。
所以需要加一个绝对穿透量的阈值作为 AND 条件。我们的做法是:
rate(cache_null_hit_total[30s]) > 50
这个 50 不是拍脑袋定的。我们测过线上 MySQL 读实例的承载能力,在缓存全挂的极端情况下,单实例可以扛住约 1200 QPS 的读请求而不出现连接超时。取这个值的 4% 作为安全阈值,就是约 50 QPS。也就是说,即使所有请求都穿透,只要绝对量不超过 50 QPS,数据库完全扛得住,没必要半夜把人叫起来。
最终的告警规则是两条组合:
groups:
- name: cache_penetration
rules:
- alert: HighNullRateAndVolume
expr: |
rate(cache_null_hit_total[30s]) / rate(cache_request_total[30s]) > 0.01
and
rate(cache_null_hit_total[30s]) > 50
for: 30s
labels:
severity: critical
annotations:
summary: "缓存空值率异常,疑似穿透攻击"
for: 30s 是为了过滤掉瞬时抖动,两个条件同时持续 30 秒才触发。从实际效果看,这个组合规则把穿透攻击的发现时间从分钟级降到了秒级,而且过去两个月零误报。
空值缓存策略的联动:告警之后的自动止血
告警只是第一步,真正有用的系统需要在告警的同时自动止血。做法是在检测到穿透时,对空结果也做短时缓存。
具体逻辑:当 cache_null_hit 计数器在滑动窗口内超过阈值时,中间件自动开启空值缓存模式,把数据库返回 null 的 key 也写入 Redis,值设为一个特殊标记(比如 "__NULL__"),过期时间 60 秒。
代码层面大概是这样(Go 伪代码):
func GetWithPenetrationProtection(ctx context.Context, key string) (string, error) {
val, err := cache.Get(ctx, key)
if err == nil {
if val == "__NULL__" {
return "", ErrNotFound
}
return val, nil
}
// cache miss, query db
dbVal, err := db.Query(ctx, key)
if err == ErrNotFound {
// 检查当前空值率
if nullRateExceeded(ctx) {
cache.Set(ctx, key, "__NULL__", 60*time.Second)
}
return "", ErrNotFound
}
if err != nil {
return "", err
}
cache.Set(ctx, key, dbVal, 300*time.Second)
return dbVal, nil
}
这里有一个细节:空值缓存的过期时间不能太长。设 60 秒是为了挡住攻击波峰,同时确保如果真的新增了合法数据,最多等一分钟就能被查到。我们试过设 300 秒,结果运营后台新建商品后前端一直 404,被产品追着问——这是用血换来的教训。
从单机到集群:指标聚合的坑
上面说的都是单机视角,但生产环境是集群。如果每个节点独立计算空值率再分别判断,会出现漏报:一个 10 节点的集群,攻击流量被均分到每个节点,单节点空值率可能都没超过 1%,但加起来已经打爆数据库了。
正确的做法是在负载均衡层或网关层做全量聚合。我们在 Nginx 层用 ngx_http_lua_module 做了一个计数钩子,把所有节点的 cache_null_hit 汇总到一个全局 Counter,再暴露给 Prometheus。这样不管流量怎么分发,告警看的是全局视角。
如果网关层不好改,退一步的方案是用 Prometheus 的 sum 聚合:
sum(rate(cache_null_hit_total[30s])) / sum(rate(cache_request_total[30s])) > 0.01
但这种做法要求每个节点的 label 一致,且 sum 会把所有实例的指标加在一起,如果某个实例重启导致 Counter 归零,短时间内 rate 会算出负数,需要用 rate(...) > 0 过滤一下。
常见问题
空值率告警和缓存雪崩告警怎么区分?
空值率针对的是「数据库也查不到」的请求,雪崩针对的是「缓存大面积失效导致回源量暴涨」。雪崩的监控指标应该是「缓存未命中但数据库有数据的请求量」,也就是 cache_miss_total - cache_null_hit_total。当这个值在短时间内(比如 10 秒内)增长超过正常水平的 3 倍,且命中率下降超过 10 个百分点,触发雪崩告警。两者的告警路径和响应策略完全不同:穿透靠空值缓存挡,雪崩靠限流和预热解决。
为什么用 30 秒的窗口而不是更短?
试过 10 秒和 15 秒。10 秒的问题在于 Prometheus 的 rate 函数对短窗口的估算误差较大,尤其低流量时会算出一些奇怪的尖峰。15 秒可用,但我们的采集间隔是 15 秒,用 15 秒窗口意味着每次只算一个采样点,波动太大。30 秒窗口覆盖两个采样点,既能保证及时性,又足够平滑。如果你的采集间隔是 10 秒,用 20 秒窗口更合适。
空值缓存的 key 太多会不会撑爆 Redis 内存?
确实有这个风险。我们的做法是:空值缓存只在检测到穿透模式时才开启,正常时段不缓存空值。另外给空值 key 设置了独立的内存上限,通过 Redis 的 maxmemory-policy volatile-lru 配合独立的 key 前缀(比如 null_cache:),确保即使攻击者遍历海量不存在的 ID,空值缓存也不会挤掉正常业务数据。实际压测中,100 万并发不存在的 key,空值缓存占用内存在 200MB 以内,60 秒后自动过期释放。