4. 锁与并发:无锁队列、读写锁、CAS操作,如何设计高并发订单簿

订单簿这东西,说白了就是整个交易系统的核心战场。所有买卖指令都在这里碰撞,每秒几万笔甚至几十万笔的读写操作,稍有不慎就是数据不一致或者性能雪崩。我这些年调过的订单簿系统,十有八九的瓶颈都出在锁上。

今天咱们就聊聊,怎么在保证数据正确的前提下,把并发做到极致。

4.1 为什么锁会成为瓶颈?

先想一个问题:订单簿里最频繁的操作是什么?

  • 挂单(插入买/卖单)
  • 撤单(删除订单)
  • 撮合(修改订单状态)

这些操作都需要修改共享数据——价格队列、订单链表、成交量统计。如果每个操作都加一把大锁,那系统就退化成单线程了。你想想看,CPU 8核16核在那闲着,就一个线程在干活,多浪费。

核心矛盾:订单簿的读写比例大约是 7:3,读多写少。但写操作往往需要修改多个数据结构,锁的粒度如果太粗,读操作也得排队等。

我记得有一次线上事故,某个期货品种的订单簿延迟从 50 微秒飙到了 5 毫秒。查了半天,发现是某个撤单操作持有了全局锁,导致所有查询都得等着。嗯,这就是典型的锁粒度过粗问题。

4.2 读写锁:读多写少的首选

读写锁(Read-Write Lock)的思路很简单:读操作可以并发,写操作独占。这正好匹配订单簿的场景——大部分时间是查询盘口深度,只有少数时间在修改。

在 C++ 里,我习惯用 std::shared_mutex(C++17 引入):

#include <shared_mutex>

class OrderBook {
private:
    std::shared_mutex mtx_;
    std::map<double, OrderList> bids_;  // 买单队列
    std::map<double, OrderList> asks_;  // 卖单队列

public:
    // 读操作:多个线程可以同时调用
    double getBestBid() {
        std::shared_lock<std::shared_mutex> lock(mtx_);
        if (bids_.empty()) return 0.0;
        return bids_.rbegin()->first;  // 最高买价
    }

    // 写操作:独占
    void addOrder(const Order& order) {
        std::unique_lock<std::shared_mutex> lock(mtx_);
        // 插入订单到对应价格队列
        if (order.side == BUY) {
            bids_[order.price].push_back(order);
        } else {
            asks_[order.price].push_back(order);
        }
    }
};

我的经验:读写锁在订单簿的查询接口上效果很好。但要注意,如果写操作太频繁(比如高频交易场景),写线程可能会被读线程饿死。这时候可以考虑「写优先」策略,或者用 std::shared_mutextry_lock 做超时处理。

4.3 CAS 操作:无锁编程的基石

读写锁虽然好,但毕竟还有锁的开销。在高频交易领域,微秒级别的延迟都嫌多。这时候就得请出 CAS(Compare-And-Swap)了。

CAS 是 CPU 提供的一条原子指令,大概逻辑是:

// 伪代码:如果 *ptr == old_value,就把 *ptr 改成 new_value
bool CAS(int* ptr, int old_value, int new_value) {
    if (*ptr == old_value) {
        *ptr = new_value;
        return true;
    }
    return false;
}

在 C++ 里,我们用 std::atomic 来封装 CAS 操作:

#include <atomic>

class LockFreeOrderQueue {
private:
    struct Node {
        Order data;
        Node* next;
    };
    std::atomic<Node*> head_;

public:
    void push(const Order& order) {
        Node* new_node = new Node{order, nullptr};
        Node* old_head = head_.load();
        do {
            new_node->next = old_head;
            // CAS:如果 head_ 还是 old_head,就换成 new_node
        } while (!head_.compare_exchange_weak(old_head, new_node));
    }

    bool pop(Order& result) {
        Node* old_head = head_.load();
        while (old_head != nullptr) {
            if (head_.compare_exchange_weak(old_head, old_head->next)) {
                result = old_head->data;
                delete old_head;
                return true;
            }
        }
        return false;
    }
};

注意:CAS 有个经典问题叫 ABA 问题。简单说就是:线程 A 读到值是 X,然后被挂起;线程 B 把 X 改成 Y 又改回 X;线程 A 醒来后 CAS 发现还是 X,就以为没变过。这在订单簿里可能导致重复处理同一笔订单。解决方案是加一个版本号(比如用 64 位指针,低 16 位存版本号)。

我曾经在一个期权做市商项目里,用 CAS 实现了一个无锁的订单队列,把单笔订单的入队延迟从 200 纳秒降到了 50 纳秒。嗯,这个优化效果还是很明显的。

4.4 无锁队列:真正的并发利器

如果只是简单的入队出队,CAS 就够了。但订单簿需要更复杂的数据结构——比如按价格排序的队列。这时候就得设计真正的无锁数据结构了。

我推荐一个经典方案:Michael-Scott 无锁队列。它用两个 CAS 操作分别控制头指针和尾指针:

template<typename T>
class MSQueue {
private:
    struct Node {
        T data;
        std::atomic<Node*> next;
        Node(const T& val) : data(val), next(nullptr) {}
    };
    std::atomic<Node*> head_;
    std::atomic<Node*> tail_;

public:
    MSQueue() {
        Node* dummy = new Node(T());
        head_.store(dummy);
        tail_.store(dummy);
    }

    void enqueue(const T& value) {
        Node* node = new Node(value);
        Node* tail;
        while (true) {
            tail = tail_.load();
            Node* next = tail->next.load();
            if (tail == tail_.load()) {  // 检查 tail 是否被修改
                if (next == nullptr) {
                    // 尝试把新节点挂到 tail 后面
                    if (tail->next.compare_exchange_weak(next, node)) {
                        break;  // 成功
                    }
                } else {
                    // tail 落后了,帮它前进
                    tail_.compare_exchange_weak(tail, next);
                }
            }
        }
        // 更新 tail 指针
        tail_.compare_exchange_weak(tail, node);
    }
};

关键点:无锁队列的核心思想是「帮助」——如果一个线程发现另一个线程的操作没完成,就帮它完成。这样就不会有线程被阻塞,所有线程都在干活。

4.5 订单簿的并发设计实战

说了这么多理论,咱们看看实际怎么用。下面是我在一个项目中用过的订单簿并发架构:

订单簿并发架构图 订单输入线程 (多个生产者) 无锁订单队列 (CAS 实现) 订单簿核心 读写锁保护 价格队列 订单链表 成交量统计 查询线程 (多个消费者) 行情发布线程 (快照 + 增量) 设计要点 1. 订单输入用无锁队列 避免生产者竞争 2. 订单簿核心用读写锁 读多写少场景优化 3. 查询线程直接读核心 共享锁允许多读 4. 行情发布用独立线程 避免阻塞订单处理 5. 写操作批量处理 减少锁竞争

这个架构的核心思路是分层:

  1. 输入层:多个订单输入线程通过无锁队列提交订单,避免锁竞争。
  2. 处理层:订单簿核心用读写锁保护,写操作独占,读操作共享。
  3. 输出层:行情发布线程定期读取订单簿快照,生成增量行情。

避坑指南:我曾经在一个项目中,把订单簿的写操作也放到了无锁队列里处理。结果发现,虽然入队很快,但出队后的处理逻辑(比如更新价格队列)还是需要锁。后来我改成「无锁入队 + 读写锁处理」的混合模式,性能反而更好。说白了,无锁不是万能的,要结合具体场景。

4.6 性能对比:哪种方案适合你?

方案 延迟(微秒) 吞吐量(万笔/秒) 实现复杂度 适用场景
全局互斥锁 5-10 10-20 低并发、原型验证
读写锁 2-5 30-50 读多写少、常规交易
无锁队列 + 读写锁 1-2 50-100 高频交易、低延迟
完全无锁 0.5-1 100+ 极高 极高频、FPGA 加速

从表里能看出来,读写锁在大多数场景下已经够用了。只有当你真的需要微秒级延迟时,才值得去搞无锁方案。毕竟,无锁代码的调试难度是普通代码的 3 倍以上——我吃过这个亏。

4.7 总结

订单簿的并发设计,说白了就是平衡「正确性」和「性能」。我的建议是:

  • 先用读写锁把系统跑起来,保证功能正确。
  • 用性能分析工具(比如 perf、火焰图)找到真正的瓶颈。
  • 只在热点路径上用无锁优化,其他地方保持简单。

记住一句话:过早优化是万恶之源。但该优化的时候,也别手软。

最后提醒:无锁编程的 ABA 问题、内存序问题(memory order)、伪共享问题(false sharing),每一个都能让你调试到怀疑人生。如果你不是团队里最懂并发的那个人,建议先用读写锁,等性能瓶颈真的出现了再考虑升级。


公众号:蓝海资料掘金营,微信deep3321