当整张表都驻留内存,磁盘时代为"少一次 I/O"而设计的 B+树反而成了负担:每个内部节点要塞满几十个 key 才能撑起扇出,一次比较要遍历半个缓存行;而哈希表虽点查极快,却完全丧失有序性与范围扫描。2013 年 HyPer 团队提出的 ART(Adaptive Radix Tree)给出了第三条路——用按子节点数量自适应伸缩的四种节点,让稀疏分支只占几十字节、稠密分支一步寻址,再配合路径压缩把树高压到与 key 长度同阶。本文从节点内存布局逐层拆解它的内部机制,并给出一份可运行的 Python 教学版实现。
一、内存索引的困境:B+树与哈希表都不够用
磁盘数据库把 B+树节点对齐到 4KB 页,目的是用一次 I/O 换尽可能大的扇出。但内存数据库里没有"页",真正的成本是缓存行(64 字节)与指针追逐:
- B+树:节点内几十个 key 需要二分或线性比较,比较过程反复访问同一节点的不同缓存行;短 key 场景下大量空间浪费在分隔符与指针上。
- 哈希表:点查 O(1) 且缓存友好(如 Swiss Table 的 SIMD 分组探测),但无法做范围扫描、前缀匹配、有序迭代,而这些都是 SQL 索引的刚需。
- 经典基数树(Radix Tree / Patricia Trie):按字节逐层切分,树高只与 key 长度有关,与数据量无关,且天然有序;但每个节点固定 256 个指针(约 2KB),在稀疏分支上浪费得触目惊心。
ART 的出发点非常直接:保留基数树的"树高由 key 长度决定",但把节点大小改成随实际子节点数浮动。这一个改动,让内存占用下降一个数量级的同时,还保住了有序性。
二、与经典 Radix Tree 的分界:自适应节点才是主角
需要先划清界限,避免与站内已有的基数树文章混淆:Linux 页缓存 xarray / 路由最长前缀匹配那一类 radix tree,关注的是固定扇出下的路径压缩与内核 slab 内存管理,节点形态是统一的。ART 则来自主存数据库索引研究(Leis 等,VLDB 2013),它的全部设计都围绕一个问题——同一个 trie 里,根附近分支极稠密、叶子附近分支极稀疏,凭什么让它们用同样大的节点?
ART 的答案是四种节点,按"当前子节点数"动态升级与降级:
| 节点 | 子节点上限 | 查找方式 | 节点量级 |
|---|---|---|---|
| Node4 | 4 | 最多 4 字节 key 线性比较 | 约 50 字节 |
| Node16 | 16 | 16 字节 key 数组,一条 SSE2 指令并行比对 | 约 160 字节 |
| Node48 | 48 | 256 字节下标数组 + 48 指针间接寻址 | 约 640 字节 |
| Node256 | 256 | 直接用字节值下标,O(1) 无比较 | 约 2KB |
一棵真实 ART 里,绝大多数节点是 Node4 与 Node16(树底部分支极稀疏),只有根节点附近少数几层会涨到 Node48/Node256。于是"整树内存"从固定 256 路 radix tree 的 2KB × 节点数,降到接近"每个分支只花它该花的字节"。
三、四种节点的内存布局细节
Node4 与 Node16:把 key 挤进一条向量指令
Node4 存两个小数组:4 个单字节 keys 与 4 个 children 指针,按 key 升序排列。查找时逐个比对,最多 4 次,全在一个缓存行内完成。Node16 把 key 数组扩到 16 字节——这个数字不是巧合,正好等于一个 SSE2 寄存器的宽度:用 _mm_cmpeq_epi8 把目标字节广播后与 16 个 key 一次比较,再用 _mm_movemask_epi8 取出命中位,配合位宽查表即可定位 child 下标。16 次比较被压成 1 条指令,这与 Swiss Table 用 SIMD 扫描 16 个 ctrl 字节是同一类思路。
Node48:用 256 字节索引换 48 个指针
若分支数超过 16,直接开 256 指针数组太浪费。Node48 的做法是:保留一个长度固定 256 的 child_index[256] 字节数组(字节值 → 0..47 的下标,255 表示空),再存 48 个紧凑排列的 child 指针。查找 child_index[b] 一次命中,指针数组则只有 48 项而非 256 项。代价是多一次间接寻址,收益是节点从 2KB 降到约 640 字节,且插入时不必移动大量指针。
Node256:稠密分支的终点
当子节点真的接近 256(典型是根节点或高位字节分布很散的层),直接用 children[b] 下标访问,零比较、零间接层。虽然占 2KB,但这类节点在整棵树里数量极少,摊到每个 key 上几乎可以忽略。
升级是单向渐进的:Node4 → Node16 → Node48 → Node256,在插入导致溢出时整体拷贝重建;删除导致子节点数跌破阈值时再逐级降级。这样节点形态始终贴合当前数据分布。
四、路径压缩:悲观与乐观两种取法
基数树里有大量"单链"路径(一连串只有一个子节点的层),逐层下探纯属浪费。ART 用路径压缩把这段公共前缀折叠进节点自身:
- 悲观压缩(pessimistic):节点里存下完整的被跳过 key 片段,查找时逐字节比对,不匹配立即返回。正确但每次下探都要读一段额外内存。
- 乐观压缩(optimistic / lazy expansion):节点只记
prefix_len(外加少量字节用于快速排除),查找时直接跳过这段长度、不比对,一直走到叶子。代价是:叶子必须保存完整 key,命中后做一次leaf.key == query_key的最终校验,否则不同 key 可能因前缀被跳过而"假阳性"。
乐观压缩是 ART 的默认选择,因为它把"每层一次内存访问"砍掉了;而那次末尾的 key 比较,本来在哈希类索引里也省不掉。这也解释了为什么 ART 的实现里叶子一定存完整 key——它不是冗余,而是正确性兜底。
| 维度 | 悲观压缩 | 乐观压缩 |
|---|---|---|
| 查找时前缀比对 | 逐字节比对 | 仅跳过长度 |
| 失败查询 | 提前终止,更快 | 需走到叶子才判定 |
| 成功查询 | 多读前缀内存 | 更快,末尾一次 key 校验 |
| 节点额外开销 | 存完整片段 | 仅长度 + 少量字节 |
五、查找、插入与节点升降级
查找
从根开始,对内部节点:跳过(或比对)压缩前缀 → 取 key 的下一个字节 → 在当前节点里找对应 child(Node4/16 扫描或 SIMD、Node48 查下标数组、Node256 直接下标)→ 下探并推进 depth。到达叶子后做完整 key 校验。整个过程比较次数是 O(key 长度),与表里有 1 万还是 1 亿条记录无关——这是基数树相对比较型树最大的性质差异。
插入
沿查找路径下探,会遇到三种分裂情形:
- 空槽插入:在当前节点加一个 (byte, child) 对;若子数超过该节点容量,触发升级。
- 与压缩前缀冲突:新 key 与节点 prefix 在第 i 字节分叉,就新建一个父 Node4,把 prefix 前 i 字节留给父节点,原节点保留余下片段,两者各挂一个分支。
- 叶子碰撞:两个不同 key 一直同路径到叶子,此时从第一个不同字节起逐层造 Node4,直到分叉为止(ART 实现普遍要求 key 以终止字节结尾,保证没有任何 key 是另一个 key 的前缀)。
删除
移除叶子后,若某节点只剩 1 个子节点,可与子节点合并、把它的压缩前缀接到一起(路径重新压缩);子数跌破下一级阈值时降级(Node256 → Node48 → Node16 → Node4)。频繁升降级会带来拷贝开销,所以生产实现往往设滞回区间,避免在阈值附近抖动。
六、范围查询与前缀迭代:哈希表给不了的东西
因为节点内 keys 始终按字节升序,ART 的中序遍历天然按字典序输出。做 lower_bound(k) 只需沿 k 的路径记录"每层第一个大于当前字节的兄弟分支"作为回溯栈,之后顺序弹出即可推进迭代。前缀查询更直接:消耗完前缀字节后,定位到的那个子树就是全部匹配项,一次 DFS 输出。这正是内存数据库愿意用 ART 替代哈希索引的核心原因:点查接近哈希表,同时保住 ORDER BY、BETWEEN、LIKE 'abc%' 这类能力。
七、并发控制:乐观锁耦合(OLC)
传统 B+树用 latch coupling(下探时持有父锁直到拿到子锁),写锁会成为高并发瓶颈。ART 通常改用乐观锁耦合:每个节点带一个版本号字(低位置锁标志),读者完全不加锁——读前取版本、读完再取一次并确认版本未变且未上锁,若被写过就重试本次下探;写者只对目标节点加短锁并递增版本。由于绝大多数操作是读、且写集中在树底部少数节点,读路径几乎零同步开销。代价是写密集场景重试增多,需要配合版本字的奇偶校验与内存序(acquire/release)来避免读到撕裂状态。
八、用 Python 复刻一个教学版 ART
下面这份实现保留 ART 最核心的三件事:四种容量档位的自适应升级、乐观路径压缩、叶子完整 key 校验。为了可读性,Node48/Node256 用统一的 keys/children 数组表达(真实实现会换成 256 字节下标表与直接指针数组),并发控制与 SIMD 也未包含。它可以直接运行。
TERM = 0x00 # key 末尾终止字节:保证没有任何 key 是另一个 key 的前缀
def enc(key):
return bytes(key) + bytes([TERM])
class Leaf:
__slots__ = ("key", "value")
def __init__(self, key, value):
self.key = key # 存完整 key:乐观压缩的正确性兜底
self.value = value
class Node:
__slots__ = ("prefix", "keys", "children", "cap")
CAPS = (4, 16, 48, 256) # 四档自适应容量
def __init__(self, prefix=b"", cap=4):
self.prefix = bytes(prefix) # 被压缩掉的公共前缀(乐观:查找时只跳过)
self.keys = [] # 子节点首字节,保持升序
self.children = []
self.cap = cap
def find(self, b):
# Node4/16 线性扫描;真实实现 Node16 用 SSE2 一次比 16 字节
for i, k in enumerate(self.keys):
if k == b:
return i
return -1
def add(self, b, child):
pos = 0
while pos < len(self.keys) and self.keys[pos] < b:
pos += 1
self.keys.insert(pos, b)
self.children.insert(pos, child)
return len(self.keys) > self.cap # 是否溢出、需要升级
class MiniART:
def __init__(self):
self.root = None
self.size = 0
self.nodes = {4: 0, 16: 0, 48: 0, 256: 0}
self.upgrades = 0
def _upgrade(self, n):
i = Node.CAPS.index(n.cap)
if i + 1 >= len(Node.CAPS):
return n # 已是 Node256
bigger = Node(n.prefix, Node.CAPS[i + 1])
bigger.keys = n.keys[:]
bigger.children = n.children[:]
self.nodes[n.cap] -= 1
self.nodes[bigger.cap] += 1
self.upgrades += 1
return bigger
def get(self, key):
k = enc(key)
node = self.root
depth = 0
while isinstance(node, Node):
p = node.prefix
if k[depth:depth + len(p)] != p:
raise KeyError(key) # 乐观压缩下也可提前排除
depth += len(p)
idx = node.find(k[depth]) # 终止字节保证下标一定存在
if idx < 0:
raise KeyError(key)
node = node.children[idx]
depth += 1
if node.key != k: # 末尾完整 key 校验,防假阳性
raise KeyError(key)
return node.value
def put(self, key, value):
k = enc(key)
if self.root is None:
self.root = Leaf(k, value)
self.size += 1
return
self.root = self._insert(self.root, k, value, 0)
def _insert(self, node, k, value, depth):
if isinstance(node, Leaf):
old = node.key
if old == k:
node.value = value
return node
d = depth
while old[d] == k[d]: # 终止字节保证必在某处分叉
d += 1
n = Node(k[depth:d], 4) # 新建公共前缀节点
self.nodes[4] += 1
n.add(old[d], Leaf(old, node.value))
n.add(k[d], Leaf(k, value))
self.size += 1
return n
# 内部节点:先检查压缩前缀是否仍匹配
p = node.prefix
i = 0
while i < len(p) and p[i] == k[depth + i]:
i += 1
if i < len(p):
# 前缀在第 i 字节分叉:造新父节点,原节点下移一层
split = Node(p[:i], 4)
self.nodes[4] += 1
node.prefix = p[i + 1:]
split.add(p[i], node)
split.add(k[depth + i], Leaf(k, value))
self.size += 1
return split
depth += len(p)
b = k[depth]
idx = node.find(b)
if idx < 0:
overflow = node.add(b, Leaf(k, value))
self.size += 1
return self._upgrade(node) if overflow else node
node.children[idx] = self._insert(node.children[idx], k, value, depth + 1)
return node
if __name__ == "__main__":
t = MiniART()
for i in range(10000):
t.put(("user:%06d" % i).encode(), i)
assert t.get(b"user:000042") == 42
print("size =", t.size, "upgrades =", t.upgrades, "nodes =", t.nodes)节点内存估算与前缀迭代
下面两个函数分别量化"自适应节点到底省了多少内存",以及演示有序/前缀遍历。教学版用一次全树 DFS 过滤前缀;真实实现会在消耗完前缀字节后直接定位到子树,避免遍历无关分支。
# 承接上一代码块(放在同一文件中,沿用其 t 实例)
NODE_BYTES = {4: 56, 16: 168, 48: 640, 256: 2048} # 量级估算,含头部与对齐
def stats(t):
total = sum(t.nodes.values())
mem = sum(t.nodes[cap] * NODE_BYTES[cap] for cap in t.nodes)
flat = total * NODE_BYTES[256] # 若全部用固定 256 路节点
return {
"keys": t.size,
"nodes": t.nodes,
"upgrades": t.upgrades,
"node_bytes": mem,
"bytes_per_key": round(mem / max(t.size, 1), 1),
"vs_fixed_256x": round(flat / max(mem, 1), 1),
}
def walk(node, out):
if hasattr(node, "key"):
# 乐观压缩不存完整路径,叶子里的完整 key 才是唯一可信来源
out.append((node.key, node.value))
return
# children 与升序 keys 一一对应,DFS 顺序即字典序
for ch in node.children:
walk(ch, out)
def iter_prefix(t, prefix):
out = []
if t.root is not None:
walk(t.root, out)
p = bytes(prefix)
# 叶子 key 末尾带终止字节,输出时去掉
return [(k[:-1], v) for k, v in out if k.startswith(p)]
print(stats(t))
print(iter_prefix(t, b"user:00000")[:3])九、性能对比与工程取舍
下表给出结构层面的定性对比;数值量级参考 ART 原始论文(Leis 等,VLDB 2013,HyPer 主存数据库)在整型/字符串 key 上的公开结论,是量级参考而非本环境实测,实际结果强依赖 key 分布、CPU 缓存与实现质量。
| 索引 | 点查 | 范围/前缀 | 内存开销 | 主要瓶颈 |
|---|---|---|---|---|
| 哈希表(Swiss Table) | 最优,SIMD 分组探测 | 不支持 | 低(1 字节 ctrl/槽) | 扩容抖动、无序 |
| B+树 | 节点内比较偏慢 | 优秀(叶链顺序扫描) | 中 | 缓存行利用、latch 竞争 |
| 固定 256 路 Radix Tree | 极快,零比较 | 优秀 | 极高(约 2KB/节点) | 稀疏分支浪费 |
| ART | 接近哈希,O(key 长度) | 优秀(天然有序) | 低(多数为 Node4/16) | 长公共前缀、升降级拷贝 |
落地时有几个容易踩的坑:
- 整型 key 必须转成大端字节序,否则字典序与数值序不一致,范围查询全错;浮点同理需要保序编码(flip sign bit 等)。
- 变长 key 要加终止字节(或单独存长度),否则
"abc"与"abcd"会产生前缀歧义;这也是本文教学版TERM存在的原因。 - 长公共前缀是天敌:如 URL、带固定前缀的分布式 ID,会造出很长的压缩链,此时可考虑前缀截断或改用带哈希预检的混合索引。
- 升降级要设滞回,避免子数在 4/16/48 阈值附近反复插入删除导致频繁整节点拷贝。
- 实际系统里 ART 常见于主存数据库的索引层(HyPer、DuckDB 的 ART 索引)、倒排索引的词典结构,以及需要"点查 + 前缀/范围"双能力的 KV 引擎。若只做等值点查,哈希表仍然更划算。
十、结语
ART 的思路可以浓缩成一句话:不要让数据结构的静态形状,去承担数据分布带来的动态代价。四种节点把"稀疏 vs 稠密"的适配做进节点内部,乐观路径压缩把树高压缩到 key 长度,SIMD 与 OLC 则分别榨干了单节点比较与并发读的开销。把它和 Swiss Table 放在一起看会更有意思:前者用 1 字节元数据换取哈希表的缓存效率,后者用可变节点换取 trie 的空间效率——两者都是"用一点额外的结构信息,换掉一大票无谓的内存访问"。理解了这一层,再面对高吞吐内存索引的选型,判断依据就不只是复杂度符号,而是缓存行、分支分布与访问模式的实际形状。

发表评论 取消回复