ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

Redis 从 0 到 1(二十三):一个热 key 打到 10 万 QPS——hotkey 与本地缓存的 Go 实现

Redis 从 0 到 1(二十三):一个热 key 打到 10 万 QPS——hotkey 与本地缓存的 Go 实现

我不会起名字322· 后端 / 算法 / 数据库

📘 技术栈 | 🧩 力扣 Hot100 | 🐹 Go 项目 | 🟥 Redis | 🐬 MySQL

文章目录

  • Redis 从 0 到 1(二十三):一个热 key 打到 10 万 QPS——hotkey 与本地缓存的 Go 实现
    • 一、现象:单 key 的 QPS 超过了整个实例的容量
    • 二、hotkey 的 3 种发现方式(收藏点 1)
    • 三、二级缓存:本地缓存 + 短 TTL + 版本号(收藏点 2)
    • 四、效果对比(收藏点 3)
    • 五、排查步骤:节点 QPS 打满但 key 分布均匀
    • 六、总结

Redis 从 0 到 1(二十三):一个热 key 打到 10 万 QPS——hotkey 与本地缓存的 Go 实现

上一篇《Redis 从 0 到 1(二十二):一个 500MB 的 Hash 拖垮实例——bigkey 的 Go 渐进删除》我们解决了"大"的问题,链接:https://blog.csdn.net/2501_92769340/article/details/167399887 。
本篇解决"热"的问题:bigkey 撑大的延迟,hotkey 打爆的是 QPS。

一、现象:单 key 的 QPS 超过了整个实例的容量

明星官宣,微博系统里那条动态的缓存 key 瞬间被打到 10 万 QPS。上了 Cluster 也没用——CRC16 决定这个 key 永远落在一个槽、一个节点上,那个节点的单线程(第 5 篇)就这么被钉死了。

hotkey 的本质:负载均衡是按"key 数量"分的,不是按"访问热度"分的。一万个冷门 key 摊在 10 个节点,不如一个 10 万 QPS 的热 key 对一个节点的冲击大。解法是把读请求从 Redis 上卸掉一层:本地缓存。

二、hotkey 的 3 种发现方式(收藏点 1)

方式原理优缺点
monitor 采样MONITOR抓命令流,统计 key 频率看到真实分布,但 MONITOR 本身吃 30%+ CPU,只能采样 30 秒
OBJECT FREQLFU 策略下记录每个 key 的访问计数零成本,但要求淘汰策略含 lfu 且计数是采样值
代理/客户端埋点在 Redis 前面(proxy)或客户端统计最准确,但要改链路
# 方式 2 示例:切到 allkeys-lfu 后看访问频率CONFIG SET maxmemory-policy allkeys-lfu OBJECT FREQ hot:key:weibo:12345# 输出访问计数# 方式 1 示例:采样 10 秒(生产低峰期做)redis-cli --latency-history 不适用;用:timeout10redis-cli monitor|grep-oE'"[^"]+"'|sort|uniq-c|sort-rn|head

发现之后做确认:redis-cli -c --stat看节点 QPS 分布,配合 hot key 名单锁定。

三、二级缓存:本地缓存 + 短 TTL + 版本号(收藏点 2)

架构:

请求 → [本地缓存 L1] 命中?──是──→ 返回(不经 Redis) │否 ▼ [Redis L2] 命中?──是──→ 回填 L1(短 TTL)→ 返回 │否 ▼ 回源数据库 → 回填 L2 + L1

关键设计三点:

  1. L1 TTL 要短(5~30 秒):牺牲一点一致性,换 Redis 90% 的读流量消失;
  2. 版本号防穿透:L1 缓存 value + version,L2 更新时 version+1,L1 发现 version 落后就回 L2 拉新;
  3. L1 要有容量上限:本地内存不是无限的,LRU 淘汰(第 6 章策略同款思路)。

Go 实现(简化版本地缓存,生产可换 freecache/bigcache):

packagehotkeyimport("context""sync""time""github.com/redis/go-redis/v9")typeitemstruct{valuestringversionint64expire time.Time}typeLocalCachestruct{mu sync.Mutex datamap[string]*item sizeintrdb*redis.Client ttl time.Duration}funcNewLocalCache(rdb*redis.Client,sizeint,l1TTL time.Duration)*LocalCache{c:=&LocalCache{data:make(map[string]*item,size),size:size,rdb:rdb,ttl:l1TTL}goc.gc()// 定期清理过期项returnc}// Get 两级读取:L1 命中且未过期直接返回;否则打 L2func(c*LocalCache)Get(ctx context.Context,keystring)(string,error){c.mu.Lock()ifit,ok:=c.data[key];ok&&time.Now().Before(it.expire){v:=it.value c.mu.Unlock()returnv,nil}c.mu.Unlock()// L1 未命中 → L2(Redis),顺便带版本号val,err:=c.rdb.Get(ctx,key).Result()iferr!=nil{return"",err}c.mu.Lock()iflen(c.data)>=c.size{// 简单淘汰:随机踢一个(生产换 LRU)fork:=rangec.data{delete(c.data,k)break}}c.data[key]=&item{value:val,version:time.Now().Unix(),expire:time.Now().Add(c.ttl)}c.mu.Unlock()returnval,nil}// Invalidate 写路径调用:L2 更新后,主动作废 L1(多实例时配合 Redis 订阅广播)func(c*LocalCache)Invalidate(keystring){c.mu.Lock()delete(c.data,key)c.mu.Unlock()}func(c*LocalCache)gc(){t:=time.NewTicker(time.Minute)forranget.C{c.mu.Lock()now:=time.Now()fork,it:=rangec.data{ifnow.After(it.expire){delete(c.data,k)}}c.mu.Unlock()}}

写路径:更新数据库 → 更新 Redis → 作废所有实例的 L1。多实例作废用 Redis Pub/Sub 广播(第 18 篇:这是 Pub/Sub 为数不多的正确用法——消息丢了也有 TTL 兜底):

rdb.Publish(ctx,"cache:invalidate",key)// 各实例订阅后调用 Invalidate

四、效果对比(收藏点 3)

假设热 key 10 万 QPS、L1 TTL 15 秒、命中率 90%:

层级QPS
应用收到的请求100,000
打到 Redis≈10,000(10% 未命中 + 失效瞬间)
打到数据库≈0(L2 兜底)

Redis 节点压力降一个数量级,且 L1 命中率可监控(加计数器上报)。

五、排查步骤:节点 QPS 打满但 key 分布均匀

  1. redis-cli -c --stat或监控看单节点QPS 是否远超其他节点;
  2. 是该节点的所有 key 都热,还是某几个?MONITOR 采样 10 秒按 key 聚合;
  3. 确认 hotkey 后:评估改造为"本地缓存 + 短 TTL";
  4. 无法本地缓存的场景(强一致),考虑 key 拆分(key:1~key:10 带副本读,牺牲一致性换分散)。

六、总结

  1. hotkey 打的是单节点,Cluster 治不了单槽热点;
  2. 发现三板斧:MONITOR 采样、OBJECT FREQ、客户端埋点;
  3. 解法核心:L1 本地缓存(短 TTL + 容量上限)+ L2 Redis + Pub/Sub 广播失效;
  4. 目标是把 Redis 热 key 的 QPS 卸掉 90%。

下一篇预告:单 key 的问题解决了,下一篇优化"次数"——下一篇《1 万次 GET 从 800ms 降到 9ms——Pipeline 的 Go 压测》,RTT 是隐藏的性能税。

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进