我不会起名字322· 后端 / 算法 / 数据库
📘 技术栈 | 🧩 力扣 Hot100 | 🐹 Go 项目 | 🟥 Redis | 🐬 MySQL
文章目录
- Redis 缓存命中率从 99% 掉到 60%:4 个成因与 5 条排查命令
- 一、先别信监控:命中率要用增量算
- 二、4 个成因
- 成因 1:key 集中过期(TTL 撞车)
- 成因 2:大量冷 key 写入(假性命中率下降)
- 成因 3:热数据被淘汰掉了
- 成因 4:穿透(查了根本不存在的 key)
- 三、5 条排查命令
- 四、配置模板
- 五、4 步排查顺序
- 六、总结
Redis 缓存命中率从 99% 掉到 60%:4 个成因与 5 条排查命令
监控上缓存命中率一夜之间从 99% 掉到 60%,紧接着数据库 QPS 涨了三倍,慢查询开始报警。Redis 本身没报错,内存也没满,连接数正常——只是缓存突然不中用了。
命中率是缓存系统里最灵敏的一个指标:它掉 40 个点,意味着每 10 次查询里有 4 次直接打到数据库上。这篇文章先讲清命中率怎么算才准(很多监控算错了,导致你看到的数字本身就不可信),再拆 4 个成因和对应的 5 条排查命令。
一、先别信监控:命中率要用增量算
INFO stats里有两个计数器:
redis-cli INFO stats|grepkeyspace# keyspace_hits:18273645# keyspace_misses:184321很多人直接拿这两个数算:hits / (hits + misses)。问题是这两个是从 Redis 启动以来的累计值——你的实例跑了 180 天,累计了 1800 万次命中,那么今天发生什么,都会被这个巨大的分母稀释掉,曲线会平得毫无意义。
正确的算法是增量命中率:
hit_rate = Δhits / (Δhits + Δmisses) 其中 Δ = 本次采样值 - 上次采样值(间隔 30s 或 1min)用命令行快速算(间隔 60 秒采两次):
# 第一次redis-cli INFO stats|grep-E"keyspace_hits|keyspace_misses">/tmp/a.txtsleep60# 第二次redis-cli INFO stats|grep-E"keyspace_hits|keyspace_misses">/tmp/b.txtawk-F:' NR==FNR { h1=$2; next } /keyspace_hits/ { h2=$2 } /keyspace_misses/ { m1=$2; getline; } '/tmp/a.txt /tmp/b.txt2>/dev/null# 更简单的方式:直接看 1 分钟内的变化量redis-cli--stat# ⭐ 它打印的就是每秒/每分钟的增量,含 keyspace 变化redis-cli --stat是最省事的工具,它输出的就是增量,第二列开始就有keys、hits、misses的实时变化。
二、4 个成因
成因 1:key 集中过期(TTL 撞车)
最典型的一类:批量任务在某一刻写入了几十万个 key,并且给了完全相同的 TTL。这些 key 会在同一秒集体失效,那一瞬间所有请求全部穿透到数据库。
验证方法(看 key 的过期分布):
redis-cli INFO keyspace# db0:keys=1523846,expires=1520012,avg_ttl=86392000expires / keys接近 1 说明几乎所有 key 都有 TTL;如果avg_ttl也很规整,就要警惕集中过期。
解决:TTL 加随机抖动,把过期时间打散。
// Go 示例:基础 TTL + 随机抖动,避免同一批 key 同时失效functtlWithJitter(base time.Duration,jitter time.Duration)time.Duration{ifjitter<=0{returnbase}returnbase+time.Duration(rand.Int63n(int64(jitter)))}// 用法:基础 30 分钟,抖动 0~10 分钟ttl:=ttlWithJitter(30*time.Minute,10*time.Minute)rdb.Set(ctx,key,val,ttl)不方便的场景(key 由别的服务写入),至少在批量任务里分批设置不同的 TTL。
成因 2:大量冷 key 写入(假性命中率下降)
命中率的分母里包含了所有查询,如果突然涌入一大批「从来没查过的新 key」,那第一次查必然 miss。这是正常的冷启动,不是故障。
三种常见来源:
- 新业务/新 key 前缀上线:新接口的 key 是全新的;
- 实例重启或主从切换:内存里的 key 全没了,等于回到冷启动;
- 批量写入把缓存灌满:数据预热任务写了大量 key,但真正被查询的只是其中一小部分。
判断依据:看keyspace里的keys总数是不是突然变大,以及命中率下降的时间点是不是与某个发布/重启对齐:
redis-cli INFO keyspace# 关注 keys 的增长曲线redis-cli DBSIZE这一类的正确处理是「等它预热」+「提前预热」,而不是调参数。
成因 3:热数据被淘汰掉了
maxmemory到顶之后,Redis 会按maxmemory-policy淘汰 key。如果策略选错,被淘汰的可能是最热的那一批:
redis-cli INFO stats|grepevicted_keys# 淘汰计数,持续上涨说明一直在淘汰redis-cli CONFIG GET maxmemory-policy| 策略 | 行为 | 适用场景 |
|---|---|---|
noeviction | 不淘汰,直接拒写 | 数据不能丢;缓存场景别用 |
allkeys-lru | 所有 key 里淘汰最久未用的 | 纯缓存(最常用) |
volatile-lru | 只淘汰设了 TTL的 key 里最久未用的 | 有持久数据 + 缓存混存 |
allkeys-lfu | 淘汰访问频率最低的(4.0+) | ⭐ 有明显冷热分化时比 LRU 更准 |
volatile-lfu | 只在有 TTL 的 key 里按频率淘汰 | 混存 + 冷热分化 |
allkeys-random | 随机淘汰 | 基本不用 |
另一个隐蔽的杀手是bigkey:一个几 MB 的 key 会占掉大量空间,让maxmemory更快触顶,间接导致热数据被挤走。
redis-cli--bigkeys# 找每种类型最大的 keyredis-cli MEMORY USAGE<bigkey># 精确核算单个 key成因 4:穿透(查了根本不存在的 key)
如果业务在查一批数据库里也没有的数据(比如被刷接口、爬虫遍历不存在的 ID、或者上游传了非法参数),那么每次查询必然 miss,而且永远不会被缓存命中。
特征很明显:miss 的增长量与请求量严格成正比,且 key 总量不增长(因为这些 key 压根没写进去)。
三种处理手段:
- 空值缓存:查不到时也写一个短 TTL 的空标记(注意别把空值 TTL 设太长,会导致真数据写入后短时间读不到);
- 布隆过滤器:写入时同步更新布隆过滤器,查询前先判存在性(注意布隆过滤器有误判率,且删除支持有限);
- 参数校验:在业务层挡掉明显非法的请求,这是最便宜的一招。
三、5 条排查命令
| # | 命令 | 看什么 | 说明 |
|---|---|---|---|
| 1 | redis-cli INFO stats | grep keyspace | keyspace_hits/keyspace_misses | ⭐ 必须算增量,累计值没有意义 |
| 2 | redis-cli --stat | 实时增量 | 最直接,含 keys/hits/misses 每秒变化 |
| 3 | redis-cli INFO keyspace | keys/expires/avg_ttl | 判断集中过期、冷启动、key 总量变化 |
| 4 | redis-cli --hotkeys | 热 key 排行 | ⭐ 需要maxmemory-policy是allkeys-lfu或volatile-lfu |
| 5 | redis-cli --bigkeys+MEMORY USAGE <key> | 大 key | 判断是不是 bigkey 挤占了空间 |
补充两条按需使用的:
redis-cli OBJECT FREQ<key># 查看某个 key 的访问频率计数(LFU 策略下)redis-cli SLOWLOG GET10# 大 key / 复杂命令常常伴随慢查询redis-cli INFO memory|grep-E"^used_memory:|^maxmemory:"# 是否触顶--hotkeys需要 LFU 策略才准确。如果你的实例是 LRU 策略又不想改,OBJECT FREQ一样看不了——这种时候可以临时改用MONITOR(只能短时间短命令,会显著影响性能,生产慎用):
timeout10redis-cli MONITOR|awk'{print $4}'|sort|uniq-c|sort-rn|head-20# 统计 10 秒内被访问最多的命令/key(⚠ 高 QPS 实例上不要用)四、配置模板
# ===== 淘汰策略:有明显冷热分化时用 LFU ===== maxmemory 20gb maxmemory-policy allkeys-lfu # 混存场景(有不能丢的持久数据)用 volatile-lfu,并确保缓存 key 都设了 TTL # ===== LFU 的衰减与计数器(可选微调) ===== lfu-log-factor 10 # 越大,频率区分度越高(默认 10) lfu-decay-time 1 # 频率计数衰减周期(分钟),默认 1 # ===== 避免集中过期:TTL 一定要带抖动 ===== # 这条靠代码保证,配置层面只能做到"监控 TTL 分布"Go 侧的空值缓存与穿透防护(可直接抄):
packagemainimport("context""errors""time""github.com/redis/go-redis/v9")varErrNotFound=errors.New("not found")// getWithCache 带空值缓存的读取,避免同一个不存在的 key 反复穿透funcgetWithCache(ctx context.Context,rdb*redis.Client,keystring,ttl,emptyTTL time.Duration,loadfunc(context.Context,string)(string,error),)(string,bool,error){val,err:=rdb.Get(ctx,key).Result()iferr==nil{ifval=="__NULL__"{// 空值标记:说明数据库里确实没有return"",false,ErrNotFound}returnval,true,nil}if!errors.Is(err,redis.Nil){return"",false,err// 真故障,不要当成 miss}// 缓存没有 → 回源real,err:=load(ctx,key)iferr!=nil{return"",false,err}ifreal==""{// ⭐ 空值也写缓存,TTL 要短,避免真数据写入后读不到_=rdb.Set(ctx,key,"__NULL__",emptyTTL).Err()return"",false,ErrNotFound}// ⭐ TTL 带抖动,避免同批 key 同时失效jitter:=time.Duration(int64(ttl)/10)_=rdb.Set(ctx,key,real,ttl+jitter).Err()returnreal,true,nil}五、4 步排查顺序
第 1 步:确认命中率的算法对不对。
先用redis-cli --stat看实时增量,确认命中率是真掉了还是监控算错了(累计口径是最常见的假警报)。
第 2 步:看 key 总量和过期分布,先排除冷启动和集中过期。INFO keyspace:keys是不是突然涨了(冷启动/新业务)、expires占比是不是接近 100% 且avg_ttl很规整(集中过期)。这两类占了实际案例的一大半。
第 3 步:看有没有在淘汰。INFO stats | grep evicted_keys持续上涨 +used_memory接近maxmemory→ 热数据被挤走了。接着用--bigkeys找占空间的大 key,并重新评估maxmemory-policy(冷热分化明显就用 LFU)。
第 4 步:排除穿透。
如果 miss 与请求量严格成正比、key 总量不涨、且上面三步都正常 → 大概率是穿透。按key维度统计访问,确认是不是有一批根本不存在的 ID 在被反复查询,然后上空值缓存或布隆过滤器。
六、总结
- 命中率要用增量算(
Δhits / (Δhits + Δmisses),或直接用redis-cli --stat)。用累计值算出来的曲线会被历史稀释,导致真出事时看不出来。 - 4 个成因:① key 集中过期(TTL 撞车,加随机抖动);② 大量冷 key 写入(冷启动,属正常现象,靠预热解决);③ 热数据被淘汰(maxmemory 触顶 + 策略选错 + bigkey 挤占);④ 穿透(查不存在的数据,空值缓存 / 布隆 / 参数校验)。
- 5 条命令:
INFO stats(增量)、--stat(实时)、INFO keyspace(key 与 TTL 分布)、--hotkeys(热 key,需 LFU)、--bigkeys+MEMORY USAGE(大 key)。MONITOR只能短时用,高 QPS 实例上别碰。 - 排查顺序:先确认算法 → 再看 key 总量/TTL 分布(排除冷启动与集中过期)→ 然后查淘汰与 bigkey → 最后才怀疑穿透。这个顺序能覆盖绝大多数真实案例。
- 命中率要和数据库 QPS 一起看:命中率掉 = 数据库压力涨,两者是同一个信号的两面,告警应该成对配置,而不是只盯一个。
本文命令适用于 Redis 6/7;
--hotkeys需要 LFU 类淘汰策略,OBJECT FREQ同理。5.x 及更早版本没有--memkeys,--hotkeys的输出也依赖策略配置。