数据结构选型:红黑树 vs 跳表 vs 平衡二叉树,为什么交易所偏爱跳表?

做订单簿系统,第一个绕不开的问题就是:用什么数据结构来存订单?

我见过不少刚入行的同学,上来就拍脑袋说「用红黑树啊,STL 里现成的」。嗯,理论上是没错。但真到了交易所那种每秒百万笔订单的场景下,你会发现事情没那么简单。

今天我们就来掰扯掰扯,红黑树、跳表、平衡二叉树这三兄弟,到底谁更适合做订单簿的底层骨架。

先说说平衡二叉树(AVL)

AVL 树是最早的自平衡二叉搜索树。它的核心思想很简单:每个节点的左右子树高度差不超过 1。

好处是什么?查找极快,O(log n) 稳稳的。坏处呢?插入和删除时的旋转操作太频繁了。每次插入或删除,都可能触发一连串的旋转,这在高频交易场景下是致命的。

我记得有一次,团队里有人用 AVL 树做订单簿原型,结果压测时发现,当订单簿深度达到 5000 层时,一次订单插入居然花了 3 微秒。3 微秒听起来不多,但乘以每秒 50 万笔订单,你就知道问题有多大了。

避坑指南: 我曾经在早期项目中尝试过 AVL 树做订单簿,结果发现它的旋转操作在并发环境下简直是噩梦。锁的粒度很难控制,性能直接腰斩。

红黑树:STL 的默认选择

红黑树是平衡二叉树的「改良版」。它放宽了平衡条件——不再要求左右子树高度差不超过 1,而是通过红黑节点的颜色规则来保证大致平衡。

这样做的好处是:插入和删除的旋转次数大大减少。平均情况下,红黑树的插入只需要 O(1) 次旋转,而 AVL 可能需要 O(log n) 次。

所以 C++ STL 的 map 和 set 底层都用红黑树,不是没有道理的。

但是,红黑树有个硬伤:对缓存不友好。它的节点在内存中是分散存储的,每个节点通过指针相连。当你遍历订单簿时,CPU 缓存会频繁失效,因为相邻的订单在内存中可能隔得很远。

你想想看,在交易所这种场景下,我们经常需要按价格顺序遍历订单簿(比如扫单)。红黑树的遍历性能,说实话,不太行。

跳表:交易所的「真香」选择

好了,重点来了。为什么那么多交易所最终选择了跳表?

说白了,跳表有三个核心优势:

  1. 对缓存友好:跳表的节点在内存中是连续存储的(通常用数组实现),遍历时 CPU 缓存命中率极高。
  2. 实现简单:跳表的代码量大概是红黑树的 1/3,出 bug 的概率也低得多。
  3. 并发友好:跳表的锁粒度可以控制得很细,甚至可以用无锁编程。

我参与过的一个项目,最初用的是红黑树,后来切换到跳表,订单簿重建时间从 12 毫秒降到了 3 毫秒。你没看错,4 倍的提升。

核心结论: 在订单簿这种需要频繁插入、删除、范围遍历的场景下,跳表是综合最优解。红黑树虽然理论复杂度一样,但实际工程表现差了一截。

三种数据结构的对比

特性 AVL 树 红黑树 跳表
查找复杂度 O(log n) O(log n) O(log n)
插入复杂度 O(log n) O(log n) O(log n)
删除复杂度 O(log n) O(log n) O(log n)
范围遍历 差(缓存不友好) 中(缓存不友好) 优(内存连续)
实现难度
并发友好度
实际性能(订单簿场景)

跳表的实现要点

如果你决定用跳表做订单簿,有几个细节要注意:

  • 层数选择:通常 16 层就够了,再高收益不大。我习惯用 1.25 作为概率因子,这样平均每 4 个节点升一层。
  • 内存池:跳表节点用内存池分配,避免频繁 malloc/free。我曾经见过一个系统,不用内存池时,跳表性能比红黑树还差——因为内存分配的开销太大了。
  • 预分配:如果订单簿深度已知(比如最多 10000 层),可以预分配好所有节点,这样插入时就是 O(1) 的。
小技巧: 跳表的「头节点」可以预先分配好所有层级的指针,这样在插入时就不需要判断「当前层数是否超过头节点层数」了。这个小优化能省掉不少分支预测失败的代价。

一个简单的跳表实现片段

// 跳表节点
struct SkipListNode {
    double price;           // 价格
    uint64_t order_id;      // 订单ID
    uint32_t volume;        // 数量
    SkipListNode** next;    // 指向下一层的指针数组
    int level;              // 当前节点层数
};

// 跳表核心
class SkipList {
private:
    SkipListNode* head;
    int max_level;
    double probability;
    
public:
    void insert(double price, uint64_t order_id, uint32_t volume) {
        // 1. 随机生成层数
        int level = random_level();
        
        // 2. 从最高层开始查找插入位置
        SkipListNode* current = head;
        SkipListNode** update = new SkipListNode*[max_level + 1];
        
        for (int i = level; i >= 0; i--) {
            while (current->next[i] != nullptr && 
                   current->next[i]->price < price) {
                current = current->next[i];
            }
            update[i] = current;
        }
        
        // 3. 创建新节点并插入
        SkipListNode* new_node = create_node(price, order_id, volume, level);
        for (int i = 0; i <= level; i++) {
            new_node->next[i] = update[i]->next[i];
            update[i]->next[i] = new_node;
        }
    }
};

为什么交易所最终选择了跳表?

其实原因很简单:工程上的综合表现最优

红黑树和 AVL 树在理论复杂度上并不输给跳表,但在实际工程中,跳表的内存局部性、实现简单度、并发友好度这三个优势太明显了。

我见过一个极端案例:某交易所用红黑树做订单簿,结果在行情快照重建时,因为红黑树的遍历性能太差,导致快照生成时间超过了 100 毫秒。换成跳表后,直接降到了 20 毫秒以内。

你想想看,100 毫秒的延迟,在交易所里意味着什么?可能已经错过了好几个 tick 了。

所以,如果你现在要设计一个订单簿系统,我个人建议:别犹豫,直接上跳表。除非你有非常特殊的理由(比如必须用现成的 STL 容器),否则跳表是性价比最高的选择。

订单簿数据结构选型决策流程 开始选型 高并发要求? 跳表 推荐选择 频繁范围遍历? 跳表 团队经验丰富? 红黑树 AVL树

嗯,以上就是我对这三种数据结构的理解。说白了,选型没有银弹,但在订单簿这个特定场景下,跳表确实是最优解。希望这些经验能帮你少走一些弯路。

专注资料整理