聚合国内IT技术精华文章,分享IT技术精华,帮助IT从业人士成长

炸了!Redis bigkey导致生产事故!

2022-04-19 18:32 浏览: 2381553 次 我要评论(0 条) 字号:


文章来源:【公众号:BiggerBoy】


目录
  • 什么是 bigkey?

  • 危害是什么?

  • 怎么产生的?

  • 如何发现线上是否存在 bigkey?

  • 如何消除 bigkey?

  • 如何优雅删除 bigkey


这篇文章给大家分享一个 Redis 生产事故的复盘,主要分析 Redis 中的 bigkey 相关问题。


什么是 bigkey?


在 Redis 中数据都是 key-value 的形式存储的。bigkey 是指 key 对应的 value 所占的内存空间比较大。


例如一个 String 类型的 value 最大可以存 512MB 的数据,一个 list 类型的 value 最多可以存储 2^32-1 个元素。


如果按照数据结构来细分的话,一般分为字符串类型 bigkey 和非字符串类型 bigkey。也有叫 bigvalue 的,被问到时不要惊讶。


但在实际生产环境中出现下面两种情况,我们就可以认为它是 bigkey:

  • 字符串类型:它的 big 体现在单个 value 值很大,一般认为超过 10KB 就是 bigkey。

  • 非字符串类型:哈希、列表、集合、有序集合,它们的 big 体现在元素个数太多。


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


bigkey 的危害


bigkey 的危害体现在几个方面:


| 内存空间不均匀(平衡)

例如在 Redis Cluster 中,大量 bigkey 落在其中一个 Redis 节点上,会造成该节点的内存空间使用率比其他节点高,造成内存空间使用不均匀。


| 请求倾斜

对于非字符串类型的 bigkey 的请求,由于其元素较多,很可能对于这些元素的请求都落在 Redis cluster 的同一个节点上,造成请求不均匀,压力过大。


| 超时阻塞

由于 Redis 单线程的特性,操作 bigkey 比较耗时,也就意味着阻塞 Redis 可能性增大。这就是造成生产事故的罪魁祸首!导致 Redis 间歇性卡死、影响线上正常下单!


| 网络拥塞

每次获取 bigkey 产生的网络流量较大,假设一个 bigkey 为 1MB,每秒访问量为 1000,那么每秒产生 1000MB 的流量,对于普通的千兆网卡(按照字节算是 128MB/s)的服务器来说简直是灭顶之灾。


而且一般服务器会采用单机多实例的方式来部署,也就是说一个 bigkey 可能会对其他实例造成影响,其后果不堪设想。


| 过期删除

有个 bigkey,它安分守己(只执行简单的命令,例如 hget、lpop、zscore 等),但它设置了过期时间,当它过期后,会被删除,如果没有使用 Redis 4.0 的过期异步删除(lazyfree-lazy-expire yes),就会存在阻塞 Redis 的可能性。


bigkey 的产生


一般来说,bigkey 的产生都是由于程序设计不当,或者对于数据规模预料不清楚造成的。


来看几个例子:

  • 社交类:如果对于某些明星或者大 v 的粉丝列表不精心设计下,必是 bigkey。

  • 统计类:例如按天存储某项功能或者网站的用户集合,除非没几个人用,否则必是 bigkey。

  • 缓存类:将数据从数据库 load 出来序列化放到 Redis 里,这个方式很常用,但有两个地方需要注意,第一:是不是有必要把所有字段都缓存;第二:有没有相互关联的数据,有的同学为了图方便把相关数据都存一个 key 下,产生 bigkey。


如何发现 bigkey


redis-cli --bigkeys 可以命令统计 bigkey 的分布。


但是在生产环境中,开发和运维人员更希望自己可以定义 bigkey 的大小,而且更希望找到真正的 bigkey 都有哪些,这样才可以去定位、解决、优化问题。


判断一个 key 是否为 bigkey,只需要执行 debug object key 查看 serializedlength 属性即可,它表示 key 对应的 value 序列化之后的字节数。


例如我们执行如下操作:
debug object test:bigkey:hash
Value at:00007FCD1AC28870 refcount:1 encoding:hashtable serializedlength:3122200 lru:5911519 lru_seconds_idle:8788


可以发现 serializedlength=3122200 字节,约为 2.97M,同时可以看到 encoding 是 hashtable,也就是 hash 类型。


那么可以通过 strlen 来看一下字符串的字节数为 2247394 字节,约为 2MB。


再来看一个 string 类型的,执行如下操作:

debug object test:bigkey:string
Value at:0x7fc06c1b1430 refcount:1 encoding:raw serializedlength:1256350 lru:11686193
lru_seconds_idle:20


可以发现 serializedlength=1256350 字节,约为 1.19M,同时可以看到 encoding 是 raw,也就是字符串类型。


那么可以通过 strlen 来看一下字符串的字节数为 2247394 字节,约为 2MB:
> strlen test:bigkey:string
(integer) 2247394


serializedlength 不代表真实的字节大小,它返回对象使用 RDB 编码序列化后的长度,值会偏小,但是对于排查 bigkey 有一定辅助作用,因为不是每种数据结构都有类似 strlen 这样的方法。


实际生产的操作方式


在实际生产环境中发现 bigkey 的两种方式如下:


| 被动收集

许多开发人员确实可能对 bigkey 不了解或重视程度不够,但是这种 bigkey 一旦大量访问,很可能就会带来命令慢查询和网卡跑满问题,开发人员通过对异常的分析通常能找到异常原因可能是 bigkey。


这种方式虽然不是被笔者推荐的,但是在实际生产环境中却大量存在,建议修改 Redis 客户端,当抛出异常时打印出所操作的 key,方便排 bigkey 问题。


| 主动检测

scan+debug object:如果怀疑存在 bigkey,可以使用 scan 命令渐进地扫描出所有的 key,分别计算每个 key 的 serializedlength,找到对应 bigkey 进行相应的处理和报警,这种方式是比较推荐的方式。


如何优化 bigkey


由于开发人员对 Redis 的理解程度不同,在实际开发中出现 bigkey 在所难免,重要的是,能通过合理的检测机制及时找到它们,进行处理。


作为开发人员在业务开发时应注意不能将 Redis 简单暴力的使用,应该在数据结构的选择和设计上更加合理,避免出现 bigkey。


| 拆分

基本思路就是,让 key/value 更加小。在设计之初就思考可不可以做一些优化(例如拆分数据结构)尽量让这些 bigkey 消失在业务中。


当出现 bigkey 已经影响到正常使用了,则考虑重新构建自己的业务 key,对 bigkey 进行拆分。


对于 list 类型,可以将一个大的 list 拆成若干个小 list:list1、list2、…listN。对于 hash 类型,可以将数据分段存储,比如一个大的 key,假设存了 1 百万的用户数据,可以拆分成 200 个 key,每个 key 下面存放 5000 个用户数据。


| 局部操作

如果 bigkey 不可避免,也要思考一下要不要每次把所有元素都取出来。


例如,对于 hash 类型有时候仅仅需要 hmget,而不是 hgetall;对于 list 类型可以使用 range 取一个范围内的元素;删除也是一样,尽量使用优雅的方式来处理,而不是暴力的使用 del 删除。(下面会重点讲如何优雅删除 bigkey)


| lazy free

可喜的是,Redis 在 4.0 版本支持 lazy delete free 的模式,删除 bigkey 不会阻塞 Redis。


如何优雅删除 bigkey


因为 redis 是单线程的,删除比较大的 keys 就会阻塞其他的请求。


当发现 Redis 中有 bigkey 并且确认要删除时(业务上需要把 key 删除时),如何优雅地删除 bigkey?其实在 Redis 中,无论是什么数据结构,del 命令都能将其删除。


但是相信通过上面的分析后你一定不会这么做,因为删除 bigkey 通常来说会阻塞 Redis 服务。


下面给出一组测试数据分别对 string、hash、list、set、sorted set 五种数据结构的 bigkey 进行删除,bigkey 的元素个数和每个元素的大小不尽相同。


| 删除时间测试

下面测试和服务器硬件、Redis 版本比较相关,可能在不同的服务器上执行速度不太相同,但是能提供一定的参考价值。


①字符串类删除测试


下表展示了删除 512KB~10MB 的字符串类型数据所花费的时间,总体来说由于字符串类型结构相对简单,删除速度比较快,但是随着 value 值的不断增大,删除速度也逐渐变慢。

②非字符串类删除测试


下表展示了非字符串类型的数据结构在不同数量级、不同元素大小下对 bigkey 执行 del 命令的时间,总体上看元素个数越多、元素越大,删除时间越长,相对于字符串类型,这种删除速度已经足够可以阻塞 Redis。

从上分析可见,除了 string 类型,其他四种数据结构删除的速度有可能很慢,这样增大了阻塞 Redis 的可能性。


| 如何提升删除的效率

既然不能用 del 命令,那有没有比较优雅的方式进行删除呢?Redis 提供了一些和 scan 命令类似的命令:sscan、hscan、zscan。


①string


字符串删除一般不会造成阻塞:
del bigkey


②hash、list、set、sorted set


下面以 hash 为例子,使用 hscan 命令,每次获取部分(例如 100 个)fieldvalue,再利用 hdel 删除每个 field(为了快速可以使用 Pipeline):
public void delBigHash(String bigKey) {
    Jedis jedis = new Jedis(“127.0.0.1”, 6379);
    // 游标
    String cursor = “0”;
    while (true) {
        ScanResult<Map.Entry<StringString>> scanResult = jedis.hscan(bigKey, cursor, new ScanParams().count(100));
        // 每次扫描后获取新的游标
        cursor = scanResult.getStringCursor();
        // 获取扫描结果
        List<Entry<StringString>> list = scanResult.getResult();
        if (list == null || list.size() == 0) {
            continue;
        }
        String[] fields = getFieldsFrom(list);
        // 删除多个field
        jedis.hdel(bigKey, fields);
        // 游标为0时停止
        if (cursor.equals(“0”)) {
            break;
        }
    }
    // 最终删除key
    jedis.del(bigKey);
}

/**
获取field数组
@param list
@return
*/

private String[] getFieldsFrom(List<Entry<StringString>> list) {
    List<String> fields = new ArrayList<String>();
    for(Entry<StringString> entry : list) {
        fields.add(entry.getKey());
    }
    return fields.toArray(new String[fields.size()]);
}


请勿忘记每次执行到最后执行 del key 操作。


| 实战代码

①JedisCluster 示例:
/**
 * 刪除 BIG key
 * 应用场景:对于 big key,可以使用 hscan 首先分批次删除,最后统一删除
 * (1)比直接删除的耗时变长,但是不会产生慢操作。
 * (2)新业务实现尽可能拆开,不要依赖此方法。
 * @param key key
 * @param scanCount 单次扫描总数(建议值:100)
 * @param intervalMills 分批次的等待时间(建议值:5)
 */

void removeBigKey(final String key, final int scanCount, final long intervalMills)


实现:

JedisCluster jedisCluster = redisClusterTemplate.getJedisClusterInstance();
// 游标初始值为0
String cursor = ScanParams.SCAN_POINTER_START;
ScanParams scanParams = new ScanParams();
scanParams.count(scanCount);
while (true) {
 // 每次扫描后获取新的游标
 ScanResult<Map.Entry<StringString>> scanResult = jedisCluster.hscan(key, cursor, scanParams);
 cursor = scanResult.getStringCursor();
 // 获取扫描结果为空
 List<Map.Entry<StringString>> list = scanResult.getResult();
 if (CollectionUtils.isEmpty(list)) {
  break;
 }
 // 构建多个删除的 key
 String[] fields = getFieldsKeyArray(list);
 jedisCluster.hdel(key, fields);
 // 游标为0时停止
 if (ScanParams.SCAN_POINTER_START.equals(cursor)) {
  break;
 }
 // 沉睡等待,避免对 redis 压力太大
 DateUtil.sleepInterval(intervalMills, TimeUnit.MILLISECONDS);
}
// 执行 key 本身的删除
jedisCluster.del(key);

构建的 key:
/**
 * 获取对应的 keys 信息
 * @param list 列表
 * @return 结果
 */

private String[] getFieldsKeyArray(List<Map.Entry<String, String>> list) {
     String[] strings = new String[list.size()];
     for(int i = 0; i < list.size(); i++) {
         strings[i] = list.get(i).getKey();
     }
     return strings;
}


①redisTemplate 的写法


估计是 redis 进行了一次封装,发现还是存在很多坑。


语法如下:
/**
 * 获取集合的游标。通过游标可以遍历整个集合。
 * ScanOptions 这个类中使用了构造者 工厂方法 单例。 通过它可以配置返回的元素
 * 个数 count  与正则匹配元素 match. 不过count设置后不代表一定返回的就是count个。这个只是参考
 * 意义
 *
 * @param key
 * @param options 
 * @return
 * @since 1.4
 */

Cursor<V> scan(K key, ScanOptions options);


②注意的坑


实际上这个方法存在很多需要注意的坑:

  • cursor 要关闭,否则会内存泄漏

  • cursor 不要重复关闭,或者会报错

  • cursor 经测试,直接指定的 count 设置后,返回的结果其实是全部,所以需要自己额外处理


参考代码如下:


声明 StringRedisTemplate:
@Autowired
private StringRedisTemplate template;


核心代码:
public void removeBigKey(String key, int scanCount, long intervalMills) throws CacheException {
     final ScanOptions scanOptions = ScanOptions.scanOptions().count(scanCount).build();
     //TRW 避免内存泄漏
     try(Cursor<Map.Entry<Object,Object>> cursor =
                    template.opsForHash().scan(key, scanOptions)) {
         if(ObjectUtil.isNotNull(cursor)) {
                // 执行循环删除
                List<String> fieldKeyList = new ArrayList<>();
                while (cursor.hasNext()) {
                    String fieldKey = String.valueOf(cursor.next().getKey());
                    fieldKeyList.add(fieldKey);
                    if(fieldKeyList.size() >= scanCount) {
                        // 批量删除
                        Object[] fields = fieldKeyList.toArray();
                        template.opsForHash().delete(key, fields);
                        logger.info("[Big key] remove key: {}, fields size: {}",
                                key, fields.length);
                        // 清空列表,重置操作
                        fieldKeyList.clear();
                        // 沉睡等待,避免对 redis 压力太大
                        DateUtil.sleepInterval(intervalMills, TimeUnit.MILLISECONDS);
                    }
                }
            }
            // 最后 fieldKeyList 中可能还有剩余,不过一般数量不大,直接删除速度不会很慢
      // 执行 key 本身的删除
      this.opsForValueDelete(key);
     } catch (Exception e) {
      // log.error();
     }
}


这里我们使用 TRW 保证 cursor 被关闭,自己实现 scanCount 一次进行删除,避免一个一个删除网络交互较多。使用睡眠保证对 Redis 压力不要过大。


以上就是本期的全部内容,再回顾一下,本期带大家一起分析了 redis bigkey 的定义、如何产生、危害以及如何发现线上是否存在 bigkey、如何消除 bigkey,最后详细分析了如何优雅删除 bigkey,并给出了删除的解决方案,希望工作中遇到类似问题时能给你提供一个解决思路。

-------------  END  -------------

扫码免费获取600+页石杉老师原创精品文章汇总PDF

原创技术文章汇总

点个在看你最好看



网友评论已有0条评论, 我也要评论

发表评论

*

* (保密)

Ctrl+Enter 快捷回复