在高并发的场景中,热点数据一直是我们需要关注的问题。如何去衡量热点数据是关键。这篇文章主要讨论基于 HeavyKeeper 的实现方式。 在看代码之前,先了解 HeavyKeeper原理和设计思想。为什么要这么设计,是如何来实现的。
1.传统的第一种方式 HashMap + Heap的方式
使用 Hash<String, Long> 来记录访问的次数,使用Heap来记录访问 topK 的数据。
优点:实现简单,容易理解。
缺点:HashMap 的空间复杂度O(n),内存会迅速增长,尤其是在高并发的场景下,可能会导致内存溢出。
2.基于 Count-Min Sketch 的方式
CMS 是一个经典的概率数据结构,可以用来估计元素的频率。它使用多个哈希函数和一个二维数组来记录元素的访问次数。
例如:使用 行 列的二维数组来记录访问次数,每次访问一个元素时,使用 个哈希函数将元素映射到二维数组的 个位置,并将这些位置的值加1。要查询一个元素的访问次数时,使用同样的哈希函数将元素映射到二维数组的 个位置,并取这些位置的最小值作为估计的访问次数。
优点:空间复杂度为 O(d*w),与元素的数量无关,适合高并发的场景。
缺点:存在哈希冲突导致的频率高估问题,且不支持自动剔除已经“变冷”的数据。
3.基于 HeavyKeeper 的方式
结合了 Sketch(哈希表)的低内存占用和特殊的指数衰减机制,能够精准识别“大象流”(频繁项)并迅速衰减“老鼠流”(长尾项)。
3.1 核心算法原理
HeavyKeeper 由两个核心部分组成:Bucket 矩阵(用于计数) 和 小顶堆(用于维护 Top-K)。
3.2 数据结构
- Bucket 矩阵:一个 的二维数组。
每个 Bucket 包含两个字段:
Fingerprint (fp):元素的指纹(哈希摘要),用于识别身份。
Count:该指纹对应的计数值。 - 小顶堆 (Min-Heap):存储当前检测到的 Top-K 个元素及其对应的估计频率。
3.3 核心逻辑:指数衰减插入
当一个元素 item 进入系统时,通过 个哈希函数映射到每一行的相应 Bucket 中:
-
-
匹配(Match): 如果 item 的指纹与 Bucket 中的 fp 一致,Count 直接加 1。
-
-
- 冲突(Conflict):
如果指纹不一致,说明发生了哈希冲突。此时不直接覆盖,而是以概率 进行衰减:
其中 是衰减基数(通常取 1.08)。
衰减成功: Count 减 1。若减至 0,则将当前 Bucket 的 fp 替换为新元素的指纹,并将 Count 置为 1。
衰减失败:保持原状,不对该 Bucket 做任何操作。
- 冲突(Conflict):
-
- 最终判定:减分还是篡位
系统随机生成一个 0 到 1 之间的浮点数 :
- 判定 A(未触发衰减):如果 ,守擂者太强大了,挑战者被无视,Bucket 保持不变。
- 判定 B(触发衰减):如果 ,守擂者的积分 减去 1。
- 如果减完之后 :守擂者虽然变弱了,但还能撑住,挑战者依然不能上台。
- 如果减完之后 :守擂者彻底倒下!这时候挑战者正式上位,把自己的 Fingerprint 存进去,并将 设为 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 只看到自己流量,对小流量节点可能漏判。增强方案:
-
本地检测 + 全局汇总:每个节点本地跑 HeavyKeeper,定期把 Top-K 推到 Redis(用 ZSET 或 Redis TopK 模块),中心节点合并出全局热点
-
广播热点列表:中心节点将全局热点通过 Redis pub/sub / Nacos 配置中心广播给所有节点
-
Redis 端检测:直接使用 Redis Bloom 模块的
TOPK命令,所有节点共享一个 HeavyKeeper