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
相关推荐
行百里er35 分钟前
Redis 完结篇——Redis 热点问题在 Spring Boot 项目中实战
redis·后端
凤山老林8 小时前
Spring Boot 集成 Redis Stream 做可靠消息队列:持久化、消费组重组与流式处理实战
spring boot·redis·后端
行百里er10 小时前
常见的 Redis 面试题
redis
鱼宵11 小时前
LangChain4j 生产化改造:记忆落 Redis、向量落 ES,重启再也不丢
数据库·redis·elasticsearch·langchain4j
码匠许师傅13 小时前
【C++三方组件】redis-plus-plus:现代 Redis C++ 客户端
java·c++·redis
豆角焖肉14 小时前
SpringBoot + Redis 缓存实战
java·spring boot·redis·后端·缓存·菜单缓存
来福猿14 小时前
分布式锁 Redis 实现源码分析:SETNX、过期时间与可重入设计
数据库·redis·分布式
行百里er1 天前
Redis 模块——JSON、向量搜索与时序
redis·后端
蓝胖的四次元口袋1 天前
Redis知识梳理(1)
数据库·redis·缓存
wefg11 天前
【Redis】C++ 自定义客户端
数据库·redis·缓存