我不会起名字322 · 后端 / 算法 / 数据库
文章目录
- [Redis 从 0 到 1(二十三):一个热 key 打到 10 万 QPS------hotkey 与本地缓存的 Go 实现](#Redis 从 0 到 1(二十三):一个热 key 打到 10 万 QPS——hotkey 与本地缓存的 Go 实现)
-
- [一、现象:单 key 的 QPS 超过了整个实例的容量](#一、现象:单 key 的 QPS 超过了整个实例的容量)
- [二、hotkey 的 3 种发现方式(收藏点 1)](#二、hotkey 的 3 种发现方式(收藏点 1))
- [三、二级缓存:本地缓存 + 短 TTL + 版本号(收藏点 2)](#三、二级缓存:本地缓存 + 短 TTL + 版本号(收藏点 2))
- [四、效果对比(收藏点 3)](#四、效果对比(收藏点 3))
- [五、排查步骤:节点 QPS 打满但 key 分布均匀](#五、排查步骤:节点 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 FREQ | LFU 策略下记录每个 key 的访问计数 | 零成本,但要求淘汰策略含 lfu 且计数是采样值 |
| 代理/客户端埋点 | 在 Redis 前面(proxy)或客户端统计 | 最准确,但要改链路 |
bash
# 方式 2 示例:切到 allkeys-lfu 后看访问频率
CONFIG SET maxmemory-policy allkeys-lfu
OBJECT FREQ hot:key:weibo:12345 # 输出访问计数
# 方式 1 示例:采样 10 秒(生产低峰期做)
redis-cli --latency-history 不适用;用:
timeout 10 redis-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 + version,L2 更新时 version+1,L1 发现 version 落后就回 L2 拉新;
- L1 要有容量上限:本地内存不是无限的,LRU 淘汰(第 6 章策略同款思路)。
Go 实现(简化版本地缓存,生产可换 freecache/bigcache):
go
package hotkey
import (
"context"
"sync"
"time"
"github.com/redis/go-redis/v9"
)
type item struct {
value string
version int64
expire time.Time
}
type LocalCache struct {
mu sync.Mutex
data map[string]*item
size int
rdb *redis.Client
ttl time.Duration
}
func NewLocalCache(rdb *redis.Client, size int, l1TTL time.Duration) *LocalCache {
c := &LocalCache{data: make(map[string]*item, size), size: size, rdb: rdb, ttl: l1TTL}
go c.gc() // 定期清理过期项
return c
}
// Get 两级读取:L1 命中且未过期直接返回;否则打 L2
func (c *LocalCache) Get(ctx context.Context, key string) (string, error) {
c.mu.Lock()
if it, ok := c.data[key]; ok && time.Now().Before(it.expire) {
v := it.value
c.mu.Unlock()
return v, nil
}
c.mu.Unlock()
// L1 未命中 → L2(Redis),顺便带版本号
val, err := c.rdb.Get(ctx, key).Result()
if err != nil {
return "", err
}
c.mu.Lock()
if len(c.data) >= c.size {
// 简单淘汰:随机踢一个(生产换 LRU)
for k := range c.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()
return val, nil
}
// Invalidate 写路径调用:L2 更新后,主动作废 L1(多实例时配合 Redis 订阅广播)
func (c *LocalCache) Invalidate(key string) {
c.mu.Lock()
delete(c.data, key)
c.mu.Unlock()
}
func (c *LocalCache) gc() {
t := time.NewTicker(time.Minute)
for range t.C {
c.mu.Lock()
now := time.Now()
for k, it := range c.data {
if now.After(it.expire) {
delete(c.data, k)
}
}
c.mu.Unlock()
}
}
写路径:更新数据库 → 更新 Redis → 作废所有实例的 L1。多实例作废用 Redis Pub/Sub 广播(第 18 篇:这是 Pub/Sub 为数不多的正确用法------消息丢了也有 TTL 兜底):
go
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 分布均匀
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 是隐藏的性能税。