误用Redis命令导致服务器挂了,领导让我写事故报告
共 5136字,需浏览 11分钟
·
2020-12-12 09:06
击上方“Java专栏”,选择“置顶或者星标”
第一时间阅读精彩文章!
大家肯定用过Redis,也知道Redis的命令以及用法,但是假如在某些场景下,误用了一些命令,后果会非常严重,所以要坚决杜绝这样的事情发生,由于我自己之前误用过,所以事故报告它来了!
前言
我相信大家都猜到了这个导致服务器挂的命令是什么,没错,他就是 “keys” 命令。误用这个Redis命令的场景是:
由于业务需要,会定时更新一批缓存的数据,但是一个个获取key效率低下,所以就想到了批量获取的思路,然后就使用了keys命令,在本地或者测试环境下,由于缓存中key的数量并不是那么多,所以没有出现缓存挂或者服务器宕机的情况,但是随着历史数据的增加和业务的增长,缓存中的key越来越多,达到了几百万甚至上千万,所以使用keys命令的时候,查询出来的符合查询规则的数据量也非常大,导致服务器阻塞,随后宕机!
解析KEYS命令
先来看下官网的介绍
查找所有符合给定模式pattern(正则表达式)的 key 。
时间复杂度为O(N),N为数据库里面key的数量。
例如,Redis在一个有1百万个key的数据库里面执行一次查询需要的时间是40毫秒 。
警告: KEYS
的速度非常快,但在一个大的数据库中使用它仍然可能造成性能问题,如果你需要从一个数据集中查找特定的 KEYS
, 你最好还是用 Redis 的集合结构 SETS 来代替。
支持的正则表达模式:
h?llo
匹配hello
,hallo
和hxllo
h*llo
匹配hllo
和heeeello
h[ae]llo
匹配hello
和hallo,
但是不匹配hillo
h[^e]llo
匹配hallo
,hbllo
, … 但是不匹配hello
h[a-b]llo
匹配hallo
和hbllo
如果你想取消字符的特殊匹配(正则表达式,可以在它的前面加\
。
返回值
array-reply: 所有符合条件的key
例子:
redis> MSET one 1 two 2 three 3 four 4
OK
redis> KEYS *o*
1) "four"
2) "one"
3) "two"
redis> KEYS t??
1) "two"
redis> KEYS *
1) "four"
2) "three"
3) "one"
4) "two"
redis>
可以看到官网在介绍keys的时候从优点和缺点都给出了信息,优点就是在数据量没那么大的时候,keys的效率确实非常高,但是缺点也很明显,那就是会影响服务器的性能,导致服务器阻塞,进而影响其他服务的使用
了解了keys命令之后,Garnett确实也不推荐搭建使用,所以这里我们就不深入去研究keys命令了,那么不推荐使用这个,有什么替代方案吗,当然有了,那就是SCAN命令
解析SCAN命令
先来看下官网的介绍
SCAN 命令及其相关的 SSCAN, HSCAN 和 ZSCAN 命令都用于增量迭代一个集合元素。
SCAN 命令用于迭代当前数据库中的key集合。
SSCAN 命令用于迭代SET集合中的元素。
HSCAN 命令用于迭代Hash类型中的键值对。
ZSCAN 命令用于迭代SortSet集合中的元素和元素对应的分值
以上列出的四个命令都支持增量式迭代,它们每次执行都只会返回少量元素,所以这些命令可以用于生产环境,而不会出现像 KEYS 或者 SMEMBERS 命令带来的可能会阻塞服务器的问题。
不过,SMEMBERS 命令可以返回集合键当前包含的所有元素, 但是对于SCAN这类增量式迭代命令来说,有可能在增量迭代过程中,集合元素被修改,对返回值无法提供完全准确的保证。
官网再次提到了在使用KEYS时候的缺点,所以还是特别要重视的
SCAN命令的基本用法
SCAN命令是一个基于游标的迭代器。这意味着命令每次被调用都需要使用上一次这个调用返回的游标作为该次调用的游标参数,以此来延续之前的迭代过程
当SCAN命令的游标参数被设置为 0 时, 服务器将开始一次新的迭代, 而当服务器向用户返回值为 0 的游标时, 表示迭代已结束。
以下是一个 SCAN 命令的迭代过程示例 :
redis 127.0.0.1:6379> scan 0
1) "17"
2) 1) "key:12"
2) "key:8"
3) "key:4"
4) "key:14"
5) "key:16"
6) "key:17"
7) "key:15"
8) "key:10"
9) "key:3"
10) "key:7"
11) "key:1"
redis 127.0.0.1:6379> scan 17
1) "0"
2) 1) "key:5"
2) "key:18"
3) "key:0"
4) "key:2"
5) "key:19"
6) "key:13"
7) "key:6"
8) "key:9"
9) "key:11"
在上面这个例子中, 第一次迭代使用 0 作为游标, 表示开始一次新的迭代。第二次迭代使用的是第一次迭代时返回的游标 17 ,作为新的迭代参数 。
显而易见,SCAN命令的返回值 是一个包含两个元素的数组, 第一个数组元素是用于进行下一次迭代的新游标, 而第二个数组元素则是一个数组, 这个数组中包含了所有被迭代的元素。
在第二次调用 SCAN 命令时, 命令返回了游标 0 , 这表示迭代已经结束, 整个数据集已经被完整遍历过了。
full iteration :以 0 作为游标开始一次新的迭代, 一直调用 SCAN 命令, 直到命令返回游标 0 , 我们称这个过程为一次完整遍历。
SCAN命令参数解析
SCAN cursor [MATCH pattern] [COUNT count]
1.scan 命令提供三个参数,第一个是cursor,第二个是要匹配的正则,第三个是单次遍历的槽位
若选择了可选参数 MATCH XXX ,则按照正则返回匹配的keys
若选择了可选参数COUNT XXX,则设置每次迭代返回的数量,默认为10
SCAN在代码中的用法
// 游标初始值为0
String cursor = ScanParams.SCAN_POINTER_START;
String key = "PLFX-ZZSFP-*";
ScanParams scanParams = new ScanParams();
scanParams.match(key);// 匹配以 PLFX-ZZSFP-* 为前缀的 key
scanParams.count(1000);
while (true){
//使用scan命令获取数据,使用cursor游标记录位置,下次循环使用
ScanResult scanResult = jedis.scan(cursor, scanParams);
cursor = scanResult.getStringCursor();// 返回0 说明遍历完成
List list = scanResult.getResult();
long t1 = System.currentTimeMillis();
for(int m = 0;m < list.size();m++){
String mapentry = list.get(m);
System.out.println(mapentry);
//jedis.del(key, mapentry);
}
long t2 = System.currentTimeMillis();
System.out.println("获取" + list.size()+ "条数据,耗时: " + (t2-t1) + "毫秒,cursor:" + cursor);
if ("0".equals(cursor)){
break;
}
}
通过不断的移动游标直到全部数据获取完成
在这里使用的是jedis单机的场景,假如使用redis集群的话,使用jedisCluster,不能直接使用,需要获取每个jedis的实例,然后一个个获取,以下是代码:
Map clusterNodes = jedisCluster.getClusterNodes();
for (Map.Entry entry : clusterNodes.entrySet()) {
//获取单个的jedis对象
Jedis jedis = entry.getValue().getResource();
// 判断非从节点(因为若主从复制,从节点会跟随主节点的变化而变化),此处要使用主节点从主节点获取数据
if (!jedis.info("replication").contains("role:slave")) {
List keys = getScan(jedis, matchKey);
if (keys.size() > 0) {
Map> 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 list1 = new ArrayList();
list1.add(key);
map.put(slot, list1);
}
}
for (Map.Entry> integerListEntry : map.entrySet()) {
list.addAll(integerListEntry.getValue());
}
}
}
}
public static List getScan(Jedis redisService, String key) {
List list = new ArrayList<>();
//扫描的参数对象创建与封装
ScanParams params = new ScanParams();
params.match(key);
//扫描返回一百行,这里可以根据业务需求进行修改
params.count(100);
String cursor = "0";
ScanResult scanResult = redisService.scan(cursor, params);
//scan.getStringCursor() 存在 且不是 0 的时候,一直移动游标获取
while (null != scanResult.getStringCursor()) {
//封装扫描的结果
list.addAll(scanResult.getResult());
if (! "0".equals( scanResult.getStringCursor())) {
scanResult = redisService.scan(cursor, params);
} else {
break;
}
}
return list;
}
总结
1)KEYS 的算法采用O(N)复杂度的遍历算法,没有limit限制,一次性遍历所有key,属于暴力搜索。假如redis服务器存在千万级别的key数量,但是又由于低版本的redis为单线程,那么如果执行keys命令,将会造成卡顿,一段时间内无法处理其他命令,造成其他客户端阻塞。所以生产环境不建议使用。
2)SCAN命令支持增量式迭代, 它们每次执行都只会返回少量元素, 所以这些命令可以用于生产环境, 而不会出现像KEYS 命令、 SMEMBERS命令带来的问题 —— 当 KEYS命令被用于处理一个大的数据库时, 又或者 SMEMBERS用于处理一个大的集合键时, 它们可能会阻塞服务器达数秒之久。
综上:
keys可一次性返回我们想要的所有key,但是若key的数量级比较大会造成阻塞
scan可分次返回匹配的key,不会造成阻塞,但是返回的key可能有重复,客户端需要根据需要进行去重
本文从实际场景出发,了解使用redis批量获取key的命令,解析keys和scan的作用和区别,所以在使用这些命令的时候,一定要仔细考察业务场景,合理使用。
假如面试中你被问到这些,我相信你看了这篇一定能拨动面试官的心!
希望你们是我最好的观众!
---END---