基于京东:HotKey实现自动缓存热点Key!!!

一.引言

某些热点数据,我们提前如果能够预判到的话,可以提前人工给数据加缓存,也就是缓存预热,将其缓存在本地或者Redis中,提高访问性能同时,减低数据库压力,也减轻后端服务的压力。但是,有些时候,我们无法预料到哪些数据是热点,比如一个冷门数据,突然变成一个热点数据,没来得及缓存,突然被大量访问,系统不就故障了吗!?

因此,我们就需要帮助我们快速发现热点Key,并且自动缓存哪就不解决太多问题了嘛!!!因此京东的这个HotKey中间件就来了。

二.介绍

官网:JDHotKey

介绍:

对任意突发性的无法预先感知的热点数据,包括并不限于热点数据(如突发大量请求同一个商品)、热用户(如恶意爬虫刷子)、热接口(突发海量请求同一个接口)等,进行毫秒级精准探测到。然后对这些热数据、热用户等,推送到所有服务端JVM内存中,以大幅减轻对后端数据存储层的冲击,并可以由使用者决定如何分配、使用这些热key(譬如对热商品做本地缓存、对热用户进行拒绝访问、对热接口进行熔断或返回默认值)。这些热数据在整个服务端集群内保持一致性,并且业务隔离,worker端性能强悍。

京东APP后台热数据探测框架,历经多次高压压测和2020年京东618、双11大促考验。

在上线运行的这段时间内,每天探测的key数量数十亿计,精准捕获了大量爬虫、刷子用户,另准确探测大量热门商品并毫秒级推送到各个服务端内存,大幅降低了热数据对数据层的查询压力,提升了应用性能。

核心组件

它的主要核心组件如下:
1) Etcd 集群

Etcd 作为一个高性能的配置中心,可以以极小的资源占用,提供高效的监听订阅服务。主要用于存放规则配置,各 worker的 ip 地址,以及探测出的热 key、手工添加的热 key 等。Etcd 常用于配置中心和注册中心
2) client端iar包

就是在服务中添加的引用jar,引入后,就可以便捷地去判断某 key 是否热 key。同时,该 jar 完成了 key 上报、监听 Etcd

里的 rule 变化、worker 信息变化、热 key 变化,对热 key 进行本地 Caffeine 缓存等。
3) worker端集群

worker 端是一个独立部署的 Java 程序,启动后会连接 Etcd,并定期上报自己的 ip 信息,供 client 端获取地址并进行长连

接。之后,主要就是对各个 client 发来的待测 key 进行 累加计算,当达到 Etcd 里设定的 rule 阈值后,将热 key 推送到各

个client.
4) dashboard 控制台

控制台是一个带可视化界面的 Java 程序,也是连接到 Etcd,之后在控制台设置各个 APP 的 key 规则,譬如 2 秒出现,20次算热 key。然后当 worker 探测出来热 key 后,会将 key 发往 etcd,dashboard 也会监听热 key 信息,进行入库保存记录。同时,dashboard 也可以手工添加、删除热 key,供各个 client 端监听。

更详细的内容,可见京东技术团队 官方的文章,最具可信度。

三.安装

1. Etcd 安装:

在etcd下载页面下载对应操作系统的etcd,https://github.com/etcd-io/etcd/releases 使用3.4.x以上。

下载后解压压缩包,会得到3个脚本

  • etcd:etcd 服务本身
  • etcdctl:客户端,用于操作 etcd,比如读写数据
  • etcdutl:备份恢复工具

输入etcd 启动!!!

执行 etcd 脚本后,可以启动 etcd 服务,服务默认占用 2379 和 2380 端口,作用分别如下:

  • ·2379:提供 HTTP API服务,和 etcdct 交互
  • ·2380:集群中节点间通讯

2. worker安装

从 hotkey 官方仓库 下载源码 ->>>> 下载地址

注意::: JDK 的版本必须小于 17!否则会报找不到类,因为有些类jdk17已经弃用了!!!

的错误!

项目导入 IDEA后,打开 worker 模块。worker 是一个 Spring Boot 项目,启动前需要先修改 applicaiton.yml 中的配置。

比如端口配置( 8111)

修改完配置后,直接点击WorkerApplication 启动即可,

如下图,此时 worker 就已经正常启动,并且连接上 Etcd 了:

3. 启动 hotkey 控制台

接着打开 dashboard 项目,执行 resource 目录下的 db.sql文件,创建 dashboard 所需的库表。hotkey 依赖 MySQL

储用户账号信息、热点阈值规则等。

在执行脚本前,记得先配置好 MySQL 连接,并且在 SQL 脚本文件中创建和指定数据库

执行脚本过程如图

修改端口及数据库配置

server:
  port: 8182
spring:
  datasource:
    username: ${MYSQL_USER:root}
    password: ${MYSQL_PASS:1234}
    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}

dashboard 也是一个 SpringBoot 项目,直接在 IDEA 内执行 DashboardApplication 启动即可

访问 http://127.0.0.1:8182,端口就是你自己配置的 即可看到界面:

初始Sql的时候,会有一个默认账号: admin 密码: 123456 (加密了的) 登录:

初次使用时需要先添加 APP。建议先在用户管理菜单中,添加一个新用户,设置昵称为 APP 名称、并填写所属 APP,如

GCXF-App,密码此处就设置为 123456。之后就可以登录这个新建的用户来给应用设置规则了(当然也可以使用 admin 账

户添加),而且系统会自动创建一个 APP。

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

我这里的意思就是判断poem_开头的key,如果5秒内访问 如果5秒访问 10 次,就会被推送到jvm 内存中,将这个热 key 缓存3 分钟。

对应的规则配置如下:

java 复制代码
[
    {
        "key":"poem_",
        "prefix":true,
        "interval":5,
        "threshold":10,
        "duration":180,
        "desc":"搜索热点key"
    }
]
  • key:(*代表任意以key为前缀) 只要到时候client端上报这样子的key于这边相互匹配就可以完成统计
  • prefix:是否前缀,
  • interval:间隔时间(秒),
  • threshold:阈值,
  • duration-缓存时间(秒):默认60
  • desc:描述

4. 引入 hotkey client

有2 种引入 hotkey client 的方式:

1.手动源码打包

2.通过 Maven 远程仓库 引入

由于 Maven 远程仓库的包引用量过少,而且不具备官方权威性,所以更推荐通过 hotkey 源码手动打包。

所以选择方式 1,手动将 hotkey 源码中的 client 模块通过 Maven 打成 jar 包:

在我们引入的项目中新键一个lib文件,然后放入client的jar包

引入依赖;

java 复制代码
    <!-- hotkey -->
        <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 的配置类,会读取配置文件并执行初始化逻辑

java 复制代码
@Configuration
@ConfigurationProperties(prefix = "hotkey")
@Data
public class HotKeyConfig {

    /**
     * Etcd 服务器完整地址
     */
    private String etcdServer = "http://127.0.0.1:2379";

    /**
     * 应用名称
     */
    private String appName = "app";

    /**
     * 本地缓存最大数量
     */
    private int caffeineSize = 10000;

    /**
     * 批量推送 key 的间隔时间
     */
    private long pushPeriod = 1000L;

    /**
     * 初始化 hotkey
     */
    @Bean
    public void initHotkey() {
        ClientStarter.Builder builder = new ClientStarter.Builder();
        ClientStarter starter = builder.setAppName(appName)
                .setCaffeineSize(caffeineSize)
                .setPushPeriod(pushPeriod)
                .setEtcdServer(etcdServer)
                .build();
        starter.startPipeline();
    }
}

注:这里spring-boot不要太高,因为不允许是void的返回值的(也可以用提供的接口来解决),所有干脆调到3.0.2下就可以了

pom.xml

java 复制代码
# 热 key 探测
hotkey:
  app-name: GCXF-App
  caffeine-size: 10000
  push-period: 1000
  etcd-server: http://localhost:2379

app-name: GCXF-App: 这是一个键值对,其中app-name是键,GCXF-App是值。这表示应用程序的名称被设置为GCXF-App。这个值可能用于标识这个配置所属的应用程序。

caffeine-size: 10000: 这也是一个键值对,其中caffeine-size是键,10000是值。这个值可能代表某种缓存(可能是指Caffeine缓存库)的大小设置,单位可能是条目数、字节或其他,具体取决于上下文。Caffeine是一个高性能的Java缓存库。

push-period: 1000: 这是一个表示时间间隔的键值对,意味着这个操作每秒去Push一次 上报一次。

etcd-server: http://localhost:2379: 这个值指定了etcd服务器的地址和端口,客户端可以通过这个地址与etcd服务器进行通信。

这里的App-name与你先去在etcd中配置app必须一致

启动:可以看到有一个客户端链接了

OK,我们就可以使用了

四.使用

主要有如下4个方法可供使用

  1. boolean JdHotKeyStore.isHotKey(String key)
  2. Object JdHotKeyStore.get(String key)
  3. void JdHotKeyStore.smartSet(String key, Object value)
  4. 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值,通常用于redis热key缓存

3 void smartSet(String key, Object value),方法给热key赋值value,如果是热key,该方法才会赋值,非热key,什么也不做

4 Object getValue(String key),该方法是一个整合方法,相当于isHotKey和get两个方法的整合,该方法直接返回本地缓存的value。 如果是热key,则存在两种情况,1是返回value,2是返回null。返回null是因为尚未给它set真正的value,返回非null说明已经调用过set方法了,本地缓存value有值了。 如果不是热key,则返回null,并且将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即可
    }
}

代码测试:

java 复制代码
  

    @Autowired
    private HotKeyPoemMapper hotKeyPoemMapper;
    @Autowired
    private PoemNameMapper poemNameMapper;
    private RestHighLevelClient restHighLevelClient = new RestHighLevelClient(RestClient.builder(
            HttpHost.create("http://192.168.184.128:9200")
    ));

    /**
     * 查询所有古诗
     *
     * @param name
     * @return
     */
    @Override
    public PoemNameVO selectPoemByName(String name) {
        String key = "poem_" + name;

        // 判断是否是热搜

        if (JdHotKeyStore.isHotKey(key)) {
            // 获取缓存
            Object poemNameVO = JdHotKeyStore.get(key);
            if (poemNameVO != null) {
                return (PoemNameVO) poemNameVO;
            }
        }
        PoemName poenNmae = null;
        poenNmae = poemNameMapper.selectPoemByName(name);

        if (poenNmae == null) {
            throw new PoenException(MessageContast.POEM_ERROR);
        }
        PoemNameVO poemNameVO = new PoemNameVO();
        //获取注释
        List<poemExplain> poemExplains = poemNameMapper.selectBypoenID(Long.valueOf(poenNmae.getId()));
        //判断是否有该收藏古诗 1返回true null 返回fasle
        PoemNameVO poemNameVO1 = poemNameMapper.selectCollectByHeader(name);
        if (poemNameVO1 == null) {
            poemNameVO.setTrue(false);
        } else {
            poemNameVO.setTrue(true);
        }
        List<String> list = new ArrayList<>();
        //获取全文集合
        String[] split = poenNmae.getAllpoem().split("。");
        for (String s : split) {
            list.add(s);
        }
        poemNameVO.setAllpoem(list);
        list = new ArrayList<>();
        //获取背景集合
        String[] split1 = poenNmae.getPoemDrop().split("。");
        for (String s : split1) {
            list.add(s);
        }
        poemNameVO.setPoemDrops(list);
        //复制传参
        BeanUtils.copyProperties(poenNmae, poemNameVO);
        poemNameVO.setPoemExplain(poemExplains);

        if(JdHotKeyStore.isHotKey(key)){
            // 设置缓存
            JdHotKeyStore.smartSet(key, poemNameVO);
            //并且给热点key累加排行
            poemNameMapper.sumTheHotKey(name);
        }
    

        return poemNameVO;


    }

因为上面我们配置了5秒内访问10次就会变成热点Key,之后我们就会把这个数据存储到本地缓存中,下次访问的时候就会直接从本地缓存中去读取了,并不会在去查询数据库了。

我们测试一下

通过接口5秒内访问了10次后

然后可以看间 事实热点已经有了

当我们再次访问,就不会查询数据库了,都是通过本地缓存来查询,可以感觉到非常块!

源码;

1) 热 key 会自动续期吗?否则可能出现缓存雪崩的问题?

java 复制代码
  public static boolean isHotKey(String key) {
        try {
            if (!inRule(key)) {
                return false;
            } else {
                boolean isHot = isHot(key);
                if (!isHot) {
                    HotKeyPusher.push(key, (KeyType)null);
                } else {
                    ValueModel valueModel = getValueSimple(key);
                    if (isNearExpire(valueModel)) {
                        HotKeyPusher.push(key, (KeyType)null);
                    }
                }

                KeyHandlerFactory.getCounter().collect(new KeyHotModel(key, isHot));
                return isHot;
            }
        } catch (Exception var3) {
            return false;
        }
    }

    public static Object get(String key) {
        ValueModel value = getValueSimple(key);
        if (value == null) {
            return null;
        } else {
            Object object = value.getValue();
            return object instanceof Integer && Constant.MAGIC_NUMBER == (Integer)object ? null : object;
        }
    }

分析:

然后看下源码,就知道为什么了。源码中的逻辑是,首先会校验这个key是否在规则中,如果不是当然返回fasle,然后才判断是否是热点key,如果已经是热 key ,返回缓存值但是不会再 push,离过期还有2秒内的时候,会再次 push,这样这个 key 可能被继续设置为热 key。

也就是说,如果一个 key 持续被访问,很有可能在过期前一直被设置为热点,减少了出现雪崩问题的可能性。

2.)能够和 redis 分布式缓存结合

热 key 探测 = 热 key 发现 +本地缓存。可以只利用热 key 的判断方法,来给我们判断哪些是热Key,不利用热 key 的存储方法即可,通过换成redis存储也是可以

方法:

1.不是热 key,就查数据库。对于热 key,写缓存时,再判断一下是否为热 key,是热 key 才设置 Redis 分布式缓存。后续的热 key 就可以从分布式缓存中获取值。(缓存存储的技术或者位置变了)

2.利用热 key 探测的本地缓存,将原本査数据库的逻辑改为査 Redis,Redis 查不到才查询数据库,形成多级缓存。

3.) 如何更新本地缓存

需要有一个入口让缓存失效,进行人工干预。hotkey 提供了 JdHotKeystore.remove()方法,可以手动删除本地缓存并移除热点 key。

以利用控制台手动删除:

不过一般情况下,热点信息一般都是不太会变更的数据,过期时间设置短一点即可。

相关推荐
只因在人海中多看了你一眼10 分钟前
分布式缓存 + 数据存储 + 消息队列知识体系
分布式·缓存
Dlwyz1 小时前
redis-击穿、穿透、雪崩
数据库·redis·缓存
Oak Zhang6 小时前
sharding-jdbc自定义分片算法,表对应关系存储在mysql中,缓存到redis或者本地
redis·mysql·缓存
门牙咬脆骨7 小时前
【Redis】redis缓存击穿,缓存雪崩,缓存穿透
数据库·redis·缓存
门牙咬脆骨7 小时前
【Redis】GEO数据结构
数据库·redis·缓存
Dlwyz11 小时前
问题: redis-高并发场景下如何保证缓存数据与数据库的最终一致性
数据库·redis·缓存
吴半杯14 小时前
Redis-monitor安装与配置
数据库·redis·缓存
ö Constancy15 小时前
设计LRU缓存
c++·算法·缓存
小王码农记15 小时前
vue中路由缓存
前端·vue.js·缓存·typescript·anti-design-vue
会code的厨子15 小时前
Redis缓存高可用集群
redis·缓存