使用Scan命令解决keys *造成业务异常问题❌-线上环境
1. 需求场景
- 群里又告警了,部分业务接口超时(卡redis了),最近业务上面全量查询接口在优化时候,添加的一些缓存,但是为了清除无效缓存占用的空间,在有些地方需要模糊查询删除,业务中使用的keys进行模糊匹配 ,keys命令阻塞Redis线程导致其他业务不可用一直等待至接口超时,所以需使用scan命令优化掉这个异常。
Redis 版本用的5.0.7 单机(现在最新Redis版本都 7.2.5 了, 当时还买了金毛的Redis7面试宝典)
2. key* 原理和代码 ⚠️
- keys * 模糊匹配和MySQL的模糊匹配%key% 有点相似, 但是Redis是单线程,命令都是排队进行的, 你keys*数据量小执行快还好说,你直接阻塞个10s以上, 后面的业务get/set/list/zset命令直接堆积阻塞, 直接爆炸了,只能说危险危险⚠️。
其实我们在用Redis Desktop Manager 在搜索栏进行搜索的时候也是模糊查找keys* ,当时我刚来公司的时候, 直接在线上搜索栏上面查关键字,线上数据量特别大不像测试环境, 结果群里直接出现500异常告警,当时好蠢啊,后面leader直接在配置文件里面把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]
hash数据结构scan扫描 HSCAN key cursor [MATCH pattern] [COUNT count]
集合数据结构scan扫描 SSCAN cursor [MATCH pattern] [COUNT count]
zset 有序集合 ZSCAN key cursor [MATCH pattern] [COUNT count]
/**
* 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删除
BusinessExecutod.execute(new AsyncMatchDelCacheThread(redisUtil, Sets.newHashSet());
// 删除产品下用户操作权限緩存
// 删除产品下用户权限缓存
............
5. 总结
- 其实当初使用keys *实现业务需求的时候(不是我写的), 就没有考虑过大数据这个层面吗,方案评审是怎么通过的,codereview是怎么看的,可能那是为了快速迭代 完全不在意这些点,结果出了问题 又要给他们填坑,keys *使用在初期就可以pass掉的。也要感谢挖坑的人,他不挖坑我怎么会有机会填坑呢,也是学习到了Scan的原理和应用。
6. 参考
转载自:https://juejin.cn/post/7377012094569496603