Redis应用—6.热key探测设计与实践

大纲

1.热key引发的巨大风险

2.以往热key问题怎么解决

3.热key进内存后的优势

4.热key探测关键指标

5.热key探测框架JdHotkey的简介

6.热key探测框架JdHotkey的组成

7.热key探测框架JdHotkey的工作流程

8.热key探测框架JdHotkey的性能表现

9.关于热key探测框架JdHotkey的一些问题

10.JdHotkey的安装部署与使用

1.热key引发的巨大风险

(1)数据层的风险

(2)服务层的风险

在拥有大量并发用户的系统中,热key一直以来都是一个不可避免的问题。某商品突然成爆款、海量用户突然涌入某店铺、秒杀瞬间的大量爬虫请求,这些突发的无法预知的热key都是系统潜在的巨大风险。风险是什么呢?主要是数据层,其次是服务层。

(1)数据层的风险

热key对数据层的冲击显而易见,譬如数据存放在Redis或者MySQL中。以Redis为例,那个未知的热数据会按照Hash规则存放在某个Redis分片上。

平时使用时都是从该分片获取它的数据,由于Redis高性能 + 集群模式,每秒假设该分片能支撑20万次读取,这足以支持大部分的日常使用了。

但是以京东为例的这些头部互联网公司,很容易出现某个爆品。爆品会瞬间引入每秒百万级的请求,当然流量多数会在几秒内就消失。但就是这短短的几秒热key,就会瞬间造成其所在Redis分片集群瘫痪。

原因很简单:Redis作为一个单线程的结构,所有的请求到来后都会去排队。当请求量远大于自身处理能力时,后面的请求就会陷入等待、超时。

由于该Redis分片完全被这个key的请求给打满,导致该分片上所有其他数据操作都无法继续提供服务。也就是热key不仅仅影响自己,还会影响和它合租的数据。很显然,在这个极短的时间窗口内,无法快速扩容10倍来支撑这个热点的。虽然Redis已经很优秀,但这种场景下,Redis却成为了最大的瓶颈。

(2)服务层的风险

热key对服务层的影响也不可小视。比如原本有1000台Tomcat,每台每秒能支撑1000QPS。假设数据层稳定、这样服务层每秒能承接100万个请求。

但是由于某个爆品的出现、或者由于大促优惠活动,突发大批机器人以远超正常用户的速度发起极其密集的请求,这些机器人轻易发出普通用户的百倍请求量,从而大幅挤占正常用户的资源。

原本能承接100万,现在来了150万,其中50万个是机器人请求。那么就导致了至少1/3的正常用户无法访问,带来较差的用户体验。

2.以往热key问题怎么解决

(1)Redis热key的解决方式

(2)刷子爬虫用户的解决方式

(3)限流的方式

下面分别以Redis的热key、刷子用户、限流等典型的场景来看。

(1)Redis热key的解决方式

这种场景的解决方式比较百花齐放,比较常见的有:

一.使用二级缓存

读取到Redis的key-value信息后,就直接写入到JVM缓存多一份。同时设置JVM缓存过期时间,设置淘汰策略譬如队列满时淘汰最先加入的。或者使用Guava Cache或Caffeine Cache进行单机本地缓存。但是这种做法普遍整体命中率偏低。

二.改写Redis源码加入热点探测功能

当Redis服务端发现有热key时就推送到JVM,但这种方法主要是不通用,而且有一定难度。

三.改写Jedis、Letture等Redis客户端的jar

通过本地计算来探测热点key,如果发现是热key那么就在本地缓存起来,然后通知集群内其他机器。

(2)刷子爬虫用户的解决方式

**方式一:**日常累积黑名单通过配置中心推送到JVM内存,但这种方法存在滞后无法实时感知的问题。

**方式二:**通过本地累加进行实时计算,单位时间内超过阈值的算刷子。如果服务器比较多,存在用户请求被分散,本地计算不能甄别刷子的问题。

**方式三:**引入其他组件如Redis,进行集中式累加计算,超过阈值的拉取到本地内存。问题就是需要频繁读写Redis,依旧存在Redis性能瓶颈问题。

(3)限流的方式

一.单机维度的限流多采用本地累加计数

二.集群维度的限流多采用第三方中间件如Sentinel

三.网关维度的限流多使用Nginx + Lua

(4)总结

综上,我们会发现虽然它们都可以归结到热key这个领域内。但是并没有一个统一的解决方案,我们更期望于有一个统一的框架,这个统一的框架能解决所有的需要对热key进行实时感知的场景。

最好是无论是什么key、是什么维度,只要拼接好这个字符串,把它交给框架去探测,并且设定好判定为热key的阈值(比如2秒该字符串出现20次)。那么在毫秒时间内,该热key就能进入到应用的JVM内存中,而且在整个服务集群内保持一致性,要么集群一起都有,要么一起没有。

3.热key进内存后的优势

热key问题归根到底就是如何找到热key,并将热key放到JVM内存的问题。

只要该key在内存里,我们就能极快地对它做逻辑,内存访问和Redis访问的速度不在一个量级。比如刷子用户,可以对其屏蔽、降级、限制访问速度。比如热接口,可以进行限流、返回默认值。比如Redis的热key,可以极大地提高访问速度。

以Redis访问key为例,可以很容易的计算出性能指标。譬如有1000台服务器,某key所在的Redis集群能支撑20万/s的访问。那么平均每台机器每秒大概能访问该key200次,超过的部分就会进入等待。由于Redis的瓶颈,将极大地限制Server的性能。

而如果该key是在本地内存中,读取一个内存中的值,每秒多少万次都是很正常的,不存在数据层的瓶颈。当然,如果通过增加Redis集群规模的形式,也能提升数据的访问上限。但问题是事先不知道热key在哪,而全量增加Redis的规模会大大增加成本。

4.热key探测关键指标

(1)实时性

(2)准确性

(3)集群一致性

(4)高性能

(1)实时性

这个很容易理解,key往往是突发性瞬间就热了,根本不允许手工去配置中心添加热key再推送到JVM。

热key大部分时间不可预知,来得非常迅速。可能某个商家上个活动,瞬间热key就出现了。如果短时间内没能进到内存,就有Redis集群被打爆的风险。

所以热key探测框架最重要的就是实时性,最好是某个key刚准备热,在1秒内它就已进到整个服务集群的内存里,1秒后就不会再去密集访问Redis了。

同理,对于刷子用户也一样,刚开始刷,1秒内就把它给禁掉了。

(2)准确性

这个很重要,也容易实现。累加数量,做到不误探,精准探测,保证探测出的热key是完全符合用户自己设定的阈值。

(3)集群一致性

这个比较重要,尤其是某些带删除key的场景,要能做到删key时整个集群内的该key都会删掉,以避免数据的错误。

(4)高性能

这个是核心之一,高性能带来的就是低成本。热key探测目的就是为了降低数据层负载,提升应用层性能,节省资源。理论上,在不影响实时性的情况下,要完成实时热key探测,所消耗的机器资源越少,那么经济价值就越大。

5.热key探测框架JdHotkey的简介

(1)热key探测框架JdHotkey的特点

(2)热key探测框架JdHotkey的使用

(3)热key探测框架JdHotkey的强实时性和高性能

(4)热key探测框架JdHotkey的架构设计

在经历了多次被突发海量请求压垮数据层服务的场景,并时刻面临大量的爬虫刷子机器人用户的请求,京东根据既有经验设计开发了一套通用轻量级热key探测框架------JdHotkey。

(1)热key探测框架JdHotkey的特点

热key探测框架JdHotkey具有:热数据探测、限流熔断、统计等多种功能。

它很轻量级,既不改Redis源码也不改Redis的客户端jar包。当然,它与Redis没一点关系,完全不依赖Redis,它是一个独立的系统。

(2)热key探测框架JdHotkey的使用

首先部署好JdHotkey热key探测系统,然后在应用的Server代码里引入jar,之后在应用的Server代码中就可以像使用一个本地HashMap来使用该系统。

热key探测框架JdHotkey自身会完成如下一切处理:包括对待测key的上报、对热key的推送、本地热key的缓存、过期淘汰策略。框架只会告知是不是热key,其他的逻辑则由我们自己去实现即可。

(3)热key探测框架JdHotkey的强实时性和高性能

热key探测框架JdHotkey有很强的实时性。默认下,500ms即可探测出待测key是否热key,是就会进到JVM内存中。当然,JdHotkey框架也提供了更快频率的设置方式。通常在非极端场景建议保持默认值即可,更高的频率会带来更大的资源消耗。

热key探测框架JdHotkey还有着强悍的性能表现。一台8核8G机器,在承担该框架热key探测计算任务时,每秒可处理来自数千台服务器发来的高达16万个的待测key。8核单机吞吐量16万,16核机器每秒可达30万+探测量,当然前提是CPU很稳定。

高性能代表了低成本,所以可以仅仅采用10台16核机器,即可完成每秒近300万次的key探测任务。一旦找到了热key,那该数据的访问耗时就和Redis不在一个数量级了。

(4)热key探测框架JdHotkey的架构设计

热key探测框架JdHotkey的架构图如下所示:

6.热key探测框架JdHotkey的组成

(1)etcd集群

(2)Client端jar包

(3)Worker端集群

(4)Dashboard控制台

该框架主要由4个部分组成。

(1)etcd集群

etcd是一个高性能的配置中心,etcd可以以极小的资源占用,提供高效的监听订阅服务。主要用于存放规则配置、Worker的IP、探测出的热key、手工添加的热key。

(2)Client端jar包

就是在服务中添加的引用jar,引入后,就可以以便捷的方式去判断某key是否热是key。同时该jar还完成了:key上报、监听etcd的规则配置的变化、Worker信息变化、热key的变化、对热key进行本地Caffeine缓存等。

(3)Worker端集群

Worker端是一个独立部署的Java程序,启动后会连接etcd,并定期上报自己的IP信息。Client端会通过etcd获取Worker端的地址并进行长连接。之后,Worker端主要就是对各个Client发来的待测key进行累加计算。当达到etcd里设定的rule阈值后,将热key推送到各个Client。

(4)Dashboard控制台

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

综上,可以看到热key探测框架JdHotkey没有依赖于任何定制化的组件。与Redis更是毫无关系,核心就是靠Netty连接,Client端送出待测key,然后由各个Worker完成分布式计算,算出热key后就直接推送到Client端,非常轻量级。

7.热key探测框架JdHotkey的工作流程

(1)首先搭建etcd集群

(2)启动Dashboard可视化界面

(3)启动Worker集群

(4)启动Client端

(5)Client端主要有如下4个⽅法可供使⽤

(1)首先搭建etcd集群

etcd是一个全局共用的配置中心,etcd能让所有的Client读取到完全一致的Worker信息和Rule信息。

(2)启动Dashboard可视化界面

在界面上添加各个APP的待测规则,如app1它包含两个规则:第一个是userId_开头的key,如userId_abc,每2秒出现20次则算热key。第二个是skuId_开头的每1秒出现超过100次则算热key。只有命中规则的key才会被发送到Worker进行计算。

(3)启动Worker集群

Worker集群可以配置APP级别的隔离,也可以不隔离。做了隔离后,这个app就只能使用这几个Worker,以避免其他APP在性能资源上产生竞争。

Worker启动后,会从etcd读取之前配置好的规则,并持续监听规则的变化。然后,Worker会定时上报自己的IP信息到etcd。如果一段时间没有上报,etcd会将该Worker信息删掉。

Worker上报的IP会供Client进行长连接,各Client以etcd里该app能用的Worker信息为准进行长连接,并且会根据Worker的数量将待测的key进行hash后平均分配到各个Worker。

之后,Worker就开始接收并计算各个Client发来的key。当某key达到规则里设定的阈值后,将其推送到该APP全部客户端jar。之后再推送一份到etcd,供Dashboard监听记录。

(4)启动Client端

Client端启动后会连接etcd,通过etcd获取规则、获取专属的Worker IP信息,之后持续监听该信息。

获取到IP信息后,Client会通过Netty建立和Worker的长连接。

Client会启动一个定时任务:每500ms就批量发送一次待测key到对应的Worker机器。发送规则是key的HashCode对Worker数量取余,所以固定的key肯定会发送到同一个Worker。这500ms内,就是本地搜集累加待测key及其数量,到期就批量发出即可。注意,已经热了的key不会再次发送,除非本地该key缓存已过期。

当Worker探测出来热key后,会推送到Client。Client端会采用Caffeine进行本地缓存,会根据当初设置的rule里的过期时间进行本地过期设置。

当然,如果在控制台手工新增、删除了热key,那么Client端也会监听到,并会对本地Caffeine进行增删处理。这样,各个热key在整个Client集群内是保持一致性的。

jar包对外提供了判断是否是热key的方法。如果是热key,那么只需要关心自己的逻辑处理就好。是限流它、是降级它访问的接口、还是返回value,都依赖于自己的逻辑。

注意:我们关注的只有key本身,也就是一个字符串而已,而不关心value。那么此时必然有一个疑问:如果是Redis的热key,框架告诉了哪个是热key,并没有告知value。是的,该框架只提供了是否是热key的方法。如果是Redis热key,就需要用户自己去Redis获取value。然后调用框架的set方法,将value也set进去就好。如果不是热key,那么就走原来的逻辑即可。

所以可将JdHotkey框架当成一个具备热key的HashMap但需要自己维护value值。

综上,该框架以非常轻量级的做法,实现了毫秒级热key精准探测和集群一致性。适用于大量场景,任何对某些字符串有热度匹配需求的场景都可以使用。

(5)Client端主要有如下4个⽅法可供使⽤

一.boolean isHotKey(String key)

该⽅法会返回给定的key是否是热key。如果是则返回true,如果不是则返回false,并且会将key上报到探测集群进⾏数量计算。该⽅法通常⽤于只需要判断key是否热、不需要缓存value的场景,如刷⼦⽤户、接⼝访问频率等。

二.Object get(String key)

该⽅法会返回给定的key在本地缓存的value值。可⽤于判断是热key后,再去获取本地缓存的value值,通常⽤于Redis热key缓存。

三.void smartSet(String key, Object value)

⽅法给热key赋值value。如果是热key,该⽅法才会赋值,⾮热key,那么该方法什么也不做。

四.Object getValue(String key)

该⽅法是⼀个整合⽅法,相当于isHotKey和get两个⽅法的整合,该⽅法直接返回本地缓存的value。如果是热key,则存在两种情况,1是返回value,2是返回null。返回null是因为尚未给它set真正的value,返回⾮null说明已经调⽤过set⽅法了,本地缓存value有值了。如果不是热key,则返回null,并且将key上报到探测集群进⾏数量探测。

8.热key探测框架JdHotkey的性能表现

(1)etcd端

(2)Worker端

(1)etcd端

etcd性能优异,官方宣称秒级读写可达数万。框架仅仅使用etcd来进行热key的推送,以及其他少量信息的监听读写。数千级别的客户端连接,平时秒级百来个热key诞生。所以CPU占用率不超过5%,大部分时间在1%左右。

(2)Worker端

Worker端是JdHotkey框架最核心的一环,也是承载分布式计算压力最大的部分,需要根据秒级各Client发来的key总量来进行资源分配。假如每秒有100万个key待测,那么需要知道单个Worker的处理能力,然后决定分配多少个Worker机器来均分这些计算任务。这里也是调优的核心,越高的QPS,就是越低的成本。

经过测试发现:8核8G的Worker,单机每秒可处理10万级别的key探测计算和推送任务。16核16G的Worker,则可较为轻松应对20万每秒的处理任务。

9.关于热key探测框架JdHotkey的一些问题

(1)Worker挂了怎么办

由于Client根据Worker的数量对key进行Hash后分发,所以同一个key一定会被发往同一个Worker。

假设4台Worker挂了一台,那么key就自动Hash到另外3台。而在这个过程中,就会丢失最多一个探测周期内的所有发来的key。比如2秒10次算热,那么就可能全部被rehash,丢失这2秒的数据。

它的影响是什么呢?要不要去存下所有发来的key呢?首先挂机是极其罕见的事件,即便挂了,对于特别热的key,完全不影响。对于特别热的key,Hash丢几秒,不影响它继续瞬间变热。对于不热的key,它挂不挂,它也热不了。对于那些将热未热的,可能这次会让它热不起来。但也没有什么影响,因为业务服务完全可以吃下这个将热未热的key。

否则,引入一堆别的组件如存储、Worker间通信传输key等,那么它的复杂度、性能都会受影响,所以Worker挂了对系统没有任何影响。

(2)为什么全部要Worker汇总计算,而不是客户端自己计算

首先,客户端是会本地累加的。在固定的上报周期内,如500ms内,本地就是在累加的。客户端会每500ms批量上报一次给Worker。如果上报频率很高,如10ms一次,那么大概率本地同一个key是没有累加。

有人会说,把这个间隔拉长。比如本地计算3秒后,本地判定热key,再上报给其他机器。那么这种场景首先对于京东是不可行的,哪怕1秒都不行。比如一个用户刷子,它在非常频繁地刷接口,一秒刷了500次,而正常用户一秒最多点5次,它已经是非常严重的刷子了,但本地还是判断不出它是不是刷子。

为什么不能把上报间隔拉长?

**原因一:**因为机器多,随便一个APP小组都有数千台机器,一秒500次请求。那么一个机器连1次都平均不到,大部分是0次,本地如何判断它是刷子呢?总不能访问一次就算它刷吧。

**原因二:**然后抢购场景,有些秒杀商品1-2秒就没了,流量就停了。如果本地计算了3秒才去上报,那活动已经结束了,这时热key已没价值了。

所以要在活动即将开始前的如10ms内,就要把该商品推送到所有Client的JVM里,根本等不了1秒。

(3)为什么是Worker推送,而不是发送热key到etcd,客户端直接监听

**原因一:**Worker和Client是长连接,产生热key后直接推送过去,链路短耗时少。如果发到etcd,客户端再通过etcd获取,多了一层中转,耗时增加。

**原因二:**etcd性能不够,存在单点风险。比如有5000台Client,每秒产生100个热key,则每秒就对应50万次推送。用2台Worker就轻松完成,随着Worker横向扩展,每秒推送上限线性增加。但无论是etcd、Redis等任何组件,都不可能做到1秒50万次拉取或推送。因为Worker是各自隔离的,而etcd是单点的。

(4)为什么是etcd而不是zk之类的

**原因一:**etcd里具备一个过期删除的功能,别的配置中心没有这个功能。etcd可以设置一个key几秒过期,etcd会自动删除它。删除时还会给所有监听的client回调,这个功能在这个框架里是在用的。

**原因二:**etcd的性能和稳定性、低负载等各项指标非常优异,完全满足需求。而zk在很多暴涨流量前面和高负载下,并不是那么稳定,性能也差得远。毕竟zk只有一个Leader机器可以处理事务请求。

10.JdHotkey的安装部署与使用

(1)etcd安装

这⾥选择v3.4.18分⽀,创建如下etcd-install.sh脚本⽂件,执⾏脚本sh etch-install.sh即可下载安装。

#!/bin/bash
ETCD_VER=v3.4.18
# choose either URL
GOOGLE_URL=https://storage.googleapis.com/etcd
GITHUB_URL=https://github.com/etcd-io/etcd/releases/download
DOWNLOAD_URL=${GOOGLE_URL}
rm -f /tmp/etcd-${ETCD_VER}-linux-amd64.tar.gz
rm -rf /tmp/etcd-download-test && mkdir -p /tmp/etcd-download-test
curl -L ${DOWNLOAD_URL}/${ETCD_VER}/etcd-${ETCD_VER}-linux-amd64.tar.gz -o
/tmp/etcd-${ETCD_VER}-linux-amd64.tar.gz
tar xzvf /tmp/etcd-${ETCD_VER}-linux-amd64.tar.gz -C /tmp/etcd-download-
test --strip-components=1
rm -f /tmp/etcd-${ETCD_VER}-linux-amd64.tar.gz
/tmp/etcd-download-test/etcd --version
/tmp/etcd-download-test/etcdctl version

一.单机启动

单机启动,使⽤如下命令:

$ nohup /tmp/etcd-download-test/etcd \
--listen-client-urls 'http://0.0.0.0:2379' \
--advertise-client-urls 'http://0.0.0.0:2379' 2>&1 &

查看节点状态,使用如下命令:

$ /tmp/etcd-download-test/etcdctl --write-out=table \
--endpoints=localhost:2379 endpoint status

二.集群部署

⾸先准备三台机器,IP为192.168.10.97、192.168.10.102、192.168.10.103。在97这台机器上创建start-etcd.sh,并指定name为demo-etcd-1。将start-etcd.sh复制到其他两台机器上,在102这台机器上将name改为demo-etcd-2,host改为192.168.10.102。在103这台机器上将name改为demo-etcd-3,host改为192.168.10.103。

这样启动脚本就准备好了,接着在每台机器上,执⾏start-etcd.sh脚本。并且在其中⼀台机器上,查看集群状态:

$ /tmp/etcd-download-test/etcdctl --write-out=table \
--endpoints=192.168.10.97:2379,192.168.10.102:2379,192.168.10.103:2379 \
endpoint status

脚本start-etcd.sh如下:

#!/bin/bash
NAME=careerplan-etcd-1
HOST=http://192.168.10.97
PORT=2379
CLUSTER_PORT=2380
CLUSTER=demo-etcd-1=http://192.168.10.97:2380,demo-etcd-2=http://192.168.10.102:2380,demo-etcd-3=http://192.168.10.103:2380
CLUSTER_TOKEN=demo-etcd-token
CLUSTER_STATE=new
ETCD_CMD="/tmp/etcd-download-test/etcd --name ${NAME} \
--listen-client-urls ${HOST}:${PORT} \
--advertise-client-urls ${HOST}:${PORT} \
--listen-peer-urls ${HOST}:${CLUSTER_PORT} \
--initial-advertise-peer-urls ${HOST}:${CLUSTER_PORT} \
--initial-cluster ${CLUSTER} \
--initial-cluster-token ${CLUSTER_TOKEN} \
--initial-cluster-state ${CLUSTER_STATE} \
--auto-compaction-retention=10 \
--quota-backend-bytes=8589934592 \
$*"
echo -e "Running '${ETCD_CMD}'\nBEGIN ETCD OUTPUT\n"
nohup ${ETCD_CMD} > /tmp/logs/etcd.log 2>&1 &

(2)创建数据库

由于JdHotkey的Dashboard需要依赖数据库,所以首先需要安装MySQL,然后创建名为hotkey_db的数据库,接着执⾏Dashboard模块中路径为./src/main/resource/db.sql里的数据库脚本。

(3)部署JdHotkey项目

下载JdHotkey代码:

$ git clone https://gitee.com/jd-platform-opensource/hotkey.git

然后在hotkey项⽬⽬录下,通过maven打包编译代码,maven install。

一.部署Dashboard

在dashboard/target⽬录下,打包会⽣成dashboard-0.0.2-SNAPSHOT.jar。将jar包上传⾄Dashboard服务器,启动Dashboard:

$ nohup java -jar dashboard-0.0.2-SNAPSHOT.jar \
 --etcd.server=192.168.10.97:2379,192.168.10.102:2379,192.168.10.103:2379 \
 --spring.datasource.url='jdbc:mysql://ip:port/hotkey_db?useUnicode=true&characterEncoding=utf-8&useSSL=false&serverTimezone=Asia/Shanghai&allowPublicKeyRetrieval=true' \
 --spring.datasource.username=root \
 --spring.datasource.password=123456 2>&1 &

其中etcd.serve参数,根据单机启动或者集群部署,来选择配置,数据库配置根据实际使⽤数据库来配置。

访问http://192.168.10.97:8081/
⽤户名/密码:admin/123456
⽤户注销:http://192.168.10.97:8081/user/LoginOut

注:如果连接etcd失败,需要开通部分端⼝对外访问,直接点就关闭防⽕墙
systemctl stop firewalld.service

二.部署Worker

Worker的部署与Dashboard类似。在worker/target⽬录下,打包会⽣成worker-0.0.4-SNAPSHOT.jar。将jar包上传⾄Worker服务器,启动Worker。

$ nohup java -jar worker-0.0.4-SNAPSHOT.jar \
 --etcd.server=192.168.10.97:2379,192.168.10.102:2379,192.168.10.103:2379 \
 --thread.count=8 2>&1 &

(4)Client端的使用

一.项⽬引⼊Client端jar包

第三步将hotkey项⽬通过maven打包后,会放在本地的maven仓库中,所以可使⽤如下maven坐标:

<!-- 京东hotkey,热key探测框架-->
<dependency>
    <groupId>com.jd.platform.hotkey</groupId>
    <artifactId>hotkey-client</artifactId>
    <version>${hotkey.version}</version> 
</dependency>

二.hotkey相关配置(application.yml)

# hotkey相关配置
hotkey:
    app-name: demo-redis 
# etcd服务器地址,集群⽤逗号分隔 
    etcd-server: http://192.168.10.97:2379 
# 设置本地缓存最⼤数量,默认5万 
    caffeine-size: 50000 
# 批量推送key的间隔时间,默认500ms,该值越⼩,上报热key越频繁,相应越及时,建议根据实际情况调整 
# 如单机每秒qps10个,那么0.5秒上报⼀次即可,否则是空跑。该值最⼩为1,即1ms上报⼀次。push-period: 500

三.初始化hotkey

//hotkey的配置信息
@Data
@Component
@ConfigurationProperties(prefix = "hotkey")
public class HotkeyProperties {
    private String appName;
    private String etcdServer;
    private Integer caffeineSize;
    private Long pushPeriod;
}

@Component
@EnableConfigurationProperties(HotkeyProperties.class)
public class HotkeyConfig {
    //配置内容对象
    @Autowired
    private HotkeyProperties hotkeyProperties;

    @PostConstruct
    public void initHotkey() {
        log.info("init hotkey, appName:{}, etcdServer:{}, caffeineSize:{}, pushPeriod:{}",
            hotkeyProperties.getAppName(), hotkeyProperties.getEtcdServer(),
            hotkeyProperties.getCaffeineSize(), hotkeyProperties.getPushPeriod());
        ClientStarter.Builder builder = new ClientStarter.Builder();
        ClientStarter starter = builder.setAppName(hotkeyProperties.getAppName())
            .setEtcdServer(hotkeyProperties.getEtcdServer())
            .setCaffeineSize(hotkeyProperties.getCaffeineSize())
            .setPushPeriod(hotkeyProperties.getPushPeriod())
            .build();
        starter.startPipeline();
    }
}

四.在RedisCache中增加⼀个⽅法

先查hotkey内存中是否有数据。如果有就使⽤内存中的数据,减轻Redis的压⼒。如果没有,则查询Redis中的数据。如果还是没有,则查询数据库。

@Component
public class RedisCache {
    ...
    //缓存获取
    //首先尝试从内存中获取,内存中没有则从redis中获取
    //@param key
    //@return java.lang.String
    public Object getCache(String key) {
        //从内存中获取商品信息
        //如果是热key,则存在两种情况,1是返回value,2是返回null;
        //返回null是因为尚未给它set真正的value,返回非null说明已经调用过set方法了,本地缓存value有值了;
        //如果不是热key,则返回null,并且将key上报到探测集群进行数量探测;
        Object hotkeyValue = JdHotKeyStore.getValue(key);
        log.info("从内存中获取信息,key:{},value:{}", key, hotkeyValue);
        if (hotkeyValue != null) {
            return hotkeyValue;
        }
        String value = this.get(key);
        log.info("从缓存中获取信息,key:{},value:{}", key, value);
        
        //方法给热key赋值value,如果是热key,该方法才会赋值,非热key,什么也不做
        //如果是热key,存储在内存中
        //每次从缓存中获取数据后都尝试往热key中放一下
        //如果不放,则在成为热key之前,将数据放入缓存中了,但是没放到内存中
        //如果此时变成热key了,但是下次查询内存没查到,查缓存信息,查到了,就直接返回了,内存中就没有数据
        JdHotKeyStore.smartSet(key, value);
        return value;
    }
    ...
}

五.以商品信息为例,使⽤hotkey

@Service
public class GoodsServiceImpl implements GoodsService {
    ...
    private SkuInfoDTO getSkuInfoBySkuId(Long skuId) {
        String goodsInfoKey = RedisKeyConstants.GOODS_INFO_PREFIX + skuId;
        //从内存或者缓存中获取数据
        Object goodsInfoValue = redisCache.getCache(goodsInfoKey);
      
        if (Objects.equals(CacheSupport.EMPTY_CACHE, goodsInfoValue)) {
            //如果是空缓存,则是防止缓存穿透的,直接返回null
            return null;
        } else if (goodsInfoValue instanceof SkuInfoDTO) {
            //如果是对象,则是从内存中获取到的数据,直接返回
            return (SkuInfoDTO) goodsInfoValue;
        } else if (goodsInfoValue instanceof String) {
            //如果是字符串,则是从缓存中获取到的数据,重新设置过期时间,转换成对象之后返回
            redisCache.expire(goodsInfoKey, CacheSupport.generateCacheExpireSecond());
            return JsonUtil.json2Object((String) goodsInfoValue, SkuInfoDTO.class);
        }

        // 未在内存和缓存中获取到值,从数据库中获取
        return getSkuInfoBySkuIdFromDB(skuId);
    }
    ...
}

从DB中查询数据的⽅法如下:

@Service
public class GoodsServiceImpl implements GoodsService {
    ...
    private SkuInfoDTO getSkuInfoBySkuIdFromDB(Long skuId) {
        String skuInfoLock = RedisKeyConstants.GOODS_LOCK_PREFIX + skuId;
        boolean lock = redisLock.lock(skuInfoLock);
        if (!lock) {
            log.info("缓存数据为空,从数据库查询商品信息时获取锁失败,skuId:{}", skuId);
            throw new BaseBizException("查询失败");
        }
        try {
            log.info("缓存数据为空,从数据库中获取数据,skuId:{}", skuId);
            SkuInfoDO skuInfoDO = skuInfoDAO.getById(skuId);
            String goodsInfoKey = RedisKeyConstants.GOODS_INFO_PREFIX + skuId;
            if (Objects.isNull(skuInfoDO)) {
                //如果商品编码对应的商品⼀开始不存在,设置空缓存,防⽌缓存穿透
                redisCache.setCache(goodsInfoKey, CacheSupport.EMPTY_CACHE, CacheSupport.generateCachePenetrationExpireSecond());
                return null;
            }
            SkuInfoDTO dto = skuInfoConverter.convertSkuInfoDTO(skuInfoDO);
            dto.setSkuId(skuInfoDO.getId());
            dto.setSkuImage(JSON.parseArray(skuInfoDO.getSkuImage(), SkuInfoDTO.ImageInfo.class));
            dto.setDetailImage(JSON.parseArray(skuInfoDO.getDetailImage(), SkuInfoDTO.ImageInfo.class));
            //设置缓存过期时间,2天加上随机⼏⼩时
            redisCache.setCache(goodsInfoKey, dto, CacheSupport.generateCacheExpireSecond());
            return dto;
        } finally {
            redisLock.unlock(skuInfoLock);
        }
    }
    ...
}

六.接下来将缓存存储,改为setCache()⽅法

setCache()方法会尝试将数据存储在hotkey的内存中(如果是热key),然后再将数据存储在缓存中。

@Component
public class RedisCache {
    ...
    //缓存存储
    //将数据存储在内存和Redis中,如果不是热key,就只存储Redis
    public void setCache(String key, Object value, int seconds) {
        //法给热key赋值value,如果是热key,该方法才会赋值,非热key,什么也不做
        //如果是热key,存储在内存中
        JdHotKeyStore.smartSet(key, value);
        this.set(key, JsonUtil.object2Json(value), seconds);
    }
    ...
}

七.整合hotkey-client时遇到了jar包冲突的问题

将maven依赖调整为如下,指定gRPC的版本为1.30.1:

<!-- 这⾥指定gRPC的版本为1.30.1,因为hotkey中的etcd-java中依赖的版本会存在jar包冲突 -->
<dependency>
    <groupId>io.grpc</groupId>
    <artifactId>grpc-netty</artifactId>
    <version>${grpc-version}</version>
    <scope>compile</scope> 
</dependency> 
<dependency>
    <groupId>io.grpc</groupId>
    <artifactId>grpc-protobuf</artifactId>
    <version>${grpc-version}</version>
    <scope>compile</scope> 
</dependency> 
<dependency>
    <groupId>io.grpc</groupId>
    <artifactId>grpc-core</artifactId>
    <version>${grpc-version}</version>
    <scope>compile</scope> 
</dependency> 
<dependency>
    <groupId>io.grpc</groupId>
    <artifactId>grpc-context</artifactId>
    <version>${grpc-version}</version>
    <scope>compile</scope> 
</dependency> 
<dependency>
    <groupId>io.grpc</groupId>
    <artifactId>grpc-api</artifactId>
    <version>${grpc-version}</version>
    <scope>compile</scope> 
</dependency> 
<dependency>
    <groupId>io.grpc</groupId>
    <artifactId>grpc-stub</artifactId>
    <version>${grpc-version}</version>
    <scope>compile</scope> 
</dependency> 
<!-- 京东hotkey,热key探测框架--> 
<dependency>
    <groupId>com.jd.platform.hotkey</groupId>
    <artifactId>hotkey-client</artifactId>
    <version>${hotkey.version}</version> 
</dependency>
相关推荐
听说唐僧不吃肉4 小时前
Redis篇之Redis高可用模式参数调优,提高Redis性能
redis
G丶AEOM4 小时前
Redis内存淘汰策略有哪些
java·redis
Lian_Aseubel5 小时前
更新数据时Redis的操作
数据库·redis·缓存
编程、小哥哥6 小时前
Redis 缓存、加锁(独占/分段)、发布/订阅,常用特性的使用和高级编码操作
数据库·redis·缓存
是谢添啊6 小时前
不同版本的 Redis 的键值对内存占用情况示例
数据库·redis·内存占用·jemalloc·memory usage
熬夜的猪9 小时前
Redis 最佳实践
java·redis·后端
yuanbenshidiaos9 小时前
多线程----互斥
java·jvm·redis
小码ssim10 小时前
docker搭建Redis集群及哨兵(windows10环境,OSS Cluster)
数据库·redis·缓存