热点数据检测 HeavyKeeper

28 阅读4分钟

在高并发的场景中,热点数据一直是我们需要关注的问题。如何去衡量热点数据是关键。这篇文章主要讨论基于 HeavyKeeper 的实现方式。 在看代码之前,先了解 HeavyKeeper原理和设计思想。为什么要这么设计,是如何来实现的。

1.传统的第一种方式 HashMap + Heap的方式

使用 Hash<String, Long> 来记录访问的次数,使用Heap来记录访问 topK 的数据。
优点:实现简单,容易理解。
缺点:HashMap 的空间复杂度O(n),内存会迅速增长,尤其是在高并发的场景下,可能会导致内存溢出。

2.基于 Count-Min Sketch 的方式

CMS 是一个经典的概率数据结构,可以用来估计元素的频率。它使用多个哈希函数和一个二维数组来记录元素的访问次数。
例如:使用 ddww 列的二维数组来记录访问次数,每次访问一个元素时,使用 dd 个哈希函数将元素映射到二维数组的 dd 个位置,并将这些位置的值加1。要查询一个元素的访问次数时,使用同样的哈希函数将元素映射到二维数组的 dd 个位置,并取这些位置的最小值作为估计的访问次数。
优点:空间复杂度为 O(d*w),与元素的数量无关,适合高并发的场景。
缺点:存在哈希冲突导致的频率高估问题,且不支持自动剔除已经“变冷”的数据。

3.基于 HeavyKeeper 的方式

结合了 Sketch(哈希表)的低内存占用和特殊的指数衰减机制,能够精准识别“大象流”(频繁项)并迅速衰减“老鼠流”(长尾项)。
3.1 核心算法原理
HeavyKeeper 由两个核心部分组成:Bucket 矩阵(用于计数) 和 小顶堆(用于维护 Top-K)。

3.2 数据结构

  • Bucket 矩阵:一个 d×wd \times w 的二维数组。
    每个 Bucket 包含两个字段:
    Fingerprint (fp):元素的指纹(哈希摘要),用于识别身份。
    Count:该指纹对应的计数值。
  • 小顶堆 (Min-Heap):存储当前检测到的 Top-K 个元素及其对应的估计频率。

3.3 核心逻辑:指数衰减插入
当一个元素 item 进入系统时,通过 dd 个哈希函数映射到每一行的相应 Bucket 中:

    1. 匹配(Match): 如果 item 的指纹与 Bucket 中的 fp 一致,Count 直接加 1。

    1. 冲突(Conflict):
      如果指纹不一致,说明发生了哈希冲突。此时不直接覆盖,而是以概率 PP 进行衰减:
      P=bcountP = b^{-count}
      其中 bb 是衰减基数(通常取 1.08)。
      衰减成功: Count 减 1。若减至 0,则将当前 Bucket 的 fp 替换为新元素的指纹,并将 Count 置为 1。
      衰减失败:保持原状,不对该 Bucket 做任何操作。
    1. 最终判定:减分还是篡位
      系统随机生成一个 0 到 1 之间的浮点数 rr
    • 判定 A(未触发衰减):如果 r>Pr > P,守擂者太强大了,挑战者被无视,Bucket 保持不变。
    • 判定 B(触发衰减):如果 rPr \le P,守擂者的积分 CC 减去 1。
    • 如果减完之后 C>0C > 0:守擂者虽然变弱了,但还能撑住,挑战者依然不能上台。
    • 如果减完之后 C=0C = 0:守擂者彻底倒下!这时候挑战者正式上位,把自己的 Fingerprint 存进去,并将 CC 设为 1。

判断这里思想和leetcode里面有道算法题有点相似,169. 多数元素

了解完原理,我来看一下具体的代码实现:

import java.util.*;
import java.util.concurrent.*;
import java.util.concurrent.locks.ReentrantLock;
import java.util.function.Consumer;

/**
* 基于 HeavyKeeper 的热点 Key 检测器
*/
public class HotKeyDetector {

  // ----- 配置 -----
  public static class Config {
      public int k = 100;              // Top-K 容量
      public int d = 4;                // 哈希行数
      public int w = 4096;             // 每行桶数
      public double base = 1.08;       // 衰减底数
      public int hotThreshold = 1000;  // 热点判定阈值(窗口内估计访问量)
      public long decayIntervalMs = 10_000; // 周期衰减间隔
      public double decayFactor = 0.9; // 每次周期衰减保留比例
  }

  // ----- Sketch -----
  private static class Bucket {
      long fingerprint;
      int count;
  }

  public static class HotItem {
      public final String key;
      public final int count;
      public HotItem(String key, int count) {
          this.key = key;
          this.count = count;
      }
      @Override public String toString() { return key + "=" + count; }
  }

  private final Config cfg;
  private final Bucket[][] buckets;
  private final ReentrantLock[] rowLocks;     // 每行一把锁,减小竞争
  private final long[] seedsHash;
  private final long[] seedsFp;

  // Top-K 候选堆(按 count 小顶堆)
  private final PriorityQueue<HotItem> minHeap;
  private final Map<String, HotItem> heapIndex;
  private final ReentrantLock heapLock = new ReentrantLock();

  // 当前已判定为热点的 Key 集合(供业务快速查询)
  private final Set<String> hotKeys = ConcurrentHashMap.newKeySet();
  private final Consumer<String> onHotKeyDetected; // 新热点回调

  private final ScheduledExecutorService scheduler;
  private final ThreadLocalRandom rng = ThreadLocalRandom.current();

  public HotKeyDetector(Config cfg, Consumer<String> onHotKeyDetected) {
      this.cfg = cfg;
      this.onHotKeyDetected = onHotKeyDetected;
      this.buckets = new Bucket[cfg.d][cfg.w];
      this.rowLocks = new ReentrantLock[cfg.d];
      for (int i = 0; i < cfg.d; i++) {
          rowLocks[i] = new ReentrantLock();
          for (int j = 0; j < cfg.w; j++) buckets[i][j] = new Bucket();
      }
      this.seedsHash = new long[cfg.d];
      this.seedsFp = new long[cfg.d];
      Random r = new Random(42);
      for (int i = 0; i < cfg.d; i++) {
          seedsHash[i] = r.nextLong();
          seedsFp[i] = r.nextLong();
      }
      this.minHeap = new PriorityQueue<>(cfg.k,
          Comparator.comparingInt(a -> a.count));
      this.heapIndex = new HashMap<>();

      this.scheduler = Executors.newSingleThreadScheduledExecutor(t -> {
          Thread th = new Thread(t, "hotkey-decay");
          th.setDaemon(true);
          return th;
      });
      scheduler.scheduleAtFixedRate(this::decay,
          cfg.decayIntervalMs, cfg.decayIntervalMs, TimeUnit.MILLISECONDS);
  }

  /** 业务每次访问 Key 时调用 */
  public void offer(String key) {
      long fp = fingerprint(key);
      int hashCode = key.hashCode();
      int maxCount = 0;

      for (int i = 0; i < cfg.d; i++) {
          int idx = (int) Math.floorMod(mix(hashCode, seedsHash[i]), cfg.w);
          rowLocks[i].lock();
          try {
              Bucket b = buckets[i][idx];
              if (b.count == 0) {
                  b.fingerprint = fp;
                  b.count = 1;
                  maxCount = Math.max(maxCount, 1);
              } else if (b.fingerprint == fp) {
                  b.count++;
                  maxCount = Math.max(maxCount, b.count);
              } else {
                  double decayProb = Math.pow(cfg.base, -b.count);
                  if (rng.nextDouble() < decayProb) {
                      b.count--;
                      if (b.count == 0) {
                          b.fingerprint = fp;
                          b.count = 1;
                          maxCount = Math.max(maxCount, 1);
                      }
                  }
              }
          } finally {
              rowLocks[i].unlock();
          }
      }

      if (maxCount > 0) updateHeap(key, maxCount);
  }

  /** 判断 Key 当前是否为热点(供业务旁路使用) */
  public boolean isHot(String key) {
      return hotKeys.contains(key);
  }

  /** 取当前 Top-K 热点列表(按 count 降序) */
  public List<HotItem> topK() {
      heapLock.lock();
      try {
          List<HotItem> list = new ArrayList<>(minHeap);
          list.sort((a, b) -> Integer.compare(b.count, a.count));
          return list;
      } finally {
          heapLock.unlock();
      }
  }

  public void shutdown() { scheduler.shutdownNow(); }

  // ---------- 内部 ----------

  private void updateHeap(String key, int count) {
      heapLock.lock();
      try {
          HotItem existing = heapIndex.get(key);
          if (existing != null) {
              minHeap.remove(existing);
              HotItem updated = new HotItem(key, count);
              minHeap.offer(updated);
              heapIndex.put(key, updated);
              checkHot(key, count);
              return;
          }
          if (minHeap.size() < cfg.k) {
              HotItem item = new HotItem(key, count);
              minHeap.offer(item);
              heapIndex.put(key, item);
              checkHot(key, count);
          } else {
              HotItem top = minHeap.peek();
              if (count > top.count) {
                  minHeap.poll();
                  heapIndex.remove(top.key);
                  hotKeys.remove(top.key); // 被挤出 Top-K,自然降级
                  HotItem item = new HotItem(key, count);
                  minHeap.offer(item);
                  heapIndex.put(key, item);
                  checkHot(key, count);
              }
          }
      } finally {
          heapLock.unlock();
      }
  }

  private void checkHot(String key, int count) {
      if (count >= cfg.hotThreshold && hotKeys.add(key)) {
          // 首次升格为热点,触发回调
          if (onHotKeyDetected != null) onHotKeyDetected.accept(key);
      }
  }

  /** 周期性衰减:所有桶 count *= decayFactor */
  private void decay() {
      for (int i = 0; i < cfg.d; i++) {
          rowLocks[i].lock();
          try {
              for (int j = 0; j < cfg.w; j++) {
                  Bucket b = buckets[i][j];
                  if (b.count > 0) {
                      b.count = (int) (b.count * cfg.decayFactor);
                      if (b.count == 0) b.fingerprint = 0;
                  }
              }
          } finally {
              rowLocks[i].unlock();
          }
      }

      // 同步降级堆里不再达标的 Key
      heapLock.lock();
      try {
          List<HotItem> snapshot = new ArrayList<>(minHeap);
          for (HotItem item : snapshot) {
              int realCount = queryUnlocked(item.key);
              if (realCount < cfg.hotThreshold) hotKeys.remove(item.key);
              if (realCount == 0) {
                  minHeap.remove(item);
                  heapIndex.remove(item.key);
                  hotKeys.remove(item.key);
              } else if (realCount != item.count) {
                  minHeap.remove(item);
                  HotItem updated = new HotItem(item.key, realCount);
                  minHeap.offer(updated);
                  heapIndex.put(item.key, updated);
              }
          }
      } finally {
          heapLock.unlock();
      }
  }

  private int queryUnlocked(String key) {
      long fp = fingerprint(key);
      int hashCode = key.hashCode();
      int max = 0;
      for (int i = 0; i < cfg.d; i++) {
          int idx = (int) Math.floorMod(mix(hashCode, seedsHash[i]), cfg.w);
          Bucket b = buckets[i][idx];
          if (b.fingerprint == fp) max = Math.max(max, b.count);
      }
      return max;
  }

  private long fingerprint(String key) {
      return mix(key.hashCode(), seedsFp[0]);
  }
  private long mix(int h, long seed) {
      long x = (h ^ seed) * 0xff51afd7ed558ccdL;
      x = (x ^ (x >>> 33)) * 0xc4ceb9fe1a85ec53L;
      return x ^ (x >>> 33);
  }
}

4.配合本地缓存使用(典型场景)

热点 Key 检测最常见的用法是配合本地缓存(Caffeine)做"两级缓存",挡住打到 Redis 的热点流量。

import com.github.benmanes.caffeine.cache.Cache;
import com.github.benmanes.caffeine.cache.Caffeine;
import java.time.Duration;

public class HotAwareCacheService {

    private final RedisTemplate redis;            // 远程缓存
    private final Cache<String, Object> local;    // 本地缓存(仅放热点)
    private final HotKeyDetector detector;

    public HotAwareCacheService(RedisTemplate redis) {
        this.redis = redis;
        this.local = Caffeine.newBuilder()
            .maximumSize(10_000)
            .expireAfterWrite(Duration.ofSeconds(5))   // 本地缓存短 TTL
            .build();

        HotKeyDetector.Config cfg = new HotKeyDetector.Config();
        cfg.hotThreshold = 500;        // 10s 内访问超 500 次 → 热点
        cfg.decayIntervalMs = 10_000;
        cfg.decayFactor = 0.5;

        this.detector = new HotKeyDetector(cfg, hotKey -> {
            // 新热点被检测到:预热本地缓存
            Object val = redis.get(hotKey);
            if (val != null) local.put(hotKey, val);
            System.out.println("hot key detected: " + hotKey);
        });
    }

    public Object get(String key) {
        detector.offer(key);

        // 热点 Key 走本地缓存
        if (detector.isHot(key)) {
            Object v = local.getIfPresent(key);
            if (v != null) return v;
            v = redis.get(key);
            if (v != null) local.put(key, v);
            return v;
        }
        // 普通 Key 直接走 Redis
        return redis.get(key);
    }

    public void evict(String key) {
        redis.delete(key);
        local.invalidate(key);
    }
}

5.集群协同(进阶)

单机检测有局限:每个 JVM 只看到自己流量,对小流量节点可能漏判。增强方案:

  1. 本地检测 + 全局汇总:每个节点本地跑 HeavyKeeper,定期把 Top-K 推到 Redis(用 ZSET 或 Redis TopK 模块),中心节点合并出全局热点

  2. 广播热点列表:中心节点将全局热点通过 Redis pub/sub / Nacos 配置中心广播给所有节点

  3. Redis 端检测:直接使用 Redis Bloom 模块的 TOPK 命令,所有节点共享一个 HeavyKeeper