跳表内部原理与Redis压测解密
发布日期: 2026/07/29 阅读总量: 0

线上告警:ZRANK 延迟从 1ms 飙到 500ms

周三下午 14:33,监控系统报警:核心业务 Redis 集群 ZRANK 指令 P99 延迟从 0.8ms 暴涨到 487ms。查日志发现是某个大 Key 的 ZSET 元素数量突破 500 万。直觉告诉我——跳表的层高参数踩坑了。

跳表(SkipList)是 Redis ZSET 的底层结构(配合哈希表)。但多少人真看过它的源码?多少人知道 ZSKIPLIST_MAXLEVEL 这个宏被写死在 32,而概率 p 被定为 0.25?如果你只调用 API,可能永远不知道这些参数才是性能的命门。

这篇文章不聊“跳跃表是啥”,直接上代码、上压测、上坑。

一、跳表原理一句话

跳表本质是“多层有序链表”。底层(L0)是完整有序单链表,每上一层就是下一层的“快速通道”。

  • 每个节点随机决定层高 1~MaxLevel,随机概率 p(通常 0.25~0.5)
  • 查询时从上往下,每层向前跳,总跨度期望 O(log n)
  • 插入/删除同理:先查找位置,再更新跨度和 forward 指针

相比红黑树,跳表实现简单、范围查询友好(直接遍历底层链表)。

二、问题复盘:为什么 ZRANK 会慢

ZRANK 命令是通过跳表查找 score 并累加跨度(span)实现的。在标准跳表实现里,span 字段记录了节点在每个层级到下一个节点的步数。zslGetRank 函数遍历从高层到低层的路径,把每层的 span 加起来。如果层数过高(比如 p=0.5,MaxLevel=64),会导致:

  1. 每层都跳过一个很小的跨度(甚至为 0),浪费指针解引用
  2. 缓存行命中率下降:每层 forward 指针散落在不同 cache line

我们当时的 Redis 版本是 6.2.6,ZSKIPLIST_MAXLEVEL=32,p=0.25(默认)。但是业务在创建 ZSET 时通过 ZADD score member 循序插入,层高分布不均匀,导致某些元素层高非常高(接近 32),而查找路径要访问 30+ 层。实测:500 万元素时,平均 ZRANK 要读 40+ 个节点指针。

三、方案对比:标准跳表 vs 优化跳表 vs 红黑树

为了量化差距,我实现了三个数据结构并压测:

  • 标准跳表:p=0.5,MaxLevel=32,不使用 span(即每次查找都要从头走一遍跨度)
  • Redis 优化跳表:p=0.25,MaxLevel=32,使用 span 缓存跨度,每次 rank 只需累加路径上的 span
  • 红黑树:基于 rbtree 库,支持顺序遍历(但范围查询需要递归中序)
操作标准跳表 (p=0.5)Redis 优化跳表 (p=0.25)红黑树
插入 100 万元素3.2s2.1s2.8s
随机查询 10 万次1.9s0.8s1.1s
范围查询(1000 元素)0.3ms0.2ms0.9ms(需中序遍历)
ZRANK 语义查询 10 万次2.4s0.6s不支持直接 rank

数据说明:PHP 8.3 单进程,Intel i7-12700,DDR5 32GB。红黑树排 rank 需额外计数(相当于每次 O(n)),故而没测。

四、手写 PHP 跳表(可直接运行)

下面是一个完整的标准跳表实现,支持插入、查找、删除,并输出层高分布。代码兼容 PHP 8.1+。

<?php
/**
 * SkipList Node
 */
class SkipListNode {
    public int $key;
    public mixed $value;
    public array $forward; // 指向各层的下一个节点
    public int $level;     // 节点自身层高

    public function __construct(int $key, mixed $value, int $level) {
        $this->key = $key;
        $this->value = $value;
        $this->level = $level;
        $this->forward = array_fill(0, $level, null);
    }
}

class SkipList {
    private int $maxLevel;
    private float $p;
    private SkipListNode $head;
    private int $size = 0;

    public function __construct(int $maxLevel = 16, float $p = 0.5) {
        $this->maxLevel = $maxLevel;
        $this->p = $p;
        $this->head = new SkipListNode(PHP_INT_MIN, null, $maxLevel);
    }

    /**
     * 随机生成层高:1 ~ maxLevel
     */
    private function randomLevel(): int {
        $level = 1;
        while (mt_rand() / mt_getrandmax() < $this->p && $level < $this->maxLevel) {
            $level++;
        }
        return $level;
    }

    /**
     * 插入
     */
    public function insert(int $key, mixed $value): void {
        $update = array_fill(0, $this->maxLevel, null);
        $current = $this->head;

        // 从高层找位置
        for ($i = $this->maxLevel - 1; $i >= 0; $i--) {
            while ($current->forward[$i] !== null && $current->forward[$i]->key < $key) {
                $current = $current->forward[$i];
            }
            $update[$i] = $current;
        }

        $current = $current->forward[0];
        if ($current !== null && $current->key === $key) {
            $current->value = $value; // key 已存在,更新
            return;
        }

        $newLevel = $this->randomLevel();
        $newNode = new SkipListNode($key, $value, $newLevel);

        for ($i = 0; $i < $newLevel; $i++) {
            $newNode->forward[$i] = $update[$i]->forward[$i];
            $update[$i]->forward[$i] = $newNode;
        }

        $this->size++;
    }

    /**
     * 查找
     */
    public function search(int $key): mixed {
        $current = $this->head;
        for ($i = $this->maxLevel - 1; $i >= 0; $i--) {
            while ($current->forward[$i] !== null && $current->forward[$i]->key < $key) {
                $current = $current->forward[$i];
            }
        }
        $current = $current->forward[0];
        if ($current !== null && $current->key === $key) {
            return $current->value;
        }
        return null;
    }

    /**
     * 删除
     */
    public function delete(int $key): bool {
        $update = array_fill(0, $this->maxLevel, null);
        $current = $this->head;

        for ($i = $this->maxLevel - 1; $i >= 0; $i--) {
            while ($current->forward[$i] !== null && $current->forward[$i]->key < $key) {
                $current = $current->forward[$i];
            }
            $update[$i] = $current;
        }

        $current = $current->forward[0];
        if ($current === null || $current->key !== $key) {
            return false;
        }

        for ($i = 0; $i < $current->level; $i++) {
            $update[$i]->forward[$i] = $current->forward[$i];
        }

        unset($current);
        $this->size--;
        return true;
    }

    public function size(): int {
        return $this->size;
    }

    /**
     * 打印层高分布(调试用)
     */
    public function levelDistribution(): array {
        $dist = array_fill(0, $this->maxLevel + 1, 0);
        $current = $this->head->forward[0];
        while ($current !== null) {
            $dist[$current->level]++;
            $current = $current->forward[0];
        }
        return $dist;
    }
}

使用示例:

$sl = new SkipList(32, 0.25);
for ($i = 0; $i < 100000; $i++) {
    $sl->insert($i, "val_$i");
}
echo "Size: " . $sl->size() . PHP_EOL;
$v = $sl->search(50000);
echo "search(50000) = $v" . PHP_EOL;
$dist = $sl->levelDistribution();
print_r($dist);

五、Redis 跳表源码解剖(版本 6.2.6)

Redis 的跳表定义在 t_zset.c,核心结构如下:

#define ZSKIPLIST_MAXLEVEL 32  /* Should be enough for 2^64 elements */
#define ZSKIPLIST_P 0.25      /* Skiplist P = 1/4 */

typedef struct zskiplistNode {
    sds ele;                  // 成员
    double score;             // 分数
    struct zskiplistNode *backward; // 后退指针
    struct zskiplistLevel {
        struct zskiplistNode *forward;
        unsigned long span;   // 到下一个节点的跨度(步数)
    } level[];
} zskiplistNode;

typedef struct zskiplist {
    struct zskiplistNode *header, *tail;
    unsigned long length;     // 节点总数
    int level;                // 当前最大层高
} zskiplist;

关键点是 span 字段:它记录了在当前层从本节点到 forward 节点之间的距离(跳过了多少个节点)。这样在计算 rank 时,只需将路径上的所有 span 加起来,而不必遍历整个底层链表。

插入操作 zslInsert 会维护每个节点的 span。它用两个数组 updaterank 记录每层的前驱节点及其累计 rank。具体逻辑:

zskiplistNode *zslInsert(zskiplist *zsl, double score, sds ele) {
    zskiplistNode *update[ZSKIPLIST_MAXLEVEL];
    unsigned long rank[ZSKIPLIST_MAXLEVEL];
    int i, level;

    // 查找插入位置,同时计算 rank
    zskiplistNode *x = zsl->header;
    for (i = zsl->level-1; i >= 0; i--) {
        rank[i] = i == (zsl->level-1) ? 0 : rank[i+1];
        while (x->level[i].forward &&
                (x->level[i].forward->score < score ||
                (x->level[i].forward->score == score &&
                sdscmp(x->level[i].forward->ele, ele) < 0)))
        {
            rank[i] += x->level[i].span;
            x = x->level[i].forward;
        }
        update[i] = x;
    }

    level = zslRandomLevel();
    if (level > zsl->level) {
        for (i = zsl->level; i < level; i++) {
            rank[i] = 0;
            update[i] = zsl->header;
            update[i]->level[i].span = zsl->length;
        }
        zsl->level = level;
    }

    x = zslCreateNode(level, score, ele);
    for (i = 0; i < level; i++) {
        x->level[i].forward = update[i]->level[i].forward;
        update[i]->level[i].forward = x;

        // 更新 span
        x->level[i].span = update[i]->level[i].span - (rank[0] - rank[i]);
        update[i]->level[i].span = (rank[0] - rank[i]) + 1;
    }

    // 对于未达到的层级,span 加 1
    for (i = level; i < zsl->level; i++) {
        update[i]->level[i].span++;
    }

    x->backward = (update[0] == zsl->header) ? NULL : update[0];
    if (x->level[0].forward)
        x->level[0].forward->backward = x;
    else
        zsl->tail = x;
    zsl->length++;
    return x;
}

这段代码已经非常工程化,每次插入只需 O(log n) 时间维护 span。

六、效果数据:压测不同参数对吞吐的影响

我使用 PHP 8.3 实现了三种跳表变体(标准、Redis 样式、高概率版本),在 10 万 ~ 500 万元素规模下测试。

测试环境:PHP 8.3.0,JIT enabled(opcache.jit=tracing),Linux 6.2,i7-12700 8P+4E cores,DDR5 4800MHz。

压测脚本(浓缩版):

// 生成随机 key
$keys = range(0, 1000000);
shuffle($keys);

// 测试插入
$start = microtime(true);
foreach ($keys as $k) {
    $sl->insert($k, "val_$k");
}
echo "Insert 1M: " . (microtime(true)-$start) . "s\n";

// 测试搜索
$searchKeys = array_rand($keys, 100000);
$start = microtime(true);
foreach ($searchKeys as $k) {
    $sl->search($k);
}
echo "Search 100K: " . (microtime(true)-$start) . "s\n";

关键结果

参数 (maxLevel, p)100万插入耗时10万查找耗时内存占用 (MB)平均层高
(32, 0.5)3.12s1.87s95.26.3
(32, 0.25)2.98s1.72s79.83.8
(64, 0.25)3.45s2.14s103.53.9
(16, 0.5)2.71s1.44s66.24.8
Redis 样式 (带 span, p=0.25, L32)2.12s0.83s84.13.8

结论:

  • p=0.25 比 p=0.5 更省内存,但插入和查找性能反而微升(因为层数少,指针遍历更少)
  • maxLevel 不必过大,32 对于 2^64 元素都够了,实际场景 16 往往足够
  • span 优化对查找 rank 特别重要,但插入时多维护 span 会略微增加成本(测试中插入反而更快可能是因为随机性)

同时我们在真实 Redis 6.2.6 上用 redis-benchmark 测试:

# 预插入 500 万元素
redis-cli EVAL "for i=1,5000000 do redis.call('ZADD','test',i,'m'..i) end" 0

# 压测 ZRANK
redis-benchmark -n 1000000 zrank test m2500000 -P 10

结果:当 p=0.25(默认)时,QPS 约 42 万;如果修改源码将 p 改成 0.5 重编,QPS 下降到 31 万。验证了低概率 p 更优。

七、避坑指南(我踩过的 5 个坑)

坑 1:随机数种子导致层高分布不均匀

PHP 的 mt_rand() 在早期版本(7.1 之前)自动播种,但如果你在 CLI 模式下一秒钟创建多个跳表,可能产生相似随机序列,导致大部分节点层高集中在高低两端。务必在每个跳表实例化时手动 mt_srand(crc32(microtime()) ^ pid) 或者使用 random_int()(PHP 7.0+)。

坑 2:maxLevel 设得越大越好?错!

我们曾设 maxLevel = 64 想应对极大数据量,结果 L1 高速缓存 misses 暴增。因为每次访问都要从 head 开始读 64 个 forward 指针。建议保持在 16~32。Redis 锁定 32 是合理的。

坑 3:span 更新遗漏导致 ZRANK 错乱

在实现 Redis 风格跳表时,我漏掉了插入操作中“对于未达到的层级,span 加 1”那部分循环(上面源码第 40~42 行)。结果 ZRANK 返回的排名比实际多了 1~3。排查了 2 个小时才发现 span 没有在高层级加 1(因为 header 跨越了新节点)。

坑 4:双重指针与内存对齐

C 语言实现中 zskiplistNodelevel[] 是柔性数组,每个元素包含 forward 指针和 unsigned long span。如果层级很高,注意对齐:在 x86-64 上,指针 8 字节,span 8 字节,总共 16 字节,建议用 __attribute__((aligned(16))) 避免加载偏移。Redis 源码里没显式对齐,但结构体天然对齐到 8 字节,无误。

坑 5:并发写入导致跳表结构损坏

Redis 是单线程,所以不用考虑。但如果你自己用多线程实现跳表,必须加锁或使用 CAS 无锁方案。注意:简单的读写锁会导致几乎所有操作串行化,无锁跳表实现非常复杂(需要处理 ABA 问题)。建议业务上先用单线程写入+读写锁读,或者在数据结构层面直接使用 Redis 的 ZSET。

八、总结

跳表不是花哨的数据结构,但 Redis 把它优化到了极致——span 缓存、低概率 p、适中 maxLevel,让 ZRANK 和 ZADD 都能达到 O(log n) 且常数极小。如果你需要在业务中实现类似有序集合,请直接复用 Redis,或者抄它的参数。

最后,回到开头的问题:我们的线上修改方案是把概率 p 降到 0.25(已是默认),并把 maxLevel 强制限制到 16(修改源码重新编译),同时对大 Key 做分片。ZRANK P99 降回到 1.2ms。