大家好,我是程序员牛肉
最近在捣鼓我们公司内部的高性能缓存中间件Squirrel。在看相关解决热key问题文档的时候,突然想到了京东的JD-hotkey框架。这玩意可算是名声在外了,号称能够解决海量激增QPS压塌服务层的问题。
而其也在一次次的京东双十一网购热潮中完美的抗下了压力,该框架的含金量不言而喻。因此我们今天来向大家介绍一下这个框架。
先说说这个框架是干什么的吧。我们可以设想这样一个场景:在购物网站中我们可能会用redis来存一些排行榜上的商品。但是此时由于该商品进行了优惠力度比较大的减免,成为了一个“爆品”,再加上平台的大量推流,每秒瞬间引入了数百万的请求量。
这数百万的请求打到Redis的同一个键上,使其瞬间成为了一个热key。这种极短时间窗口内的海量流量直接给其所在的redis集群干瘫痪了。而且这种流量又是突发顺时流量,也就是在一天中也就那几秒的流量高峰。为了这几秒的流量高峰去对整个系统做扩充也不太划算。
而且这玩意不仅仅是你的存储层扛不住,甚至连你的应用层tomcat也扛不出。海量的请求会导致你的tomcat运行缓慢,降低并发效率。
这这种情况其实在业内有一个专业的名词:热key问题。在实际案例中哪些是热key呢?这个框架的作者给了我们一些标准:
而目前主流的统计热key思路也就以下几种:
方法一:凭借业务经验,进行预估 其实这个方法还是挺有可行性的。比如某商品在做秒杀,那这个商品的key就可以判断出是热key。缺点很明显,并非所有业务都能预估出哪些key是热key。
方法二:在客户端进行收集 这个方式就是在操作redis之前,加入一行代码进行数据统计。那么这个数据统计的方式有很多种,也可以是给外部的通讯系统发送一个通知信息。缺点就是对客户端代码造成入侵。
方法三:在Proxy层做收集 有些集群架构是下面这样的,Proxy可以是Twemproxy,是统一的入口。可以在Proxy层做收集上报,但是缺点很明显,并非所有的redis集群架构都有proxy。
方法四:用Redis自带命令: (1)monitor命令,该命令可以实时抓取出redis服务器接收到的命令,然后写代码统计出热key是啥。当然,也有现成的分析工具可以给你使用,比如redis-faina。但是该命令在高并发的条件下,有内存暴增的隐患,还会降低redis的性能。
(2)hotkeys参数,redis 4.0.3提供了redis-cli的热点key发现功能,执行redis-cli时加上–hotkeys选项即可。但是该参数在执行的时候,如果key比较多,执行起来比较慢。
方法五:自己抓包评估 有Redis客户端采用TCP协议与服务端进行交互,通信协议采用的是RESP,自己写程序监听端口进行分析就完事了。
已老实,这也太麻烦了。难道就没有一个现成的工具能让我们使用来实时检测热key嘛?
此时金光闪闪的京东告诉你:有的,我们已经帮你解决这个问题了。而这就是我们今天要介绍的框架:JD-hotkey。
京东自研的 JD-hotkey 框架是专为应对京东 APP 后台的高并发场景而设计的,用于探测和处理热数据。该框架能实时探测和处理大量的热键(hotkey),在毫秒级别内检测出热点数据。主要用于捕获爬虫、刷子用户和热门商品请求,并将这些热键毫秒级推送到各个服务端内存中,从而减少对数据层(如 Redis、MySQL)的查询压力,提升应用性能。JD-hotkey 支持对热商品进行本地缓存、对热用户进行访问拒绝、对热接口进行熔断等操作。
这玩意有多厉害?我只能说:无需质疑,战绩可查。以下内容截取自对应代码仓库的ReadME文件。
该项目已开源,对应的项目地址为:
https://gitee.com/jd-platform-opensource/hotkey 对应代码仓库
首先我们要明确一点:JD-hotkey解决热key问题的底层逻辑是找出热key之后将其推到对应的JVM中。这样重复请求的时候直接从JVM就可以获取数据,无需再与redis进行通信。
而从技术角度看的话,该框架的整体架构为:
其各个组成部分的作用为:
etcd作为一个高性能的配置中心,可以以极小的资源占用,提供高效的监听订阅服务。主要用于存放规则配置,各worker的ip地址,以及探测出的热key、手工添加的热key等。
[这玩意当ZooKeeper看就好,而该服务框架中之所以不使用Zookeeper是因为在高并发压力下,etcd的稳定性要比Zookeeper更好]
2 client端jar包
就是在服务中添加的引用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次算热。然后当worker探测出来热key后,会将key发往etcd,dashboard也会监听热key信息,进行入库保存记录。同时,dashboard也可以手工添加、删除热key,供各个client端监听。
这个框架的工作流程大致可以被描述为:
[这么听有点绕,用大白话讲就是 客户端通过持有hotkey的client包来定期给worker上报他自己内部访问的比较频繁的key(认定规则是从ETCD集群中拉取的)。而worker会以一个滑动窗口的时间来记录这些key在一定时间内的总访问次数。如果总访问次数超出我们对于热key的配置规则的话,worker就会尝试将这个热key直接推送到对应的JVM中。这样下次请求就不需要访问redis了,而是直接从自己的JVM中获取。]
而所谓的推到JVM中其实也是基于Spring Caffeine去做的。不了解的可以先学一学这个缓存库
[Caffeine 是一个高性能的 Java 缓存库,旨在提供快速、灵活和高效的缓存解决方案。它是 Guava Cache 的一个替代品,提供了更高的性能和更多的功能。]
接下来我们继续看一看在这个框架中一些比较重要的源码,看懂他们将有利于你更加熟练的掌握这个框架:
Worker端:
当一个worker启动之后,就会开启很多监听器来监听dashboard中的各种配置。比如监听热key配置规则:
当监听到热key的rule规则更改之后,我们调用了ruleChange方法来更新rule。点进这个方法:
可以发现他在获取到对应的appname和rule之后,将其放到了KeyRuleHolder中。这个类是用来存储各个app的rule信息的一个类,那为什么对rule进行更改只是需要将其放到KeyRuleHolder中呢?
不看源码,先推测。既然我们在前面讲架构的时候就已经讲过了这些rule要在client端中用到来判断热key。那么client端肯定就要有监听这个类的方法。
让我们去看看client的Starter类,就可以看到这样一个方法:
歪日,这不就串起来了。原来client端用了EventBus来监听Rule的变化事件,通过这种手段就实现了Client端的实时感知配置规则。
在这里再顺手简单的讲一下什么是evenbus。大家可以将其类比与MQ。不过MQ是跨进程(JVM)通信,而eventbus是在一个进程(JVM)内通信。
[EventBus 是一种基于发布-订阅模式的事件总线框架,广泛用于实现应用程序中各个组件之间的解耦和异步通信。EventBus 的主要功能是通过事件驱动的方式,实现组件之间的消息传递和处理。]
除此之外,worker最核心的 基于滑动窗口统计热key 相关代码 也要看一下
当我们尝试构造一个滑动窗口类的时候,会触发它的默认构造方法:
上面这段代码其实就是在构造滑动窗口长度。需要注意的是,我们可以看见这里竟然有两个窗口。为什么需要两个窗口呢?
我们可以认为这个timeSlinceSize数组在逻辑上是环形的,所以我们在计算当前时间片的位置的时候才需要进行取余的操作:
让我们回归正题:为什么要有两个时间片?核心原因还是因为我们对于时间窗口的可用性要求比较高。通过交替使用两个窗口,可以在一个窗口进行数据写入的同时,另一个窗口进行数据读取和清理,确保读写操作互不干扰,从而提高系统的并发处理能力和性能。
所以我们可以看到在addcount的时候,是需要计算当前可用的时间窗口的:
留心一下这种设计,后面我们还会遇到的。而这个滑动窗口类主要被用在worker中的keylistener中用来计算热键阈值。
这段代码就是计算hotkey的相关代码。他从cache中取出相关的热key,计算访问次数之后判断当前key是否还是热key,如果是的话,就更新一下过期时间。如果已经不是热key了就删除该缓存,并且将其推送到client中。
而在这一过程中其实是有线程安全问题的:所有的线程进来之后最后要更新key的热值的话,都会走到KeyListener中的addcount方法。
多个线程如果操作的是同一个key的话,就会获取到同一个时间片进行累加
因为存在线程安全问题,所以我们手动的给这个方法加了锁。但比如阈值是10的话,两个线程携带同一个key之后进入同一个时间片,而此时恰巧两次addcount都超出阈值了,回到newkey中,就会导致client端被推送了两次热key。
而第二次本来应该是存储在下一个时间片中,作为累计来统计hotkey阈值的。也就是说这种bug在一定程度上会导致我们的worker在统计热key的时候少计算一部分。
不过作者在相关的代码注释中给我们关于这个问题的回答:
简单的讲就是:作者认为这种概率实在是太小了,而且热key的热度一定是很高的,我们就算是丢失几次次数也没有多大的影响。相比之下如果为了这种线程安全问题直接给滑动窗口加锁的话,所带来的性能开销要大得多。
看完了worker中的一些代码,我们在再来看一看client端的:
先看一看client和worker的交互吧:
client每0.5秒会向worker中推一次待测试的key:
每10秒会向worker推送一次数量统计:
这样做的核心目的是为了减少与worker端的交互次数,减轻worker端的压力。那么既然是间隔和worker交互的,client端就一定会有一个设计来临时存储需要发送给worker的key和数量统计。
我们可以先看看client端对key进行统计的:
迎面而来就是两个ConcurrentHashMap,我们先不看这个玩意,继续往下看。这个类中包含了两个方法,分别是收集key以及上报key:
收集key:
上报key:
上报key这个方法主要就在我们上面提到的每10秒会向worker推送一次数量统计中被调用:
让我们回归正题:为什么要有两个ConcurrentHashMap呢?一个ConcurrentHashMap还满足不了这个操作吗?
我们可以设想这样一个场景:如果只有一个CurrentHashMap,那么在向Worker推送消息的时候,实际上是没有办法进行写操作的。
而我们通过使用两个ConcurrentHasMap就实现了读写分离。在上报A中的key的时候,你就先往B写。当上报B里面的key的时候,你就先往A里面写。
此时你在回头看一看刚才那个timeSliceSize:
我们再来详细的从代码层面看一看这块是怎么实现读写分离的:
在cllect阶段,我们会对atomicLong值进行计算,如果是偶数就使用map0,如果是奇数就使用map1来存储对应的key。
而在上报阶段:
我们先对atomicLong进行了加1的操作,导致这个数字的奇偶性逆转。此时如果是偶数的话,我们就上报map1。如果是奇数,我们就上报map2。
为了防止这块的逻辑绕住你,我来举一个例子:假设此时的atomic值为1。在collect的时候为奇数,就会将key存放在map1中。
在上报阶段,由于我们先对这个数进行了自增,导致现在atomic值为2。在上报的时候为偶数,我们就会选择map1上报。
但是新的key在调用collect存放的时候,由于现在已经是2了,就会导致其被存放到map0中。
通过这种方法我们就使用两个currentHashmap完成了对key的读写分离。而其实在统计数量的时候也是同理:
[这里直接给初始空间设置为了512,避免了频繁扩容导致的性能损耗]
除此之外,当我们的client端尝试上报key和count给worker的时候,就会涉及到网络通信。网络通信中就需要将这些消息进行序列化发送,而为了提高性能,这个框架在序列化的时候并没有使用我们熟悉的FastJson进行序列化,而是采用了Protobuf。
这玩意的性能要高的多,现在比较热门的GO语言序列化底层就是用Protobuf做的。
[Protobuf,全称为 Protocol Buffers,是由 Google 开发的一种高效的、跨语言的序列化数据格式。它被广泛应用于数据存储、通信协议和 RPC(远程过程调用)等场景。Protobuf 的主要优点包括高效、灵活和跨平台。]
如果你对各种序列化协议感兴趣的话,可以看一看我之前写的这一篇文章:
序列化就是转0101010?今天面试就到这里了,你先回去等通知吧。
今天这篇就先写到这里吧。这一篇主要讲了JD-hotkey中一些比较重要的源码,后面我会再出一篇讲一讲hotkey的整体流程。希望我的文章可以帮到你。 关于hotkey的检测问题,你还有什么想说的吗?欢迎在评论区留言。
关注我,带你了解更多计算机干货!
end
扫码关注腾讯云开发者
领取腾讯云代金券
Copyright © 2013 - 2025 Tencent Cloud. All Rights Reserved. 腾讯云 版权所有
深圳市腾讯计算机系统有限公司 ICP备案/许可证号:粤B2-20090059 深公网安备号 44030502008569
腾讯云计算(北京)有限责任公司 京ICP证150476号 | 京ICP备11018762号 | 京公网安备号11010802020287
Copyright © 2013 - 2025 Tencent Cloud.
All Rights Reserved. 腾讯云 版权所有