在拥有大量并发用户的系统中,热 key 一直以来都是一个不可避免的问题。或许是突然某些商品成了爆款,或许是海量用户突然涌入某个店铺,或许是秒杀时瞬间大量开启的爬虫用户, 这些突发的无法预先感知的热 key 都是系统潜在的巨大风险。
风险是什么呢?主要是数据层,其次是服务层。
热 key 对数据层的冲击显而易见,譬如数据存放在 redis 或者 MySQL 中,以 redis 为例,那个未知的热数据会按照 hash 规则被存在于某个 redis 分片上,平时使用时都从该分片获取它的数据。
由于 redis 性能还不错,再加上集群模式,每秒我们假设它能支撑 20 万次读取,这足以支持大部分的日常使用了。但是,以京东为例的这些头部互联网公司,动辄某个爆品,会瞬间引入每秒上百万甚至数百万的请求,当然流量多数会在几秒内就消失。但就是这短短的几秒的热 key,就会瞬间造成其所在 redis 分片集群瘫痪。
原因也很简单,redis 作为一个单线程的结构,所有的请求到来后都会去排队,当请求量远大于自身处理能力时,后面的请求会陷入等待、超时。由于该 redis 分片完全被这个 key 的请求给打满,导致该分片上所有其他数据操作都无法继续提供服务,也就是热 key 不仅仅影响自己,还会影响和它合租的数据。
很显然,在这个极短的时间窗口内,我们是无法快速扩容 10 倍以上 redis 来支撑这个热点的。虽然 redis 已经很优秀,但是它的内心是这样的:“臣妾做不到啊!”
热 key 对服务层的影响也不可小视,譬如你原本有 1000 台 Tomcat,每台每秒能支撑 1000QPS,假设数据层稳定、这样服务层每秒能承接 100 万个请求。但是由于某个爆品的出现、或者由于大促优惠活动,突发大批机器人以远超正常用户的速度发起极其密集的请求,这些机器人只需要很小的代价就能发出百倍于普通用户的请求量,从而大幅挤占正常用户的资源。
原本能承接 100 万,现在来了 150 万,其中 50 万个是机器人请求,那么就导致了至少 1/3 的正常用户无法访问,带来较差的用户体验。
根据以上的场景,我们可以总结出来什么是有危害的热 key。
什么是热 key
1 MySQL 等数据库会被频繁访问的热数据
如爆款商品的 skuId
2 redis 的被密集访问的 key
如爆款商品的各维度信息,skuId、shopId 等等
3 机器人、爬虫、刷子用户
如用户的 userId、uuid、ip 等
4 某个接口地址
如 /sku/query
或者更精细维度的
5 用户 id + 接口信息
如 userId + /sku/query,这代表某个用户访问某个接口的频率
6 服务器 id + 接口信息
如 ip + /sku/query,这代表某台服务器某个接口被访问的频率
7 用户 id + 接口信息 + 具体商品
如 userId + /sku/query + skuId,这代表某个用户访问某个商品的频率
以上我们都称之为有风险的 key,注意,我们的热 key 探测框架只关心 key,其实就是一个字符串,随意怎么组合成这个字符串由使用者自己决定,所以该框架具备非常强的灵活性,可以完成热数据探测、限流熔断、统计等多种功能。
以往热 key 问题怎么解决
我们分别以 redis 的热 key、刷子用户、限流等典型的场景来看。
redis 热 key:
这种以往的解决方式比较百花齐放,比较常见的有:
1》上二级缓存,读取到 redis 的 key-value 信息后,就直接写入到 jvm 缓存一份,设置个过期时间,设置个淘汰策略譬如队列满时淘汰最先加入的。特点就是无脑缓存,不关心数据是不是热点,缓存数据在应用集群内无法达成一致性。
2》改写 redis 源码加入热点探测功能,有热 key 时推送到 jvm。问题主要是不通用,且有一定难度。
3》改写 jedis、letture 等 redis 客户端的 jar,通过本地计算来探测热点 key,是热 key 的就本地缓存起来并通知集群内其他机器。
4》其他
刷子爬虫用户:
常见的有:
1》日常累积后,将这批黑名单通过配置中心推送到 jvm 内存。存在滞后无法实时感知的问题。
2》通过本地累加,进行实时计算,单位时间内超过阈值的算刷子。如果服务器比较多,存在用户请求被分散,本地计算达不到甄别刷子的问题。
3》引入其他组件如 redis,进行集中式累加计算,超过阈值的拉取到本地内存。问题就是需要频繁读写 redis,依旧存在 redis 的性能瓶颈问题。
限流:
1》单机维度的接口限流多采用本地累加计数
2》集群维度的多采用第三方中间件,如 sentinel
3》网关层的,如 Nginx+lua
综上,我们会发现虽然它们都可以归结到热 key 这个领域内,但是并没有一个统一的解决方案,我们更期望于有一个统一的框架,它能解决所有的对热 key 有实时感知的场景,最好是无论是什么 key、是什么维度,只要我拼接好这个字符串,把它交给框架去探测,设定好判定为热的阈值(如 2 秒该字符串出现 20 次),则毫秒时间内,该热 key 就能进入到应用的 jvm 内存中,并且在整个服务集群内保持一致性,要有都有,要删全删。
热 key 进内存后的优势
热 key 问题归根到底就是如何找到热 key,并将热 key 放到 jvm 内存的问题。只要该 key 在内存里,我们就能极快地来对它做逻辑,内存访问和 redis 访问的速度不在一个量级。
譬如刷子用户,我们可以对其屏蔽、降级、限制访问速度。热接口,我们可以进行限流,返回默认值。redis 的热 key,我们可以极大地提高访问速度。
以 redis 访问 key 为例,我们可以很容易的计算出性能指标,譬如有 1000 台服务器,某 key 所在的 redis 集群能支撑 20 万 /s 的访问,那么平均每台机器每秒大概能访问该 key200 次,超过的部分就会进入等待。由于 redis 的瓶颈,将极大地限制 server 的性能。
而如果该 key 是在本地内存中,读取一个内存中的值,每秒多少个万次都是很正常的,不存在任何数据层的瓶颈。当然,如果通过增加 redis 集群规模的形式,也能提升数据的访问上限,但问题是事先不知道热 key 在哪里,而全量增加 redis 的规模,带来的成本提升又不可接受。
热 key 探测的关键指标
1 实时性
这个很容易理解,key 往往是突发性瞬间就热了,根本不给你再慢悠悠手工去配置中心添加热 key 再推送到 jvm 的机会。它大部分时间不可预知,来得也非常迅速,可能某个商家上个活动,瞬间热 key 就出现了。如果短时间内没能进到内存,就有 redis 集群被打爆的风险。
所以热 key 探测框架最重要的就是实时性,最好是某个 key 刚有热的苗头,在 1 秒内它就已经进到整个服务集群的内存里了,1 秒后就不会再去密集访问 redis 了。同理,对于刷子用户也一样,刚开始刷,1 秒内我就把它给禁掉了。
2 准确性
这个很重要,也容易实现,累加数量,做到不误探,精准探测,保证探测出的热 key 是完全符合用户自己设定的阈值。
3 集群一致性
这个比较重要,尤其是某些带删除 key 的场景,要能做到删 key 时整个集群内的该 key 都会删掉,以避免数据的错误。
4 高性能
这个是核心之一,高性能带来的就是低成本,做热 key 探测目的就是为了降低数据层的负载,提升应用层的性能,节省服务器资源。不然,大家直接去整体扩充 redis 集群规模就好了。
理论上,在不影响实时性的情况下,要完成实时热 key 探测,所消耗的机器资源越少,那么经济价值就越大。
京东热 key 探测框架架构设计
在经历了多次被突发海量请求压垮数据层服务的场景,并时刻面临大量的爬虫刷子机器人用户的请求,我们根据既有经验设计开发了一套通用轻量级热 key 探测框架 ——JdHotkey。
它很轻量级,既不改 redis 源码也不改 redis 的客户端 jar 包,当然,它与 redis 没一点关系,完全不依赖 redis。它是一个独立的系统,部署后,在 server 代码里引入 jar,之后就像使用一个本地的 HashMap 一样来使用它即可。
框架自身会完成一切,包括对待测 key 的上报,对热 key 的推送,本地热 key 的缓存,过期、淘汰策略等等。框架会告诉你,它是不是个热 key,其他的逻辑交给你自己去实现即可。
它有很强的实时性,默认情况下,500ms 即可探测出待测 key 是否热 key,是热 key 它就会进到 jvm 内存中。当然,我们也提供了更快频率的设置方式,通常如果非极端场景,建议保持默认值就好,更高的频率带来了更大的资源消耗。
它有着强悍的性能表现,一台 8 核 8G 的机器,在承担该框架热 key 探测计算任务时(即下面架构图里的 worker 服务),每秒可以处理来自于数千台服务器发来的高达 16 万个的待测 key,8 核单机吞吐量在 16 万,16 核机器每秒可达 30 万以上探测量,当然前提是 cpu 很稳定。高性能代表了低成本,所以我们就可以仅仅采用 10 台机器,即可完成每秒近 300 万次的 key 探测任务,一旦找到了热 key,那该数据的访问耗时就和 redis 不在一个数量级了。如果是加 redis 集群呢?把 QPS 从 20 万提升到 200 万,我们又需要扩充多少台服务器呢?
该框架主要由 4 个部分组成
1 etcd 集群
etcd 作为一个高性能的配置中心,可以以极小的资源占用,提供高效的监听订阅服务。主要用于存放规则配置,各 worker 的 ip 地址,以及探测出的热 key、手工添加的热 key 等。
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 端监听。
综上,可以看到该框架没有依赖于任何定制化的组件,与 redis 更是毫无关系,核心就是靠 netty 连接,client 端送出待测 key,然后由各个 worker 完成分布式计算,算出热 key 后,就直接推送到 client 端,非常轻量级。
该框架工作流程
1 首先搭建 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,获取规则、获取专属的 worker ip 信息,之后持续监听该信息。获取到 ip 信息后,会通过 netty 建立和 worker 的长连接。
client 会启动一个定时任务,每 500ms(可设置)就批量发送一次待测 key 到对应的 worker 机器,发送规则是 key 的 hashcode 对 worker 数量取余,所以固定的 key 肯定会发送到同一个 worker。这 500ms 内,就是本地搜集累加待测 key 及其数量,到期就批量发出去即可。注意,已经热了的 key 不会再次发送,除非本地该 key 缓存已过期。
当 worker 探测出来热 key 后,会推送过来,框架采用 caffeine 进行本地缓存,会根据当初设置的 rule 里的过期时间进行本地过期设置。当然,如果在控制台手工新增、删除了热 key,client 也会监听到,并对本地 caffeine 进行增删。这样,各个热 key 在整个 client 集群内是保持一致性的。
jar 包对外提供了判断是否是热 key 的方法,如果是热 key,那么你只需要关心自己的逻辑处理就好,是限流它、是降级它访问的部分接口、还是给它返回 value,都依赖于自己的逻辑处理,非常的灵活。
注意,我们关注的只有 key 本身,也就是一个字符串而已,而不关心 value,我们只探测 key。那么此时必然有一个疑问,如果是 redis 的热 key,框架告诉了我哪个是热 key,并没有给我 value 啊。是的,框架提供了是否是热 key 的方法,如果是 redis 热 key,就需要用户自己去 redis 获取 value,然后调用框架的 set 方法,将 value 也 set 进去就好。如果不是热 key,那么就走原来的逻辑即可。所以可以将框架当成一个具备热 key 的 HashMap 但需要自己去维护 value 的值。
综上,该框架以非常轻量级的做法,实现了毫秒级热 key 精准探测,和集群规模一致性,适用于大量场景,任何对某些字符串有热度匹配需求的场景都可以使用。
热 key 探测框架性能表现
该 key 已经历了多次大促压测、极端场景压测以及 618 大促线上使用,这期间修复了很多不常见、甚至有些匪夷所思的问题,之前也发表过相关问题总结文章。
这里我们仅对它的性能表现进行简单的阐述。
etcd 端
etcd 性能优异,官方宣称秒级读写可达数万,实际我们使用中仅仅是热 key 的推送,以及其他少量信息的监听读写,负载非常轻。数千级别的客户端连接,平时秒级百来个的热 key 诞生,cpu 占用率不超过 5%,大部分时间在 1% 左右。
worker 端
worker 端是该框架最核心的一环,也是承载分布式计算压力最大的部分,需要根据秒级各 client 发来的 key 总量来进行资源分配。譬如每秒有 100 万个 key 待测,那么我们需要知道单个 worker 的处理能力,然后决定分配多少个 worker 机器来均分这些计算任务。
这一块也是调优的核心地方,越高的 qps,就是越低的成本。我简单列举一些之前的测试数据。
8 核 8G 的 worker 单机场景负载,totalDealCount 为累计计算过的 key 数量(进行完累加、推送热 key 到 client 等完毕后,数量 + 1),totalReceiveCount 为累计收到的 key 数量(刚收到尚未参与计算).expireCount 为收到时从客户端发出到 worker 收到已经超过 5 秒,不参与计算的 key 数量。
以上每 10 秒打印一次,可以看到处理量每 10 秒大概是 160 万次。
机器 cpu 占有率达到 70% 左右,高峰地方多是 gc 导致,整体到这个压力级别,我们认为它已经不能再大幅加压了。
换用 16 核 16G 机器后,同样的数据量即 10 秒 160 万不变,16 核机器要轻松的多。
cpu 占有率在 30% 多,整体负载比较轻。
加大数据源后
10 秒达到 200 万时,cpu 上升至 40% 多,说明还有继续增加压力的空间。后续经过极限压力写入,我们验证了单机在 30 万以上 QPS 情况下可稳定工作半小时以上,但 CPU 负载已很高,存在不确定性风险,这样的性能表现足以应对大部分 “突发” 场景。
综上,我们可以给出性能的简单结论,使用 8 核的 worker 机器,单机每秒可处理每秒 10 万级别的 key 探测计算和推送任务。使用 16 核的机器,可较为轻松应对 20 万每秒的处理任务。
用户可以根据该性能标准,来分配相应的 worker 数量。譬如你的应用每秒有 100 万个请求,你要探测的维度有 userId、skuId 两个,那么就需要自己去估算大概有多少个 skuId 和 userId,假如 100 万个请求分别来自于 100 万个不同的用户、每个用户都访问了不同的 sku,那么就是 200 万的待测 key。所以你需要 10 台 worker 会比较稳妥。
该框架已在京东 APP 后台上线使用,并经历了多次大促压测演练以及 618 大促,表现相当稳定,社区版也已经发布(https://gitee.com/jd-platform-opensource/hotkey)。希望该框架能成为所有热 key 场景问题的通用解决方案,能为各个有相关问题困扰的个人、公司提供一份助力。