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

缓存的核心问题是 "如何确定缓存数据":
- 可预判的热点(如重点推广的题库 / 题目),通过 “缓存预热” 人工提前缓存;
- 不可预判的突发热点(如题目被推广或攻击导致访问暴增),若未及时缓存,瞬时高流量可能拖垮系统 —— 这就是 "热点问题"。
企业级项目中,热点靠人工设置根本来不及,此时系统需要 自动发现 热点,将其做多级缓存来顶住大流量访问的压力。那么回归到本项目的需求,我们希望自动检测并缓存热门数据。
方案设计
自动缓存热门题库需要以下五个步骤:
1. 记录访问:用户每访问一次题库,统计次数 +1
2. 访问统计:统计一段时间内题库的访问次数,这是最难实现的一部分
3. 阈值判断:访问频率超过一定的阈值,变为热点数据
4. 缓存数据:缓存热点数据
5. 获取数据:后续访问时,从缓存中获取数据
还有其他难点,如热点数据如何更新,如何恢复为正常数据等等。这些都可以基于一个企业级热点 key 探测框架 京东 hotkey 来实现自动缓存热门题库。
hotkey 简介
京东 hotkey 是一款轻量级、高可用的热 key 探测中间件,历经京东 618、双 11 大促实战考验。
核心优势:
1. 高性能:8 核 8G 单机每秒可处理 16 万个待测 key,16 核机器达 30 万 +,10 台集群可支撑每秒近 300 万次探测;
2. 实战验证:每日探测数十亿 key,能毫秒级识别热门数据并推送,精准捕获爬虫、刷子用户;
3. 价值显著:大幅降低数据层查询压力,提升应用性能,从容应对大促高压场景。
这是一个真正经历过实战的高性能热点 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,可以携带参数来修改配置:
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 脚本文件中创建和指定数据库:
create database hotkey_db; use hotkey_db;
然后修改下 application.yml 配置文件,包括 dashboard 占用端口号(本教程使用 8121)、数据库配置和 etcdServer 地址等
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=GMTdriver-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 引入即可:
<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 个方法可供使用:
boolean JdHotKeyStore.isHotKey(String key)
Object JdHotKeyStore.get(String key)
void JdHotKeyStore.smartSet(String key, Object value)
Object JdHotKeyStore.getValue(String key)
1. boolean isHotKey(String key)
该方法会返回该 key 是否是热 key,如果是则返回 true,如果不是则返回 false,并且会将 key 上报到探测集群进行数量计算。该方法通常用于判断只需要判断 key 是否热,不需要缓存 value 的场景,如刷子用户、接口访问频率等。
2. Object get(String key)
该方法返回该 key 本地缓存的value值,可用于判断是热 key 后,再去获取本地缓存的 value 值。
3. void smartSet(String key, Object value)
方法给热 key 赋值 value,如果是热 key,该方法才会赋值,非热 key,不作反应
4. Object getValue(String key)
该方法是一个整合方法,相当于 isHotKey 和 get 两个方法的整合,该方法直接返回本地缓存的 value。
如果是热 key:
- 若本地缓存中已经通过
set方法存了真实值(比如题库详情),就返回这个值(非 null)。 - 若还没调用
set存值(刚判定为热 key,还没来得及缓存数据),就返回 null。
如果不是热 key:
- 直接返回 null,同时自动把这个 key 上报给 Hotkey 的 worker 集群,让集群统计它的访问次数(用于后续判断是否会成为热 key)。
官方推荐的最佳实践
1)判断用户是否是刷子:
if (JdHotKeyStore.isHotKey(“pin__” + thePin)) {// 进行限流
}
2)判断商品 id 是否是热点:
Object skuInfo = JdHotKeyStore.getValue("skuId__" + skuId);
if(skuInfo == null) {JdHotKeyStore.smartSet("skuId__" + skuId, theSkuInfo);
} else {// 使用缓存好的 value 即可
}
个人更推荐这种写法,更加清晰:
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 分钟。
对应的规则配置如下:
[{"duration": 600,"key": "bank_detail_","prefix": true,"interval": 5,"threshold": 10,"desc": "热门题库缓存"}
]
在控制台新增规则:

7. 项目应用 hotkey
获取题库接口 getQuestionBankVOById,先通过 isHotKey 判断当前题目是否是热点题目,如果是,则从数据库获取后放入本地缓存;之后直接从本地缓存获取即可。
@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);// 生成 keyString key = "bank_detail_" + id;// 如果是热 keyif (JdHotKeyStore.isHotKey(key)) {// 从本地缓存中获取缓存值Object cachedQuestionBankVO = JdHotKeyStore.get(key);if (cachedQuestionBankVO != null) {// 如果缓存中有值,直接返回缓存的值return ResultUtils.success((QuestionBankVO) cachedQuestionBankVO);}}// 原本查询数据的逻辑(查数据库)// 设置本地缓存JdHotKeyStore.smartSet(key, questionBankVO);// 获取封装类return ResultUtils.success(questionBankVO);
}
