低延迟交易系统架构

低延迟交易系统架构的完整拆解:端到端延迟的组成与度量方法、内核旁路与 DPDK 与 Solarflare 选型、用户态协议栈与忙轮询、无锁队列与内存布局、CPU 亲和与 NUMA 与缓存优化、共享内存通信、内存管理与确定性、FPGA 硬件加速以及 PTP 时间同步。

低延迟交易系统的目标是把从行情到报单的路径压到微秒级。这个目标带来的是与常规后端系统完全不同的设计哲学:不做通用性,只做单点优化;不用抽象,用硬件特性;不追求吞吐,追求尾延迟。一台 10 万元的低延迟机器,可能跑不过一个精心调优的 3 万元配置。

真正难的地方在于延迟优化的收益递减与复杂度递增。从毫秒到 100 微秒,改改配置就行;从 100 微秒到 10 微秒,要换网卡、改协议栈、重写数据结构;从 10 微秒到 1 微秒,要上 FPGA、要重做时间同步。每降一个数量级,工程复杂度上升一个数量级。

本文按「度量 → 网络 → 内存 → CPU → 通信 → 语言 → 硬件 → 时钟」的顺序展开。订单层面的算法问题在 订单管理与执行算法 中讨论,本文聚焦基础设施层。

目录

  1. 延迟的组成与度量
  2. 内核旁路:DPDK 与 Solarflare
  3. 用户态协议栈与忙轮询
  4. 无锁数据结构与内存布局
  5. CPU 亲和、NUMA 与缓存
  6. 共享内存与进程间通信
  7. 内存管理与确定性
  8. FPGA 与硬件加速
  9. 时间同步与延迟测量

1. 延迟的组成与度量

端到端延迟可以拆成五段,每段的优化手段完全不同:

段典型耗时优化手段
网络传输20~40 μs托管机位、光纤
内核协议栈10~50 μs内核旁路
应用处理5~20 μs无锁、零拷贝
交易所撮合10~100 μs不可控
回报回传20~40 μs同网络段

度量的第一原则是不要在关键路径上测量。clock_gettime 调用本身要几十纳秒,在 1 微秒的路径上占比过大。正确做法是用硬件时间戳网卡(如 Solarflare 的 ef_vi)在网卡侧打戳,应用侧只记录相对时间。

// 用 rdtsc 做低开销计时(注意频率校准)
static inline uint64_t rdtsc() {
    uint32_t lo, hi;
    __asm__ __volatile__("rdtsc" : "=a"(lo), "=d"(hi));
    return ((uint64_t)hi << 32) | lo;
}

void on_tick(const Tick& t) {
    uint64_t t0 = rdtsc();
    process(t);
    uint64_t t1 = rdtsc();
    // 累积直方图,而不是平均值
    latency_hist_.record((t1 - t0) * ns_per_cycle_);
}

关注 P99.9 而不是平均值。平均值 5 微秒、P99.9 500 微秒的系统,在极端行情下会持续丢单。延迟优化的目标是把尾部压下去。

2. 内核旁路:DPDK 与 Solarflare

Linux 内核协议栈的处理开销在 10~50 微秒,对低延迟系统不可接受。内核旁路(kernel bypass)让应用直接操作网卡:

方案原理延迟成本
标准 socket内核协议栈10~50 μs免费
AF_XDP内核零拷贝旁路3~10 μs免费
DPDK用户态轮询驱动1~5 μs中等
Solarflare ef_vi专用网卡 API0.5~2 μs高
专用 FPGA 网卡硬件解析< 0.5 μs极高

Solarflare(现 AMD Xilinx)的 ef_vi 接口是行业标准,它提供了:

  • 用户态收发:绕过内核,直接映射网卡的环形缓冲区。
  • 硬件时间戳:每个包在网卡侧打戳,精度纳秒级。
  • PTP 硬件时钟:与交易所时钟同步。
  • 过滤与组播:硬件侧过滤,只把关心的包送到用户态。
// ef_vi 风格的收包循环(简化)
while (running) {
    int n = ef_eventq_poll(vi, events, MAX_EVENTS);
    for (int i = 0; i < n; ++i) {
        if (events[i].type == EF_EVENT_TYPE_RX) {
            const uint8_t* pkt = ef_vi_receive_buffer(vi, &events[i]);
            uint64_t hw_ts = ef_vi_receive_get_timestamp(vi, &events[i]);
            on_packet(pkt, hw_ts);      // 处理
            ef_vi_receive_post(vi, buf); // 归还缓冲区
        }
    }
}

DPDK 的优势是网卡厂商支持广、生态成熟,但需要独占网卡(不能同时用内核网络),且部署复杂。

3. 用户态协议栈与忙轮询

旁路之后,UDP/TCP 协议栈也要在用户态实现。关键点:

1. 零拷贝:网卡 DMA 直接写到应用缓冲区,不做内存复制
2. 忙轮询:不睡眠,持续 poll,牺牲 CPU 换取低延迟
3. 批量处理:一次收多个包,摊薄系统调用开销
4. 无中断:关闭网卡中断,完全靠轮询

忙轮询是低延迟系统的标配,代价是每个轮询线程独占一个物理核:

void busy_poll_loop() {
    pin_to_core(3);              // 绑定到独占核
    while (running) {
        int n = rx_ring.poll(batch, BATCH_SIZE);
        if (n == 0) continue;    // 空转,不睡眠
        for (int i = 0; i < n; ++i) handle(batch[i]);
    }
}

用 sched_setscheduler 设为实时调度,并用 isolcpus 内核参数隔离 CPU 核,避免被其他进程抢占:

isolcpus=2,3,4,5,6,7 nohz_full=2,3,4,5,6,7 rcu_nocbs=2,3,4,5,6,7   # 内核启动参数

nohz_full 关闭这些核上的时钟中断,rcu_nocbs 把 RCU 回调移走,都是为了让交易线程不被任何中断打扰。

4. 无锁数据结构与内存布局

跨线程通信在低延迟系统里必须无锁。锁的问题不是加锁本身,而是锁竞争导致的不可预测延迟——一次争抢可能让线程被调度走几百微秒。

结构场景复杂度
SPSC 环形队列单生产单消费低
MPSC 队列多生产单消费中
无锁哈希表订单簿高
序列锁(seqlock)读多写少中

SPSC 环形队列是最常用也最可靠的,实现要点是用原子索引而非指针,且避免伪共享:

template <typename T, size_t N>
class SpscQueue {
    static_assert((N & (N - 1)) == 0, "N must be power of 2");
    alignas(64) std::atomic<size_t> head_{0};   // 生产者索引
    alignas(64) std::atomic<size_t> tail_{0};   // 消费者索引
    alignas(64) T buffer_[N];

public:
    bool push(const T& v) {
        size_t h = head_.load(std::memory_order_relaxed);
        size_t next = (h + 1) & (N - 1);
        if (next == tail_.load(std::memory_order_acquire)) return false;  // 满
        buffer_[h] = v;
        head_.store(next, std::memory_order_release);
        return true;
    }

    bool pop(T& v) {
        size_t t = tail_.load(std::memory_order_relaxed);
        if (t == head_.load(std::memory_order_acquire)) return false;     // 空
        v = buffer_[t];
        tail_.store((t + 1) & (N - 1), std::memory_order_release);
        return true;
    }
};

alignas(64) 让 head_ 和 tail_ 落在不同的缓存行上,避免伪共享(false sharing)。少了这个,两个线程会因为同一个缓存行的争用而性能暴跌数倍。更多无锁结构的实现细节可以参考 C++ 无锁数据结构 。

5. CPU 亲和、NUMA 与缓存

现代服务器有多个 NUMA 节点,跨节点访问内存的延迟是本地访问的 1.5~2 倍。低延迟系统必须做到网卡、CPU、内存三者同节点:

cat /sys/class/net/eth0/device/numa_node            # 查看网卡所在 NUMA 节点
numactl --cpunodebind=0 --membind=0 ./trading_app   # 绑定进程到对应节点

三级缓存的开销对比:

层级延迟容量
L11 ns32~64 KB
L24 ns256 KB~1 MB
L315 ns8~32 MB
本地内存80 nsGB 级
远程 NUMA140 nsGB 级

订单簿这种高频访问的结构应该尽量压进 L2/L3。手段包括:结构体紧凑化(用 int32 而不是 int64 存价格档位)、数组代替链表(连续内存预取友好)、热冷分离(把常用字段放一起)。

// 紧凑的价格档位:8 字节
struct alignas(8) PriceLevel {
    int32_t price_ticks;   // 价格以 tick 为单位(整数)
    int32_t qty;           // 数量
};
// 对比:用 double 价格 + int64 数量 = 16 字节,缓存效率减半

价格用整数 tick 而不是浮点,除了缓存效率,更重要的是避免浮点比较的精度问题。

6. 共享内存与进程间通信

策略进程与执行进程分离时,IPC 延迟成为瓶颈。几种方式的延迟对比:

方式延迟说明
TCP loopback5~20 μs内核协议栈
Unix socket3~10 μs仍走内核
共享内存 + 自旋100~500 ns用户态
同进程函数调用< 10 ns最快

共享内存 + 自旋等待是最常用的方案:

// 共享内存布局:环形队列 + 事件标志
struct SharedChannel {
    alignas(64) std::atomic<uint64_t> seq{0};
    alignas(64) char data[SLOT_SIZE];
    // 生产者写入后递增 seq,消费者自旋等待 seq 变化
};

同进程函数调用最快,但把策略和执行耦合在一起,任何一个崩溃都会带走另一个。权衡点是:延迟要求 < 1 微秒时用同进程,否则用共享内存分离进程。跨进程通信还要注意共享内存布局的 ABI 稳定性问题。

7. 内存管理与确定性

低延迟系统里,malloc 是不可接受的。glibc 的 malloc 在多线程下有锁竞争,最坏情况会阻塞几十微秒。

三条原则:

1. 启动时预分配所有内存(预热)
2. 运行时零分配(no allocation on hot path)
3. 用对象池/竞技场(arena)代替 malloc/free
class OrderPool {
    std::vector<Order> storage_;          // 预分配
    std::vector<Order*> free_list_;
public:
    OrderPool(size_t n) : storage_(n) {
        free_list_.reserve(n);
        for (auto& o : storage_) free_list_.push_back(&o);
    }
    Order* acquire() {
        if (free_list_.empty()) return nullptr;   // 不分配,返回空
        Order* o = free_list_.back();
        free_list_.pop_back();
        return o;
    }
    void release(Order* o) { free_list_.push_back(o); }
};

除了 malloc,还要警惕缺页中断:首次访问未映射的内存会触发缺页,耗时几微秒。用 mlockall 锁定内存,用 MAP_POPULATE 预映射:

mlockall(MCL_CURRENT | MCL_FUTURE);   // 禁止换页

对于 Java 系统,GC 停顿是致命的。ZGC 可以做到亚毫秒停顿,但在微秒级场景下仍不够,通常的解法是避免在关键路径上分配对象,用对象池 + 堆外内存。

8. FPGA 与硬件加速

当软件优化到极限(~1 微秒)后,唯一的出路是硬件。FPGA 能做的事:

功能软件延迟FPGA 延迟
行情解码1~5 μs100~300 ns
策略计算(简单)0.5~2 μs50~200 ns
报单编码0.5~1 μs50~100 ns
端到端(简单策略)3~10 μs0.5~1.5 μs

FPGA 的实现方式是流水线化:行情包进入后,逐级经过解码、策略、风控、编码,全程不落到 CPU:

[网卡 PHY] → [包解析] → [订单簿更新] → [策略逻辑] → [风控] → [报单生成] → [MAC]
    ↑ 全部在 FPGA 内的流水线上,单周期推进

代价是策略必须能被硬件化:只能实现查表、比较、加减这类简单逻辑,无法跑复杂模型。FPGA 适合做市、套利这类规则简单的策略。开发成本极高,一个中等复杂度的 FPGA 交易系统需要数人年的投入。

9. 时间同步与延迟测量

没有精确时钟,前面所有优化都无法验证。三个层次的方案:

方案精度部署
NTP1~10 ms免费
PTP(软件时间戳)10~100 μs交换机支持
PTP(硬件时间戳)< 100 ns专用网卡 + 边界时钟
GPS/北斗< 50 ns天线 + 恒温晶振

PTP 的配置要点:

pmc -u -b 0 'GET TIME_STATUS_NP'   # 查看 PTP 同步状态,master_offset 应在 ±100ns 内

延迟测量的常见错误是用同一台机器的时钟测量两端:如果行情服务器和交易服务器的时钟差 1 微秒,你测出的「网络延迟」就包含了这 1 微秒的误差。正确做法是用硬件时间戳,网卡在包到达的瞬间打戳,不受主机时钟影响。

延迟统计要按分位数记录而不是平均:

class LatencyHistogram {
    static constexpr int BUCKETS = 1000;   // 0~100 μs,0.1 μs 一档
    std::array<uint64_t, BUCKETS> counts_{};
    uint64_t total_{0};
public:
    void record(uint64_t ns) {
        size_t idx = std::min<size_t>(ns / 100, BUCKETS - 1);
        counts_[idx]++;
        total_++;
    }
    uint64_t percentile(double p) const {
        uint64_t target = static_cast<uint64_t>(total_ * p);
        uint64_t acc = 0;
        for (size_t i = 0; i < BUCKETS; ++i) {
            acc += counts_[i];
            if (acc >= target) return i * 100;
        }
        return BUCKETS * 100;
    }
};

权衡取舍

维度常规架构中低延迟极致低延迟
延迟10~100 ms100 μs~1 ms< 10 μs
成本低中极高
开发周期周月年
可维护性高中低
团队要求普通后端系统工程师内核 + 硬件

延迟投入的决策依据是策略的边际收益曲线。做市策略在 10 微秒和 100 微秒之间的收益差可能是年化 20%,值得投入;中频策略在这个区间的收益差接近零,投入就是浪费。

技术选型上的取舍也很明确:DPDK 通用但延迟不如 ef_vi,ef_vi 快但绑定单一厂商网卡,FPGA 最快但开发成本极高且无法迭代策略。大多数团队的最优解是 ef_vi + 用户态协议栈 + 无锁数据结构,把延迟做到 2~5 微秒,性价比最高。

常见坑清单

  1. 在关键路径上打日志:一次 printf 就是几十微秒,必须异步日志或干脆不打。
  2. 忽略伪共享:共享变量落在同一缓存行,多线程性能暴跌数倍。
  3. 跨 NUMA 访问:网卡在节点 0、内存在节点 1,延迟翻倍。
  4. 运行时 malloc:glibc malloc 有锁,最坏阻塞几十微秒。
  5. 没做 CPU 隔离:交易线程被其他进程抢占,延迟抖动到毫秒级。
  6. 用平均值评估延迟:掩盖了尾部问题,必须看 P99.9。
  7. 时间不同步:测量结果包含时钟误差,优化方向完全错误。
  8. 中断没关:网卡中断、定时器中断打断轮询循环。
  9. 缺页中断:首次访问内存触发缺页,用 mlockall 预锁定。
  10. 只优化单点:行情 1 微秒、报单 50 微秒,整体仍受最慢环节制约。

小结

低延迟系统的核心原则是减少不确定性而不是减少平均值。一个平均 2 微秒、P99 20 微秒的系统,远不如平均 5 微秒、P99 6 微秒的系统稳定。所有优化的目标都是把尾部压平:CPU 隔离、忙轮询、预分配内存、无锁通信,本质上都是在消除「什么时候会被打断」的不确定性。

判断是否需要低延迟架构的标准很简单:如果信号在 1 毫秒后衰减不到 10%,就不要做低延迟。低延迟是手段而不是目的,为不需要延迟的策略做延迟优化是最常见的工程浪费。

下一步建议阅读 撮合引擎设计 ,看交易所在收到你的报单后如何撮合;如果你关心报单侧的状态管理,可以回到 订单管理与执行算法 。

继续阅读

探索更多技术文章

浏览归档,发现更多关于系统设计、工具链和工程实践的内容。

全部文章 返回首页

「量化交易」更多文章

  1. 风险模型与因子归因
  2. 回测偏差与过拟合防范
  3. 市场微结构与流动性