在高并发系统中,你是否遇到过这样的诡异现象:Redis 集群的某个节点 CPU 突然飙高,其他节点却闲庭信步?这极有可能是 热 Key(Hot Key) 在作祟。当某个 Key 的访问量远远超过其他 Key 时,它会成为整个缓存系统的瓶颈。在十三Tech 的实战项目中,热 Key 防护是高并发缓存设计的必修课。本文将从本地缓存原理出发,带你深入理解热 Key 的发现与解决之道。
go-cache:简单的本地内存缓存
在讨论热 Key 之前,我们先看看最基础的本地缓存实现。go-cache 是一个基于内存的轻量级缓存库,虽然功能简单,但其设计思想值得学习。
核心结构
type cache struct {
defaultExpiration time.Duration
items map[string]Item
mu sync.RWMutex
onEvicted func(string, interface{})
janitor *janitor
}
type Item struct {
Object interface{}
Expiration int64
}
mu:读写锁,保证并发安全defaultExpiration:默认过期时间janitor:后台清理器,定期删除过期数据items:底层存储,简单的 map 结构onEvicted:淘汰回调函数
初始化与清理机制
func New(defaultExpiration, cleanupInterval time.Duration) *Cache {
items := make(map[string]Item)
return newCacheWithJanitor(defaultExpiration, cleanupInterval, items)
}
当 cleanupInterval > 0 时,会启动一个后台 Goroutine 定期清理过期 Key:
func (j *janitor) Run(c *cache) {
ticker := time.NewTicker(j.Interval)
for {
select {
case <-ticker.C:
c.DeleteExpired()
case <-j.stop:
ticker.Stop()
return
}
}
}
func (c *cache) DeleteExpired() {
var evictedItems []keyAndValue
now := time.Now().UnixNano()
c.mu.Lock()
for k, v := range c.items {
if v.Expiration > 0 && now > v.Expiration {
ov, evicted := c.delete(k)
if evicted {
evictedItems = append(evictedItems, keyAndValue{k, ov})
}
}
}
c.mu.Unlock()
// 异步触发淘汰回调
for _, v := range evictedItems {
c.onEvicted(v.key, v.value)
}
}
Add 与 Get
func (c *cache) Add(k string, x interface{}, d time.Duration) error {
c.mu.Lock()
_, found := c.get(k)
if found {
c.mu.Unlock()
return fmt.Errorf("Item %s already exists", k)
}
c.set(k, x, d)
c.mu.Unlock()
return nil
}
func (c *cache) Get(k string) (interface{}, bool) {
c.mu.RLock()
item, found := c.items[k]
if !found {
c.mu.RUnlock()
return nil, false
}
if item.Expiration > 0 {
if time.Now().UnixNano() > item.Expiration {
c.mu.RUnlock()
return nil, false
}
}
c.mu.RUnlock()
return item.Object, true
}
注意:
Get校验过期时间时并不执行懒删除,以避免将读锁升级为写锁,这是性能与一致性的权衡。
分片优化
go-cache 还提供了 shardedCache,通过 djb33 哈希将 Key 分散到多个 bucket 中,减小锁粒度,提升并发性能。
然而,go-cache 缺乏容量控制和淘汰策略,内存可能无限制增长,不适合作为热 Key 防护的核心组件。
Kratos HotKey:生产级的热点防护方案
相比 go-cache 的简单实现,Kratos 框架的 HotKey 模块是一个更完善的解决方案,它结合了 LRU 本地缓存 和 TopK 热点发现算法。
LRU 本地缓存
// Cache 是一个 LRU 缓存(非线程安全)
type Cache struct {
MaxEntries int
OnEvicted func(key Key, value interface{})
ll *list.List
cache map[interface{}]*list.Element
}
MaxEntries:最大容量,超过时淘汰最久未使用的条目ll:双向链表,按访问时间排序,最近访问的在头部cache:哈希表,实现 O(1) 的查找
LRU 的核心优势在于固定内存占用和热点数据的自动保留——频繁访问的 Key 会留在缓存中,冷门 Key 被淘汰。
HotKey 核心结构
type Option struct {
HotKeyCnt int // TopK 的 K 值,记录前 N 个热 Key
LocalCacheCnt int // 本地缓存容量
AutoCache bool // 是否自动缓存热点 Key
CacheMs int // 缓存时间(毫秒)
MinCount int // 进入 TopK 的最小计数门槛
WhileList []*CacheRuleConfig // 白名单规则
BlackList []*CacheRuleConfig // 黑名单规则
LocalCache LocalCache // 可自定义的 LRU 缓存实现
}
HotKey 初始化
func NewHotkey(option *Option) (*HotKeyWithCache, error) {
var err error
h := &HotKeyWithCache{option: option}
if option.HotKeyCnt > 0 {
factor := uint32(math.Log(float64(option.HotKeyCnt)))
if factor < 1 {
factor = 1
}
// 使用 HeavyKeeper 算法实现 TopK
h.topk = topk.NewHeavyKeeper(
uint32(option.HotKeyCnt),
1024*factor, 4, 0.925,
uint32(option.MinCount),
)
}
// 初始化黑白名单规则
if len(h.option.WhileList) > 0 {
h.whilelist, err = h.initCacheRules(h.option.WhileList)
if err != nil {
return nil, err
}
}
if len(h.option.BlackList) > 0 {
h.blacklist, err = h.initCacheRules(h.option.BlackList)
if err != nil {
return nil, err
}
}
// 初始化本地缓存
if h.option.AutoCache || len(h.whilelist) > 0 {
if h.option.LocalCache != nil {
h.localCache = h.option.LocalCache
} else {
h.localCache = NewLocalCache(int(h.option.LocalCacheCnt))
}
}
return h, nil
}
热点发现与缓存
// Add 将 Key 加入 TopK,返回是否为热点
func (h *HotKeyWithCache) Add(key string, incr uint32) bool {
if h.topk == nil {
return false
}
h.mutex.Lock()
defer h.mutex.Unlock()
_, hotkey := h.topk.Add(key, incr)
return hotkey
}
// AddWithValue 加入 TopK 并自动缓存热点数据
func (h *HotKeyWithCache) AddWithValue(key string, value interface{}, incr uint32) bool {
if h.topk == nil && h.localCache == nil {
return false
}
h.mutex.Lock()
defer h.mutex.Unlock()
var added bool
if h.topk != nil {
var expelled string
expelled, added = h.topk.Add(key, incr)
// 被 TopK 淘汰的 Key 从本地缓存中移除
if len(expelled) > 0 && h.localCache != nil {
h.localCache.Remove(expelled)
}
// 自动缓存热点数据(不在黑名单中)
if h.option.AutoCache && added && !h.inBlacklist(key) {
h.localCache.Add(key, value, uint32(h.option.CacheMs))
return added
}
}
// 白名单强制缓存
if ttl, ok := h.inWhitelist(key); ok {
h.localCache.Add(key, value, ttl)
}
return added
}
热度衰减
func (h *HotKeyWithCache) Fading() {
if h.topk == nil {
return
}
h.mutex.Lock()
defer h.mutex.Unlock()
h.topk.Fading()
}
Fading 遍历 TopK,将所有元素的计数值除以 2。这是一种时间衰减机制,让近期的访问频率比历史访问更有话语权,避免某个 Key 因历史累计而成为"伪热点"。
规则匹配
func (h *HotKeyWithCache) inBlacklist(key string) bool {
if len(h.blacklist) == 0 {
return false
}
for _, b := range h.blacklist {
if b.value == key {
return true
}
if b.regexp != nil && b.regexp.Match([]byte(key)) {
return true
}
}
return false
}
黑白名单支持精确匹配和正则匹配,为热点防护提供了精细化的控制能力。
总结
热 Key 问题是高并发缓存系统的经典挑战。go-cache 适合简单的本地缓存场景,而 Kratos 的 HotKey 模块通过 LRU + TopK(HeavyKeeper)+ 时间衰减 + 黑白名单 的组合机制,提供了生产级的热点防护能力。
在十三Tech 的缓存架构中,我们的实践经验是:
- 第一层:本地 HotKey 缓存,拦截绝大部分热点流量
- 第二层:Redis 集群,承载正常流量的读写
- 兜底:数据库,应对缓存穿透和失效
理解本地缓存的实现原理和热点发现算法,将帮助你在面对高并发冲击时,设计出更健壮的缓存防护体系。