Redis 热 key 是什么问题,如何导致的?
Redis cluster是由master和slave节点组成的,对某个key的读写都是根据key的hash计算出对应slot(位置),根据这个slot找到对应的分片来操作kv,比如电商的秒杀、采集服务查询在线的设备等等,可能会发生大量的请求访问同一个key,所有的请求都会到同一个server上,它的负载会很高,这个时候增加redis实例也没用,因为根据hash算法还是会到一个server,这成为了瓶颈,如果这个热点key的value比较大的话,也会造成网卡达到瓶颈,这就是“热点key”问题。
检查热点key的方案有什么?
-
对集群中每个slot做监控
热key最明显的影响是整个redis集群中的qps并没有那么大的前提下,流量分布在集群中slot不均的问题,那么我们可以最先想到的就是对于每个slot中的流量做监控,上报之后做每个slot的流量对比,就能在热key出现时发现影响到的具体slot。虽然这个监控最为方便,但是粒度过于粗了,仅适用于前期集群监控方案,并不适用于精准探测到热key的场景。
-
proxy的代理机制作为整个流量入口统计
如果redis集群是proxy代理模式,由于所有的请求都会先到proxy再到具体的slot节点,那么这个热点key的探测统计就可以放在proxy中做,在proxy中基于时间滑动窗口,对每个key做计数,然后统计出超出对应阈值的key。为了防止过多冗余的统计,还可以设定一些规则,仅统计对应前缀和类型的key。这种方式对于redis架构有要求。
-
redis基于LFU的热点key发现机制
redis 4.0以上的版本支持了每个节点上的基于LFU的热点key发现机制,使用redis-cli –hotkeys即可,执行redis-cli时加上–hotkeys选项。这个命令的执行时间比较长,可以定时在节点中使用该命令来发现对应热点key。
-
基于Redis客户端做探测
由于redis的命令每次都是从客户端发出,基于此我们可以在redis client的一些代码处进行统计计数,每个client做基于时间滑动窗口的统计,超过一定的阈值之后上报至server,然后统一由server下发至各个client,并且配置对应的过期时间。
对于热点key怎么解决?
-
线上对特定的key或slot做限流
-
使用二级(本地)缓存
既然一级缓存扛不住压力,可以使用GuavaCache工具增加二级缓存,在服务端每次获取到对应热key时,使用本地缓存存储一份,等本地缓存过期后再重新请求,降低redis集群压力。我们之前就遇到一种业务,在数据采集过程中,每个设备对应一个code,因为流数据的频率很高,kafka发送过程中使用分区发送,使用redis的incr为每个设备对应的code生成自增号,用自增号与分区数求模,如果10000台设备,自增号就是0~9999,取模后进行分区发送就可以做到每个分区均匀分布,自增号生成后放到redis的hash结构中,每接到一次数据就从hash中取,没有就自增生成再放入hash,这时候每个设备的自增数一经生成是不会再发生改变的,为了降低redis压力,避免高频的从hash中取,使用本地缓存进行优化。
需要注意的是,本地缓存对于我们的最大的影响就是数据不一致的问题,我们设置多长的缓存过期时间,就会导致最长有多久的线上数据不一致问题,这个缓存时间需要衡量自身的集群压力以及业务接受的最大不一致时间。
private static LoadingCache<String, List<Object>> configCache = CacheBuilder.newBuilder() .concurrencyLevel(8) //并发读写的级别,建议设置cpu核数 .expireAfterWrite(1, TimeUnit.HOURS) //写入数据后多久过期 .initialCapacity(1000) //初始化cache的容器大小 .maximumSize(10000)//cache的容器最大长度 .recordStats() // build方法中可以指定CacheLoader,在缓存不存在时通过CacheLoader的实现自动加载缓存 .build(new CacheLoader<String, List<Object>>() { @Override public List<Object> load(String hotKey) throws Exception { } }); /** * 此缓存演示如何结合redis自增数 hash 本地缓存使用进行设备自增数的生成、缓存、本地缓存 * 本地缓存使用Guava Cache */ public class DeviceIncCache { /** * 本地缓存 */ private Cache<String, Integer> localCache = CacheBuilder.newBuilder() .concurrencyLevel(16) // 并发级别 .initialCapacity(1000) // 初始容量 .maximumSize(10000) // 缓存最大长度 .expireAfterAccess(1, TimeUnit.HOURS) // 缓存1小时没被使用就过期 .build(); @Autowired private RedisTemplate<String, Integer> redisTemplate; /** * redis自增数缓存的key */ private static final String DEVICE_INC_COUNT = "device_inc_count"; /** * redis设备编码对应自增数的hash缓存key */ private static final String DEVICE_INC_VALUE = "device_inc_value"; /** * 获取设备自增数 */ public int getInc(String deviceCode){ // 1.从本地缓存获取 Integer inc = localCache.get(deviceCode); if(inc != null) { return inc; } // 2.本地缓存未命中,从redis的hash缓存获取 inc = (Integer)redisTemplate.opsForHash().get(DEVICE_INC_VALUE, deviceCode); // 3. redis的hash缓存中没有,说明是新设备,先为设备生成一个自增号 if(inc == null) { inc = redisTemplate.opsForValue().increment(DEVICE_INC_COUNT).intValue; // 添加到redis hash缓存 redisTemplate.opsForHash().put(DEVICE_INC_VALUE, deviceCode, inc); } // 4.添加到本地缓存 localCache.put(deviceCode, inc); // 4.返回自增数 return inc; } }
-
拆key
热key问题影响较大,日常开发时怎么既能保证不出现热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的数量做取余。