MoreKey & BigKey

MoreKey问题

假如现在Redis服务上有100W条记录,如何遍历?keys *可以吗?

模拟大量的key

首先要在Redis里面插入100W条记录,这里先生成一个临时文件使用命令:

for((i=1;i<=100*10000);i++); do echo "set k$i v$i" >>/tmp/redisTest.txt; done;

等待完毕后使用more查看文件内容:

root@zygzyg:~# more /tmp/redisTest.txt 
set k1 v1
set k2 v2
set k3 v3
set k4 v4
set k5 v5
set k6 v6
set k7 v7
set k8 v8
...

可以看到命令已经成功的写入文件(大概耗时几秒钟吧)。

批量执行命令

上面已经生成了一个有100W条命令的文件,那么如何将这些记录写入Redis呢?这里可以使用Redis管道(Redis Pipline)

这里使用Pipline即可将redisTest.txt中的命令写入Redis。

root@3581da7bfc4e:/data#cat /tmp/redisTest.txt | redis-cli -h 127.0.0.1 -p 6379 --pipe
All data transferred. Waiting for the last reply...
Last reply received from server.
errors: 0, replies: 1000000

这里可以看到已经成功写入100W条记录了,使用DBSIZE查看:

127.0.0.1:6379> DBSIZE
(integer) 1000000

测试keys *

127.0.0.1:6379> keys * 

......

1000000) "k375734"
1000001) "k911335"
1000002) "k702315"
1000003) "k538803"
1000004) "k254614"
(113.48s)

可以看到这里耗时有113秒左右,由于Redis执行命令是单线程,也就是说这里的keys *执行返回之前该线程是处于阻塞状态的。所以keys *这个命令有致命的弊端,在实际生产中最好是不要使用。

⚠️这个命令没有offsetlimit参数,也就是一旦执行就会查出所有满足条件的key,由于Redis执行命令是单线程的,它的所有操作都是原子的,而keys算法是遍历算法,复杂度是O(n),如果Redis中有百万级或者千万级以上的key,这个指令就会导致Redis服务阻塞,所有读写Redis的其他指令都会被迫延迟甚至超时报错,可能会引起缓存雪崩、数据库压力飙升甚至宕机。

禁用Redis命令

既然这些命令在生产上容易出现问题,那么如何在生产上限制这些命令的使用呢?

可以通过Redis配置redis.conf来禁用这些命令

# It is also possible to completely kill a command by renaming it into
# an empty string:
# 简答说就是将想要禁用的命令后面配置为空字符串即可
# rename-command CONFIG ""
rename-command KEYS ""
rename-command FLUSHALL ""
rename-command FLUSHDB ""

通过上述配置就可以禁用KEYSFLUSHALLFLUSHDB命令了,再次使用会报如下错误:

127.0.0.1:6379> keys *
(error) ERR unknown command 'keys', with args beginning with: '*' 
127.0.0.1:6379> FLUSHALL
(error) ERR unknown command 'FLUSHALL', with args beginning with: 
127.0.0.1:6379> FLUSHDB
(error) ERR unknown command 'FLUSHDB', with args beginning with: 

SCAN命令

既然KEYS *会造成阻塞,那么该如何遍历呢?Redis还提供了SCAN命令,有点类似于MySQL中的limit,但是并不是完全相同。

SCAN cursor [MATCH pattern] [COUNT count]
  • cursor:游标。
  • pattern:匹配的模式。
  • count:指定从数据集里面返回多少元素,默认是10。

它是基于游标的迭代器,需要基于上一次的游标延续之前的迭代过程以0作为游标开始一次新的迭代,直到命令返回游标0完成一次遍历,不保证每次执行都返回某个给定数量的元素,支持模糊查询一次返回的数量不可控,只能是大概率符合count参数。

127.0.0.1:6379> SCAN 0 MATCH k* COUNT 10
1) "196608"
2)  1) "k176810"
    2) "k738876"
    3) "k689302"
    4) "k526619"
    5) "k600652"
    6) "k152020"
    7) "k289919"
    8) "k819980"
    9) "k98466"
   10) "k755213"
   11) "k10086"
127.0.0.1:6379> SCAN 196608 MATCH k* COUNT 10
1) "425984"
2)  1) "k181985"
    2) "k870789"
    3) "k535112"
    4) "k990462"
    5) "k591390"
    6) "k188791"
    7) "k917745"
    8) "k483131"
    9) "k511975"
   10) "k273108"
   11) "k479350"

SCAN的遍历顺序

SCAN的遍历顺序非常特别,它不是从第一维数组的第零位一直遍历到末尾,而是采用了高位进位加法来遍历。之所以使用这样特殊的方式进行遍历,是考虑到字典的扩容和缩容时避免槽位的遍历重复和遗漏。

BigKey问题

多大的Key算BigKey呢?严格来说真正大的应该是key对应的value。可以参考《阿里云Redis开发规范》

string类型控制在10KB以内,hash、list、set、zset元素个数不要超过5000。

反例:一个包含200万个元素的list。

非字符串的bigkey,不要使用del删除,使用hscan、sscan、zscan方式渐进式删除,同时要注意防止bigkey过期时间自动删除问题(例如一个200万的zset设置1小时过期,会触发del操作,造成阻塞,而且该操作不会不出现在慢查询中(latency可查),查找方法删除方法

那么BigKey会造成哪些问题呢?

BigKey会造成哪些问题

  1. 如果是集群模式下,无法做到负载均衡,导致请求倾斜到某个实例上,而这个实例的QPS会比较大,内存占用也较多;对于Redis单线程模型又容易出现CPU瓶颈,当内存出现瓶颈时,只能进行纵向扩容。
  2. 涉及到大key的操作,尤其是使用hgetalllrange 0 -1gethmget 等操作时,网卡可能会成为瓶颈,也会到导致堵塞其它操作,QPS就有可能出现突降或者突升的情况,趋势上看起来十分不平滑,严重时会导致应用程序连不上,实例或者集群在某些时间段内不可用的状态。
  3. 假如这个key需要进行删除操作,如果直接进行DEL操作,被操作的实例会被阻塞,导致无法响应应用的请求,而这个Block的时间会随着key的变大而变长。

BigKey是如何产生的

一般来说,bigkey是由于程序员的程序设计不当,或对数据规模预料不清楚造成的:

  1. 社交类:粉丝列表,如果某些明星或大V,粉丝逐渐增加。一定会造成BigKey。
  2. 统计类:如果按天存储某项功能或网站的用户集合,除非没几个人用,否则必定是bigkey。
  3. 缓存类:作为数据库数据的冗余存储,这种是redis的最常用场景,但有2点要注意:
    1. 是不是有必要把所有数据都缓存?
    2. 有没有相关关联的数据?

举个例子,该同学把某明星一个专辑下的所有视频信息都缓存成了一个巨大的json,这个json达到了6MB。

BigKey如何发现

  1. redis-cli--bigkeys

    redis-cli原生自带–bigkeys功能,可以找到某个实例5种数据类型(String、hash、list、set、zset)的最大key。

    其好处有能给出每种数据结构TOP 1的BigKey,同时还能给出每种数据类型的键值个数和平均大小。

    不足之处有,当想要查询大于10kb的所有key时,–bigkeys就无能为力了,这时可以用memory useage来计算每个键值对的字节数。

    root@3581da7bfc4e:/data# redis-cli --bigkeys
    # 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 '"k176810"' with 7 bytes
    [42.58%] Biggest string found so far '"Test"' with 19 bytes
    [100.00%] Sampled 1000000 keys so far
    -------- summary -------
    Sampled 1000004 keys in the keyspace!
    Total key length in bytes is 6888908 (avg len 6.89)
    Biggest string found '"Test"' has 19 bytes
    0 lists with 0 items (00.00% of keys, avg size 0.00)
    0 hashs with 0 fields (00.00% of keys, avg size 0.00)
    1000004 strings with 6888925 bytes (100.00% of keys, avg size 6.89)
    0 streams with 0 entries (00.00% of keys, avg size 0.00)
    0 sets with 0 members (00.00% of keys, avg size 0.00)
    0 zsets with 0 members (00.00% of keys, avg size 0.00)
    

    如果加上 -i 0.1,那么每隔100条scan命令就会休眠0.1秒,这要QPS就不会剧烈抬升,但是扫描的时间会变长。

  2. memory useage

    命令MEMORY USAGE 给出一个key和它值在内存中占用的字节数,返回的结果是key的值以及为管理该key分配的内存总字节数,对于嵌套数据类型,可以使用选项SAMPLES,其中COUNT表示抽样的元素个数,默认值为5。当需要抽样所有元素时,使用SAMPLES 0

    127.0.0.1:6379> MEMORY USAGE Test
    (integer) 48
    
  3. 在redis实例上执行bgsave,然后对dump出来的rdb文件进行分析,找到其中的大KEY。

BigKey如何删除

DEL命令在删除单个集合类型的Key时,命令的时间复杂度是O(M),其中M是集合类型Key包含的元素个数。

DEL keyTime complexity: O(N) where N is the number of keys that will be removed. When a key to remove holds a value other than a string, the individual complexity for this key is O(M) where M is the number of elements in the list, set, sorted set or hash. Removing a single key that holds a string value is O(1).

生产环境中遇到过多次因业务删除大Key,导致Redis阻塞,出现故障切换和应用程序雪崩的故障。

  1. String:一般使用DEL,如果过于庞大则使用UNLINK

  2. Hash:通过hscan命令,每次获取少量字段,再用hdel命令,每次删除1个字段,《阿里云Redis开发手册》中的Java代码:

    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<Entry<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);
    }
    
  3. List:使用ltrim渐进式删除,直到全部删除完成,《阿里云Redis开发手册》中的Java代码:

    public void delBigList(String host, int port, 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) {
            //每次从左侧截掉100个
            jedis.ltrim(bigListKey, left, llen);
            counter += left;
        }
        //最终删除key
        jedis.del(bigListKey);
    }
    
  4. Set:使用sscan每次获取部分元素,再使用srem命令删除每个元素,《阿里云Redis开发手册》中的Java代码:

    public void delBigSet(String host, int 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<String> scanResult = jedis.sscan(bigSetKey, cursor, scanParams);
            List<String> memberList = scanResult.getResult();
            if (memberList != null && !memberList.isEmpty()) {
                for (String member : memberList) {
                    jedis.srem(bigSetKey, member);
                }
            }
            cursor = scanResult.getStringCursor();
        } while (!"0".equals(cursor));
        //删除bigkey
        jedis.del(bigSetKey);
    }
    
  5. ZSet:使用zscan每次获取部分元素,再使用ZREMRANGEBYRANK命令删除每个元素,《阿里云Redis开发手册》中的Java代码:

    public void delBigZset(String host, int 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<Tuple> scanResult = jedis.zscan(bigZsetKey, cursor, scanParams);
            List<Tuple> tupleList = scanResult.getResult();
            if (tupleList != null && !tupleList.isEmpty()) {
                for (Tuple tuple : tupleList) {
                    jedis.zrem(bigZsetKey, tuple.getElement());
                }
            }
            cursor = scanResult.getStringCursor();
        } while (!"0".equals(cursor));
        
        //删除bigkey
        jedis.del(bigZsetKey);
    }
    

BigKey生产调优

为了解决redis使用del命令删除大体积的key,或者使用flushdbflushall删除数据库时,造成redis阻塞的情况,在redis 4.0引入了lazyfree机制,可将删除操作放在后台,让后台子线程(bio)执行,避免主线程阻塞。

redis.conf文件里Lazy Freeing的相关说明:

  • Redis有两个用于删除键的原语。一个称为DEL,是对象的阻塞删除。这意味着服务器停止处理新命令以同步方式回收与对象相关联的所有内存。如果删除的键与小对象相关联,则执行DEL命令所需的时间非常快,可与大多数其他O(1)或O(log_N)命令相媲美。但是,如果键与包含数百万元素的聚合值相关联,则服务器可能会阻塞很长时间(甚至几秒钟)以完成操作。

  • Redis提供了非阻塞删除原语,例如UNLINK(非阻塞DEL)和FLUSHALLFLUSHDB命令的ASYNC选项,以便在后台回收内存。这些命令在恒定时间内执行。另一个线程将尽可能快地逐步释放对象。

  • DELUNLINKFLUSHALLFLUSHDBASYNC选项由用户控制。由应用程序的设计决定何时使用哪个。但是,Redis服务器有时必须删除键或刷新整个数据库作为其他操作的副作用。具体来说,Redis在以下场景中独立于用户调用删除对象:

    1. 当因为maxmemorymaxmemory策略配置而需要为新数据腾出空间以避免超过指定的内存限制时,会发生清除操作。
    2. 因为过期:当具有关联的生存时间的键必须从内存中删除时。
    3. 因为命令的副作用,该命令将数据存储在可能已经存在的键上。例如,当RENAME命令被另一个命令替换时,它可能会删除旧键内容。同样,SUNIONSTORE或具有STORE选项的SORT可能会删除现有键。SET命令本身会删除指定键的任何旧内容,以便用指定字符串替换它。
    4. 当副本与其主服务器执行完全重新同步时,会发生清除操作,以便加载刚刚传输的整个数据库内容。
  • 在所有上述情况下,默认情况是以阻塞方式删除对象,就像调用DEL一样。但是,您可以针对每种情况进行特定配置,以便以非阻塞方式释放内存,就像调用UNLINK一样,使用以下配置指令:

    # 针对redis内存使用达到maxmeory,并设置有淘汰策略时;在被动淘汰键时,是否采用lazy free机制;
    lazyfree-lazy-eviction yes
    # 针对设置有TTL的键,达到过期后,被redis清理删除时是否采用lazy free机制;此场景建议开启,因TTL本身是自适应调整的速度。
    lazyfree-lazy-expire yes
    # 针对有些指令在处理已存在的键时,会带有一个隐式的DEL键的操作。如rename命令,当目标键已存在,redis会先删除目标键,如果这些目标键是一个big key,那就会引入阻塞删除的性能问题。 此参数设置就是解决类问题,建议可开启。
    lazyfree-lazy-server-del yes
    # 针对slave进行全量数据同步,slave在加载master的RDB文件前,会运行flushall来清理自己的数据场景,
    # 参数设置决定是否采用异常flush机制。如果内存变动不大,建议可开启。可减少全量同步耗时,从而减少主库因输出缓冲区爆涨引起的内存使用增长。
    replica-lazy-flush yes
    
  • 还可以通过以下配置指令将DEL命令的默认行为修改为与UNLINK完全相同,以便在无法轻松替换用户代码DEL调用为UNLINK调用的情况下使用:

    lazyfree-lazy-user-del yes
    
  • FLUSHDBFLUSHALLSCRIPT FLUSHFUNCTION FLUSH支持异步和同步删除,可以通过将[SYNC|ASYNC]标志传递到命令中来控制。当没有传递标志时,将使用此配置来确定数据是否应异步删除:

    lazyfree-lazy-user-flush yes