分布式缓存一般被定义为一个数据集合,它将数据分布(或分区)于任意数目的集群节点上。集群中的一个具体节点负责缓存中的一部分数据,整体对外提供统一的访问接口
Amazon 于 2007 年提出的一种改进的一致性哈希算法 [4]。该算法将整个哈希空间分为若干等大小的 Q 份数据分区(也称为虚拟节点,Q>>N,N 为缓存节点数),每个缓存节点依据其处理能力分配不同数量的数据分区。客户端请求的数据 Key 值经哈希函数映射至哈希环上的位置记为 token,token 值再次被哈希映射为某一分区标识。得到分区标识后,客户端从分区服务器映射表中查询存放该数据分区的缓存节点后进行数据访问。使用该算法对相同数据 Key 进行计算,其必然会被映射到固定的 DataServer 上,如图:
热key是什么问题,如何导致的?
一般来说,我们使用的缓存Redis都是多节点的集群版,对某个key进行读写时,会根据该key的hash计算出对应的slot,根据这个slot就能找到与之对应的分片(一个master和多个slave组成的一组redis集群)来存取该K-V。但是在实际应用过程中,对于某些特定业务或者一些特定的时段(比如电商业务的商品秒杀活动),可能会发生大量的请求访问同一个key。所有的请求(且这类请求读写比例非常高)都会落到同一个redis server上,该redis的负载就会严重加剧,此时整个系统增加新redis实例也没有任何用处,因为根据hash算法,同一个key的请求还是会落到同一台新机器上,该机器依然会成为系统瓶颈2,甚至造成整个集群宕掉,若此热点key的value 也比较大,也会造成网卡达到瓶颈,这种问题称为 “热key” 问题。
热key最明显的影响是整个redis集群中的qps并没有那么大的前提下,流量分布在集群中slot不均的问题,那么我们可以最先想到的就是对于每个slot中的流量做监控,上报之后做每个slot的流量对比,就能在热key出现时发现影响到的具体slot。虽然这个监控最为方便,但是粒度过于粗了,仅适用于前期集群监控方案,并不适用于精准探测到热key的场景。
如果我们使用的是图2的redis集群proxy代理模式,由于所有的请求都会先到proxy再到具体的slot节点,那么这个热点key的探测统计就可以放在proxy中做,在proxy中基于时间滑动窗口,对每个key做计数,然后统计出超出对应阈值的key。为了防止过多冗余的统计,还可以设定一些规则,仅统计对应前缀和类型的key。这种方式需要至少有proxy的代理机制,对于redis架构有要求。
redis 4.0以上的版本支持了每个节点上的基于LFU的热点key发现机制,使用redis-cli –hotkeys即可,执行redis-cli时加上–hotkeys选项。可以定时在节点中使用该命令来发现对应热点key。
由于redis的命令每次都是从客户端发出,基于此我们可以在redis client的一些代码处进行统计计数,每个client做基于时间滑动窗口的统计,超过一定的阈值之后上报至server,然后统一由server下发至各个client,并且配置对应的过期时间。
这个方式看起来更优美,其实在一些应用场景中并不是那么合适,因为在client端这一侧的改造,会给运行的进程带来更大的内存开销,更直接的来说,对于Java和goLang这种自动内存管理的语言,会更加频繁的创建对象,从而触发gc导致接口响应耗时增加的问题,这个反而是不太容易预料到的事情。
最终可以通过各个公司的基建,做出对应的选择。
DataServer 收到客户端的请求后,由每个具体处理请求的工作线程(Worker Thread)进行请求的统计。工作线程用来统计热点的数据结构均为 ThreadLocal 模式的数据结构,完全无锁化设计。热点识别算法使用精心设计的多级加权 LRU 链和 HashMap 组合的数据结构,在保证服务端请求处理效率的前提下进行请求的全统计,支持 QPS 热点和流量热点(即请求的 QPS 不大但是数据本身过大而造成的大流量所形成的热点)的精准识别。每个采样周期结束时,工作线程会将统计的数据结构转交到后台的统计线程池进行分析处理。统计工作异步在后台进行,不抢占正常的数据请求的处理资源。
一种最简单粗暴的方式,对于特定的slot或者热key做限流,这个方案明显对于业务来说是有损的,所以建议只用在出现线上问题,需要止损的时候进行特定的限流。
本地缓存也是一个最常用的解决方案,既然我们的一级缓存扛不住这么大的压力,就再加一个二级缓存吧。由于每个请求都是由service发出的,这个二级缓存加在service端是再合适不过了,因此可以在服务端每次获取到对应热key时,使用本地缓存存储一份,等本地缓存过期后再重新请求,降低redis集群压力。以java为例,guavaCache就是现成的工具。
本地缓存对于我们的最大的影响就是数据不一致的问题,我们设置多长的缓存过期时间,就会导致最长有多久的线上数据不一致问题,这个缓存时间需要衡量自身的集群压力以及业务接受的最大不一致时间。
可是client统计热点直接缓存,也可以根据server下发的热点key消息,所有机器进行缓存。缓存会占用业务方机器的内存, 如果缓存的热key过大或者数量过多, 可能会导致业务方机器OOM
如何既能保证不出现热key问题,又能尽量的保证数据一致性呢?拆key也是一个好的解决方案。
我们在放入缓存时就将对应业务的缓存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的数量做取余。
对于熟悉微服务配置中心的伙伴来讲,我们的思路可以向配置中心的一致性转变一下。拿nacos来举例,它是如何做到分布式的配置一致性的,并且相应速度很快?那我们可以将缓存类比配置,这样去做。
长轮询+本地化的配置。首先服务启动时会初始化全部的配置,然后定时启动长轮询去查询当前服务监听的配置有没有变更,如果有变更,长轮询的请求便会立刻返回,更新本地配置;如果没有变更,对于所有的业务代码都是使用本地的内存缓存配置。这样就能保证分布式的缓存配置时效性与一致性。
2.5.1 服务端设计
本方案通过在 DataServer 上划分一块 HotZone 存储区域的方式来解决热点数据的访问。该区域存储当前产生的所有读热点的数据,由客户端配置的缓存访问逻辑来处理各级缓存的访问。多级缓存架构如下:
所有 DataServer 的 HotZone 存储区域之间没有权重关系,每个 HotZone 都存储相同的读热点数据。客户端对热点数据 Key 的请求会随机到任意一台 DataServer 的 HotZone 区域,这样单点的热点请求就被散列到多个节点乃至整个集群。
2.5.2 客户端设计
当客户端在第一次请求前初始化时,会获取整个 Tair 集群的节点信息以及完整的数据路由表,同时也会获取配置的热点散列机器数(即客户端访问的 HotZone 的节点范围)。随后客户端随机选择一个 HotZone 区域作为自身固定的读写 HotZone 区域。在 DataServer 数量和散列机器数配置未发生变化的情况下,不会改变选择。即每个客户端只访问唯一的 HotZone 区域。
客户端收到服务端反馈的热点 Key 信息后,至少在客户端生效 N 秒。在热点 Key 生效期间,当客户端访问到该 Key 时,热点的数据会首先尝试从 HotZone 节点进行访问,此时 HotZone 节点和源数据 DataServer 节点形成一个二级的 Cache 模型。客户端内部包含了两级 Cache 的处理逻辑,即对于热点数据,客户端首先请求 HotZone 节点,如果数据不存在,则继续请求源数据节点,获取数据后异步将数据存储到 HotZone 节点里。使用 Tair 客户端的应用常规调用获取数据的接口即可,整个热点的反馈、识别以及对多级缓存的访问对外部完全透明。HotZone 缓存数据的一致性由客户端初始化时设置的过期时间来保证,具体的时间由具体业务对缓存数据不一致的最大容忍时间来决定。
客户端存储于本地的热点反馈过期后,数据 Key 会到源 DataServer 节点读取。如果该 Key 依旧在服务端处于热点状态,客户端会再次收到热点反馈包。因为所有客户端存储于本地的热点反馈信息的失效节奏不同,所以不会出现同一瞬间所有的请求都回源的情况。即使所有请求回源,也仅需要回源读取一次即可,最大的读取次数仅为应用机器数。若回源后发现该 Key 已不是热点,客户端便回到常规的访问模式。
对于写热点,因为一致性的问题,难以使用多级缓存的方式来解决。如果采用写本地 Cache,再异步更新源 DataServer 的方案。那么在 Cache 写入但尚未更新的时候,如果业务机器宕机,就会有已写数据丢失的问题。同时,本地 Cache 会导致进行数据更新的某应用机器当前更新周期内的修改对其他应用机器不可见,从而延长数据不一致的时间。故多级 Cache 的方案无法支持写热点。最终写热点采用在服务端进行请求合并的方式进行处理。
热点 Key 的写请求在 IO 线程被分发到专门的热点合并线程处理,该线程根据 Key 对写请求进行一定时间内的合并,随后由定时线程按照预设的合并周期将合并后的请求提交到引擎层。合并过程中请求结果暂时不返回给客户端,等请求合并写入引擎成功后统一返回。这样做不会有一致性的问题,不会出现写成功后却读到旧数据,也避免了 LDB 集群返回成功,数据并未落盘的情况(假写)。具体的合并周期在服务端可配置,并支持动态修改生效。
写热点的方案对客户端完全透明,不需要客户端做任何修改。