需求分析
随着面试刷题平台用户访问量增长,系统性能和稳定性面临更高要求。为减少页面与题目加载时间、降低数据库压力,需通过缓存优化高频访问数据,但核心痛点在于热点数据的不可预判性。

缓存的核心问题是 "如何确定缓存数据":
- 可预判的热点(如重点推广的题库 / 题目),通过 "缓存预热" 人工提前缓存;
- 不可预判的突发热点(如题目被推广或攻击导致访问暴增),若未及时缓存,瞬时高流量可能拖垮系统 ------ 这就是 "热点问题"。
企业级项目中,热点靠人工设置根本来不及,此时系统需要 自动发现 热点,将其做多级缓存来顶住大流量访问的压力。那么回归到本项目的需求,我们希望自动检测并缓存热门数据。
方案设计
自动缓存热门题库需要以下五个步骤:
-
记录访问:用户每访问一次题库,统计次数 +1
-
访问统计:统计一段时间内题库的访问次数,这是最难实现的一部分
-
阈值判断:访问频率超过一定的阈值,变为热点数据
-
缓存数据:缓存热点数据
-
获取数据:后续访问时,从缓存中获取数据
还有其他难点,如热点数据如何更新,如何恢复为正常数据等等。这些都可以基于一个企业级热点 key 探测框架**京东 hotkey**来实现自动缓存热门题库。
hotkey 简介
京东 hotkey 是一款轻量级、高可用的热 key 探测中间件,历经京东 618、双 11 大促实战考验。
核心优势:
-
高性能:8 核 8G 单机每秒可处理 16 万个待测 key,16 核机器达 30 万 +,10 台集群可支撑每秒近 300 万次探测;
-
实战验证:每日探测数十亿 key,能毫秒级识别热门数据并推送,精准捕获爬虫、刷子用户;
-
价值显著:大幅降低数据层查询压力,提升应用性能,从容应对大促高压场景。
这是一个真正经历过实战的高性能热点 key 探测框架,整体架构如下:

四大核心组件介绍
1.Etcd 集群:核心配置与注册中心负责存储系统配置(如热点判定、缓存规则)、组件注册(client/worker 节点注册)及状态同步,保证分布式环境下各组件信息一致,是整个框架的中枢。
2.Client 端 Jar 包:数据采集与缓存执行器集成在业务应用中,负责采集本地 key 访问日志,并将数据异步上报至 worker 集群;同时接收 worker 推送的热点 key,触发本地缓存(如 Caffeine)存储,是 "数据入口" 与 "缓存落地载体"。
3.Worker 端集群:热点计算核心接收所有 client 上报的访问数据,通过预设规则(如 5 秒访问≥10 次)实时计算热点 key;识别出热点后,将结果推送至对应 client 端,同时反馈至 dashboard;集群部署保证高吞吐与高可用,是 "热点识别大脑"。
4. Dashboard 控制台:可视化运维平台提供可视化界面,支持配置管理(阈值、规则)、热点监控(实时热点 key、访问量统计)、节点状态查看(client/worker 运行状态)及告警配置,是 "运维操作入口"。
hotkey 后端开发
1. 安装 Etcd

执行 etcd 脚本后,可以启动 etcd 服务,服务默认占用 2379 和 2380 端口,作用分别如下:
- 2379:提供 HTTP API 服务,和 etcdctl 交互
- 2380:集群中节点间通讯
2. 安装 hotkey worker
从 hotkey 官方仓库 下载源码,项目导入 IDEA 后,打开 worker 模块。worker 是一个 Spring Boot 项目,启动前需要先修改 applicaiton.yml 中的配置。比如端口配置:

修改完配置后,直接启动即可。
如图,此时 worker 就已经正常启动,并且连接上 Etcd 了:

后续如果要打包部署,可通过 Maven 打包得到 worker 的 jar 包,如在整个 hotkey 项目根目录执行 mvn package,会依次对各模块打包。
然后可以通过命令启动 worker,可以携带参数来修改配置:
bash
java -jar worker-0.0.4-SNAPSHOT.jar --etcd.server=127.0.0.1:2379
3. 启动 hotkey 控制台
接着打开 dashboard 项目,执行 resource 目录下的 db.sql 文件,创建 dashboard 所需的库表。hotkey 依赖 MySQL 来存储用户账号信息、热点阈值规则等。
在执行脚本前,记得先配置好 MySQL 连接,并且在 SQL 脚本文件中创建和指定数据库:
bash
create database hotkey_db; use hotkey_db;
然后修改下 application.yml 配置文件,包括 dashboard 占用端口号(本教程使用 8121)、数据库配置和 etcdServer 地址等
java
server:
port: 8121
spring:
datasource:
username: ${MYSQL_USER:root}
password: ${MYSQL_PASS:123456}
url: jdbc:mysql://${MYSQL_HOST:localhost}:3306/hotkey_db?useUnicode=true&characterEncoding=utf-8&useSSL=true&serverTimezone=UTC&useTimezone=true&serverTimezone=GMT
driver-class-name: com.mysql.cj.jdbc.Driver
etcd:
server: ${etcdServer:http://127.0.0.1:2379}
访问 http://127.0.0.1:8121,即可看到界面:

输入管理员的账号密码(admin:123456)后,即可成功登录:

初次使用时需要先添加 APP。建议先在用户管理菜单中,添加一个新用户,设置昵称为 APP 名称、并填写所属 APP,密码此处就设置为 123456。之后就可以登录这个新建的用户来给应用设置规则了 (当然也可以使用 admin 账户添加),而且系统会自动创建一个 APP。

随后,在规则配置中,选择对应的 APP,新增对应的热点探测规则:

如下图就是一组规则:

核心规则解析
| 字段名 | 取值 | 规则含义 |
|---|---|---|
| duration | 600 | 缓存有效时长:热点 key 被识别后,本地缓存(如 Caffeine)保留 600 秒(10 分钟)。 |
| key | "bank_detail_" | 目标 key 前缀:仅对 "以 bank_detail_ 开头" 的 key 生效(如 bank_detail_123、bank_detail_456,对应不同题库 ID)。 |
| prefix | true | 前缀匹配开关:true 表示按上述 key 前缀匹配,false 则表示精确匹配完整 key。 |
| interval | 5 | 统计周期:每 5 秒为一个窗口,统计该时间段内的 key 访问次数。 |
| threshold | 10 | 热点阈值:一个统计周期(5 秒)内,某 key(或前缀 key)访问次数 ≥10 次,即判定为热点。 |
| desc | "热门题库缓存" | 规则描述:标注该规则用途,方便运维识别。 |
4. 引入 hotkey client
手动将 hotkey 源码中的 client 模块通过 Maven 打成 jar 包:

从生成的 target 中找到 with-dependencies 的 jar 包,可以修改名称为 hotkey-client-0.0.4-SNAPSHOT.jar:

也可以直接下载已经打包好的 jar,本教程为大家提供了软件包:https://pan.baidu.com/s/1u73-Nlolrs8Rzb1_b6X6HA ,提取码:c2sd8JJn6xdn3s/k8FDwXGbD8cnuAHza5d8eV1bgQYRlYsY=
接着在要引入 hotkey client 的项目中创建 lib 文件夹,放入 client 的 jar 包。注意要把该 jar 包添加到 Git 仓库中,否则其他人无法正常运行你的项目。
然后通过 Maven 引入即可:
XML
<dependency>
<artifactId>hotkey-client</artifactId>
<groupId>com.jd.platform.hotkey</groupId>
<version>0.0.4-SNAPSHOT</version>
<scope>system</scope>
<systemPath>${project.basedir}/lib/hotkey-client-0.0.4-SNAPSHOT.jar</systemPath>
</dependency>
引入依赖后,在代码中编写初始化 client 的配置类,会读取配置文件并执行初始化逻辑:

5. 了解开发模式
只要使用 JdHotKeyStore 这个类即可判断 key 是否成为热点和获取热点 key 对应的本地缓存。
这个类主要有如下 4 个方法可供使用:
java
boolean JdHotKeyStore.isHotKey(String key)
Object JdHotKeyStore.get(String key)
void JdHotKeyStore.smartSet(String key, Object value)
Object JdHotKeyStore.getValue(String key)
- boolean isHotKey(String key)
该方法会返回该 key 是否是热 key,如果是则返回 true,如果不是则返回 false,并且会将 key 上报到探测集群进行数量计算。该方法通常用于判断只需要判断 key 是否热,不需要缓存 value 的场景,如刷子用户、接口访问频率等。
- Object get(String key)
该方法返回该 key 本地缓存的value值,可用于判断是热 key 后,再去获取本地缓存的 value 值。
- void smartSet(String key, Object value)
方法给热 key 赋值 value,如果是热 key,该方法才会赋值,非热 key,不作反应
- Object getValue(String key)
该方法是一个整合方法,相当于 isHotKey 和 get 两个方法的整合,该方法直接返回本地缓存的 value。
如果是热 key:
- 若本地缓存中已经通过
set方法存了真实值(比如题库详情),就返回这个值(非 null)。 - 若还没调用
set存值(刚判定为热 key,还没来得及缓存数据),就返回 null。
如果不是热 key:
- 直接返回 null,同时自动把这个 key 上报给 Hotkey 的 worker 集群,让集群统计它的访问次数(用于后续判断是否会成为热 key)。
官方推荐的最佳实践
1)判断用户是否是刷子:
java
if (JdHotKeyStore.isHotKey("pin__" + thePin)) {
// 进行限流
}
2)判断商品 id 是否是热点:
java
Object skuInfo = JdHotKeyStore.getValue("skuId__" + skuId);
if(skuInfo == null) {
JdHotKeyStore.smartSet("skuId__" + skuId, theSkuInfo);
} else {
// 使用缓存好的 value 即可
}
个人更推荐这种写法,更加清晰:
java
if (JdHotKeyStore.isHotKey(key)) {
//注意是get,不是getValue。getValue会获取并上报,get是纯粹的本地获取
Object skuInfo = JdHotKeyStore.get("skuId__" + skuId);
if(skuInfo == null) {
JdHotKeyStore.smartSet("skuId__" + skuId, theSkuInfo);
} else {
//使用缓存好的value即可
}
}
6. 配置 hotkey 规则
根据我们的需求,判断 bank_detail_ 开头的 key,如果 5 秒访问 10 次,就会被推送到 jvm 内存中,将这个热 key 缓存 10 分钟。
对应的规则配置如下:
java
[
{
"duration": 600,
"key": "bank_detail_",
"prefix": true,
"interval": 5,
"threshold": 10,
"desc": "热门题库缓存"
}
]
在控制台新增规则:

7. 项目应用 hotkey
获取题库接口 getQuestionBankVOById,先通过 isHotKey 判断当前题目是否是热点题目,如果是,则从数据库获取后放入本地缓存;之后直接从本地缓存获取即可。
java
@GetMapping("/get/vo")
public BaseResponse<QuestionBankVO> getQuestionBankVOById(QuestionBankQueryRequest questionBankQueryRequest, HttpServletRequest request) {
ThrowUtils.throwIf(questionBankQueryRequest == null, ErrorCode.PARAMS_ERROR);
Long id = questionBankQueryRequest.getId();
ThrowUtils.throwIf(id <= 0, ErrorCode.PARAMS_ERROR);
// 生成 key
String key = "bank_detail_" + id;
// 如果是热 key
if (JdHotKeyStore.isHotKey(key)) {
// 从本地缓存中获取缓存值
Object cachedQuestionBankVO = JdHotKeyStore.get(key);
if (cachedQuestionBankVO != null) {
// 如果缓存中有值,直接返回缓存的值
return ResultUtils.success((QuestionBankVO) cachedQuestionBankVO);
}
}
// 原本查询数据的逻辑(查数据库)
// 设置本地缓存
JdHotKeyStore.smartSet(key, questionBankVO);
// 获取封装类
return ResultUtils.success(questionBankVO);
}