赞
踩
最近研究redis大数据处理,网上查了查资料,这里记录一下。
在 redis 实际使用中,会遇到一个问题:如何从海量的 key 中找出满足特定前缀的 key 列表来?
redis 提供了一个简单暴力的指令 keys
用来列出所有满足特定正则字符串规则的 key。
keys xxx*
这个指令有致命的弊端,在实际环境中最好不要使用:
这个指令没有 offset、limit 参数,是要一次性吐出所有满足条件的 key,由于 redis 是单线程的,其所有操作都是原子的,而 keys 算法是遍历算法,复杂度是 O(n),如果实例中有千万级以上的 key,这个指令就会导致 Redis 服务卡顿,所有读写 Redis 的其它的指令都会被延后甚至会超时报错,可能会引起缓存雪崩甚至数据库宕机。
我们可以通过配置设置禁用这些命令,在 redis.conf 中,在 SECURITY 这一项中,我们新增以下命令:
- rename-command flushall ""
- rename-command flushdb ""
- rename-command config ""
- rename-command keys ""
另外,对于FLUSHALL命令,需要设置配置文件中appendonly no,否则服务器是无法启动。
Redis 为了解决这个问题,它在 2.8 版本中加入了scan
。
scan 相比 keys 具备有以下特点:
scan 优缺点如下:
优点:
游标
,复杂度O(1), 整体遍历一遍只需要O(N)
;模式匹配
;缺点:
遍历不到
;数据条数不一定
,极度依赖内部实现;可能有重复
,应用层必须能够处理重入逻辑;SCAN 命令及其相关的 SSCAN 命令、 HSCAN 命令和 ZSCAN 命令都用于增量地迭代(incrementally iterate)一集元素(a collection of elements):
scan 参数提供了三个参数,第一个是 cursor 整数值
,第二个是 key 的正则模式
,第三个是遍历的 limit hint
。第一次遍历时,cursor 值为 0,然后将返回结果中第一个整数值作为下一次遍历的 cursor。一直遍历到返回的 cursor 值为 0 时结束。
在 Redis 中所有的 key 都存储在一个很大的字典中,这个字典的结构和 Java 中的 HashMap 一样,是一维数组 + 二维链表结构,第一维数组的大小总是 2^n(n>=0),扩容一次数组大小空间加倍,也就是 n++。
scan 指令返回的游标就是第一维数组的位置索引,我们将这个位置索引称为槽 (slot)。如果不考虑字典的扩容缩容,直接按数组下标挨个遍历就行了。limit 参数就表示需要遍历的槽位数,之所以返回的结果可能多可能少,是因为不是所有的槽位上都会挂接链表,有些槽位可能是空的,还有些槽位上挂接的链表上的元素可能会有多个。每一次遍历都会将 limit 数量的槽位上挂接的所有链表元素进行模式匹配过滤后,一次性返回给客户端。
scan 的遍历顺序非常特别。它不是从第一维数组的第 0 位一直遍历到末尾,而是采用了高位进位加法来遍历。之所以使用这样特殊的方式进行遍历,是考虑到字典的扩容和缩容时避免槽位的遍历重复和遗漏。
首先我们用动画演示一下普通加法和高位进位加法的区别。
从动画中可以看出高位进位法从左边加,进位往右边移动,同普通加法正好相反。但是最终它们都会遍历所有的槽位并且没有重复。
关于scan命令的遍历顺序,我们可以用一个小栗子来具体看一下。
- 127.0.0.1:6379> keys *
- 1) "db_number"
- 2) "key1"
- 3) "myKey"
- 127.0.0.1:6379> scan 0 MATCH * COUNT 1
- 1) "2"
- 2) 1) "db_number"
- 127.0.0.1:6379> scan 2 MATCH * COUNT 1
- 1) "1"
- 2) 1) "myKey"
- 127.0.0.1:6379> scan 1 MATCH * COUNT 1
- 1) "3"
- 2) 1) "key1"
- 127.0.0.1:6379> scan 3 MATCH * COUNT 1
- 1) "0"
- 2) (empty list or set)

我们的Redis中有3个key,我们每次只遍历一个一维数组中的元素。如上所示,SCAN命令的遍历顺序是
0->2->1->3
这个顺序看起来有些奇怪。我们把它转换成二进制就好理解一些了。
00->10->01->11
我们发现每次这个序列是高位加1的。普通二进制的加法,是从右往左相加、进位。而这个序列是从左往右相加、进位的。这一点我们在redis的源码中也得到印证。
在dict.c文件的dictScan函数中对游标进行了如下处理
- v = rev(v);
- v++;
- v = rev(v);
意思是,将游标倒置,加一后,再倒置,也就是我们所说的“高位加1”的操作。
这里大家可能会有疑问了,为什么要使用这样的顺序进行遍历,而不是用正常的0、1、2……这样的顺序呢,这是因为需要考虑遍历时发生字典扩容与缩容的情况(不得不佩服开发者考虑问题的全面性)。
我们来看一下在SCAN遍历过程中,发生扩容时,遍历会如何进行。加入我们原始的数组有4个元素,也就是索引有两位,这时需要把它扩充成3位,并进行rehash。
rehash
原来挂接在xx下的所有元素被分配到0xx和1xx下。
在上图中,当我们即将遍历10时,dict进行了rehash,这时,scan命令会从010开始遍历,而000和100(原00下挂接的元素)不会再被重复遍历。
再来看看缩容的情况。假设dict从3位缩容到2位,当即将遍历110时,dict发生了缩容,这时scan会遍历10。这时010下挂接的元素会被重复遍历,但010之前的元素都不会被重复遍历了。所以,缩容时还是可能会有些重复元素出现的。
Java 的 HashMap 在扩容时会一次性将旧数组下挂接的元素全部转移到新数组下面。如果 HashMap 中元素特别多,rehash时线程就会出现卡顿现象。Redis 为了解决这个问题,它采用渐进式 rehash。
它会同时保留旧数组和新数组,然后在定时任务中以及后续对 hash 的指令操作中渐渐地将旧数组中挂接的元素迁移到新数组上。这意味着要操作处于 rehash 中的字典,需要同时访问新旧两个数组结构。如果在旧数组下面找不到元素,还需要去新数组下面去寻找。
scan 也需要考虑这个问题,对与 rehash 中的字典,它需要同时扫描新旧槽位,然后将结果融合后返回给客户端。
有时候会因为业务人员使用不当,在 Redis 实例中会形成很大的对象,比如一个很大的 hash,一个很大的 zset 这都是经常出现的。这样的对象对 Redis 的集群数据迁移带来了很大的问题,因为在集群环境下,如果某个 key 太大,会数据导致迁移卡顿。另外在内存分配上,如果一个 key 太大,那么当它需要扩容时,会一次性申请更大的一块内存,这也会导致卡顿。如果这个大 key 被删除,内存会一次性回收,卡顿现象会再一次产生。
在平时的业务开发中,要尽量避免大 key 的产生。
如果你观察到 Redis 的内存大起大落,这极有可能是因为大 key 导致的,这时候你就需要定位出具体是那个 key,进一步定位出具体的业务来源,然后再改进相关业务代码设计。
那如何定位大 key 呢?
为了避免对线上 Redis 带来卡顿,这就要用到 scan 指令,对于扫描出来的每一个 key,使用 type 指令获得 key 的类型,然后使用相应数据结构的 size 或者 len 方法来得到它的大小,对于每一种类型,保留大小的前 N 名作为扫描结果展示出来。
上面这样的过程需要编写脚本,比较繁琐,不过 Redis 官方已经在 redis-cli 指令中提供了这样的扫描功能,我们可以直接拿来即用。
- redis-cli -h 127.0.0.1 -p 7001 –-bigkeys
-
如果你担心这个指令会大幅抬升 Redis 的 ops 导致线上报警,还可以增加一个休眠参数。
- redis-cli -h 127.0.0.1 -p 7001 –-bigkeys -i 0.1
-
上面这个指令每隔 100 条 scan 指令就会休眠 0.1s,ops 就不会剧烈抬升,但是扫描的时间会变长。
redis-cli -a "password" -n 0 -p 6379 keys "*:login" | xargs -i redis-cli -a "password" -n 0 -p 6379 del {}
- [root@localhost ~]# redis-cli -c -h 10.1.37.113 -p 7002 -a PASSWORD -n 0 KEYS hello*
- 1) "hello8"
- 2) "hello5"
- [root@localhost ~]# redis-cli -c -h 10.1.37.113 -p 7002 -a PASSWORD -n 0 KEYS "hello*" | xargs -i redis-cli -c -h 10.1.37.113 -p 7002 -a PASSWORD -n 0 DEL {}
- (integer) 1
- (integer) 1
- [root@localhost ~]# redis-cli -c -h 10.1.37.113 -p 7002 -a PASSWORD -n 0 KEYS hello*
- (empty list or set)
这样一个坏处每次都要建立一个连接,量小的话还可以接受,量大的话,效率不行。
自从redis2.8以后就开始支持scan命令,模式匹配可以采取下面的形式来批删除大量的key
redis-cli -a "password" -n 0 -p 6379 --scan --pattern "*:login" | xargs -i redis-cli -a "password" -n 0 -p 6379 DEL {}
- [root@localhost ~]# redis-cli -c -h 10.1.37.113 -p 7002 -a PASSWORD -n 0 --scan --pattern "hello*"
- hello1
- hello4
- hello5
- [root@localhost ~]# redis-cli -c -h 10.1.37.113 -p 7002 -a PASSWORD -n 0 --scan --pattern "hello*" | xargs -i redis-cli -c -h 10.1.37.113 -p 7002 -a PASSWORD -n 0 DEL {}
- (integer) 1
- (integer) 1
- (integer) 1
- [root@localhost ~]# redis-cli -c -h 10.1.37.113 -p 7002 -a PASSWORD -n 0 --scan --pattern "hello*"
- [root@localhost ~]# redis-cli -c -h 10.1.37.113 -p 7002 -a PASSWORD -n 0 KEYS "hello*"
- (empty list or set)
速度处理也是非常快的。
redis-cli -c -h $ip -p $port keys $pkey | xargs -r -t -n1 redis-cli -c -h $ip -p $port del
- #!/bin/bash
-
- redis_list=("10.1.37.113:7002")
-
- pkey_list=("ValuationRuleSummary" "ValuationRuleDetail" "MerchantValuation" "QueryValuationRules" "GetMerchantByUserId")
-
- for info in ${redis_list[@]}
- do
- echo "开始执行:$info"
- ip=`echo $info | cut -d \: -f 1`
- port=`echo $info | cut -d \: -f 2`
-
- for pkey in ${pkey_list[@]}
- do
- for i in `seq 0 10000`
- do
- echo $pkey"_"$i $i
- redis-cli -c -h $ip -p $port -a PASSWORD SET $pkey"_"$i $i
- done
- done
- done
- echo "完成"

- #!/bin/bash
-
- redis_list=("10.1.37.113:7000" "10.1.37.113:7001" "10.1.37.113:7002" "10.1.37.113:7003" "10.1.37.113:7004" "10.1.37.113:7005")
-
- pkey_list=("ValuationRuleSummary*" "ValuationRuleDetail*" "MerchantValuation*" "QueryValuationRules*" "GetMerchantByUserId*")
-
- for info in ${redis_list[@]}
- do
- echo "开始执行:$info"
- ip=`echo $info | cut -d \: -f 1`
- port=`echo $info | cut -d \: -f 2`
-
- for pkey in ${pkey_list[@]}
- do
- redis-cli -c -h $ip -p $port -a PASSWORD KEYS $pkey | xargs -n 1 -t -i redis-cli -c -h $ip -p $port -a PASSWORD DEL {}
- done
- done
- echo "完成"

key.txt –待删除的key
- key1
- key2
- key3
redis_delete_key.sh
redis_list 填入集群节点
- redis_list=("127.0.0.1:6379" "127.0.0.1:6380")
-
- for info in ${redis_list[@]}
- do
- echo "开始执行:$info"
- ip=`echo $info | cut -d \: -f 1`
- port=`echo $info | cut -d \: -f 2`
- cat key.txt |xargs -t -n1 redis-cli -h $ip -p $port -c del
- done
- echo "完成"
以集合set为例子:
- function delSet()
- {
- key=$3
- rs=`redis-cli -p $1 -a $2 exists $key`
- scannum=100
- #echo $rs
- if [ `echo $rs | awk -v tem="0" '{print($1>tem)? "1":"0"}'` -eq "0" ] ; then
- echo "redis doesn't exist the key $key"
- else
- cursor="1"
- members=`redis-cli -p $1 -a $2 sscan $key 0 count $scannum`
- while [ `echo $cursor | awk -v tem="0" '{print($1>tem)? "1":"0"}'` -eq "1" ] ; do
- OLD_IFS="$IFS"
- IFS=" "
- array=($members)
- IFS="$OLD_IFS"
- count=1
- delkeys=""
- for var in ${array[@]}
- do
- if [ $count -eq 1 ] ; then
- cursor=$var
- count=$((count+1))
- else
- #rs=`redis-cli -p $1 -a $2 SREM $key $var`
- #echo "del:$var"
- delkeys=$delkeys$var" "
- fi
- done
- rs=`redis-cli -p $1 -a $2 SREM $key $delkeys`
- if [ `echo $cursor | awk -v tem="0" '{print($1>tem)? "1":"0"}'` -eq "1" ];then
- members=`redis-cli -p $1 -a $2 sscan $key $cursor count $scannum`
- fi
- done
- fi
- }
-
- key="setkey"
- echo "start remove the key $key"
- delSet $1 $2 $key
- rs=`redis-cli -p $1 -a $2 del $key`
- echo "remove the key $key successfully"

- #!/bin/bash
- ##redis主机IP
- host=$1
- ##redis端口
- port=$2
- ##key模式
- pattern=$3
- ##游标
- cursor=0
- ##退出信号
- signal=0
-
- ##循环获取key并删除
- while [ $signal -ne 1 ]
- do
- echo "cursor:${cursor}"
- sleep 2
- ##将redis scan得到的结果赋值到变量
- re=$(redis-cli -h $host -p $p -c scan $cursor count 1000 match $pattern)
- ##以换行作为分隔符
- IFS=$'\n'
- #echo $re
- echo 'arr=>'
- ##转成数组
- arr=($re)
- ##打印数组长度
- echo 'len:'${#arr[@]}
- ##第一个元素是游标值
- cursor=${arr[0]}
- ##游标为0表示没有key了
- if [ $cursor -eq 0 ];then
- signal=1
- fi
- ##循环数组
- for key in ${arr[@]}
- do
- echo $key
- if [ $key != $cursor ];then
- echo "key:"$key
- ##删除key
- redis-cli -h $host -p $port -c del $key >/dev/null 2>&1
- fi
- done
- done
- echo 'done'

使用方式:
./redis-del-keys.sh localhost 6379 user:*
表示删除本机6379端口的redis中user:开头的所以key。
所以可以通过这个方法来查询库大的整个库的key,如果库小的话直接keys *搞定
可以使用下面的脚本来得到结果
单机版:
- #!/bin/bash
- redis-cli -h 127.0.0.1 -p 6379 scan 0 > keys.txt
- a=`head -1 keys.txt`
- while [ $a -ne 0 ]
- do
- redis-cli -h 127.0.0.1 -p 6379 scan $a > key.txt
- redis-cli -h 127.0.0.1 -p 6379 scan $a >> keys.txt
- a=`head -1 key.txt`
- sleep 1
- done
集群版:
- #!/bin/bash
- redis_comm=/home/zcy/redis/bin/redis-cli
-
- #search redis cluster master IP:PORT
- redis_list=$($redis_comm -p 9001 cluster nodes | grep master | awk '{print $2}' | awk -F['@'] '{print $1}')
-
- for info in ${redis_list[@]}
- do
- ip=`echo $info | cut -d : -f 1`
- port=`echo $info | cut -d : -f 2`
-
- echo "Begin search: $ip:$port"
- $redis_comm -h $ip -p $port scan 0 > key.txt
- cat key.txt | sed '/^[0-9]/d' >> keys.txt
- a=`head -1 key.txt`
- while [ $a -ne 0 ]
- do
- $redis_comm -h $ip -p $port scan $a > key.txt
- cat key.txt | sed '/^[0-9]/d' >> keys.txt
- a=`head -1 key.txt`
- sleep 0.2
- done
- done
-
- rm key.txt

其中,sed '/^[0-9]/d'是删除数字开头的行。
如果指定库的话改为 redis-cli -h 127.0.0.1 -p 6379 -n 1 scan 0 > keys.txt
最后总结:
rehash从小到大时,scan系列命令不会重复也不会遗漏.
而rehash从大到小时,有可能会造成重复但不会遗漏.
SCAN操作能够保证 一直没变动过的元素一定能够在扫描结束的之前返回给客户端,这一点在不同情况下都可以实现;
整体来看redis的SCAN操作是很不错的,能够在hash的数据结构里面,不锁定库,稳定提供读写操作的前提下,提供比较稳定可靠的SCAN操作,不过大家再使用的时候最好知道他的优缺点,别太依靠scan做要求一致性太高的操作。
后端服务开发就是这样,可选的框架,开源实现很多,最好知其所以然并高效利用其优点,少被缺点坑了最关键。
参考:
http://chenzhenianqing.com/articles/1410.html
https://segmentfault.com/a/1190000018218584
https://www.jianshu.com/p/be15dc89a3e8
https://www.jianshu.com/p/a2d4d2eb4e14
https://www.jianshu.com/p/07c216db6e09
https://blog.51cto.com/chinahao/2056082
如果觉得文章写得不错,能对你有帮助,可以扫描我的微信二维码请我喝咖啡哦~~哈哈~~
Copyright © 2003-2013 www.wpsshop.cn 版权所有,并保留所有权利。