Redis 源码解析(11) scan

简介

由于 Redis 是单线程在处理用户的命令,而 Keys 命令会一次性遍历所有 Key,于是在 命令执行过程中,无法执行其他命令。这就导致如果 Redis 中的 key 比较多,那么 Keys 命令执行时间就会比较长,从而阻塞 Redis。

所以很多教程都推荐使用 Scan 命令来代替 Keys,因为 Scan 可以限制每次遍历的 key 数量。

Keys 的缺点:

  1. 没有limit,我们只能一次性获取所有符合条件的key,如果结果有上百万条,那么等待你的就是“无穷无尽”的字符串输出。
  2. keys命令是遍历算法,时间复杂度是O(N)。如我们刚才所说,这个命令非常容易导致Redis服务卡顿。因此,我们要尽量避免在生产环境使用该命令。

相比于keys命令,Scan命令有两个比较明显的优势:

  1. Scan命令的时间复杂度虽然也是O(N),但它是分次进行的,不会阻塞线程。
  2. Scan命令提供了 count 参数,可以控制每次遍历的集合数。

Scan 命令注意事项:

  • 复杂度虽然也是 O(n),但是它是通过游标分步进行的,不会阻塞线程;

  • 提供 limit 参数,可以控制每次返回结果的最大条数,limit 只是一个 hint,返回的结果可多可少;

  • 同 keys 一样,它也提供模式匹配功能;

  • 服务器不需要为游标保存状态,游标的唯一状态就是 scan 返回给客户端的游标整数;

  • 返回的结果可能会有重复,需要客户端去重复,这点非常重要;

  • 遍历的过程中如果有数据修改,改动后的数据能不能遍历到是不确定的;

  • 单次返回的结果是空的并不意味着遍历结束,而要看返回的游标值是否为零;

scan 扫描原理

扫描算法的核心思想是:利用同模效应,减少扩容或者缩容过程中的 hash 槽重复扫描。

比如现在有,容量为8的 hash table,其遍历顺序为:
在这里插入图片描述
即 hash 槽下标遍历顺序:
在这里插入图片描述
从二进制变化来看,其实就是从左往右依次累加1,进位向右累计

遍历过程的三种情况

  • 从迭代开始到结束,散列表没有进行 rehash 操作。
  • 从迭代开始到结束,散列表进行了扩容或缩容操作,且恰好为两次迭代间隔期间完成了rehash 操作。
  • 从迭代开始到结束,某次或某几次迭代时散列表正在进行 rehash 操作。

没有进行过rehash

第一种情况最容易理解,常规扫描完即可。

迭代间隔发生了rehash

扩容

在这里插入图片描述
如上,我们即将遍历槽位6,此时,已经完成了一次扩容,容量从 8 扩容到 16;我们剩下待遍历的数据槽位有 6、1、5、3、7,原槽位上的数据在扩容后可能分散到两个槽位,比如槽位 6 的数据可能分散到新表的槽位 6、14。
在这里插入图片描述
对比两张图,可以发现,扩容一倍后,新表待遍历槽位都是我们目前还没有遍历的数据;换句话说,扩容操作不会重复遍历数据,同时也不会遗漏数据。

缩容

我们以 hash table 容量由 16 缩容为 8 为例,如下图:
在这里插入图片描述
目前,我们刚遍历完槽位 6,即将遍历槽位 14,此时,完成了一次缩容 rehash;

由于槽位 14 已经超过目前缩容后的哈希表容量,所以会进行一次取模操作,即 14 % 8 = 6,因此,将读取槽位 6 上的数据;

值得注意的是,缩容后槽位 6 上会映射了原表槽位6、14的数据,因此,这里可能存在重复取原表槽位 6 的数据。

不过,庆幸的是,之后待扫描的数据都是没有处理过的,也就是不再有重复数据处理。

你可能已经注意到了,缩容操作可能引起合并槽位数据;但是,在原表处理的时候,两个可能合并的槽位都是连续处理的,这就意味着,在扫描的时候即使存在缩容操作,也最多重复扫描原表一个槽位的数据。将重复扫描的数据量降到了最低。

简单总结下,缩容操作会重复扫描数据(最多重复扫描一个槽位),但不会遗漏数据

迭代过程中正在进行rehash

从迭代开始到结束,某次或某几次迭代时散列表正在进行 rehash 操作,rehash 操作中会同时并存两个 hash 表:一张为扩容或缩容后的表 ht[1],一张为老表 ht[0], ht[0] 的数据通过渐进式 rehash 会逐步迁移到ht[1]中,最终完成整个迁移过程。

因为大小两表并存,所以需要从 ht[0] 和 ht[1] 中都取出数据,整个遍历过程为:先找到两个散列表中更小的表,先对小的 hash 表遍历,然后对大的 hash 表遍历,迭代的代码如下:

        t0 = &d->ht[0];
        t1 = &d->ht[1];
        //  确保始终先处理小表
        if (t0->size > t1->size) {
            t0 = &d->ht[1];
            t1 = &d->ht[0];
        }
        m0 = t0->sizemask;
        m1 = t1->sizemask;
        
        if (bucketfn) bucketfn(privdata, &t0->table[v & m0]);
        de = t0->table[v & m0];
        // 迭代第一张表
        while (de) { 
            next = de->next;
            fn(privdata, de);
            de = next;
        }

        do {
            if (bucketfn) bucketfn(privdata, &t1->table[v & m1]);
            de = t1->table[v & m1];
            // 迭代第二张表
            while (de) {
                next = de->next;
                fn(privdata, de);
                de = next;
            }

            v |= ~m1;
            v = rev(v);
            v++;
            v = rev(v);

        } while (v & (m0 ^ m1));

结合 rehash 中游标变更算法,为了让大家更好地理解该算法及整个迭代过程,举个例子简单讲解这种情况下迭代的顺序:假设 hash 表大小为8,扩容到 16,迭代从始至终每次迭代都在进行 rehash 操作,接下来两张表数据遍历次序如下图所示:
在这里插入图片描述
如果是缩容操作,其实和扩容操作遍历顺序是一样的,因为在处理的时候总是先处理小表,然后再处理大表。

值得注意的是,如果是进行插入,插入已经遍历过的桶时,scan无法遍历到数据。插入没有遍历过的桶时,scan可以遍历到数据。删除数据时相反。

执行流程

SCAN类命令由XscanCommand命令实现,其实其他几个SCAN类命令也都是最后调用scanGenericCommand对dict进行遍历.

void scanCommand(redisClient *c) {
    unsigned long cursor;
    if (parseScanCursorOrReply(c,c->argv[1],&cursor) == REDIS_ERR) return; // 取出游标
    scanGenericCommand(c,NULL,cursor); // 全部的操作在这里
}

void scanGenericCommand(redisClient *c, robj *o, unsigned long cursor) {
    int rv;
    int i, j;
    char buf[REDIS_LONGSTR_SIZE];
    list *keys = listCreate();
    listNode *node, *nextnode;
    long count = 10;
    sds pat;
    int patlen, use_pattern = 0;
    dict *ht;

    /* Object must be NULL (to iterate keys names), or the type of the object
     * must be Set, Sorted Set, or Hash. */
    // 输入类型检查
    redisAssert(o == NULL || o->type == REDIS_SET || o->type == REDIS_HASH ||
                o->type == REDIS_ZSET);

    /* Set i to the first option argument. The previous one is the cursor. */
    // 设置第一个选项参数的索引位置
    // 0      1       2     3  
    // SCAN OPTION <op_arg>         SCAN 命令的选项值从索引 2 开始
    // HSCAN <key> OPTION <op_arg>  而其他 *SCAN 命令的选项值从索引 3 开始
    // o == NULL 证明这是一个SCAN命令 否则就是SSCAN,HSCAN,ZSCAN
    i = (o == NULL) ? 2 : 3; /* Skip the key argument if needed. */

    // 开始解析可选参数count和match的值
    /* Step 1: Parse options. */
    // 分析选项参数
    while (i < c->argc) {
        j = c->argc - i;

        // COUNT <number>
        if (!strcasecmp(c->argv[i]->ptr, "count") && j >= 2) {
            if (getLongFromObjectOrReply(c, c->argv[i+1], &count, NULL)
                != REDIS_OK)
            {
                goto cleanup;
            }

            if (count < 1) {
                addReply(c,shared.syntaxerr);
                goto cleanup;
            }

            i += 2;

        // MATCH <pattern>
        } else if (!strcasecmp(c->argv[i]->ptr, "match") && j >= 2) {
            pat = c->argv[i+1]->ptr;
            patlen = sdslen(pat);

            /* The pattern always matches if it is exactly "*", so it is
             * equivalent to disabling it. */
            use_pattern = !(pat[0] == '*' && patlen == 1); // 如果pattern为权值匹配就不需要进行过滤了

            i += 2;

        // error
        } else {
            addReply(c,shared.syntaxerr);
            goto cleanup;
        }
    }

    /* Step 2: Iterate the collection.
     *
     * Note that if the object is encoded with a ziplist, intset, or any other
     * representation that is not a hash table, we are sure that it is also
     * composed of a small number of elements. So to avoid taking state we
     * just return everything inside the object in a single call, setting the
     * cursor to zero to signal the end of the iteration. */
     // 如果对象的底层实现为 ziplist 、intset 而不是哈希表,
     // 那么这些对象应该只包含了少量元素,
     // 为了保持不让服务器记录迭代状态的设计
     // 我们将 ziplist 或者 intset 里面的所有元素都一次返回给调用者
     // 并向调用者返回游标(cursor) 0

    /* Handle the case of a hash table. */
    ht = NULL;
    if (o == NULL) {       
        // 迭代目标为数据库
        ht = c->db->dict;
    } else if (o->type == REDIS_SET && o->encoding == REDIS_ENCODING_HT) {
        // 迭代目标为 HT 编码的集合
        ht = o->ptr;
    } else if (o->type == REDIS_HASH && o->encoding == REDIS_ENCODING_HT) {
        // 迭代目标为 HT 编码的哈希
        ht = o->ptr;
        count *= 2; /* We return key / value for this type. */
    } else if (o->type == REDIS_ZSET && o->encoding == REDIS_ENCODING_SKIPLIST) {
        // 迭代目标为 HT 编码的跳跃表
        zset *zs = o->ptr;
        ht = zs->dict;
        count *= 2; /* We return key / value for this type. */
    }

    if (ht) { // 不满足这个判断的话证明要遍历的数据库实现为压缩列表或者整数集合
        void *privdata[2];

        /* We pass two pointers to the callback: the list to which it will
         * add new elements, and the object containing the dictionary so that
         * it is possible to fetch more data in a type-dependent way. */
        // 我们向回调函数传入两个指针:
        // 一个是用于记录被迭代元素的列表
        // 另一个是字典对象
        // 从而实现类型无关的数据提取操作
        privdata[0] = keys;
        privdata[1] = o;
        do {
            cursor = dictScan(ht, cursor, scanCallback, privdata);
        } while (cursor && listLength(keys) < count); 
        // 在这个判断中此时count是有效的  
        // 因为后面还会根据pattern进行筛选,所以count是返回的最大值,这也意味着我们没办法精确控制返回值
    } else if (o->type == REDIS_SET) {
        int pos = 0;
        int64_t ll;

        while(intsetGet(o->ptr,pos++,&ll)) // 遍历全部
            listAddNodeTail(keys,createStringObjectFromLongLong(ll));
        cursor = 0; //遍历了全部 当然游标设置为零
    } else if (o->type == REDIS_HASH || o->type == REDIS_ZSET) {
        unsigned char *p = ziplistIndex(o->ptr,0);
        unsigned char *vstr;
        unsigned int vlen;
        long long vll;

        while(p) {
            ziplistGet(p,&vstr,&vlen,&vll);
            listAddNodeTail(keys, // 向keys中添加元素
                (vstr != NULL) ? createStringObject((char*)vstr,vlen) :
                                 createStringObjectFromLongLong(vll));
            p = ziplistNext(o->ptr,p);
        }
        cursor = 0;
    } else {
        redisPanic("Not handled encoding in SCAN.");
    }

    /* Step 3: Filter elements. */
    // 根据传入的pattern来筛选元素
    node = listFirst(keys);
    while (node) {
        robj *kobj = listNodeValue(node);
        nextnode = listNextNode(node);
        int filter = 0;

        /* Filter element if it does not match the pattern. */
        if (!filter && use_pattern) {
            if (sdsEncodedObject(kobj)) { // 是sds对象的话直接使用stringmatchlen匹配
                if (!stringmatchlen(pat, patlen, kobj->ptr, sdslen(kobj->ptr), 0))
                    filter = 1; //匹配不成功
            } else { // 否则需要进行一个转换 把数字转化为字符串
                char buf[REDIS_LONGSTR_SIZE];
                int len;

                redisAssert(kobj->encoding == REDIS_ENCODING_INT);
                len = ll2string(buf,sizeof(buf),(long)kobj->ptr);
                if (!stringmatchlen(pat, patlen, buf, len, 0)) filter = 1;
            }
        }

        /* Filter element if it is an expired key. */
        // 判断是否因为键过期而去删除
        if (!filter && o == NULL && expireIfNeeded(c->db, kobj)) filter = 1;

        /* Remove the element and its associted value if needed. */
        if (filter) { // filter设置为1代表要删除这个键
            decrRefCount(kobj); // 减去引用计数
            listDelNode(keys, node);
        }

        /* If this is a hash or a sorted set, we have a flat list of
         * key-value elements, so if this element was filtered, remove the
         * value, or skip it if it was not filtered: we only match keys. */
        // 当SCAN的对象为哈希表或者有序列表的时候 上面我们只是删除了键而已 这里还需要把值删了
        if (o && (o->type == REDIS_ZSET || o->type == REDIS_HASH)) {
            node = nextnode;
            nextnode = listNextNode(node);
            if (filter) {
                kobj = listNodeValue(node);
                decrRefCount(kobj);
                listDelNode(keys, node);
            }
        }
        node = nextnode;
    }

    /* Step 4: Reply to the client. */
    addReplyMultiBulkLen(c, 2);
    rv = snprintf(buf, sizeof(buf), "%lu", cursor); //
    redisAssert(rv < sizeof(buf));
    addReplyBulkCBuffer(c, buf, rv); // 把游标值写入返回数据中

    addReplyMultiBulkLen(c, listLength(keys)); // 循环把列表中的数据放入返回数据中
    while ((node = listFirst(keys)) != NULL) {
        robj *kobj = listNodeValue(node);
        addReplyBulk(c, kobj);
        decrRefCount(kobj);
        listDelNode(keys, node);
    }

cleanup:
    listSetFreeMethod(keys,decrRefCountVoid);
    listRelease(keys);
}

下一个具体由dictScan实现

/* dictScan() is used to iterate over the elements of a dictionary.
 *
 * dictScan() 函数用于迭代给定字典中的元素。
 *
 * Iterating works in the following way:
 *
 * 迭代按以下方式执行:
 *
 * 1) Initially you call the function using a cursor (v) value of 0.
 *    一开始,你使用 0 作为游标来调用函数。
 * 2) The function performs one step of the iteration, and returns the
 *    new cursor value that you must use in the next call.
 *    函数执行一步迭代操作,
 *    并返回一个下次迭代时使用的新游标。
 * 3) When the returned cursor is 0, the iteration is complete.
 *    当函数返回的游标为 0 时,迭代完成。
 *
 * The function guarantees that all the elements that are present in the
 * dictionary from the start to the end of the iteration are returned.
 * However it is possible that some element is returned multiple time.
 *
 * 函数保证,在迭代从开始到结束期间,一直存在于字典的元素肯定会被迭代到,
 * 但一个元素可能会被返回多次。
 *
 * For every element returned, the callback 'fn' passed as argument is
 * called, with 'privdata' as first argument and the dictionar entry
 * 'de' as second argument.
 *
 * 每当一个元素被返回时,回调函数 fn 就会被执行,
 * fn 函数的第一个参数是 privdata ,而第二个参数则是字典节点 de 。
 *
 * HOW IT WORKS.
 * 工作原理
 *
 * The algorithm used in the iteration was designed by Pieter Noordhuis.
 * The main idea is to increment a cursor starting from the higher order
 * bits, that is, instead of incrementing the cursor normally, the bits
 * of the cursor are reversed, then the cursor is incremented, and finally
 * the bits are reversed again.
 *
 * 迭代所使用的算法是由 Pieter Noordhuis 设计的,
 * 算法的主要思路是在二进制高位上对游标进行加法计算
 * 也即是说,不是按正常的办法来对游标进行加法计算,
 * 而是首先将游标的二进制位翻转(reverse)过来,
 * 然后对翻转后的值进行加法计算,
 * 最后再次对加法计算之后的结果进行翻转。
 *
 * This strategy is needed because the hash table may be resized from one
 * call to the other call of the same iteration.
 *
 * 这一策略是必要的,因为在一次完整的迭代过程中,
 * 哈希表的大小有可能在两次迭代之间发生改变。
 *
 * dict.c hash tables are always power of two in size, and they
 * use chaining, so the position of an element in a given table is given
 * always by computing the bitwise AND between Hash(key) and SIZE-1
 * (where SIZE-1 is always the mask that is equivalent to taking the rest
 *  of the division between the Hash of the key and SIZE).
 *
 * 哈希表的大小总是 2 的某个次方,并且哈希表使用链表来解决冲突,
 * 因此一个给定元素在一个给定表的位置总可以通过 Hash(key) & SIZE-1
 * 公式来计算得出,
 * 其中 SIZE-1 是哈希表的最大索引值,
 * 这个最大索引值就是哈希表的 mask (掩码)。
 *
 * For example if the current hash table size is 16, the mask is
 * (in binary) 1111. The position of a key in the hash table will be always
 * the last four bits of the hash output, and so forth.
 *
 * 举个例子,如果当前哈希表的大小为 16 ,
 * 那么它的掩码就是二进制值 1111 ,
 * 这个哈希表的所有位置都可以使用哈希值的最后四个二进制位来记录。
 *
 * WHAT HAPPENS IF THE TABLE CHANGES IN SIZE?
 * 如果哈希表的大小改变了怎么办?
 *
 * If the hash table grows, elements can go anyway in one multiple of
 * the old bucket: for example let's say that we already iterated with
 * a 4 bit cursor 1100, since the mask is 1111 (hash table size = 16).
 *
 * 当对哈希表进行扩展时,元素可能会从一个槽移动到另一个槽,
 * 举个例子,假设我们刚好迭代至 4 位游标 1100 ,
 * 而哈希表的 mask 为 1111 (哈希表的大小为 16 )。
 *
 * If the hash table will be resized to 64 elements, and the new mask will
 * be 111111, the new buckets that you obtain substituting in ??1100
 * either 0 or 1, can be targeted only by keys that we already visited
 * when scanning the bucket 1100 in the smaller hash table.
 *
 * 如果这时哈希表将大小改为 64 ,那么哈希表的 mask 将变为 111111 ,
 *
 * By iterating the higher bits first, because of the inverted counter, the
 * cursor does not need to restart if the table size gets bigger, and will
 * just continue iterating with cursors that don't have '1100' at the end,
 * nor any other combination of final 4 bits already explored.
 *
 * Similarly when the table size shrinks over time, for example going from
 * 16 to 8, If a combination of the lower three bits (the mask for size 8
 * is 111) was already completely explored, it will not be visited again
 * as we are sure that, we tried for example, both 0111 and 1111 (all the
 * variations of the higher bit) so we don't need to test it again.
 *
 * WAIT... YOU HAVE *TWO* TABLES DURING REHASHING!
 * 等等。。。在 rehash 的时候可是会出现两个哈希表的阿!
 *
 * Yes, this is true, but we always iterate the smaller one of the tables,
 * testing also all the expansions of the current cursor into the larger
 * table. So for example if the current cursor is 101 and we also have a
 * larger table of size 16, we also test (0)101 and (1)101 inside the larger
 * table. This reduces the problem back to having only one table, where
 * the larger one, if exists, is just an expansion of the smaller one.
 *
 * LIMITATIONS
 * 限制
 *
 * This iterator is completely stateless, and this is a huge advantage,
 * including no additional memory used.
 * 这个迭代器是完全无状态的,这是一个巨大的优势,
 * 因为迭代可以在不使用任何额外内存的情况下进行。
 *
 * The disadvantages resulting from this design are:
 * 这个设计的缺陷在于:
 *
 * 1) It is possible that we return duplicated elements. However this is usually
 *    easy to deal with in the application level.
 *    函数可能会返回重复的元素,不过这个问题可以很容易在应用层解决。
 * 2) The iterator must return multiple elements per call, as it needs to always
 *    return all the keys chained in a given bucket, and all the expansions, so
 *    we are sure we don't miss keys moving.
 *    为了不错过任何元素,
 *    迭代器需要返回给定桶上的所有键,
 *    以及因为扩展哈希表而产生出来的新表,
 *    所以迭代器必须在一次迭代中返回多个元素。
 * 3) The reverse cursor is somewhat hard to understand at first, but this
 *    comment is supposed to help.
 *    对游标进行翻转(reverse)的原因初看上去比较难以理解,
 *    不过阅读这份注释应该会有所帮助。
 */
unsigned long dictScan(dict *d,
                       unsigned long v,
                       dictScanFunction *fn,
                       void *privdata)
{
    dictht *t0, *t1;
    const dictEntry *de;
    unsigned long m0, m1;

    // 跳过空字典
    if (dictSize(d) == 0) return 0;

    // 迭代只有一个哈希表的字典
    if (!dictIsRehashing(d)) {

        // 指向哈希表
        t0 = &(d->ht[0]);

        // 记录 mask
        m0 = t0->sizemask;

        /* Emit entries at cursor */
        // 指向哈希桶
        de = t0->table[v & m0];
        // 遍历桶中的所有节点
        while (de) {
            fn(privdata, de);
            de = de->next;
        }

    // 迭代有两个哈希表的字典
    } else {

        // 指向两个哈希表
        t0 = &d->ht[0];
        t1 = &d->ht[1];

        /* Make sure t0 is the smaller and t1 is the bigger table */
        // 确保 t0 比 t1 要小
        if (t0->size > t1->size) {
            t0 = &d->ht[1];
            t1 = &d->ht[0];
        }

        // 记录掩码
        m0 = t0->sizemask;
        m1 = t1->sizemask;

        /* Emit entries at cursor */
        // 指向桶,并迭代桶中的所有节点
        de = t0->table[v & m0];
        while (de) {
            fn(privdata, de);
            de = de->next;
        }

        /* Iterate over indices in larger table that are the expansion
         * of the index pointed to by the cursor in the smaller table */
        // Iterate over indices in larger table             // 迭代大表中的桶
        // that are the expansion of the index pointed to   // 这些桶被索引的 expansion 所指向
        // by the cursor in the smaller table               //
        do {
            /* Emit entries at cursor */
            // 指向桶,并迭代桶中的所有节点
            de = t1->table[v & m1];
            while (de) {
                fn(privdata, de);
                de = de->next;
            }

            /* Increment bits not covered by the smaller mask */
            v = (((v | m0) + 1) & ~m0) | (v & m0);

            /* Continue while bits covered by mask difference is non-zero */
        } while (v & (m0 ^ m1));
    }

    /* Set unmasked bits so incrementing the reversed cursor
     * operates on the masked bits of the smaller table */
    v |= ~m0;

    /* Increment the reverse cursor */
    v = rev(v);
    v++;
    v = rev(v);

    return v;
}

scanGenericCommand中阐明了SCAN命令的调用过程,我们会从客户端得到一条SCAN命令,当存在key选项的时候证明需要遍历某个集合(哈希表,有序集合)中的数据,不存在即为遍历数据库中的所有键.然后可以通过传入的cursor来遍历整个数据结构,当游标返回0时意味着遍历结束.有以下几点需要注意:

  • 当所遍历的数据结构为整数集合或者压缩列表的时候意味着元素较少,此时会忽略count参数,返回全部的数据(特判).这个时候count这个参数是无效的.
  • count参数在底层编码为字典或者有序集合的实现为跳跃表和哈希表时count有效,表示返回的最大数据量,因为在遍历过程中会根据游标取出count个值,但还是要根据pattern筛选掉一些值,这个过程是不确定的.所以count代表的是返回的最大数据量(返回键值对时数量乘以2).其实这里的说法还不正确,细看第五条.
  • 游标的底层实现不是简单的递增,而是使用二进制翻转这种特殊的算法进行遍历.因为一次迭代可能会分N次进行,而这个过程中可能出现rehash,而redis中rehash是渐进式的,这使得一般的迭代可能会出现漏值.这种特殊的算法可以保证在不漏值的基础上最少重复.(可能会返回重复值,但一定不会丢失)
  • 游标一个巨大的优点就是它是无状态的,意味着我们可以不需要任何额外的数据结构存储迭代的过程,仅使用unsigned long类型的游标即可分多轮迭代.
  • 游标有一个缺点,就是为了不错过任何元素,迭代器需要返回给定桶上的所有键,这意味着一次迭代中必须返回桶中的所有数据,这样就可能超过我们给定的count值,所以count返回的也不是最大数据量,而是可能的最大数据量.
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值