线上告警: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),会导致:
- 每层都跳过一个很小的跨度(甚至为 0),浪费指针解引用
- 缓存行命中率下降:每层 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.2s | 2.1s | 2.8s |
| 随机查询 10 万次 | 1.9s | 0.8s | 1.1s |
| 范围查询(1000 元素) | 0.3ms | 0.2ms | 0.9ms(需中序遍历) |
| ZRANK 语义查询 10 万次 | 2.4s | 0.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。它用两个数组 update 和 rank 记录每层的前驱节点及其累计 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.12s | 1.87s | 95.2 | 6.3 |
| (32, 0.25) | 2.98s | 1.72s | 79.8 | 3.8 |
| (64, 0.25) | 3.45s | 2.14s | 103.5 | 3.9 |
| (16, 0.5) | 2.71s | 1.44s | 66.2 | 4.8 |
| Redis 样式 (带 span, p=0.25, L32) | 2.12s | 0.83s | 84.1 | 3.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 语言实现中 zskiplistNode 的 level[] 是柔性数组,每个元素包含 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。