在Redis中,每个key都有一个对应的value,如果某个key的value过大,就会导致Redis的性能下降或者崩溃。因为Redis需要将大key全部加载到内存中,这会占用大量的内存空间,会降低Redis的响应速度,这个问题被称为BigKey问题。不要小看这个问题,它可是能让你的Redis瞬间变成“乌龟”。由于Redis单线程的特性,操作Big Key的通常比较耗时,也就意味着阻塞Redis可能性越大,这样会造成客户端阻塞或者引起故障切换,有可能导致“慢查询”。
一般而言,下面这两种情况被称为大 key:String
类型的 key 对应的value超过10 KB
;list
、set
、hash
、zset
等集合类型,集合元素个数超过 5000个。
判断标准并不唯一,在实际业务开发中,需要根据具体的使用场景做判断。比如操作某个 key 导致请求响应时间变慢,那么这个 key 就可以判定成 Big Key。
1、危害
🚫内存不均,集群迁移困难:集群模式下,无法做到负载均衡,导致请求倾斜到某个实例上,而这个实例的QPS
会比较大,内存占用也较多;对于Redis单线程模型又容易出现CPU瓶颈,当内存出现瓶颈时,只能进行纵向库容,使用更牛逼的服务器。
🚫网络流量阻塞:涉及到大key的操作,尤其是使用hgetall
、lrange 0 -1
、get
、hmget
等操作时,网卡可能会成为瓶颈,也会到导致堵塞其它操作,QPS
就有可能出现突降或者突升的情况,趋势上看起来十分不平滑,严重时会导致应用程序连不上,实例或者集群在某些时间段内不可用的状态。
🚫超时删除:如果直接对BigKey
进行del
操作,被操作的实例会被Block住,导致无法响应应用的请求,而这个Block的时间会随着key的变大而变长。
2、定位bigKey
为了避免对线上 Redis 带来卡顿,需要用到scan
指令扫描出的每一个key
,使用type
指令获得key
的类型,然后使用相应数据结构的size
或者len
方法来得到它的大小,对于每一种类型,保留大小的前 N 名作为扫描结果展示出来。上面这样的过程需要编写脚本,比较繁琐。不过Redis
官方已经在redis-cli
指令中提供了该扫描功能,可以直接使用。
2.1 bigkeys
Redis自带的BIGKEYS
命令可以查询当前Redis中六大数据类型(String、hash、list、set、zset、stream)key
的信息,对整个数据库中的键值对大小情况进行统计分析,比如统计每种数据类型的键值对个数以及平均大小,还会输出每种数据类型中最大的bigkey
的信息(String:输出最大 bigkey 的字节长度;集合:会输出最大 bigkey 的元素个数)。
BIGKEYS
命令会扫描整个数据库,这个命令本身会阻塞Redis,找出所有的大键,并将其以一个列表的形式返回给客户端。所以在执行该命令,需确保Redis实例有足够的资源来处理它,建议在从节点执行,命令格式及回复格式如下:
$ redis-cli --bigkeys -a abc123
# Scanning the entire keyspace to find biggest keys as well as
# average sizes per key type. You can use -i 0.1 to sleep 0.1 sec
# per 100 SCAN commands (not usually needed).
[00.00%] Biggest string found so far 'a' with 3 bytes
[05.14%] Biggest list found so far 'b' with 100004 items
[35.77%] Biggest string found so far 'c' with 6 bytes
[73.91%] Biggest hash found so far 'd' with 3 fields
-------- summary -------
Sampled 506 keys in the keyspace!
Total key length in bytes is 3452 (avg len 6.82)
Biggest string found 'c' has 6 bytes
Biggest list found 'b' has 100004 items
Biggest hash found 'd' has 3 fields
504 strings with 1403 bytes (99.60% of keys, avg size 2.78)
1 lists with 100004 items (00.20% of keys, avg size 100004.00)
0 sets with 0 members (00.00% of keys, avg size 0.00)
0 streams with 0 entries (00.00% of keys, avg size 0.00)
1 hashs with 3 fields (00.20% of keys, avg size 3.00)
0 zsets with 0 members (00.00% of keys, avg size 0.00)
优化: 若担心该指令会大幅抬升 Redis 的
ops
导致线上报警,还可以增加一个-i
休眠参数。下面的指令每隔100
条scan
指令就会休眠0.1s
,ops
就不会剧烈抬升,但是扫描的时间会变长。
redis-cli -h 127.0.0.1 -p 7001 –-bigkeys -i 0.1
2.2 Debug Object | memory usage 查询大小
想查询大于10kb
的所有key,则光靠--bigkeys
不行了,还需要用到memory usage
来计算每个键值的字节数对其进行进一步的分析。在Redis4.0
之前,只能通过DEBUG OBJECT
命令估算key的内存使用(字段serializedlength
),但DEBUG OBJECT
命令是有误差的。4.0
版本及以上,可使用memory usage
命令,如果当前key存在,则返回key的value实际使用内存估算值;如果key不存在,则返回nil
。
不太推荐的命令:debug object key
查看某key的详细信息,包括该key的value大小等,当 key 不存在时,返回一个错误。还可通过SCAN+debug object key
得到当前实例所有key的大小。
#1. debug object key
#serializedlength表示key对应的value序列化之后的字节数
127.0.0.1:6379> DEBUG OBJECT key
Value at:0xb6838d20 refcount:1 encoding:raw serializedlength:9 lru:283790 lru_seconds_idle:150
redis 127.0.0.1:6379> DEBUG OBJECT key
(error) ERR no such key
#2.memory usage
127.0.0.1:6379> set k1 value1
OK
127.0.0.1:6379> memory usage k1 #这里k1 value占用57字节内存
(integer) 57
127.0.0.1:6379> memory usage aaa #aaa键不存在,返回nil.
(nil)
对于除String类型之外的类型,memory usage
命令采用抽样的方式,默认抽样5个元素,所以计算是近似值,我们也可以指定抽样的个数。要想获取key较精确的内存值,就指定更大抽样个数。但是抽样个数越大,占用cpu时间分片就越大,若需要抽取所有的元素,则指定:SAMPLES 0
。
#比如一个100w个字段的hash键:hkey,每字段的value长度是从1~1024字节的随机值。
127.0.0.1:6379> hlen hkey #hkey有100w个字段,每个字段的value长度介于1~1024个字节
(integer) 1000000
127.0.0.1:6379> MEMORY usage hkey #默认SAMPLES为5,分析hkey键内存占用521588753字节
(integer) 521588753
127.0.0.1:6379> MEMORY usage hkey SAMPLES 1000 #指定SAMPLES为1000,分析hkey键内存占用617977753字节
(integer) 617977753
127.0.0.1:6379> MEMORY usage hkey SAMPLES 10000 #指定SAMPLES为10000,分析hkey键内存占用624950853字节
(integer) 624950853
2.3 redis-rdb-tools
在redis实例上执行bgsave
,然后对rdb
快照文件进行分析,找到其中的BigKey
。redis-rdb-tools
是一个 python 的解析 rdb 文件的工具,将rdb
快照文件生成CSV
或JSON
文件,也可以导入到MySQL
生成报表来分析。
#通过pip安装rdbtools
pip install rdbtools
#将快照文件生成csv文件
rdb -c memory dump.rdb > memory.csv
#生成的 CSV 文件中有以下几列:
- `database` key在Redis的db
- `type` key类型
- `key` key值
- `size_in_bytes` key的内存大小
- `encoding` value的存储编码形式
- `num_elements` key中的value的个数
- `len_largest_element` key中的value的长度
可以在MySQL中新建表然后导入csv
文件到数据库,然后可以直接通过SQL语句进行数据的查询分析 :
CREATE TABLE `memory` (
`database` int(128) DEFAULT NULL,
`type` varchar(128) DEFAULT NULL,
`KEY` varchar(128),
`size_in_bytes` bigint(20) DEFAULT NULL,
`encoding` varchar(128) DEFAULT NULL,
`num_elements` bigint(20) DEFAULT NULL,
`len_largest_element` varchar(128) DEFAULT NULL,
PRIMARY KEY (`KEY`)
);
#导入csv文件到数据库
load data local infile 'file_path' # 文件路径
into table daily_price # 表名
character set utf8 # 编码
fields terminated by ',' # 分隔符
lines terminated by '\n' # 换行符,windows下是
ignore 1 lines; # 忽略第一行,因为表头已建好
#例子:查询内存占用最高的3个 key
mysql> SELECT * FROM memory ORDER BY size_in_bytes DESC LIMIT 3;
+----------+------+-----+---------------+-----------+--------------+---------------------+
| database | type | key | size_in_bytes | encoding | num_elements | len_largest_element |
+----------+------+-----+---------------+-----------+--------------+---------------------+
| 0 | set | k1 | 624550 | hashtable | 50000 | 10 |
| 0 | set | k2 | 420191 | hashtable | 46000 | 10 |
| 0 | set | k3 | 325465 | hashtable | 38000 | 10 |
+----------+------+-----+---------------+-----------+--------------+---------------------+
3 rows in set (0.12 sec)
3、规避危险指令
3.1 rename-command
生产上需限制keys *
/flushdb
/flushall
等危险命令以防止误用,因为这很可能造成Redis锁住,CPU飙升,并引起调用链路超时并卡住,锁住的那几秒时间里,所有请求流量全部击中到RDS
,使得数据库雪崩并宕机。可在配置文件中通过rename-command
(Deprecated)移除,可以在客户端输入指令尝试,可以看到redis服务器已经不能识别这些指令了:
rename-command KEYS "" #必禁命令,线上用这种查询方式绝对是不对的
rename-command FLUSHALL "" #必禁命令,谁会清除数据呢!!!
rename-command FLUSHDB "" #必禁命令,谁会清除数据呢!!!
rename-command CONFIG "" #可以考虑重命名下
注意: 若改变一些会被记录到
AOF
文件或者传输到从节点的(写)命令的名字,那么在写入AOF
文件后可能无法正确地恢复数据或者传输给副本时报错。所以需要谨慎操作,并保证AOF
文件和副本也能识别新的命令名称。
推荐使用ACL
来将默认用户中的命令某些命令删除,而仅对创建的某些管理用户开放这些命令。而且从Redis 6.2
开始,还可以使用ACL
规则管理对Pub/Sub
频道的访问。
3.2 ACL
在Redis 6.0
之前的版本中,登陆只需要输入密码,而密码明文配置到配置文件中(requirepass
),所有的客户端连接都使用该密码和全部的权限,风险极高。在Redis 6.0
之后实现了ACL
(Access Control List
)访问控制列表,限制连接可执行的命令和可访问的key,可以按照不同的需求设置相关的用户和权限。Redis ACL
向后兼容,用户名默认为default
,并通过requirepass
配置密码,有2种方式使用方式:
#旧版本的使用方式,默认用户。兼容旧版本Redis的支持
AUTH <password>
#新方式,还需要验证用户名
AUTH <username> <password>
#redis-cli登录参数
--user <username> 验证用户名
--pass <password> 验证密码,是参数-a的别名;配合--user使用
--askpass 强制用户输入带有STDIN掩码的密码
#ACL指令集可通过acl help参看
127.0.0.1:6379> ACL help
1) ACL <subcommand> arg arg ... arg. Subcommands are:
2) LOAD -- 从ACL文件中重新载入用户信息.
3) SAVE -- 保存当前的用户配置信息到ACL文件.
4) LIST -- 以配置文件格式显示用户详细信息.
5) USERS -- 列出所有注册的用户名.
6) SETUSER <username> [attribs ...] -- 创建或则修改一个用户.
7) GETUSER <username> -- 得到一个用户的详细信息.
8) DELUSER <username> [...] -- 删除列表中的用户.
9) CAT -- 列出可用的命令类别.
10) CAT <category> -- 列出指定类别中的命令.
11) GENPASS [<bits>] -- 生成一个安全的用户密码.
12) WHOAMI -- 返回当前的连接用户.
13) LOG [<count> | RESET] -- 显示ACL日志条目.
通过acl list
或acl getuser default
可查看到一个默认用户定义,包含了完整的用户权限的格式:user
后紧跟用户名,on
(off
)是否启用该用户,是否有密码(nopass
),访问所有可能的key
(~*
)和Pub/Sub-channel
发布订阅频道(&*
),并能够调用所有可能的命令(+@all
)。
127.0.0.1:6379> acl list
1) "user default on #6ca13d52ca70c883e0f0bb101e425a89e8624de51db2d2392593af6a84118090 ~* &* +@all"
127.0.0.1:6379> ACL GETUSER default
1) "flags"
2) 1) "on"
3) "passwords"
4) 1) "6ca13d52ca70c883e0f0bb101e425a89e8624de51db2d2392593af6a84118090"
5) "commands"
6) "+@all"
7) "keys"
8) "~*"
9) "channels"
10) "&*"
11) "selectors"
12) (empty array)
⭕️允许和禁止命令
+/-command #为用户可调用的命令列表添加、删除命令,可与`|`一起用于添加或删除子命令(如:-/+config|set)。
+/-@category #为用户添加删除可调用的模块命令,有效类别为`@admin`, `@set`,`@sortedset`,`@read`,`@write`,@all`…(`allcommands`+@all的别名;`nocommands`: -@all的别名。),通过调用`acl cat`命令查看完整列表。
+command|first-arg #允许使用携带`first-arg`参数的被禁用命令(只支持没有子命令的命令,且不支持否定形式)。比如:当select命令被禁用后,可以使用`+SELECT|0`来允许`select 0`命令的执行。
⭕️允许和禁止某些密钥和密钥权限
~pattern
添加允许的key的全局匹配模式,可指定多个模式。如~*
(allkeys
)允许所有键;~foo:*
表示以foo:
开头的key
。Redis 7
对读写进行分开管理:%RW~pattern
是~pattern
的别名;%R~pattern
授予对匹配该模式的Key的读权限;%W~pattern
授予对匹配该模式的Key的写权限。
resetkeys
将重置清空允许的key
匹配模式列表。例如,~foo:* ~bar:* resetkeys ~objects:*
将只允许客户端访问与objects:*
模式匹配的键。
⭕️允许和禁止发布/订阅频道
&pattern #(Redis 6.2)添加用户可以访问的Pub/Sub频道的全局匹配模式,可以指定多个通道模式。请注意,模式匹配仅针对`PUBLISH`和`SUBSCRIBE`提到的通道进行,而`PSUBSCRIBE`需要其通道模式和用户允许的通道模式之间的文字匹配。
allchannels #是`&*`的别名,允许用户访问所有`Pub/Sub`频道;`resetchannels`:重置允许的频道模式列表,如果用户的Pub/Sub客户端不再能够访问其各自的频道和/或频道模式,则断开这些客户端的连接。
⭕️为用户配置有效密码
> password #将此密码添加到用户的有效密码列表中(每个用户都可以拥有任意数量的密码)。`> mypass`会将“mypass”添加到有效密码列表中,并清除`nopass`标志。相反,`< password`:从有效密码列表中删除此密码,若试图删除的密码不存在,则报错。
#/!hash #将此`SHA-256`哈希值在用户的有效密码列表中添加、删除。`resetpass`重置密码列表并删除`nopass`状态,若之后未添加密码(稍后也**未设置**为`nopass`),就无法进行身份验证。
⭕️为用户配置选择器(略)
3.3 Scan
上述为了避免危险指令,禁用了keys *
,后续查询键该用什么指令呢?这时候就要scan命令出场了,复杂度和keys命令一样,也是 O(n),但是它是通过游标分步进行的,不会阻塞线程( KEYS
或者SMEMBERS
等会阻塞服务器),类似于MySQL
的limit
命令。SCAN
命令遍历所有的key
,而SSCAN|HSCAN|ZSCAN
用于增量遍历key
的value
中的元素。
- 提供
limit
参数,可以控制每次返回结果的最大条数,这里是最大条数,而不是等于limit
的条数,因为是匹配查询,是在limit
的范围内匹配查询 - 返回的结果可能会有重复
- 遍历的过程中如果有数据修改,改动后的数据能不能遍历到是不确定的;
- 单次返回的结果是空的并不意味着遍历结束,而要看返回的游标值是否为零;
`SCAN`命令用于迭代当前数据库中的`key`集合;
`SSCAN`命令用于迭代`SET`集合中的元素;
`HSCAN`命令用于迭代`Hash`类型中的键值对;
`ZSCAN`命令用于迭代`SortSet`集合中的元素和元素对应的分值
在Redis
当中,所有的key
都存储在一个很大的字典中,这个字典结构就是一维数组+二维链表的结构,scan
指令返回的游标就是一维数组的位置索引,这个位置索引称为槽 (slot)。遍历并不是从一维数组的第零位一直遍历到末尾,而是采用了高位进位加法,避免字典的扩容和缩容时槽位的遍历重复和遗漏。
#语法
$ SCAN cursor [MATCH pattern] [COUNT count] [TYPE type]
cursor
:SCAN
命令是一个基于游标的迭代器,每次被调用之后返回新的游标作为下次迭代参数,以此来延续之前的迭代过程。使用 0 作为游标,则开始一次新的迭代。Count
:指定返回的元素数量,COUNT
参数的默认值为 10 。当数据集比较大时,如果没有使用MATCH
选项, 那么命令返回的元素数量通常和COUNT
选项指定的一样,或者比COUNT
选项指定的数量稍多一些。
127.0.0.1:6379> scan 0 count 10
1) "26" #用于进行下一次迭代的新游标,返回0表示迭代已结束
2) 1) "user:206" #数组, 这个数组中包含了所有被迭代的元素。
2) "user:109"
3) "user:101"
4) "user:205"
5) "user:106"
6) "user:103"
7) "user:105"
8) "user:100"
9) "user:104"
10) "user:201"
MATCH
:模式参数, 让命令只返回和给定模式相匹配的元素。
redis 127.0.0.1:6379> sadd myset 1 2 3 foo foobar feelsgood
(integer) 6
redis 127.0.0.1:6379> sscan myset 0 match f*
1) "0"
2) 1) "foo"
2) "feelsgood"
3) "foobar"
4、删除BigKey
4.1 String类型
对于String类型的数据,观察下表的删除时间虽然删除时间随数据上升,但不至于产生阻塞,所以一般可直接使用DEL
删除,若过于庞大,可使用UNLINK
来进行删除。
key 类型 | 512KB | 1MB | 2MB | 5MB | 10MB |
---|---|---|---|---|---|
string | 0.22ms | 0.31ms | 0.32ms | 0.56ms | 1ms |
4.2 Hash | List | Set | Zset
非字符串类型,元素的个数越多,元素越大,删除时间越长。且该删除时间,足够引起 Redis 阻塞。在不考虑使用lazy free
的情况,可以使用渐进式来进行删除。
key 类型 | 10W (8 byte) | 100W | 10W (16 byte) | 100W | 10W ( byte) | 100W |
---|---|---|---|---|---|---|
hash | 51ms | 950ms | 58ms | 970ms | 96ms | 2000ms |
list | 23ms | 134ms | 23ms | 138ms | 23ms | 266ms |
set | 44ms | 873ms | 58ms | 881ms | 73ms | 1319ms |
zset | 51ms | 845ms | 57ms | 859ms | 59ms | 969ms |
hash
:hscan
循环遍历获取所有的field-value
+hdel
删除每个field
public void delBigHash(String host, int port, String password, String bigHashKey) {
Jedis jedis = new Jedis(host, port);
if (password != null && !"".equals(password)) jedis.auth(password);
ScanParams scanParams = new ScanParams().count(100);
String cursor = "0";
do {
ScanResult<Entry<String, String>> scanResult = jedis.hscan(bigHashKey, cursor, scanParams);
List<String, String> entryList = scanResult.getResult();
if (entryList != null && !entryList.isEmpty()) {
for(Entry<String, String> entry : entryList) {
jedis.hdel(bigHashKey, entry.getKey());
}
}
cursor = scanResult.getStringCursor();
}while("0".equals(cursor));
// 最终删除bigkey
jedis.del(bigHashKey);
}
list
:使用ltrim
渐进式逐步删除,直到全部删除
public void delBigList(String host, String host, String password, String bigListKey) {
Jedis jedis = new Jedis(host, port);
if(password !=null && !"".equals(password)) jedis.auth(password);
long llen = jedis.llen(bigListKey);
int counter = 0;
int left = 100;
while( counter < llen) {
jedis.ltrim(bigListKey, left, llen);
counter += left;
}
// 最终删除bigkey
jedis.del(bigListKey);
}
set
:使用sscan
循环获取部分元素,再使用srem
删除每个元素
public void delBigList(String host, String port, String password, String bigSetKey) {
Jedis jedis = new Jedis(host, port);
if(password != null && !"".equals(password)) jedis.auth(password);
ScanParams scanParams = new ScanParams().count(100);
String cursor = "0";
do {
ScanResult<List<String>> scanResult = jedis.sscan(scanParams, cursor, bigSetKey);
List<String> memberList = scanResult.getResult();
if(memberList != null && !memberList.isEmpty()) {
for(String member : memberList) {
jedis.srem(bigSetList, member);
}
}
cursor = scanResult.getStringCursor();
}while(!"0".equals(cursor));
jedis.del(bigSetKey);
}
zset
:使用zscan
循环获取部分元素,再使用zremrangebyrank逐步删除
public void delBigZsetKey(String host, String port, String password, String bigZsetKey) {
Jedis jedis = new Jedis(host, port);
if(password != null && !"".equals(password)) jedis.auth(password);
ScanParams scanParams = new ScanParams().count(100);
String cursor = "0";
do {
ScanResult<List<Tuple>> scanResult = jedis.zscan(scanParams, cursor, BigZsetKey);
List<Tuple> tupleList = scanResult.getResult();
if(tupList != null && !tupleList.isEmpty()) {
for(Tuple tuple : tupleList) {
jedis.zrem(bigZsetKey, tuple.getElement());
}
}
}while("0".equals(cursor));
jedis.del(bigZsetKey);
}
4.3 Lazy Freeing异步删除
DEL
阻塞删除,意味着服务器停止处理新的命令,以同步方式回收与对象相关的所有内存。如果被删除的键占用小,则DEL
所需时间非常短,相当于 O(1) 或 O(log_N) 命令。但若键数据量过大,服务器则会长时间阻塞直到完成删除;
所以Redis
提供了非阻塞删除指令:UNLINK
非阻塞删除和 FLUSHALL|FLUSHDB
命令的ASYNC
选项,在另一个线程将尽可能快地逐步释放对象,这时操作删除bigkey
不会阻塞Redis
。在下面四种情况,redis
将默认以阻塞方式删除对象,类似del
命令,可以通过下面的四个配置项来进行修改默认行为:
#针对redis内存使用达到maxmeory,并设置有淘汰策略maxmemory policy时;在被动淘汰键时,是否采用lazy free机制
#因为此场景开启lazy free, 可能使用淘汰键的内存释放不及时,导致redis内存超用,超过maxmemory的限制。请结合业务测试。
lazyfree-lazy-eviction no
#针对设置有TTL的键,达到过期后,被redis清理删除时是否采用lazy free机制;建议开启,因TTL本身是自适应调整的速度。
lazyfree-lazy-expire no
#针对有些指令在处理已存在的键时,会带有一个隐式的DEL键的操作。RENAME修改key名,会先删除旧键名;同样SUNIONSTORE 或 SORT 将结果存储到某键时,可能会删除原来的键的内容。建议开启,因为若操作的是bigkey,会引入阻塞删除的性能问题。
lazyfree-lazy-server-del no
#针对slave进行全量数据同步,slave在加载master的RDB文件前,会执行flushall来清理内存,该参数决定是否采用异常flush机制。若内存变动不大,建议可开启。可减少全量同步耗时,从而减少主库因输出缓冲区爆涨引起的内存使用增长。
replica-lazy-flush no
还通过下面配置项可以修改DEL
和FLUSHDB|FLUSHALL|SCRIPT FLUSH|FUNCTION|FLUSH
等的默认行为,操作更人性化:
# 修改 DEL 命令的默认行为,使其与 UNLINK 命令完全相同
lazyfree-lazy-user-del no
# FLUSHDB,FLUSHALL,SCRIPT FLUSH 和 FUNCTION FLUSH 命令都支持异步和同步删除,可以通过传递 [SYNC|ASYNC] 标志来控制。#当没有传递任何标志时,该配置将决定是否异步删除数据
lazyfree-lazy-user-flush no