1 前言
之前旁边的小伙伴问我热点数据相关问题,在给他粗略地讲解一波redis数据倾斜的案例之后,自己也顺道回顾了一些关于热点数据处理的方法论,同时也想起去年所学习JD开源项目hotkey——专门用来解决热点数据问题的框架。在这里结合两者所关联到的知识点,通过几个小图和部分粗略的讲解,来让大家了解相关方法论以及hotkey的源码解析。
2 Redis数据倾斜
2.1 定义与危害
先说说数据倾斜的定义,借用百度词条的解释:
对于集群系统,一般缓存是分布式的,即不同节点负责一定范围的缓存数据。我们把缓存数据分散度不够,导致大量的缓存数据集中到了一台或者几台服务节点上,称为数据倾斜。一般来说数据倾斜是由于负载均衡实施的效果不好引起的。
从上面的定义中可以得知,数据倾斜的原因一般是因为LB的效果不好,导致部分节点数据量非常集中。
那这又会有什么危害呢?
如果发生了数据倾斜,那么保存了大量数据,或者是保存了热点数据的实例的处理压力就会增大,速度变慢,甚至还可能会引起这个实例的内存资源耗尽,从而崩溃。这是我们在应用切片集群时要避免的。
2.2 数据倾斜的分类
2.2.1 数据量倾斜(写入倾斜)
1.图示
如图,在某些情况下,实例上的数据分布不均衡,某个实例上的数据特别多。
2.bigkey导致倾斜
某个实例上正好保存了 bigkey。bigkey 的 value 值很大(String 类型),或者是 bigkey 保存了大量集合元素(集合类型),会导致这个实例的数据量增加,内存资源消耗也相应增加。
应对方法
- 在业务层生成数据时,要尽量避免把过多的数据保存在同一个键值对中。
- 如果 bigkey 正好是集合类型,还有一个方法,就是把 bigkey 拆分成很多个小的集合类型数据,分散保存在不同的实例上。
3.Slot分配不均导致倾斜
先简单的介绍一下slot的概念,slot其实全名是Hash Slot(哈希槽),在Redis Cluster切片集群中一共有16384 个 Slot,这些哈希槽类似于数据分区,每个键值对都会根据它的 key,被映射到一个哈希槽中。Redis Cluster 方案采用哈希槽来处理数据和实例之间的映射关系。
一张图来解释,数据、哈希槽、实例这三者的映射分布情况。
这里的CRC16(city)%16384可以简单的理解为将key1根据CRC16算法取hash值然后对slot个数取模,得到的就是slot位置为14484,他所对应的实例节点是第三个。
运维在构建切片集群时候,需要手动分配哈希槽,并且把16384 个槽都分配完,否则 Redis 集群无法正常工作。由于是手动分配,则可能会导致部分实例所分配的slot过多,导致数据倾斜。
应对方法
使用CLUSTER SLOTS 命令来查
看slot分配情况,使用CLUSTER SETSLOT,CLUSTER GETKEYSINSLOT,MIGRATE这三个命令来进行slot数据的迁移,具体内容不再这里细说,感兴趣的同学可以自行学习一下。
4.Hash Tag导致倾斜
- Hash Tag 定义 :指当一个key包含 {} 的时候,就不对整个key做hash,而仅对 {} 包括的字符串做hash。
- 假设hash算法为sha1。对user:{user1}:ids和user:{user1}:tweets,其hash值都等同于sha1(user1)。
- Hash Tag 优势 :如果不同 key 的 Hash Tag 内容都是一样的,那么,这些 key 对应的数据会被映射到同一个 Slot 中,同时会被分配到同一个实例上。
- Hash Tag 劣势 :如果不合理使用,会导致大量的数据可能被集中到一个实例上发生数据倾斜,集群中的负载不均衡。
2.2.2 数据访问倾斜(读取倾斜-热key问题)
一般来说数据访问倾斜就是热key问题导致的,如何处理redis热key问题也是面试中常会问到的。所以了解相关概念及方法论也是不可或缺的一环。
1.图示
如图,虽然每个集群实例上的数据量相差不大,但是某个实例上的数据是热点数据,被访问得非常频繁。
但是为啥会有热点数据的产生呢?
2.产生热key的原因及危害
1)用户消费的数据远大于生产的数据(热卖商品、热点新闻、热点评论、明星直播)。
在日常工作生活中一些突发的事件,例如:双十一期间某些热门商品的降价促销,当这其中的某一件商品被数万次点击浏览或者购买时,会形成一个较大的需求量,这种情况下就会造成热点问题。
同理,被大量刊发、浏览的热点新闻、热点评论、明星直播等,这些典型的读多写少的场景也会产生热点问题。
2)请求分片集中,超过单 Server 的性能极限。
在服务端读数据进行访问时,往往会对数据进行分片切分,此过程中会在某一主机 Server 上对相应的 Key 进行访问,当访问超过 Server 极限时,就会导致热点 Key 问题的产生。
如果热点过于集中,热点 Key 的缓存过多,超过目前的缓存容量时,就会导致缓存分片服务被打垮现象的产生。当缓存服务崩溃后,此时再有请求产生,会缓存到后台 DB 上,由于DB 本身性能较弱,在面临大请求时很容易发生请求穿透现象,会进一步导致雪崩现象,严重影响设备的性能。
3.常用的热key问题解决办法:
解决方案一: 备份热key
可以把热点数据复制多份,在每一个数据副本的 key 中增加一个随机后缀,让它和其它副本数据不会被映射到同一个 Slot 中。
这里相当于把一份数据复制到其他实例上,这样在访问的时候也增加随机前缀,将对一个实例的访问压力,均摊到其他实例上
例如:我们在放入缓存时就将对应业务的缓存key拆分成多个不同的key。如下图所示,我们首先在更新缓存的一侧,将key拆成N份,比如一个key名字叫做”good_100”,那我们就可以把它拆成四份,“good_100_copy1”、“good_100_copy2”、“good_100_copy3”、“good_100_copy4”,每次更新和新增时都需要去改动这N个key,这一步就是拆key。
对于service端来讲,我们就需要想办法尽量将自己访问的流量足够的均匀。
如何给自己即将访问的热key上加入后缀?几种办法,根据本机的ip或mac地址做hash,之后的值与拆key的数量做取余,最终决定拼接成什么样的key后缀,从而打到哪台机器上;服务启动时的一个随机数对拆key的数量做取余。
伪代码如下:
const M = N * 2 //生成随机数 random = GenRandom(0, M) //构造备份新key bakHotKey = hotKey + “_” + random data = redis.GET(bakHotKey) if data == NULL { data = GetFromDB() redis.SET(bakHotKey, expireTime + GenRandom(0,5)) }
解决方案二: 本地缓存+动态计算自动发现热点缓存
该方案通过主动发现热点并对其进行存储来解决热点 Key 的问题。首先 Client 也会访问 SLB,并且通过 SLB 将各种请求分发至 Proxy 中,Proxy 会按照基于路由的方式将请求转发至后端的 Redis 中。
在热点 key 的解决上是采用在服务端增加缓存的方式进行。具体来说就是在 Proxy 上增加本地缓存,本地缓存采用 LRU 算法来缓存热点数据,后端节点增加热点数据计算模块来返回热点数据。
Proxy 架构的主要有以下优点:
- Proxy 本地缓存热点,读能力可水平扩展
- DB 节点定时计算热点数据集合
- DB 反馈 Proxy 热点数据
- 对客户端完全透明,不需做任何兼容
热点数据的发现与存储
对于热点数据的发现,首先会在一个周期内对 Key 进行请求统计,在达到请求量级后会对热点 Key 进行热点定位,并将所有的热点 Key 放入一个小的 LRU 链表内,在通过 Proxy 请求进行访问时,若 Redis 发现待访点是一个热点,就会进入一个反馈阶段,同时对该数据进行标记。
可以使用一个etcd或者zk集群来存储反馈的热点数据,然后本地所有节点监听该热点数据,进而加载到本地JVM缓存中。
热点数据的获取
在热点 Key 的处理上主要分为写入跟读取两种形式,在数据写入过程当 SLB 收到数据 K1 并将其通过某一个 Proxy 写入一个 Redis,完成数据的写入。
假若经过后端热点模块计算发现 K1 成为热点 key 后, Proxy 会将该热点进行缓存,当下次客户端再进行访问 K1 时,可以不经 Redis。
最后由于 proxy 是可以水平扩充的,因此可以任意增强热点数据的访问能力。
最佳成熟方案: JD开源hotKey这是目前较为成熟的自动探测热key、分布式一致性缓存解决方案。原理就是在client端做洞察,然后上报对应hotkey,server端检测到后,将对应hotkey下发到对应服务端做本地缓存,并且能保证本地缓存和远程缓存的一致性。
在这里咱们就不细谈了,这篇文章的第三部分:JD开源hotkey源码解析里面会带领大家了解其整体原理。
3 JD开源hotkey—自动探测热key、分布式一致性缓存解决方案
3.1 解决痛点
从上面可知,热点key问题在并发量比较高的系统中(特别是做秒杀活动)出现的频率会比较高,对系统带来的危害也很大。
那么针对此,hotkey诞生的目的是什么?需要解决的痛点是什么?以及它的实现原理。
在这里引用项目上的一段话来概述:对任意突发性的无法预先感知的热点数据,包括并不限于热点数据(如突发大量请求同一个商品)、热用户(如恶意爬虫刷子)、热接口(突发海量请求同一个接口)等,进行毫秒级精准探测到。然后对这些热数据、热用户等,推送到所有服务端JVM内存中,以大幅减轻对后端数据存储层的冲击,并可以由使用者决定如何分配、使用这些热key(譬如对热商品做本地缓存、对热用户进行拒绝访问、对热接口进行熔断或返回默认值)。这些热数据在整个服务端集群内保持一致性,并且业务隔离。
核心功能:热数据探测并推送至集群各个服务器
3.2 集成方式
集成方式在这里就不详述了,感兴趣的同学可以自行搜索。
3.3 源码解析
3.3.1 架构简介
1.全景图一览
流程介绍:
- 客户端通过引用hotkey的client包,在启动的时候上报自己的信息给worker,同时和worker之间建立长连接。定时拉取配置中心上面的规则信息和worker集群信息。
- 客户端调用hotkey的ishot()的方法来首先匹配规则,然后统计是不是热key。
- 通过定时任务把热key数据上传到worker节点。
- worker集群在收取到所有关于这个key的数据以后(因为通过hash来决定key 上传到哪个worker的,所以同一个key只会在同一个worker节点上),在和定义的规则进行匹配后判断是不是热key,如果是则推送给客户端,完成本地缓存。
2.角色构成
这里直接借用作者的描述:
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端监听。
3.hotkey工程结构
3.3.2 client端
主要从下面三个方面来解析源码:
1.客户端启动器
1)启动方式
@PostConstruct public void init() { ClientStarter.Builder builder = new ClientStarter.Builder(); ClientStarter starter = builder.setAppName(appName).setEtcdServer(etcd).build(); starter.startPipeline(); }
appName:是这个应用的名称,一般为${spring.application.name}的值,后续所有的配置都以此为开头
etcd:是etcd集群的地址,用逗号分隔,配置中心。
还可以看到ClientStarter实现了建造者模式,使代码更为简介。
2)核心入口
com.jd.platform.hotkey.client.ClientStarter#startPipeline
/** * 启动监听etcd */ public void startPipeline() { JdLogger.info(getClass(), "etcdServer:" + etcdServer); //设置caffeine的最大容量 Context.CAFFEINE_SIZE = caffeineSize; //设置etcd地址 EtcdConfigFactory.buildConfigCenter(etcdServer); //开始定时推送 PushSchedulerStarter.startPusher(pushPeriod); PushSchedulerStarter.startCountPusher(10); //开启worker重连器 WorkerRetryConnector.retryConnectWorkers(); registEventBus(); EtcdStarter starter = new EtcdStarter(); //与etcd相关的监听都开启 starter.start(); }
该方法主要有五个功能:
① 设置本地缓存(caffeine)的最大值,并创建etcd实例
//设置caffeine的最大容量 Context.CAFFEINE_SIZE = caffeineSize; //设置etcd地址 EtcdConfigFactory.buildConfigCenter(etcdServer);
caffeineSize是本地缓存的最大值,在启动的时候可以设置,不设置默认为200000。
etcdServer是上面说的etcd集群地址。
Context可以理解为一个配置类,里面就包含两个字段:
public class Context { public static String APP_NAME; public static int CAFFEINE_SIZE; }
EtcdConfigFactory是ectd配置中心的工厂类
public class EtcdConfigFactory { private static IConfigCenter configCenter; private EtcdConfigFactory() {} public static IConfigCenter configCenter() { return configCenter; } public static void buildConfigCenter(String etcdServer) { //连接多个时,逗号分隔 configCenter = JdEtcdBuilder.build(etcdServer); } }
通过其configCenter()方法获取创建etcd实例对象,IConfigCenter接口封装了etcd实例对象的行为(包括基本的crud、监控、续约等)
② 创建并启动定时任务:PushSchedulerStarter
//开始定时推送 PushSchedulerStarter.startPusher(pushPeriod);//每0.5秒推送一次待测key PushSchedulerStarter.startCountPusher(10);//每10秒推送一次数量统计,不可配置
pushPeriod是推送的间隔时间,可以再启动的时候设置,最小为0.05s,推送越快,探测的越密集,会越快探测出来,但对client资源消耗相应增大
PushSchedulerStarter类
/** * 每0.5秒推送一次待测key */ public static void startPusher(Long period) { if (period == null || period <= 0) { period = 500L; } @SuppressWarnings("PMD.ThreadPoolCreationRule") ScheduledExecutorService scheduledExecutorService = Executors.newSingleThreadScheduledExecutor(new NamedThreadFactory("hotkey-pusher-service-executor", true)); scheduledExecutorService.scheduleAtFixedRate(() -> { //热key的收集器 IKeyCollector<HotKeyModel, HotKeyModel> collectHK = KeyHandlerFactory.getCollector(); //这里相当于每0.5秒,通过netty来给worker来推送收集到的热key的信息,主要是一些热key的元数据信息(热key来源的app和key的类型和是否是删除事件,还有该热key的上报次数) //这里面还有就是该热key在每次上报的时候都会生成一个全局的唯一id,还有该热key每次上报的创建时间是在netty发送的时候来生成,同一批次的热key时间是相同的 List<HotKeyModel> hotKeyModels = collectHK.lockAndGetResult(); if(CollectionUtil.isNotEmpty(hotKeyModels)){ //积攒了半秒的key集合,按照hash分发到不同的worker KeyHandlerFactory.getPusher().send(Context.APP_NAME, hotKeyModels); collectHK.finishOnce(); } },0, period, TimeUnit.MILLISECONDS); } /** * 每10秒推送一次数量统计 */ public static void startCountPusher(Integer period) { if (period == null || period <= 0) { period = 10; } @SuppressWarnings("PMD.ThreadPoolCreationRule") ScheduledExecutorService scheduledExecutorService = Executors.newSingleThreadScheduledExecutor(new NamedThreadFactory("hotkey-count-pusher-service-executor", true)); scheduledExecutorService.scheduleAtFixedRate(() -> { IKeyCollector<KeyHotModel, KeyCountModel> collectHK = KeyHandlerFactory.getCounter(); List<KeyCountModel> keyCountModels = collectHK.lockAndGetResult(); if(CollectionUtil.isNotEmpty(keyCountModels)){ //积攒了10秒的数量,按照hash分发到不同的worker KeyHandlerFactory.getPusher().sendCount(Context.APP_NAME, keyCountModels); collectHK.finishOnce(); } },0, period, TimeUnit.SECONDS); }
从上面两个方法可知,都是通过定时线程池来实现定时任务的,都是守护线程。
咱们重点关注一下KeyHandlerFactory类,它是client端设计的一个比较巧妙的地方,从类名上直译为key处理工厂。具体的实例对象是DefaultKeyHandler:
public class DefaultKeyHandler { //推送HotKeyMsg消息到Netty的推送者 private IKeyPusher iKeyPusher = new NettyKeyPusher(); //待测key的收集器,这里面包含两个map,key主要是热key的名字,value主要是热key的元数据信息(比如:热key来源的app和key的类型和是否是删除事件) private IKeyCollector<HotKeyModel, HotKeyModel> iKeyCollector = new TurnKeyCollector(); //数量收集器,这里面包含两个map,这里面key是相应的规则,HitCount里面是这个规则的总访问次数和热后访问次数 p