likes
comments
collection
share

使用Scan命令解决keys *造成业务异常问题❌-线上环境

作者站长头像
站长
· 阅读数 27

1. 需求场景

  • 群里又告警了,部分业务接口超时(卡redis了),最近业务上面全量查询接口在优化时候,添加的一些缓存,但是为了清除无效缓存占用的空间,在有些地方需要模糊查询删除,业务中使用的keys进行模糊匹配 ,keys命令阻塞Redis线程导致其他业务不可用一直等待至接口超时,所以需使用scan命令优化掉这个异常。

Redis 版本用的5.0.7 单机(现在最新Redis版本都 7.2.5 了, 当时还买了金毛的Redis7面试宝典)

使用Scan命令解决keys *造成业务异常问题❌-线上环境

2. key* 原理和代码 ⚠️

  • keys * 模糊匹配和MySQL的模糊匹配%key% 有点相似, 但是Redis是单线程,命令都是排队进行的, 你keys*数据量小执行快还好说,你直接阻塞个10s以上, 后面的业务get/set/list/zset命令直接堆积阻塞, 直接爆炸了,只能说危险危险⚠️。

其实我们在用Redis Desktop Manager 在搜索栏进行搜索的时候也是模糊查找keys* ,当时我刚来公司的时候, 直接在线上搜索栏上面查关键字,线上数据量特别大不像测试环境, 结果群里直接出现500异常告警,当时好蠢啊,后面leader直接在配置文件里面把keys*命令禁止了🈲️,由于我的乱操作。

使用Scan命令解决keys *造成业务异常问题❌-线上环境

使用Scan命令解决keys *造成业务异常问题❌-线上环境

  /**
   * 模糊查询拼接
   * 消息补偿特殊处理使用
   *
   * @param pattern
   */
  public ArrayList matchAndSplice(String pattern) {
    ArrayList<Long> list = new ArrayList<>();
    Set keys = redisTemplate.keys(pattern + "*");
    if (keys != null && keys.size() > 0) {
      keys.forEach(k -> {
        Object o = redisTemplate.opsForValue().get(k);
        if (o != null) {
          ArrayList<Long> ids = (ArrayList) o;
          ids.forEach(i -> list.add(i));
        }
      });
    }
    return list;
  }

  /**
   * 模糊匹配删除 key* 出现线程阻塞.
   *
   * @param pattern
   */
  public void matchDel(String pattern) {
    Set keys = redisTemplate.keys(pattern + "*");
    if (keys != null && keys.size() > 0) {
      keys.forEach(k -> redisTemplate.delete(k));
    }
  }

3. Scan优化 原理和代码(集群和单机)

  • Scan 有点像分治算法(分而治之) 采用逆二进制迭代算法(有时间具体了解下),将keys* 一次命令拆成多个scan命令, 主要有两个关键词一个是cursor游标位置,我印象里Redis数据存储是Hash槽slot结构(数组+链表),游标就是数组的下标,count就是你这次需要扫描多少个slot槽位(这个值需要思考一下 设置小了整体运行太慢了,设置大了单个scan太慢了 可能又会出现keys*类似效果), 一次请求返回下一次的cursor游标开始位置, do while(cursor!=0) 结束。scan命令整体的时间肯定比keys * 花费的时间长,但是不会出现一直阻塞其他命令的执行。
  • 可能数据会缺少/重复,比如在游标1000位置的数据, 在我们扫描到900的时候,对数据进行了修改 1000游标位置的数据被转移到900游标位置以前的slot槽位去了/100游标位置数据被转移到1100游标位置,就会产生数据缺少/重复现象。

普通key-value scan扫描 SCAN cursor [MATCH pattern] [COUNT count]

使用Scan命令解决keys *造成业务异常问题❌-线上环境

hash数据结构scan扫描 HSCAN key cursor [MATCH pattern] [COUNT count]

使用Scan命令解决keys *造成业务异常问题❌-线上环境

集合数据结构scan扫描 SSCAN cursor [MATCH pattern] [COUNT count]

使用Scan命令解决keys *造成业务异常问题❌-线上环境

zset 有序集合 ZSCAN key cursor [MATCH pattern] [COUNT count]

使用Scan命令解决keys *造成业务异常问题❌-线上环境

  /**
   * Scan 模糊匹配删除
   *
   * @param matchKey 模糊key
   */
  public void matchDelByScan(String matchKey) {
    Set<String> keys = scanKeys(matchKey);
    if (keys != null && keys.size() > 0) {
      keys.forEach(k -> redisTemplate.delete(k));
    }
  }

  /**
   * 通过scan扫描redis集群所有节点,模糊匹配key
   *
   * @param matchKey 模糊key
   * @return
   */
  public Set<String> scanKeys(String matchKey) {
    Set<String> set = new HashSet<>();
    RedisClusterConnection redisClusterConnection = Objects.requireNonNull(redisTemplate.getConnectionFactory()).getClusterConnection();
    // 获取jedisPool
    Map<String, JedisPool> clusterNodes = ((JedisCluster) redisClusterConnection.getNativeConnection()).getClusterNodes();
    for (Map.Entry<String, JedisPool> entry : clusterNodes.entrySet()) {
      // 获取单个的jedis对象
      Jedis jedis = null;
      try {
        jedis = entry.getValue().getResource();
        // 判断非从节点(因为若主从复制,从节点会跟随主节点的变化而变化),此处要使用主节点从主节点获取数据
        if (!jedis.info("replication").contains("role:slave")) {
          List<String> keys = getScan(jedis, matchKey + "*");
          if (keys.size() > 0) {
            Map<Integer, List<String>> map = new HashMap<>(8);
            //接下来的循环不是多余的,需要注意
            for (String key : keys) {
              // cluster模式执行多key操作的时候,这些key必须在同一个slot上,不然会报:JedisDataException:
              int slot = JedisClusterCRC16.getSlot(key);
              // 按slot将key分组,相同slot的key一起提交
              if (map.containsKey(slot)) {
                map.get(slot).add(key);
              } else {
                List<String> list = new ArrayList<>();
                list.add(key);
                map.put(slot, list);
              }
            }
            for (Map.Entry<Integer, List<String>> integerListEntry : map.entrySet()) {
              set.addAll(integerListEntry.getValue());
            }
          }
        }
      } catch (Exception e) {
        logger.error("模糊扫描redis失败", e);
      } finally {
        if (null != jedis) {
          jedis.close();
        }
      }
    }
    return set;
  }

  /**
   * scan获取单redis节点key*
   *
   * @param jedis    Jedis
   * @param matchKey 模糊匹配key
   * @return key结果集
   */
  private static List<String> getScan(Jedis jedis, String matchKey) {
    List<String> list = new ArrayList<>();
    //扫描的参数对象创建与封装
    ScanParams params = new ScanParams();
    params.match(matchKey);
    //扫描返回10000行
    params.count(10000);
    String scanCursorIndex = "0";
    //scan.getStringCursor() 存在 且不是 0 的时候,一直移动游标获取
    do {
      ScanResult<String> scanResult = jedis.scan(scanCursorIndex, params);
      scanCursorIndex = scanResult.getStringCursor();
      list.addAll(scanResult.getResult());
    } while (null != scanCursorIndex && !"0".equals(scanCursorIndex));
    return list;
  }
}

4. 异步Scan模糊删除缓存任务 解决业务异常问题

  • 最后使用线程池异步处理无用的缓存key删除

使用Scan命令解决keys *造成业务异常问题❌-线上环境

使用Scan命令解决keys *造成业务异常问题❌-线上环境

BusinessExecutod.execute(new AsyncMatchDelCacheThread(redisUtil, Sets.newHashSet());
// 删除产品下用户操作权限緩存
// 删除产品下用户权限缓存
............

5. 总结

  • 其实当初使用keys *实现业务需求的时候(不是我写的), 就没有考虑过大数据这个层面吗,方案评审是怎么通过的,codereview是怎么看的,可能那是为了快速迭代 完全不在意这些点,结果出了问题 又要给他们填坑,keys *使用在初期就可以pass掉的。也要感谢挖坑的人,他不挖坑我怎么会有机会填坑呢,也是学习到了Scan的原理和应用。

6. 参考

转载自:https://juejin.cn/post/7377012094569496603
评论
请登录