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
相关推荐
IT大白鼠1 天前
Redis 系列 · 第 01 篇——认知入门:Redis 是什么
redis·nosql
彧azz1 天前
Linux 环境下 Redis 学习总结:数据类型、持久化、锁、事务、主从与缓存问题
linux·redis·笔记·学习·面试
lbb 小魔仙1 天前
OpenClaw + cpolar 实战:远程 NAS、分享小游戏、RDP,再配置公网 AI 入口
数据库·人工智能·redis·oracle·prometheus
夕除1 天前
redis--018
redis
两锭子1 天前
Redis基础
redis
烟沙九洲1 天前
Redis 缓存穿透、缓存击穿、缓存雪崩
数据库·redis
字节探索1 天前
Redis 只会当缓存用?这 10 大实战场景,让你的系统快到飞起
redis·后端
于樱花森上飞舞1 天前
【Redis】哨兵详解
java·开发语言·数据库·redis
Rain的Java大神之路2 天前
如何快速上传10G文件
java·spring boot·redis·后端·mysql·spring cloud·面试
Wang's Blog2 天前
Java 接入Redis: 通用命令与键管理
java·服务器·redis