Redis 6.2 RPOP 命令带 count 参数导致的性能退化分析

问题背景

为降低网络开销,将循环调用 RPOP list 优化为 RPOP list count 后,生产环境系统 TPS 显著下降。

使用 redis-benchmark 基准测试显示,RPOP list 1 的吞吐量远低于不带参数的 RPOP list

shell 复制代码
$ time redis-benchmark -n 50000 rpop mylist 1
====== rpop mylist 1 ======                                                  
Summary:
  throughput summary: 9140.77 requests per second
  latency summary (msec):
          avg       min       p50       p95       p99       max
        5.361     0.328     4.999     9.583    10.119    11.927

real	0m5.485s
user	0m0.292s
sys	0m0.463s

$ time redis-benchmark -n 50000 rpop mylist
====== rpop mylist ======                                                   
Summary:
  throughput summary: 72463.77 requests per second
  latency summary (msec):
          avg       min       p50       p95       p99       max
        0.379     0.128     0.359     0.559     0.863     3.599

real	0m0.705s
user	0m0.233s
sys	0m0.456s

分支差异

popGenericCommand 的关键分支中:

c 复制代码
if (!count) {
    /* Pop a single element. This is POP's original behavior that replies
     * with a bulk string. */
    value = listTypePop(o,where);
    serverAssert(value != NULL);
    addReplyBulk(c,value);
    decrRefCount(value);
    listElementsRemoved(c,c->argv[1],where,o,1);
} else {
    /* Pop a range of elements. An addition to the original POP command,
     *  which replies with a multi-bulk. */
    long llen = listTypeLength(o);
    long rangelen = (count > llen) ? llen : count;
    long rangestart = (where == LIST_HEAD) ? 0 : -rangelen;
    long rangeend = (where == LIST_HEAD) ? rangelen - 1 : -1;
    int reverse = (where == LIST_HEAD) ? 0 : 1;

    addListRangeReply(c,o,rangestart,rangeend,reverse);
    quicklistDelRange(o->ptr,rangestart,rangelen);
    listElementsRemoved(c,c->argv[1],where,o,rangelen);
}

不带 count:直接调用 listTypePop -> quicklistPopCustom,通过 QUICKLIST_TAIL 指针立即定位 ,复杂度 O ( 1 ) O(1) O(1)。

c 复制代码
robj *listTypePop(robj *subject, int where) {
    long long vlong;
    robj *value = NULL;

    int ql_where = where == LIST_HEAD ? QUICKLIST_HEAD : QUICKLIST_TAIL;
    if (subject->encoding == OBJ_ENCODING_QUICKLIST) {
        if (quicklistPopCustom(subject->ptr, ql_where, (unsigned char **)&value,
                               NULL, &vlong, listPopSaver)) {
            if (!value)
                value = createStringObjectFromLongLong(vlong);
        }
    } else {
        serverPanic("Unknown list encoding");
    }
    return value;
}

int quicklistPopCustom(quicklist *quicklist, int where, unsigned char **data,
                       unsigned int *sz, long long *sval,
                       void *(*saver)(unsigned char *data, unsigned int sz)) {
    unsigned char *p;
    unsigned char *vstr;
    unsigned int vlen;
    long long vlong;
    int pos = (where == QUICKLIST_HEAD) ? 0 : -1;

    if (quicklist->count == 0)
        return 0;

    if (data)
        *data = NULL;
    if (sz)
        *sz = 0;
    if (sval)
        *sval = -123456789;

    quicklistNode *node;
    if (where == QUICKLIST_HEAD && quicklist->head) {
        node = quicklist->head;
    } else if (where == QUICKLIST_TAIL && quicklist->tail) {
        node = quicklist->tail;
    } else {
        return 0;
    }

    p = ziplistIndex(node->zl, pos);
    if (ziplistGet(p, &vstr, &vlen, &vlong)) {
        if (vstr) {
            if (data)
                *data = saver(vstr, vlen);
            if (sz)
                *sz = vlen;
        } else {
            if (data)
                *data = NULL;
            if (sval)
                *sval = vlong;
        }
        quicklistDelIndex(quicklist, node, &p);
        return 1;
    }
    return 0;
}

带 count:调用 addListRangeReply,内部通过 listTypeInitIterator 初始化迭代器进行遍历。

c 复制代码
void addListRangeReply(client *c, robj *o, long start, long end, int reverse) {
    long rangelen, llen = listTypeLength(o);

    /* Convert negative indexes. */
    if (start < 0) start = llen+start;
    if (end < 0) end = llen+end;
    if (start < 0) start = 0;

    /* Invariant: start >= 0, so this test will be true when end < 0.
     * The range is empty when start > end or start >= length. */
    if (start > end || start >= llen) {
        addReply(c,shared.emptyarray);
        return;
    }
    if (end >= llen) end = llen-1;
    rangelen = (end-start)+1;

    /* Return the result in form of a multi-bulk reply */
    addReplyArrayLen(c,rangelen);
    if (o->encoding == OBJ_ENCODING_QUICKLIST) {
        int from = reverse ? end : start;
        int direction = reverse ? LIST_HEAD : LIST_TAIL;
        listTypeIterator *iter = listTypeInitIterator(o,from,direction);

        while(rangelen--) {
            listTypeEntry entry;
            listTypeNext(iter, &entry);
            quicklistEntry *qe = &entry.entry;
            if (qe->value) {
                addReplyBulkCBuffer(c,qe->value,qe->sz);
            } else {
                addReplyBulkLongLong(c,qe->longval);
            }
        }
        listTypeReleaseIterator(iter);
    } else {
        serverPanic("Unknown list encoding");
    }
}

listTypeIterator *listTypeInitIterator(robj *subject, long index,
                                       unsigned char direction) {
    listTypeIterator *li = zmalloc(sizeof(listTypeIterator));
    li->subject = subject;
    li->encoding = subject->encoding;
    li->direction = direction;
    li->iter = NULL;
    /* LIST_HEAD means start at TAIL and move *towards* head.
     * LIST_TAIL means start at HEAD and move *towards tail. */
    int iter_direction =
        direction == LIST_HEAD ? AL_START_TAIL : AL_START_HEAD;
    if (li->encoding == OBJ_ENCODING_QUICKLIST) {
        li->iter = quicklistGetIteratorAtIdx(li->subject->ptr,
                                             iter_direction, index);
    } else {
        serverPanic("Unknown list encoding");
    }
    return li;
}

quicklistIter *quicklistGetIteratorAtIdx(const quicklist *quicklist,
                                         const int direction,
                                         const long long idx) {
    quicklistEntry entry;

    if (quicklistIndex(quicklist, idx, &entry)) {
        quicklistIter *base = quicklistGetIterator(quicklist, direction);
        base->zi = NULL;
        base->current = entry.node;
        base->offset = entry.offset;
        return base;
    } else {
        return NULL;
    }
}

quicklistIndex 实现有一个缺陷:给定负索引 会从 list 的队尾 开始查找,给定正索引 会从 list 的队头开始查找。

c 复制代码
int quicklistIndex(const quicklist *quicklist, const long long idx,
                   quicklistEntry *entry) {
    quicklistNode *n;
    unsigned long long accum = 0;
    unsigned long long index;
    int forward = idx < 0 ? 0 : 1; /* 关键点:由索引正负决定遍历方向 */

    initEntry(entry);
    entry->quicklist = quicklist;
    printf("forward: %d, idx: %lld\n",forward, idx);

    if (!forward) {
        index = (-idx) - 1;
        n = quicklist->tail; // 只有负索引才从尾部找
    } else {
        index = idx;
        n = quicklist->head; // 正索引(如 RPOP count 计算出的)从头开始遍历
    }

  	...
}

问题点 :当执行 RPOP list 1 时,逻辑计算出的 from 索引是一个靠近队尾的正整数。代码因为索引为正,触发了 forward = 1,导致程序从 Head 开始跨越整个链表 去寻找 Tail 节点。此时,时间复杂度退化成 O ( N ) O(N) O(N) 其中 N 为队列元素个数。

定位优化

社区在 PR #9454 中优化了 quicklistIndex

优化逻辑:不再单纯依赖 idx 的正负号,而是计算目标位置距离 Head 近还是 Tail 近。

c 复制代码
int quicklistIndex(const quicklist *quicklist, const long long idx,
                   quicklistEntry *entry) {
    quicklistNode *n;
    unsigned long long accum = 0;
    unsigned long long index;
    int forward = idx < 0 ? 0 : 1; /* < 0 -> reverse, 0+ -> forward */

    initEntry(entry);
    entry->quicklist = quicklist;

    index = forward ? idx : (-idx) - 1;
    if (index >= quicklist->count)
        return 0;

    /* 如果从另一个方向查找路径更短,就改为从那个方向查找。 */
    int seek_forward = forward;
    unsigned long long seek_index = index;
    if (index > (quicklist->count - 1) / 2) {
        seek_forward = !forward;
        seek_index = quicklist->count - 1 - index;
    }

    n = seek_forward ? quicklist->head : quicklist->tail;
    while (likely(n)) {
        if ((accum + n->count) > seek_index) {
            break;
        } else {
            D("Skipping over (%p) %u at accum %lld", (void *)n, n->count,
              accum);
            accum += n->count;
            n = seek_forward ? n->next : n->prev;
        }
    }

    if (!n)
        return 0;

    /* Fix accum so it looks like we seeked in the other direction. */
    if (seek_forward != forward) accum = quicklist->count - n->count - accum;

    D("Found node: %p at accum %llu, idx %llu, sub+ %llu, sub- %llu", (void *)n,
      accum, index, index - accum, (-index) - 1 + accum);

    entry->node = n;
    if (forward) {
        /* forward = normal head-to-tail offset. */
        entry->offset = index - accum;
    } else {
        /* reverse = need negative offset for tail-to-head, so undo
         * the result of the original index = (-idx) - 1 above. */
        entry->offset = (-index) - 1 + accum;
    }

    quicklistDecompressNodeForUse(entry->node);
    entry->zi = ziplistIndex(entry->node->zl, entry->offset);
    if (!ziplistGet(entry->zi, &entry->value, &entry->sz, &entry->longval))
        assert(0); /* This can happen on corrupt ziplist with fake entry count. */
    /* The caller will use our result, so we don't re-compress here.
     * The caller can recompress or delete the node as needed. */
    return 1;
}

结论

所以当 redis 版本低于 7.0 时,应该使用 LPOP list count 代替 RPOP list count

shell 复制代码
$ time redis-benchmark -n 50000 lpop mylist 1
====== lpop mylist 1 ======             
Summary:
  throughput summary: 71326.68 requests per second
  latency summary (msec):
          avg       min       p50       p95       p99       max
        0.386     0.152     0.367     0.567     0.815     2.303

real	0m0.715s
user	0m0.233s
sys	0m0.469s
相关推荐
记录者t1 分钟前
redis常见问题:大key
数据库·redis
青石路16 分钟前
如果写Redis序列化与读Redis序列化不一致,你觉得会发生什么
java·redis
難釋懷1 小时前
Nginx代理https请求
redis·nginx·https
流星白龙10 小时前
【Redis】2.Redis重大版本
数据库·redis·junit
流星白龙10 小时前
【Redis】7.Hash表
数据库·redis·哈希算法
流星白龙11 小时前
【Redis】4.基本全局命令
数据库·redis·缓存
流星白龙14 小时前
【Redis】3.Redis安装与命令行客户端
数据库·redis·缓存
Season45018 小时前
Redis命令 (generic即通用命令)
数据库·redis·bootstrap
我不想名字重复20 小时前
redis缓存和数据库数据保持一致
数据库·redis·缓存
流星白龙1 天前
【Redis】6.计数其他命令
数据库·redis·缓存