前往小程序,Get更优阅读体验!
立即前往
首页
学习
活动
专区
圈层
工具
发布
首页
学习
活动
专区
圈层
工具
MCP广场
社区首页 >专栏 >京东用来解决热key问题的JD-hotkey框架有多牛逼?无需质疑,战绩可查!

京东用来解决热key问题的JD-hotkey框架有多牛逼?无需质疑,战绩可查!

作者头像
程序员牛肉
发布于 2024-12-05 06:02:55
发布于 2024-12-05 06:02:55
6080
举报

大家好,我是程序员牛肉

最近在捣鼓我们公司内部的高性能缓存中间件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进行通信。

而从技术角度看的话,该框架的整体架构为:

其各个组成部分的作用为:

1 etcd集群(相当于是整个框架的注册中心配置中心

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,同时和worker之间建立长连接。定时拉取配置中心上面的规则信息和worker集群信息。
  • 客户端调用hotkey的ishot()的方法来首先匹配规则,然后统计是不是热key。
  • 通过定时任务把热key数据上传到worker节点。
  • worker集群在收取到所有关于这个key的数据以后(因为通过hash来决定key 上传到哪个worker的,所以同一个key只会在同一个worker节点上),在和定义的规则进行匹配后判断是不是热key,如果是则推送给客户端,完成本地缓存。

[这么听有点绕,用大白话讲就是 客户端通过持有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?今天面试就到这里了,你先回去等通知吧。

2024-11-12

今天这篇就先写到这里吧。这一篇主要讲了JD-hotkey中一些比较重要的源码,后面我会再出一篇讲一讲hotkey的整体流程。希望我的文章可以帮到你。 关于hotkey的检测问题,你还有什么想说的吗?欢迎在评论区留言。

关注我,带你了解更多计算机干货!

end

本文参与 腾讯云自媒体同步曝光计划,分享自微信公众号。
原始发表:2024-12-03,如有侵权请联系 cloudcommunity@tencent.com 删除

本文分享自 程序员牛肉 微信公众号,前往查看

如有侵权,请联系 cloudcommunity@tencent.com 删除。

本文参与 腾讯云自媒体同步曝光计划  ,欢迎热爱写作的你一起参与!

评论
登录后参与评论
0 条评论
热度
最新
推荐阅读
领券
问题归档专栏文章快讯文章归档关键词归档开发者手册归档开发者手册 Section 归档