Grape


命令语法

命令含义:

增量迭代一个集合元素。

命令格式:

SCAN cursor [MATCH pattern] [COUNT count]

命令实战:

基本的执行遍历
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"
COUNT选项

对于增量式迭代命令不保证每次迭代所返回的元素数量,我们可以使用COUNT选项, 对命令的行为进行一定程度上的调整。COUNT 选项的作用就是让用户告知迭代命令, 在每次迭代中应该从数据集里返回多少元素。使用COUNT 选项对于对增量式迭代命令相当于一种提示, 大多数情况下这种提示都比较有效的控制了返回值的数量。

COUNT 参数的默认值为 10 。
数据集比较大时,如果没有使用MATCH 选项, 那么命令返回的元素数量通常和 COUNT 选项指定的一样, 或者比 COUNT 选项指定的数量稍多一些。
在迭代一个编码为整数集合(intset,一个只由整数值构成的小集合)、 或者编码为压缩列表(ziplist,由不同值构成的一个小哈希或者一个小有序集合)时, 增量式迭代命令通常会无视 COUNT 选项指定的值, 在第一次迭代就将数据集包含的所有元素都返回给用户。
注意: 并非每次迭代都要使用相同的 COUNT 值 ,用户可以在每次迭代中按自己的需要随意改变 COUNT 值, 只要记得将上次迭代返回的游标用到下次迭代里面就可以了。

127.0.0.1:6379> scan 0 count 2
1) "12"
2) 1) "user_level_1"
   2) "mykey"
127.0.0.1:6379>
MATCH 选项

类似于KEYS 命令,增量式迭代命令通过给定 MATCH 参数的方式实现了通过提供一个 glob 风格的模式参数, 让命令只返回和给定模式相匹配的元素。

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"
redis 127.0.0.1:6379>

返回值:

SCAN, SSCAN, HSCAN 和 ZSCAN 命令都返回一个包含两个元素的 multi-bulk
回复: 回复的第一个元素是字符串表示的无符号 64 位整数(游标),回复的第二个元素是另一个 multi-bulk 回复, 包含了本次被迭代的元素。

源码分析

此篇以scan命令为例。

命令入口

/* The SCAN command completely relies on scanGenericCommand. */
void scanCommand(client *c) {
    unsigned long cursor;
    if (parseScanCursorOrReply(c,c->argv[1],&cursor) == C_ERR) return;
    scanGenericCommand(c,NULL,cursor);
}

处理游标


/* 尝试解析存储在对象“o”中的扫描游标:如果游标有效,
 * 则将其作为无符号整数存储到*cursor中,并返回C_OK。否则返回C_ERR并向客户机发送错误。
 * 此处o->ptr存储我们输入的游标
*/
int parseScanCursorOrReply(client *c, robj *o, unsigned long *cursor) {
    char *eptr;
    /* 使用strtoul(),因为我们需要一个无符号long,
     * 所以getLongLongFromObject()不会覆盖整个游标空间。
     */
    errno = 0;
    *cursor = strtoul(o->ptr, &eptr, 10);
    if (isspace(((char*)o->ptr)[0]) || eptr[0] != '\0' || errno == ERANGE)
    {
        addReplyError(c, "invalid cursor");
        return C_ERR;
    }
    return C_OK;
}

scan的公用函数

void scanGenericCommand(client *c, robj *o, unsigned long cursor) {
    int i, j;
    list *keys = listCreate();
    listNode *node, *nextnode;
    long count = 10;
    sds pat = NULL;
    int patlen = 0, use_pattern = 0;
    dict *ht;
    /* 对象必须为空(以迭代键名),或者对象的类型必须设置为集合,排序集合或散列。*/
    serverAssert(o == NULL || o->type == OBJ_SET || o->type == OBJ_HASH ||
                o->type == OBJ_ZSET);
    /* 将i设置为第一个选项参数。前一个是游标。在对象为空时第一个参数在第2个位置,否则为第三个位置,例如:scan 0 ,sscan myset 0 match f*; */
    i = (o == NULL) ? 2 : 3; /* Skip the key argument if needed. */

scan的实际操作一共分为4步,下边我们来看下这四步。

step1:解析命令选项

/* Step 1:解析选项. */
    while (i < c->argc) {
        j = c->argc - i;
        // count选项,注意是从第二个开始
        if (!strcasecmp(c->argv[i]->ptr, "count") && j >= 2) {
            if (getLongFromObjectOrReply(c, c->argv[i+1], &count, NULL) //获取所传递的值count值并赋值给count,因为在count关键字后边是count的值,所以为c->argv[i+1].
                != C_OK)
            {
                goto cleanup; //清理list等
            }
            //如果count的值为1,返回错误。清空在函数开头创建的list。
            if (count < 1) {
                addReply(c,shared.syntaxerr);
                goto cleanup;
            }
            i += 2;
        } else if (!strcasecmp(c->argv[i]->ptr, "match") && j >= 2) {
        // match选项,同样是从第二个开始
            pat = c->argv[i+1]->ptr;   //获取到匹配规则
            patlen = sdslen(pat);
            /* 如果模式完全是“*”,那么它总是匹配的,所以这相当于禁用它。也就是说这种情况下此模式可有可无 */
            use_pattern = !(pat[0] == '*' && patlen == 1);
            i += 2;
        } else {
            addReply(c,shared.syntaxerr);
            goto cleanup;
        }
    }

此步骤主要是对命令的解析,解析出count和match的值以及对相应变量的赋值,从而在下文过滤步骤中进行处理。

step2:遍历集合构造list

  /* Step 2: 遍历集合。 
     *
     *请注意,如果对象是用ziplist、intset或任何其他非哈希表的表示进行编码的,则可以肯定它也是由少量元素组成的。因此,为了避免获取状态,我们只需在一次调用中返回对象内部的所有内容,将游标设置为0表示迭代结束。 */
    /* 处理哈希表的情况. 对应o的不同类型*/
    ht = NULL;
    if (o == NULL) {
        ht = c->db->dict;
    } else if (o->type == OBJ_SET && o->encoding == OBJ_ENCODING_HT) {
        ht = o->ptr;
    } else if (o->type == OBJ_HASH && o->encoding == OBJ_ENCODING_HT) {
        ht = o->ptr;
        count *= 2; /* We return key / value for this type. */
    } else if (o->type == OBJ_ZSET && o->encoding == OBJ_ENCODING_SKIPLIST) {
        zset *zs = o->ptr;
        ht = zs->dict;
        count *= 2; /* We return key / value for this type. */
    }
    if (ht) {     //一般的存储,不是intset, ziplist
        void *privdata[2];
        /*我们将迭代的最大次数设置为指定计数的10倍,因此如果哈希表处于病态状态(非常稀疏地填充),
          我们将避免以返回没有或很少元素为代价来阻塞太多时间。 */
        long maxiterations = count*10;
        /* 我们向回调传递两个指针:一个是它将向其中添加新元素的列表,
           另一个是包含dictionary的对象,以便能够以类型相关的方式获取更多数据。 */
        privdata[0] = keys;
        privdata[1] = o;
        do {
            //一个个扫描,从cursor开始,然后调用回调函数将数据设置到keys返回数据集里面。
            cursor = dictScan(ht, cursor, scanCallback, NULL, privdata);
        } while (cursor &&
              maxiterations-- &&
              listLength(keys) < (unsigned long)count);
    } else if (o->type == OBJ_SET) {  //如果是set,将这个set里面的数据全部返回,因为它是压缩的intset,会很小的。
        int pos = 0;
        int64_t ll;
        while(intsetGet(o->ptr,pos++,&ll))
            listAddNodeTail(keys,createStringObjectFromLongLong(ll));
        cursor = 0;
    } else if (o->type == OBJ_HASH || o->type == OBJ_ZSET) {
        //ziplist或者hash,字符串表示的数据结构,不会太大。
        unsigned char *p = ziplistIndex(o->ptr,0);
        unsigned char *vstr;
        unsigned int vlen;
        long long vll;
        while(p) { //扫描整个键,然后集中返回一条。并且返回cursor为0表示没东西了。其实这个就等于没有遍历
            ziplistGet(p,&vstr,&vlen,&vll);
            listAddNodeTail(keys,
                (vstr != NULL) ? createStringObject((char*)vstr,vlen) :
                                 createStringObjectFromLongLong(vll));
            p = ziplistNext(o->ptr,p);
        }
        cursor = 0;
    } else {
        serverPanic("Not handled encoding in SCAN.");
    }

此步骤根据不同的格式做出不同的处理,将扫描出来的元素放在list集合中,以方便过滤与取数。

step3:过滤元素

/* Step 3: 过滤元素.此处是遍历上文构造的list */
    node = listFirst(keys);
    while (node) {
        robj *kobj = listNodeValue(node);
        nextnode = listNextNode(node);
        int filter = 0;
        /* 如果它不匹配的模式则过滤,此处的过滤是在上文给出. */
        if (!filter && use_pattern) {
            if (sdsEncodedObject(kobj)) {
                if (!stringmatchlen(pat, patlen, kobj->ptr, sdslen(kobj->ptr), 0))
                    filter = 1;
            } else {
                char buf[LONG_STR_SIZE];
                int len;
                serverAssert(kobj->encoding == OBJ_ENCODING_INT);
                len = ll2string(buf,sizeof(buf),(long)kobj->ptr);
                if (!stringmatchlen(pat, patlen, buf, len, 0)) filter = 1;
            }
        }
        /* 如果key过期,过滤. */
        if (!filter && o == NULL && expireIfNeeded(c->db, kobj)) filter = 1;
        /* 如果需要过滤,删除元素及其已设置的值. */
        if (filter) {
            decrRefCount(kobj);
            listDelNode(keys, node);
        }
       
        /* 如果这是一个散列或排序集,我们有一个键-值元素的平面列表,因此如果这个元素被过滤了,
           那么删除这个值,或者如果它没有被过滤,那么跳过它:我们只匹配键。*/
        if (o && (o->type == OBJ_ZSET || o->type == OBJ_HASH)) {
            node = nextnode;
            nextnode = listNextNode(node);
            if (filter) {
                kobj = listNodeValue(node);
                decrRefCount(kobj);
                listDelNode(keys, node);
            }
        }
        node = nextnode;
    }

根据match参数过滤返回值,并且如果这个键已经过期也会直接过滤掉。最后返回元素。

step4:返回消息给客户端+清理

 /* Step 4: 返回消息给客户端. */
    addReplyMultiBulkLen(c, 2);
    addReplyBulkLongLong(c,cursor);
    addReplyMultiBulkLen(c, listLength(keys));
    while ((node = listFirst(keys)) != NULL) {
        robj *kobj = listNodeValue(node);
        addReplyBulk(c, kobj);
        decrRefCount(kobj);
        listDelNode(keys, node);
    }
//清理操作,清楚list等结构
cleanup:
    listSetFreeMethod(keys,decrRefCountVoid);
    listRelease(keys);
}

综上所述,scan可以分为四步:

  • 解析count和match参数.如果没有指定count,默认返回10条数据
  • 开始迭代集合,如果是key保存为ziplist或者intset,则一次性返回所有数据,没有游标(游标值直接返回0).由于redis设计只有数据量比较小的时候才会保存为ziplist或者intset,所以此处不会影响性能.
  • 游标在保存为hash的时候发挥作用,具体入口函数为dictScan,详情可见dictScan原理
  • 根据match参数过滤返回值,并且如果这个键已经过期也会直接过滤掉(redis中键过期之后并不会立即删除,此处涉及到redis的两种过期删除机制,惰性删除和定期删除)
  • 返回结果到客户端,是一个数组,第一个值是游标,第二个值是具体的键值对

扩展

查找大key的方法:

bigkeys参数

redis-cli 提供一个bigkeys参数,可以扫描redis中的大key

执行结果:

root@grape ~]# 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 'testLuaSet' with 11 bytes
[00.00%] Biggest string found so far 'number' with 18 bytes
-------- summary -------
Sampled 2 keys in the keyspace!
Total key length in bytes is 16 (avg len 8.00)
Biggest string found 'number' has 18 bytes
2 strings with 29 bytes (100.00% of keys, avg size 14.50)
0 lists with 0 items (00.00% of keys, avg size 0.00)
0 sets with 0 members (00.00% of keys, avg size 0.00)
0 hashs with 0 fields (00.00% of keys, avg size 0.00)
0 zsets with 0 members (00.00% of keys, avg size 0.00)

此参数命令比较简单,使用scan命令去遍历所有的键,对每个键根据其类型执行"STRLEN","LLEN","SCARD","HLEN","ZCARD"这些命令获取其长度或者元素个数。
另外该方法有两个缺点:

  • 1.线上使用:虽然scan命令通过游标遍历建空间并且在生产上可以通过对从服务执行该命令,但毕竟是一个线上操作
  • 2.set,zset,list以及hash类型只能获取有多少个元素。但其实元素多的不一定占用空间大

通过RDB文件

在redis中定义了一些opcode(1字节),去标记opcode之后保存的是什么类型的数据,在这些类型中有一个value-type值类型,如下图:

clipboard.png

value_type就是值类型这一列,括号中的数字就是保存到rdb文件中时的实际使用数字。
我们可以写代码解析rdb文件,通过value_type去获取每个value的大小。
在这里我们推荐一个开源软件:godis-cli-bigkey
详情见github:https://github.com/erpeng/god...

scan的优缺点

  • 提供键空间的遍历操作,支持游标,复杂度O(1), 整体遍历一遍只需要O(N);
  • 提供结果模式匹配;
  • 支持一次返回的数据条数设置,但仅仅是个hints,有时候返回的会多;
  • 弱状态,所有状态只需要客户端需要维护一个游标;
  • 无法提供完整的快照遍历,也就是中间如果有数据修改,可能有些涉及改动的数据遍历不到;
  • 每次返回的数据条数不一定,极度依赖内部实现;
  • 返回的数据可能有重复,应用层必须能够处理重入逻辑;
  • count是每次扫描的key个数,并不是结果集个数。count要根据扫描数据量大小而定,Scan虽然无锁,但是也不能保证在超过百万数据量级别搜索效率;count不能太小,网络交互会变多,count要尽可能的大。在搜索结果集1万以内,建议直接设置为与所搜集大小相同

参考文章


NoSay
449 声望544 粉丝