:一个热 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关键设计三点L1 TTL 要短5~30 秒牺牲一点一致性换 Redis 90% 的读流量消失版本号防穿透L1 缓存 value versionL2 更新时 version1L1 发现 version 落后就回 L2 拉新L1 要有容量上限本地内存不是无限的LRU 淘汰第 6 章策略同款思路。Go 实现简化版本地缓存生产可换 freecache/bigcachepackagehotkeyimport(contextsynctimegithub.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];oktime.Now().Before(it.expire){v:it.value c.mu.Unlock()returnv,nil}c.mu.Unlock()// L1 未命中 → L2Redis顺便带版本号val,err:c.rdb.Get(ctx,key).Result()iferr!nil{return,err}c.mu.Lock()iflen(c.data)c.size{// 简单淘汰随机踢一个生产换 LRUfork: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,00010% 未命中 失效瞬间打到数据库≈0L2 兜底Redis 节点压力降一个数量级且 L1 命中率可监控加计数器上报。五、排查步骤节点 QPS 打满但 key 分布均匀redis-cli -c --stat或监控看单节点QPS 是否远超其他节点是该节点的所有 key 都热还是某几个MONITOR 采样 10 秒按 key 聚合确认 hotkey 后评估改造为本地缓存 短 TTL无法本地缓存的场景强一致考虑 key 拆分key:1~key:10 带副本读牺牲一致性换分散。六、总结hotkey 打的是单节点Cluster 治不了单槽热点发现三板斧MONITOR 采样、OBJECT FREQ、客户端埋点解法核心L1 本地缓存短 TTL 容量上限 L2 Redis Pub/Sub 广播失效目标是把 Redis 热 key 的 QPS 卸掉 90%。下一篇预告单 key 的问题解决了下一篇优化次数——下一篇《1 万次 GET 从 800ms 降到 9ms——Pipeline 的 Go 压测》RTT 是隐藏的性能税。