HotKey Platform — 热Key检测平台
从架构设计到模块解析,从数据流转到上手使用,一文讲透热Key检测平台的完整实现。
目录
- 一、项目背景与定位
- 二、系统架构总览
- 三、技术选型
- 四、项目模块结构
- 五、数据流转全链路
- 六、SDK 模块深度解析
- 七、Server 服务端深度解析
- 八、数据库设计
- 九、控制台与可视化
- 十、完整使用指南
- 十一、关键设计决策与性能分析
- 十二、总结与展望
一、项目背景与定位
1.1 什么是热Key?
在高并发的业务场景中,某些数据(如秒杀商品ID、热门课程ID、爆款商品SKU)会在短时间内被大量请求反复访问。这些被高频命中的数据就是所谓的热Key。
热Key的危害不容小觑:
- 数据库压力集中:大量请求打到同一行数据,导致数据库连接池耗尽
- 缓存击穿:热Key过期瞬间,海量请求穿透到数据库
- 资源倾斜:单机/单节点承载过多请求,集群负载均衡失效
1.2 HotKey Platform 做了什么?
HotKey Platform 是一个实时热Key检测与可视化平台,核心能力包括:
| 能力 | 说明 |
|---|---|
| 无侵入采集 | 通过注解 + AOP 零侵入地拦截业务方法调用 |
| 高性能聚合 | 双缓冲 Map + LongAdder 实现业务线程零阻塞 |
| 滑动窗口检测 | 基于 Redis ZSet + Lua 脚本实现精确的滑动窗口计数 |
| 自动发现注册 | 业务系统启动时自动向平台注册应用和规则 |
| 可视化控制台 | 仪表盘、趋势图、Top10 排行、QPS 实时监控 |
| 生命周期管理 | 热Key自动冷却、过期记录自动清理 |
二、系统架构总览
2.1 三大角色
┌──────────────────┐ ┌─────────────┐ ┌──────────────────┐
│ 业务系统(SDK) │ ──────→ │ Redis │ ──────→ │ hotkey-server │
│ hotkey-spring- │ 上报 │ 数据中转站 │ 扫描 │ 检测 + 持久化 │
│ boot-starter │ │ │ │ + 控制台 │
└──────────────────┘ └─────────────┘ └──────────────────┘
| 角色 | 部署位置 | 职责 |
|---|---|---|
| hotkey-spring-boot-starter(SDK) | 嵌入业务系统 JVM | 拦截方法调用、采集 Key、批量上报到 Redis |
| Redis | 独立中间件 | 高速数据中转站,接收 SDK 上报的访问计数,供服务端扫描 |
| hotkey-server(服务端) | 独立服务(端口 8089) | 定时扫描 Redis、判定热Key、持久化到 MySQL、提供控制台 API |
2.2 架构全景图
┌─────────────────────────────────────────────────────────────────────┐
│ 业务系统(Demo) │
│ ┌──────────────┐ ┌──────────────┐ │
│ │ student-demo │ │ mall-demo │ ← Spring Boot 应用 │
│ │ :8090 │ │ :8091 │ │
│ └──────┬───────┘ └──────┬───────┘ │
│ │ @HotKey 注解 │ @HotKey 注解 │
│ ▼ ▼ │
│ ┌──────────────────────────────────────────────────────────┐ │
│ │ hotkey-spring-boot-starter(SDK) │ │
│ │ HotKeyAspect → BatchCollector → RedisReporter │ │
│ │ HotKeyRegistry(启动时自动注册) │ │
│ └──────────────────────┬───────────────────────────────────┘ │
└─────────────────────────┼───────────────────────────────────────────┘
│ 写入 Redis ZSet
▼
┌─────────────────────────────────────────────────────────────────────┐
│ Redis │
│ hotkey:registry:{appName} ← 注册键(TTL 24h) │
│ hotkey:{appName}:{rule}:{key} ← 有序集合(滑动窗口数据) │
└──────────────────────────┬──────────────────────────────────────────┘
│ SCAN + Lua 嗅探
▼
┌─────────────────────────────────────────────────────────────────────┐
│ hotkey-server(:8089) │
│ DetectorService ← 每 2s 嗅探扫描 + Pipeline 批量 Lua │
│ StatsService ← 仪表盘/记录/趋势查询 │
│ MySQL ← hotkey_record / hotkey_stats / hotkey_rule │
└──────────────────────────┬──────────────────────────────────────────┘
│ REST API
▼
┌─────────────────────────────────────────────────────────────────────┐
│ 前端仪表盘(ECharts) │
│ 趋势折线图 | 应用占比饼图 | Top10 柱状图 | 检测 QPS 实时曲线 │
│ 规则堆叠柱状图 | 阈值雷达图 | 活跃状态饼图 | 规则访问分布 │
└─────────────────────────────────────────────────────────────────────┘
三、技术选型
| 技术 | 版本 | 用途 |
|---|---|---|
| Java | 17 | 开发语言 |
| Spring Boot | 3.5.3 | 应用框架 |
| MyBatis-Plus | 3.5.17 | ORM 持久层 |
| Spring AOP | — | 方法拦截与注解增强 |
| Spring SpEL | — | 动态表达式解析提取 Key 值 |
| Redis (Lettuce) | — | 高速数据中转 + Lua 原子操作 |
| MySQL | 5.7+ / 8.0 | 热Key记录与统计数据持久化 |
| Maven | 3.8+ | 多模块构建与依赖管理 |
| ECharts | — | 前端可视化图表 |
四、项目模块结构
hotkey-platform/
├── pom.xml # 父 POM(依赖版本统一管理)
├── hotkey-spring-boot-starter/ # SDK 模块(业务系统引入)
│ ├── annotation/HotKey.java # @HotKey 注解定义
│ ├── aspect/HotKeyAspect.java # AOP 切面拦截 + SpEL 解析
│ ├── collector/BatchCollector.java # 双缓冲批量收集器
│ ├── reporter/RedisReporter.java # 定时 Lua 脚本上报
│ ├── registry/HotKeyRegistry.java # 启动时自动注册应用与规则
│ └── autoconfigure/ # Spring Boot 自动装配
├── hotkey-server/ # 监控平台服务端
│ ├── controller/ # REST API(热Key/规则/应用/认证)
│ ├── service/DetectorService.java # 嗅探检测 + Pipeline 批量判定
│ ├── service/StatsService.java # 统计查询 + 分页 + 过期清理
│ ├── service/AppService.java # 应用管理
│ ├── service/RuleService.java # 规则管理
│ ├── entity/ # 数据实体
│ ├── mapper/ # MyBatis-Plus Mapper
│ └── static/ # 前端(index.html + app.js + ECharts)
├── hotkey-demo-student/ # 演示-学生系统(:8090)
│ ├── controller/StudentController # 提供 /sweep、/batch 等压测接口
│ └── service/StudentService # @HotKey 标记的业务方法
└── hotkey-demo-mall/ # 演示-商城系统(:8091)
├── controller/MallController # 同上
└── service/MallService # 商品/用户/订单/库存查询
五、数据流转全链路
一条热Key从产生到最终冷却,经历以下 8 个阶段:
① 方法调用 → ② AOP拦截 → ③ SpEL提取Key → ④ 双Map本地聚合
→ ⑤ Lua脚本写入Redis → ⑥ 服务端定时扫描判定 → ⑦ MySQL持久化
→ ⑧ 热Key冷却与过期清理
完整数据流图:
业务系统 JVM
┌──────────────────────────────────────────────────────────────┐
│ │
│ Controller → Service │
│ │ │
│ ┌───────▼────────┐ │
│ │ @HotKey 注解 │ ① 方法被调用 │
│ │ rule="student" │ │
│ │ key="#id" │ │
│ └───────┬────────┘ │
│ │ │
│ ┌───────▼────────┐ │
│ │ HotKeyAspect │ ② AOP 拦截 │
│ │ SpEL 解析 │ ③ 提取 keyValue = "42" │
│ └───────┬────────┘ │
│ │ │
│ ┌───────▼────────┐ │
│ │ BatchCollector │ ④ 双Map + LongAdder 聚合 │
│ │ map0 / map1 │ "app:student:42" → count=15 │
│ └───────┬────────┘ │
│ │ 每 500ms 轮转 │
│ ┌───────▼────────┐ │
│ │ RedisReporter │ ⑤ Lua 原子写入 ZSet │
│ │ 定时上报 │ ZADD hotkey:app:student:42 │
│ └───────┬────────┘ │
└──────────────────┼───────────────────────────────────────────┘
│
┌──────▼──────┐
│ Redis │ ZSet 滑动窗口
│ ZSet 存储 │ score = 时间戳, member = 唯一标识
└──────┬──────┘
│ 每 2 秒扫描
┌──────────────────┼───────────────────────────────────────────┐
│ ┌───────▼────────┐ │
│ │ DetectorService │ ⑥ 加载活跃规则 │
│ │ SCAN + Pipeline│ 匹配 Redis Key 模式 │
│ │ Lua 计数判定 │ count >= threshold → 热Key! │
│ └───────┬────────┘ │
│ │ │
│ ┌───────▼────────┐ │
│ │ MySQL 持久化 │ ⑦ hotkey_record(热Key记录) │
│ │ │ hotkey_stats (分钟级统计) │
│ └───────┬────────┘ │
│ │ │
│ ┌───────▼────────┐ │
│ │ 多重清理机制 │ ⑧ 冷却标记 + 记录删除 │
│ │ │ Redis Key TTL 自动过期 │
│ └────────────────┘ │
│ │
│ hotkey-server │
└──────────────────────────────────────────────────────────────┘
六、SDK 模块深度解析
SDK 模块(hotkey-spring-boot-starter)是整个系统的入口,嵌入在业务系统 JVM 中运行。它负责拦截方法调用、提取 Key 值、本地聚合、批量上报到 Redis。
6.1 @HotKey 注解 — 声明式监控入口
@Target(ElementType.METHOD)
@Retention(RetentionPolicy.RUNTIME)
public @interface HotKey {
/** 规则名称(与控制台配置的规则匹配) */
String rule();
/** SpEL 表达式,用于从方法参数中提取被监控的 Key */
String key();
}
使用示例:
@Service
public class StudentService {
@HotKey(rule = "student", key = "#id")
public Student getStudentById(Long id) {
// 业务逻辑...
}
@HotKey(rule = "student", key = "#name")
public Student getStudentByName(String name) {
// 业务逻辑...
}
}
设计要点:
- 注解标注在
ElementType.METHOD上,保留策略为RUNTIME,确保 AOP 能在运行时通过反射读取 rule属性将方法调用与规则配置绑定,一个规则可以对应多个方法key属性使用 SpEL 表达式,支持从任意参数名、嵌套属性中提取值
6.2 HotKeyAspect — AOP 切面拦截
当业务方法被调用时,Spring AOP 的 @Around 通知拦截执行流程:
@Aspect
@Slf4j
@RequiredArgsConstructor
public class HotKeyAspect {
private final BatchCollector collector;
private final ExpressionParser parser = new SpelExpressionParser();
private final ParameterNameDiscoverer nameDiscoverer = new DefaultParameterNameDiscoverer();
@Value("${hotkey.app-name:default-app}")
private String appName;
@Around("@annotation(hotKey)")
public Object intercept(ProceedingJoinPoint joinPoint, HotKey hotKey) throws Throwable {
try {
String keyValue = extractKey(joinPoint, hotKey.key());
if (keyValue != null && !keyValue.isEmpty()) {
collector.collect(appName, hotKey.rule(), keyValue);
}
} catch (Exception e) {
log.warn("[热Key SDK] 切面异常: {}", e.getMessage());
}
return joinPoint.proceed(); // 无论采集是否成功,都执行业务方法
}
}
关键设计原则:
- 零侵入:采集逻辑包裹在 try-catch 中,任何异常只打 warn 日志,不影响业务方法执行
- 先采集后执行:在
joinPoint.proceed()之前采集,确保即使方法抛异常也能记录本次调用 - 异步无阻塞:
collector.collect()仅写入内存 Map,耗时为 O(1)
6.3 SpEL 表达式解析
SpEL 解析过程将注解中的表达式(如 "#id")结合方法实际参数,计算出具体的 Key 值:
注解: @HotKey(rule = "student", key = "#id")
方法: getStudentById(Long id)
调用: getStudentById(42L)
SpEL 解析过程:
1. 获取方法参数名: ["id"]
2. 获取方法参数值: [42]
3. 构建 EvaluationContext,设置变量: #id = 42
4. 解析表达式 "#id" → 得到值 42
5. toString() → keyValue = "42"
支持的 SpEL 表达式示例:
| 表达式 | 说明 | 示例值 |
|---|---|---|
#id |
直接取参数值 | "42" |
#name |
取字符串参数 | "张三" |
#user.id |
取对象嵌套属性 | "100" |
#request.getHeader('X-Id') |
调用方法 | "abc" |
注意:Maven 编译时必须开启
,否则运行时无法获取参数名。true
6.4 BatchCollector — 双缓冲批量聚合(核心性能设计)
这是整个 SDK 中最关键的性能组件,采用双缓冲 Map 轮转模式(借鉴京东 hotkey 项目),实现写入线程和上报线程之间的零阻塞。
public class BatchCollector {
private final ConcurrentHashMap map0 = new ConcurrentHashMap<>(256);
private final ConcurrentHashMap map1 = new ConcurrentHashMap<>(256);
private final AtomicLong turn = new AtomicLong(0);
public void collect(String appName, String rule, String keyValue) {
String fullKey = appName + ":" + rule + ":" + keyValue;
ConcurrentHashMap activeMap =
(turn.get() % 2 == 0) ? map0 : map1;
activeMap.computeIfAbsent(fullKey, k -> new LongAdder()).increment();
}
public List lockAndGetKeys() {
long current = turn.incrementAndGet();
ConcurrentHashMap readMap =
(current % 2 == 0) ? map0 : map1;
List keys = new ArrayList<>(readMap.size());
for (Map.Entry entry : readMap.entrySet()) {
int count = (int) entry.getValue().sum();
keys.add(entry.getKey() + ":" + count);
}
readMap.clear();
return keys;
}
}
轮转时序图:
时间轴 ──────────────────────────────────────────────→
业务线程: 写map0 写map0 写map0 写map1 写map1
│ │ │ │ │
▼ ▼ ▼ ▼ ▼
┌───────┬───────┬───────┐ ┌───────┬───────┐
map0 │ +1+1+1│ +1 │ +1 │清0 │ │ │ ...
└───────┴───────┴───────┘ └───────┴───────┘
┌───────┬───────┬───────┐ ┌───────┬───────┐
map1 │ │ │ │清0│ +1+1 │ +1 │ ...
└───────┴───────┴───────┘ └───────┴───────┘
▲
上报线程: │
──── 500ms ──── [lockAndGetKeys] ──── 500ms ────
读map0, 清空map0 读map1, 清空map1
为什么用 LongAdder 而不是 AtomicLong?
| 对比项 | AtomicLong | LongAdder |
|---|---|---|
| 高并发写入 | 所有线程 CAS 竞争同一个 value | 内部 Cell 数组分散竞争 |
| 性能 | 高并发下 CPU 空转严重 | 接近线性扩展 |
| 读取 | 直接读 value | 需 sum() 遍历 Cell(仅在上报时调用,频率低) |
| 适用场景 | 低并发 | 高并发写入、低频率读取(完美匹配本场景) |
6.5 RedisReporter — Lua 脚本原子写入
RedisReporter 内部启动一个 ScheduledExecutorService,每隔 reportPeriodMs(默认 500ms)执行一次上报:
private void report() {
List keys = collector.lockAndGetKeys();
long now = System.currentTimeMillis();
for (String entry : keys) {
int lastColon = entry.lastIndexOf(':');
String fullKey = entry.substring(0, lastColon);
int count = Integer.parseInt(entry.substring(lastColon + 1));
String redisKey = "hotkey:" + fullKey;
String member = now + "-" + Thread.currentThread().getId();
redisTemplate.execute(redisScript,
Collections.singletonList(redisKey),
String.valueOf(windowMs), String.valueOf(now), member, String.valueOf(count));
}
}
Lua 脚本详解:
-- KEYS[1]: Redis Key,格式为 "hotkey:{appName}:{ruleKey}:{keyValue}"
-- ARGV[1]: 滑动窗口大小(毫秒)
-- ARGV[2]: 当前时间戳(毫秒)
-- ARGV[3]: 成员标识(时间戳-线程ID)
-- ARGV[4]: 本批次的访问计数
-- 第一步:移除滑动窗口之外的过期成员
redis.call('ZREMRANGEBYSCORE', redisKey, 0, now - window)
-- 第二步:将本次计数展开为多条记录写入 ZSet
for i = 1, count do
redis.call('ZADD', redisKey, now, member .. '-' .. i)
end
-- 第三步:设置 Key 的 TTL 为窗口的 2 倍
redis.call('PEXPIRE', redisKey, window * 2)
-- 第四步:返回当前窗口内的总成员数
return redis.call('ZCARD', redisKey)
Redis ZSet 数据结构示意:
Key: hotkey:student-management:student:42
ZSet 内容(score = 写入时间戳):
┌──────────────────────┬─────────────────┐
│ member │ score │
├──────────────────────┼─────────────────┤
│ 1726000000000-14-1 │ 1726000000000 │
│ 1726000000000-14-2 │ 1726000000000 │
│ 1726000000500-14-1 │ 1726000000500 │
│ 1726000000500-14-2 │ 1726000000500 │
│ 1726000000500-14-3 │ 1726000000500 │
└──────────────────────┴─────────────────┘
ZCARD = 窗口内所有成员数 = 该 Key 在窗口内的总访问次数
6.6 HotKeyRegistry — 应用自动注册
应用启动时,HotKeyRegistry 监听 ApplicationReadyEvent,自动扫描所有 @HotKey 注解,将应用信息和规则写入 Redis:
@EventListener(ApplicationReadyEvent.class)
public void registerOnStartup() {
// 扫描所有 Bean 中带 @HotKey 注解的方法,收集规则名
Set rules = new HashSet<>();
for (Object bean : allBeans.values()) {
Class targetClass = AopUtils.getTargetClass(bean); // 穿透 CGLIB 代理
for (Method method : targetClass.getDeclaredMethods()) {
HotKey hotKey = method.getAnnotation(HotKey.class);
if (hotKey != null) rules.add(hotKey.rule());
}
}
// 写入 Redis 注册键
String registryKey = "hotkey:registry:" + appName;
String registryValue = ruleList + ";windowMs=" + windowMs + ",threshold=" + threshold;
redisTemplate.opsForValue().set(registryKey, registryValue, 24, TimeUnit.HOURS);
}
Redis 注册键格式:
Key: hotkey:registry:{appName}
Value: student,score;windowMs=60000,threshold=20
TTL: 24 小时
关键细节:使用
AopUtils.getTargetClass()穿透 CGLIB 代理,确保能读到原始类上的@HotKey注解。代理类的方法上没有注解,必须拿到目标类。
6.7 自动装配机制
业务系统只需引入一个依赖,Spring Boot 自动完成所有装配:
Spring Boot 启动
│
▼
读取 META-INF/spring/
org.springframework.boot.autoconfigure.AutoConfiguration.imports
│
▼
加载 HotKeyAutoConfiguration
│
├── @EnableConfigurationProperties(HotKeyProperties.class)
│ → 绑定 application.yml 中 hotkey.* 配置
│
├── @ConditionalOnClass(StringRedisTemplate.class)
│ → 仅当 classpath 有 Redis 依赖时激活
│
├── @Bean BatchCollector → 创建双Map收集器
├── @Bean HotKeyAspect → 注册 AOP 切面
├── @Bean RedisReporter → 启动定时上报线程
└── @Bean HotKeyRegistry → 注册启动监听器
配置项说明:
hotkey:
app-name: student-management # 应用名称,必须与控制台注册的一致
window-ms: 60000 # 滑动窗口大小(毫秒),默认 60 秒
threshold: 20 # 热Key判定阈值
report-period-ms: 500 # 批量上报周期(毫秒),默认 500ms
七、Server 服务端深度解析
hotkey-server 是独立部署的监控服务端,负责定时扫描 Redis、判定热Key、持久化到 MySQL、提供 REST API 和前端控制台。
7.1 DetectorService — 热Key嗅探引擎
这是服务端的核心组件,承载了 5 个定时任务:
| 任务 | 频率 | 职责 |
|---|---|---|
scanHotKeys() |
每 2 秒 | SCAN + Pipeline 嗅探热Key,写入 MySQL |
autoRegisterAppsAndRules() |
每 30 秒 | 扫描 Redis 注册键,自动创建 App/Rule |
coolExpiredKeys() |
每 10 秒 | 将超时未活跃的热Key标记为已冷却 |
cleanCooledRecords() |
每 2 分钟 | 删除冷却超过 30 分钟的记录 |
cleanExpiredStats() |
每 5 分钟 | 删除超过 30 分钟的分钟级统计数据 |
嗅探扫描流程
┌─────────────────────────────────────────────────────────────┐
│ DetectorService.scanHotKeys() │
│ 每 2 秒执行一次 │
└──────────────────────────┬──────────────────────────────────┘
│
┌────────────▼────────────┐
│ 1. 加载所有活跃规则 │
│ 过滤已下线应用 │
└────────────┬────────────┘
│
┌────────────▼────────────┐
│ 2. 对每条规则用 SCAN │
│ 遍历匹配的 Redis key │
│ pattern: hotkey:app: │
│ rule:* │
│ count: 1000 │
└────────────┬────────────┘
│
┌────────────▼────────────┐
│ 3. 每 500 个 key 一批 │
│ Pipeline 批量执行 │
│ Lua 计数脚本 │
└────────────┬────────────┘
│
┌────────┴────────┐
│ │
count >= threshold count < threshold
│ │
┌────────▼────────┐ 跳过
│ 判定为热Key! │
│ markAsHot() │
└─────────────────┘
Pipeline 批量执行(性能关键):
private List executePipelineBatch(List batch, long windowMs, long now) {
List 原文地址: https://www.cveoy.top/t/topic/qHyL 著作权归作者所有。请勿转载和采集!