一文教你彻底打败Redis Bigkey和Hotkey问题

前言

bigkey和hotkey是Redis生产中两个比较常见的问题,本文从它们的概念、危害、发现、解决的角度,来分析一下这两个问题。

bigkey

概念

通俗易懂的讲,Big Key就是某个key对应的value很大,占用的redis空间很大,本质上是大value问题。key往往是程序可以自行设置的,value往往不受程序控制,因此可能导致value很大。

redis中这些Big Key对应的value值很大,在序列化/反序列化过程中花费的时间很大,因此当我们操作Big Key时,通常比较耗时,这就可能导致redis发生阻塞,从而降低redis性能。

用几个实际的例子对大Key的特征进行描述:

  • 一个String类型的Key,它的值为5MB(数据过大)
  • 一个List类型的Key,它的列表数量为20000个(列表数量过多)
  • 一个ZSet类型的Key,它的成员数量为10000个(成员数量过多)
  • 一个Hash格式的Key,它的成员数量虽然只有1000个但这些成员的value总大小为10MB(成员体积过大)

一般业界(参考阿里、快手Redis开发规范)对于key的规范如下:

string类型控制在10KB以内,hash、list、set、zset元素个数不要超过5000。

危害

  • 慢查询:由于bigkey包含的数据量很大,导致一次请求的执行时间可能会很长,导致慢查询问题
  • 集群内存分布不均衡:集群模式下,bigkey较多的节点内存占用偏高,影响集群稳定性
  • 过期阻塞:当bigkey过期(删除)时,由于Redis单线程,会导致Redis阻塞从而影响客户端命令执行
  • 网卡超负荷:试想一个string类型的key数据量为10mb,这时10000个用户请求该key,那么会需要约100G的网卡带宽,影响服务器的正常运转

发现

主要思路为扫描Redis的所有Key,判断Key的长度即可。

  1. Redis 4.0以后的客户端提供了bigkeys命令,可以找出每种数据类型占用内存最多的Key。

  2. 阿里云redis大key搜索工具

删除

主要有两个办法1.遍历删除 2.Redis 异步删除命令

循环遍历删除
  1. Hash删除: hscan + hdel
public void delBigHash(String host, int port, String password, String bigHashKey) {
    Jedis jedis = new Jedis(host, port);
    if (password != null && !"".equals(password)) {
        jedis.auth(password);
    }
    ScanParams scanParams = new ScanParams().count(100);
    String cursor = "0";
    do {
        ScanResult<Entry<String, String>> scanResult = jedis.hscan(bigHashKey, cursor, scanParams);
        List<Entry<String, String>> entryList = scanResult.getResult();
        if (entryList != null && !entryList.isEmpty()) {
            for (Entry<String, String> entry : entryList) {
                jedis.hdel(bigHashKey, entry.getKey());
            }
        }
        cursor = scanResult.getStringCursor();
    } while (!"0".equals(cursor));
    
    //删除bigkey
    jedis.del(bigHashKey);
}
  1. List删除: ltrim
public void delBigList(String host, int port, String password, String bigListKey) {
    Jedis jedis = new Jedis(host, port);
    if (password != null && !"".equals(password)) {
        jedis.auth(password);
    }
    long llen = jedis.llen(bigListKey);
    int counter = 0;
    int left = 100;
    while (counter < llen) {
        //每次从左侧截掉100个
        jedis.ltrim(bigListKey, left, llen);
        counter += left;
    }
    //最终删除key
    jedis.del(bigListKey);
}
  1. Set删除: sscan + srem
public void delBigSet(String host, int port, String password, String bigSetKey) {
    Jedis jedis = new Jedis(host, port);
    if (password != null && !"".equals(password)) {
        jedis.auth(password);
    }
    ScanParams scanParams = new ScanParams().count(100);
    String cursor = "0";
    do {
        ScanResult<String> scanResult = jedis.sscan(bigSetKey, cursor, scanParams);
        List<String> memberList = scanResult.getResult();
        if (memberList != null && !memberList.isEmpty()) {
            for (String member : memberList) {
                jedis.srem(bigSetKey, member);
            }
        }
        cursor = scanResult.getStringCursor();
    } while (!"0".equals(cursor));
    
    //删除bigkey
    jedis.del(bigSetKey);
}
  1. SortedSet删除: zscan + zrem
public void delBigZset(String host, int port, String password, String bigZsetKey) {
    Jedis jedis = new Jedis(host, port);
    if (password != null && !"".equals(password)) {
        jedis.auth(password);
    }
    ScanParams scanParams = new ScanParams().count(100);
    String cursor = "0";
    do {
        ScanResult<Tuple> scanResult = jedis.zscan(bigZsetKey, cursor, scanParams);
        List<Tuple> tupleList = scanResult.getResult();
        if (tupleList != null && !tupleList.isEmpty()) {
            for (Tuple tuple : tupleList) {
                jedis.zrem(bigZsetKey, tuple.getElement());
            }
        }
        cursor = scanResult.getStringCursor();
    } while (!"0".equals(cursor));
    
    //删除bigkey
    jedis.del(bigZsetKey);
}
异步删除

Redis4.0 已经支持key的异步删除,使用unlink命令即可,Redis lazyfree

解决

主要通过大Key拆分的方法来解决大Key问题,我们需要将一个大key的数据拆分到多个小key上,然后通过客户端分片的方式来访问。

比如:

  • string类型
    • 将string类型转为hash类型,list类型,再对hash,list进行拆分
    • 对于纯字符串类型,可以通过
      • 1.使用更节省数据的序列化协议
      • 2.使用数据压缩算法,存取过程进行相应的压缩解压缩操作
  • list类型:拆分为list:0,list:1,list:2,list:N等小key,通过对id % N 将数据Hash到不同的子key中
  • set类型:同list

hotkey

概念

由于某个 Key 的数据一定是存储到后端某台服务器的 Redis 单个实例上,如果对这个 Key 突然出现大量的请求操作,这样就会造成流量过于集中,达到 Redis 单个实例处理上限,可能会导致 Redis 实例 CPU 使用率 100%,或者是网卡流量达到上限等,对系统的稳定性和可用性造成影响,或者更为严重出现服务器宕机,无法对外提供服务。

对于Redis单机来说,业界一般认为理论上极限OPS在10W左右,实际情况还跟具体机器配置相关。

危害

流量过于集中,导致单个Redis节点负载(单节点一般10W)过大,导致Redis服务崩溃,大量 Redis 请求失败,查询操作可能打到数据库,数据库崩溃,导致整个服务不可用。

由此可见,hotkey对于服务的可用性会产生较大的危害,所以我们应该及时的发现hotkey并解决它。

发现

通过上面的分析,出现热 Key 的危害还是很大的,我们不可能等到热 Key 出现已经拖垮了服务再去处理,那个时候业务一定已经收到影响,损失也是不言而喻的;那么能够在热 Key 出现前通过一些手段提前监控到热 Key 的出现,对于保证业务系统的稳定性是非常重要的,那么我们都有哪些手段提前观测到热 Key 的出现呢?

1.预估业务流量

根据业务系统上线的一些活动和功能,我们是可以在某些场景下提前预估热 Key 的出现的,比如业务需要进行一场商品秒杀活动,秒杀商品信息和数量一般都会缓存到 Redis 中,这种场景极有可能出现热 Key 问题的。

  • 优点:简单,凭经验发现热 Key,提早发现提早处理;
  • 缺点:没有办法预测所有热 Key 出现,比如某些热点新闻事件,无法提前预测。
2.客户端监控

一般我们在连接 Redis 服务器时都要使用专门的 SDK(比如:Java 的客户端工具 JedisRedisson),我们可以对客户端工具进行封装,在发送请求前进行收集采集,同时定时把收集到的数据上报到统一的服务进行聚合计算。

  • 优点:方案简单
  • 缺点:
    • 对客户端代码有一定入侵,或者需要对 SDK 工具进行二次开发;
    • 没法适应多语言架构,每一种语言的 SDK 都需要进行开发,后期开发维护成本较高。
3.代理层监控

如果所有的 Redis 请求都经过 Proxy(代理)的话,可以考虑改动 Proxy 代码进行收集,思路与客户端基本类似。

一文教你彻底打败Redis Bigkey和Hotkey问题_第1张图片

  • 优点:对使用方完全透明,能够解决客户端 SDK 的语言异构和版本升级问题;
  • 缺点:
    • 开发成本会比客户端高些;
    • 并不是所有的 Redis 集群架构中都有 Proxy 代理(使用这种方式必须要部署 Proxy)。
4.Redis自带命令

hotkeys 参数

Redis4.0.3 版本中添加了 hotkeys 查找特性,可以直接利用 redis-cli --hotkeys 获取当前 keyspace 的热点 key,实现上是通过 scan + object freq 完成的。

  • 优点:无需进行二次开发,能够直接利用现成的工具;
  • 缺点:
    • 由于需要扫描整个 keyspace,实时性上比较差;
    • 扫描时间与 key 的数量正相关,如果 key 的数量比较多,耗时可能会非常长。

monitor 命令

monitor 命令可以实时抓取出 Redis 服务器接收到的命令,通过 redis-cli monitor 抓取数据,同时结合一些现成的分析工具,比如 redis-faina,统计出热 Key。

  • 优点:无需进行二次开发,能够直接利用现成的工具;
  • 缺点:该命令在高并发的条件下,有内存增暴增的隐患,还会降低 Redis 的性能。
5.依靠大厂的基建能力

其实各大云厂商都有提供发现hotkey,bigkey的能力,包括各大厂的基架也都有对应的Redis监控工具,可以对hotkey和bigkey进行发现。

解决

1.多级缓存

当出现热 Key 以后,把热 Key 加载到系统的 JVM 中。后续针对这些热 Key 的请求,会直接从 JVM 中获取,而不会走到 Redis 层。这些本地缓存的工具很多,比如 Ehcache,或者 Google GuavaCache 工具,或者直接使用 HashMap 作为本地缓存工具都是可以的。

使用本地缓存需要注意两个问题:

  • 如果对热 Key 进行本地缓存,需要防止本地缓存过大,影响JVM Heap空间;
  • 需要处理本地缓存和 Redis 集群读写数据一致性问题。
2.负载均衡

通过前面的分析,我们可以了解到,之所以出现热 Key,是因为有大量的对同一个 Key 的请求落到同一个 Redis 实例上,如果我们可以有办法将这些请求负载到不同的实例上,防止出现流量倾斜的情况,那么热 Key 问题也就不存在了。

那么如何将对某个热 Key 的请求打散到不同实例上呢?我们就可以通过热 Key 备份的方式,基本的思路就是,我们可以给热 Key 加上前缀或者后缀,把一个热 Key 的数量变成 Redis 实例个数 N 的倍数 M,从而由访问一个 Redis Key 变成访问 MRedis KeyMRedis Key 经过分片分布到不同的实例上,将访问量均摊到所有实例。

// N 为 Redis 实例个数,M 为 N 的 2倍
func getData() {
	const M = N * 2
	//生成随机数
	random = GenRandom(0, M)
	//构造备份新 Key
	bakHotKey = hotKey + "_" + random
	data = redis.GET(bakHotKey)
	if data == NULL {
	    data = redis.GET(hotKey)
	    if data == NULL {
	      	//这里可以注意一下缓存击穿和缓存雪崩的问题
	        data = GetFromDB()
	        redis.SET(hotKey, data, expireTime)
	        redis.SET(bakHotKey, data, expireTime + GenRandom(0, 5))
	    } else {
	        redis.SET(bakHotKey, data, expireTime + GenRandom(0, 5))
	    }
	}
	return data
}

问题:

  • 浪费Redis内存空间,可以通过配置中心设置一个开关,当开关打开时才会访问临时的hotkey节点
  • 数据一致性
    • 多个Redis节点之间数据的一致性无法保证,数据同步的时候会存在部分数据不一致性的情况
    • 如果存在数据更新的情况,需要同时更新所有Redis节点,此处也会有数据不一致的情况
总结

对于hotkeys的解决办法,没有一个办法是所有场景的银弹,我们需要针对业务场景来选择具体的方案,但是我们可以看到的是,无论是哪种方案,都会存在一定的一致性问题,不过既然都出现hotkey了,那么肯定是并发很高的情况,对于这种情况,一般我们保证最终一致性即可,无需追求数据强一致性。

而这也给我们揭示了一个道理 —— 一致性和可用性不可兼得啊,这很CAP。

你可能感兴趣的:(Redis,redis,缓存)