system column十三Tech
← 返回技术专栏
TECH

高并发场景下的热点 Key 问题:从发现到解决

Redis 热 Key 是高并发系统的常见杀手。本文从 go-cache 本地缓存原理切入,深入解析 Kratos HotKey 的 LRU + TopK 组合方案,展示热点数据的发现与防护机制。

Go性能优化

在高并发系统中,你是否遇到过这样的诡异现象: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 集群,承载正常流量的读写
  • 兜底:数据库,应对缓存穿透和失效

理解本地缓存的实现原理和热点发现算法,将帮助你在面对高并发冲击时,设计出更健壮的缓存防护体系。

参考