rax
rax 源码分析(基数树 / Radix Tree)
rax 是 Redis 手写的压缩前缀树:把普通 Trie 里”一串只有单个孩子的节点”压成一个节点内的字符串,节点数和指针跳转都大幅减少。
Redis 里最重的用户是 Stream——以 16 字节大端消息 ID 为 key 建有序索引,此外集群槽位映射、client tracking 也靠它。
整个实现的复杂度几乎全部来自一件事:为了省内存,节点是变长的、没有具名字段,任何结构变化都伴随 realloc 和父指针回写。
代码块收起展开
// 基于本地 Redis 仓 (unstable, 2026-03), src/rax.h
#define RAX_NODE_MAX_SIZE ((1<<29)-1) // size 位域只有 29 bit, 单个压缩节点最多存 2^29-1 字节
typedef struct raxNode {
uint32_t iskey:1; /* Does this node contain a key? */ // 任何中间节点都能当 key 终点, 不限于叶子: "foo" 和 "foobar" 共存靠它
uint32_t isnull:1; /* Associated value is NULL (don't store it). */ // NULL 值连指针都不分配, rax 当有序 set 用时零 value 开销
uint32_t iscompr:1; /* Node is compressed. */
uint32_t size:29; /* Number of children, or compressed string len. */ // 同一个字段两种语义, 由 iscompr 决定
/* Data layout is as follows:
* ...
* [header iscompr=0][abc][a-ptr][b-ptr][c-ptr](value-ptr?) // 非压缩: size 个有序字符 + size 个子指针, 字符存在父节点的"边"上
* ...
* [header iscompr=1][xyz][z-ptr](value-ptr?) // 压缩: 一串单链字符, 但只有 1 个子指针(指向最后一个字符对应的节点)
* ... */
unsigned char data[]; // 变长区: 没有具名字段, 全靠宏做指针运算访问
} raxNode;
typedef struct rax {
raxNode *head;
uint64_t numele; // key 个数, raxSize() O(1) 直接返回
uint64_t numnodes; // 节点总数, 远小于所有 key 的字符总量: 这就是压缩的效果
size_t *alloc_size; // 新版加的内存记账: 每次节点 malloc/realloc/free 都同步累计, 外部可精确观测 rax 内存占用
void *metadata[];
} rax;变长布局的代价是访问全靠宏算偏移。raxNodeCurrentLength 是所有布局宏的根:header 4 字节 + size 个字符 + 对齐 padding + 子指针(压缩节点固定 1 个,非压缩 size 个)+ 可选 value 指针。
padding 的存在是因为字符区长度任意,不补齐的话后面的指针数组会落在非对齐地址上。
代码块收起展开
// 基于本地 Redis 仓 (unstable, 2026-03), src/rax.c
#define raxPadding(nodesize) ((sizeof(void*)-(((nodesize)+4) % sizeof(void*))) & (sizeof(void*)-1))
#define raxNodeCurrentLength(n) ( \
sizeof(raxNode)+(n)->size+ \
raxPadding((n)->size)+ \
((n)->iscompr ? sizeof(raxNode*) : sizeof(raxNode*)*(n)->size)+ \
(((n)->iskey && !(n)->isnull)*sizeof(void*)) \
)所有读写路径的公共底座是 raxLowWalk:从根出发尽可能深地匹配 key,返回匹配了多少字节,并通过出参告诉调用者停在了哪个节点(stopnode)、父节点里指向它的槽位(plink)、若停在压缩节点则停在第几个字符(splitpos)、以及可选的父节点栈(ts,删除和迭代用)。
代码块收起展开
// 基于本地 Redis 仓 (unstable, 2026-03), src/rax.c
static inline size_t raxLowWalk(rax *rax, unsigned char *s, size_t len, raxNode **stopnode, raxNode ***plink, int *splitpos, raxStack *ts) {
raxNode *h = rax->head;
raxNode **parentlink = &rax->head; // "父节点里存我地址的那个槽位": 节点 realloc 会搬家, 这是唯一稳定的锚点
size_t i = 0; /* Position in the string. */
size_t j = 0; /* Position in the node children (or bytes if compressed).*/
while(h->size && i < len) {
debugnode("Lookup current node",h);
unsigned char *v = h->data;
if (h->iscompr) {
for (j = 0; j < h->size && i < len; j++, i++) {
if (v[j] != s[i]) break; // 压缩串必须整段逐字节匹配, 失配就停
}
if (j != h->size) break; // j 停在失配位置, 正是插入时 ALGO 1 的分裂点
} else {
/* Children are sorted. Check the last child first: for
* sequential inserts the match is almost always at the end,
* and for random keys the extra compare is negligible vs
* the O(n) scan that follows on miss. */
if (v[h->size - 1] == s[i]) { // 先比最右分支: Stream ID 单调递增, 顺序插入几乎总命中最后一个孩子, O(size) 变 O(1)
j = h->size - 1;
} else if (s[i] > v[h->size - 1]) {
j = h->size; // 比最大分支还大, 必然不存在, 直接判 miss
break;
} else {
/* Even when h->size is large, linear scan provides good
* performances compared to other approaches that are in theory
* more sounding, like performing a binary search. */
for (j = 0; j < h->size; j++) {
if (v[j] == s[i]) break;
}
if (j == h->size) break;
}
i++;
}
if (ts) raxStackPush(ts,h); /* Save stack of parent nodes. */ // 节点为省内存不存 parent 指针, 需要父链的操作(删除/迭代)下行时现记
raxNode **children = raxNodeFirstChildPtr(h);
if (h->iscompr) j = 0; /* Compressed node only child is at index 0. */
memcpy(&h,children+j,sizeof(h)); // data[] 里的指针没有字段名, 读写一律 memcpy + 偏移
parentlink = children+j;
j = 0; /* If the new node is non compressed and we do not
iterate again (since i == len) set the split
position to 0 to signal this node represents
the searched key. */
}
debugnode("Lookup stop node is",h);
if (stopnode) *stopnode = h;
if (plink) *plink = parentlink;
if (splitpos && h->iscompr) *splitpos = j;
return i;
}
/* Find a key in the rax: return 1 if the item is found, 0 otherwise.
* If there is an item and 'value' is passed in a non-NULL pointer,
* the value associated with the item is set at that address. */
int raxFind(rax *rax, unsigned char *s, size_t len, void **value) { // Redis 8.0 起改成 int + 出参; 老版本返回 void* 与 raxNotFound 哨兵比较
raxNode *h;
debugf("### Lookup: %.*s\n", (int)len, s);
int splitpos = 0;
size_t i = raxLowWalk(rax,s,len,&h,NULL,&splitpos,NULL);
if (i != len || (h->iscompr && splitpos != 0) || !h->iskey) // 三个条件缺一不可: 没走完 / 停在压缩串中间 / 终点节点不是 key
return 0;
if (value != NULL) *value = raxGetData(h);
return 1;
}插入是 rax 最复杂的部分。raxInsert / raxTryInsert 都是 raxGenericInsert 的皮,差别只有 overwrite 标志。
核心分三种局面:走完 key 且没停在压缩串中间(复用现有节点);停在压缩节点中途因为失配(ALGO 1,分裂);key 耗尽但停在压缩串中间(ALGO 2,截断)。
代码块收起展开
// 基于本地 Redis 仓 (unstable, 2026-03), src/rax.c
int raxGenericInsert(rax *rax, unsigned char *s, size_t len, void *data, void **old, int overwrite) {
size_t i, usable;
int j = 0; /* Split position. If raxLowWalk() stops in a compressed
node, the index 'j' represents the char we stopped within the
compressed node, that is, the position where to split the
node for insertion. */
raxNode *h, **parentlink;
// ...
i = raxLowWalk(rax,s,len,&h,&parentlink,&j,NULL);
// ... 局面一: key 已有对应节点, 只需(可能)追加 value 指针
if (i == len && (!h->iscompr || j == 0 /* not in the middle if j is 0 */)) {
/* Make space for the value pointer if needed. */
if (!h->iskey || (h->isnull && overwrite)) {
h = raxReallocForData(rax,h,data); // realloc 后节点地址可能变
if (h) memcpy(parentlink,&h,sizeof(h)); // 所以必须同步改写父节点的槽位, parentlink 的意义就在这
}
if (h == NULL) {
errno = ENOMEM;
return 0;
}
/* Update the existing key if there is already one. */
if (h->iskey) {
if (old) *old = raxGetData(h);
if (overwrite) raxSetData(h,data);
errno = 0;
return 0; /* Element already exists. */
}
/* Otherwise set the node as a key. Note that raxSetData()
* will set h->iskey. */
raxSetData(h,data);
rax->numele++;
return 1; /* Element inserted. */
}
// ... 源码此处有 70 行注释, 用 "ANNIBALE"->"SCO"->[] 树推演了插入的全部 5 种 case, 值得直接读
/* ------------------------- ALGORITHM 1 --------------------------- */
if (h->iscompr && i != len) { // 局面二: 在压缩串第 j 个字符失配, 一拆三: trimmed(公共前缀) + splitnode(分叉点) + postfix(原串剩余)
// ...
/* 1: Save next pointer. */
raxNode **childfield = raxNodeLastChildPtr(h);
raxNode *next;
memcpy(&next,childfield,sizeof(next));
// ...
/* Set the length of the additional nodes we will need. */
size_t trimmedlen = j;
size_t postfixlen = h->size - j - 1;
int split_node_is_key = !trimmedlen && h->iskey && !h->isnull; // 只有从第 0 个字符就分裂时, 原节点的 key 身份才落到 split 节点上
size_t nodesize;
/* 2: Create the split node. Also allocate the other nodes we'll need
* ASAP, so that it will be simpler to handle OOM. */
raxNode *splitnode = raxNewNode(rax, 1, split_node_is_key);
raxNode *trimmed = NULL;
raxNode *postfix = NULL;
// ... 按需分配 trimmed/postfix; 任一 OOM 则全部释放并返回 0 —— 此刻树还没动过, 天然原子
splitnode->data[0] = h->data[j]; // split 节点先只挂原串的失配字符; key 一侧的失配字符由函数末尾的通用循环补上
if (j == 0) {
/* 3a: Replace the old node with the split node. */
if (h->iskey) {
void *ndata = raxGetData(h);
raxSetData(splitnode,ndata);
}
memcpy(parentlink,&splitnode,sizeof(splitnode));
} else {
/* 3b: Trim the compressed node. */
trimmed->size = j;
memcpy(trimmed->data,h->data,j);
trimmed->iscompr = j > 1 ? 1 : 0; // 只剩 1 个字符时压缩与非压缩布局相同, 统一按非压缩记
trimmed->iskey = h->iskey;
trimmed->isnull = h->isnull;
if (h->iskey && !h->isnull) {
void *ndata = raxGetData(h);
raxSetData(trimmed,ndata); // 原节点若是 key, key 身份跟着公共前缀走(前缀才是原 key 的终点)
}
raxNode **cp = raxNodeLastChildPtr(trimmed);
memcpy(cp,&splitnode,sizeof(splitnode));
memcpy(parentlink,&trimmed,sizeof(trimmed));
parentlink = cp; /* Set parentlink to splitnode parent. */
rax->numnodes++;
}
/* 4: Create the postfix node: what remains of the original
* compressed node after the split. */
if (postfixlen) {
/* 4a: create a postfix node. */
postfix->iskey = 0;
postfix->isnull = 0;
postfix->size = postfixlen;
postfix->iscompr = postfixlen > 1;
memcpy(postfix->data,h->data+j+1,postfixlen);
raxNode **cp = raxNodeLastChildPtr(postfix);
memcpy(cp,&next,sizeof(next));
rax->numnodes++;
} else {
/* 4b: just use next as postfix node. */
postfix = next; // 失配正好在最后一个字符: 后缀为空, 直接接回原 child
}
/* 5: Set splitnode first child as the postfix node. */
raxNode **splitchild = raxNodeLastChildPtr(splitnode);
memcpy(splitchild,&postfix,sizeof(postfix));
/* 6. Continue insertion: this will cause the splitnode to
* get a new child (the non common character at the currently
* inserted key). */
raxFreeNode(rax,h);
h = splitnode;
} else if (h->iscompr && i == len) {
/* ------------------------- ALGORITHM 2 --------------------------- */
// ... 局面三: key 耗尽在压缩串中间(插 "ANNI" 到 "ANNIBALE"): 拆成 trimmed("ANNI") + postfix("BALE"),
// ... key 落在 trimmed 上, 直接 return 1, 不用走下面的循环
}
/* We walked the radix tree as far as we could, but still there are left
* chars in our string. We need to insert the missing nodes. */
while(i < len) {
raxNode *child;
/* If this node is going to have a single child, and there
* are other characters, so that that would result in a chain
* of single-childed nodes, turn it into a compressed node. */
if (h->size == 0 && len-i > 1) { // 剩余部分是全新单链路径: 一次打包成压缩节点, 而不是一字符一节点
debugf("Inserting compressed node\n");
size_t comprsize = len-i;
if (comprsize > RAX_NODE_MAX_SIZE)
comprsize = RAX_NODE_MAX_SIZE; // 29 bit 上限, 超长 key 拆成多个压缩节点接力
raxNode *newh = raxCompressNode(rax,h,s+i,comprsize,&child);
if (newh == NULL) goto oom;
h = newh;
memcpy(parentlink,&h,sizeof(h));
parentlink = raxNodeLastChildPtr(h);
i += comprsize;
} else {
debugf("Inserting normal node\n");
raxNode **new_parentlink;
raxNode *newh = raxAddChild(rax,h,s[i],&child,&new_parentlink); // 按序插入新字符+新指针, 整个节点 realloc 重排
if (newh == NULL) goto oom;
h = newh;
memcpy(parentlink,&h,sizeof(h));
parentlink = new_parentlink;
i++;
}
rax->numnodes++;
h = child;
}
raxNode *newh = raxReallocForData(rax,h,data);
if (newh == NULL) goto oom;
h = newh;
if (!h->iskey) rax->numele++;
raxSetData(h,data);
memcpy(parentlink,&h,sizeof(h));
return 1; /* Element inserted. */
oom:
// ... 半途 OOM: 把已建好的半截路径临时标成 key, 再调 raxRemove 原路回滚, 树恢复一致
errno = ENOMEM;
return 0;
}原理串讲
拿源码注释自带的例子走一遍:树里已有 “ANNIBALE” -> “SCO” -> [](即 key “ANNIBALESCO”),现在 raxInsert(rax, "ANNIENTARE", 10, val, NULL)。
入口转给 raxGenericInsert,第一步永远是 raxLowWalk:从 rax->head 下行,压缩节点整段比对,非压缩节点在有序字符数组里找 s[i] 再跳对应子指针。
走到压缩节点 “ANNIBALE” 时逐字节比到第 4 位,‘E’ 对不上 ‘B’,内层 for 直接 break,函数返回 i=4,stopnode 是这个压缩节点,splitpos=4。
回到 raxGenericInsert,h->iscompr && i != len 命中 ALGORITHM 1:一个压缩节点被拆成三份——trimmed 存公共前缀 “ANNI”,splitnode 是新的分叉点(暂时只有 ‘B’ 一个分支,指向 postfix),postfix 存原串剩余的 “ALE” 并接回原来的 child “SCO”。
注意此时新 key 还一个字节都没插进去,ALGO 1 只负责”把分叉点腾出来”,然后 h = splitnode 落进函数末尾的通用插入循环:raxAddChild 给 splitnode 补上 ‘E’ 分支(它这才真正变成二路分叉),剩下的 “NTARE” 因为是全新单链,被 raxCompressNode 一次打包成一个压缩节点,最后 raxReallocForData + raxSetData 在终点节点尾部追加 value 指针。
查找则是同一条路的只读版:raxFind 调完 raxLowWalk 只验证三件事——走完了整个 key、没停在压缩串中间、终点节点 iskey。
为什么字符存在父节点的”边”上而不是子节点里?因为选路需要的全部信息(size 个字符 + size 个指针)都在父节点一段连续内存里,一次内存读取就能完成分支决策,不必逐个解引用子节点去比较首字符——后者每个候选分支都是一次潜在的 cache miss。
代价是节点自身”不知道自己叫什么”,也不存 parent 指针,所以需要父链的操作(删除、迭代回溯)得靠 raxLowWalk 的 raxStack 出参在下行时现场记录。
为什么非压缩节点用线性扫描而不是二分?源码注释直接回答了:key 是字节,分支因子最多 256,字符数组连续且极小,线性扫描对 cache 友好,实测不输”理论上更漂亮”的二分。
本地 unstable 在此之上又叠了一个针对性优化:先比最后一个子字符——子字符有序存放,而 Stream 的消息 ID 单调递增,顺序插入几乎永远落在最右分支,这一个比较把最常见路径变成 O(1);对随机 key 多付的这一次比较相比 miss 后的 O(n) 扫描可以忽略。
为什么 parentlink 无处不在?这是变长节点设计的连锁反应:节点没有多余容量,任何一次加字符、加子指针、加 value 都是 realloc,地址说变就变;唯一稳定的锚点是”父节点里存我地址的那个槽位”。
raxLowWalk 一路把这个槽位地址带下来,raxGenericInsert 每次 realloc 后 memcpy(parentlink,&h,...) 回写。
同理,data[] 里的指针”字段”没有名字只有偏移,读写一律 memcpy。
设计取舍
- 一切为省内存让路:header 只有 4 字节,变长布局、按需 value 指针、不存 parent 指针;代价是全部访问走宏 + 指针运算,插入删除处处 realloc + 回写 parentlink,rax.c 的复杂度几乎都花在这。
- isnull 位让 NULL 值的 key 连 8 字节指针都省掉,rax 因此能当零开销有序 set 用(集群槽位 -> key 的映射就是这么用的)。
- 插入分裂(ALGO 1/2)和删除后再压缩(
raxRemove的 trycompress:沿父链上溯,把”非 key 且单孩子”的链重新合并)互为逆操作,保证压缩率不随增删退化。 - OOM 安全是显式设计:分裂前把 splitnode/trimmed/postfix 全部先分配好,失败时树还没被碰过;通用循环半途 OOM 则把半截路径标成 key 后
raxRemove回滚。 - raxStack 前 32 项用结构体内静态数组,key 不深时记父链零堆分配。
底层设计要点:rax 的价值在于 有序 + 前缀压缩——Stream 的消息 ID 单调递增且高位大量重复(同一毫秒内只有序号变),前缀压缩后极省内存,同时天然按 ID 有序,XRANGE 这类范围扫描直接顺着树走。
普通 hash(dict)做不到有序范围查询,跳表(skiplist)有序但 key 是 SDS 且无前缀共享,所以 Stream 选了 rax。