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

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

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

文章目录

  • [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

关键设计三点:

  1. L1 TTL 要短(5~30 秒):牺牲一点一致性,换 Redis 90% 的读流量消失;
  2. 版本号防穿透:L1 缓存 value + version,L2 更新时 version+1,L1 发现 version 落后就回 L2 拉新;
  3. 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 分布均匀

  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 是隐藏的性能税。

相关推荐
IT规划师2 小时前
Redis 数据类型全解 - Bitmap 生产实战:把位视图讲准、用对、守住红线(六)
redis·.net
Patrick_Wilson13 小时前
Go 为什么需要 defer?从函数怎么收尾说起
后端·go·编程语言
Fatbobman(东坡肘子)14 小时前
快速消失的硬盘容量 -- 肘子的 Swift 周报 #160
开发语言·人工智能·redis·ios·swiftui·iphone·swift
Patrick_Wilson14 小时前
从文本输出理解 Go 方法与接口
后端·go·编程语言
温暖小土15 小时前
在 CentOS 上安装 Redis Stack
linux·redis·centos
Mikko718 小时前
Redis 连接数满了服务端有错误信息吗?连接数不增加却命令超时?6.2 实测连接池、超时与大 key 怎么判定
java·spring boot·redis·后端
无言听风雨丶18 小时前
Redis 运维实战与排错
运维·数据库·redis
Patrick_Wilson19 小时前
读懂一个 Go JSON 处理函数:从输入数据追踪结构体、校验与错误返回
go·json·编程语言