低延迟交易系统的目标是把从行情到报单的路径压到微秒级。这个目标带来的是与常规后端系统完全不同的设计哲学:不做通用性,只做单点优化;不用抽象,用硬件特性;不追求吞吐,追求尾延迟。一台 10 万元的低延迟机器,可能跑不过一个精心调优的 3 万元配置。
真正难的地方在于延迟优化的收益递减与复杂度递增。从毫秒到 100 微秒,改改配置就行;从 100 微秒到 10 微秒,要换网卡、改协议栈、重写数据结构;从 10 微秒到 1 微秒,要上 FPGA、要重做时间同步。每降一个数量级,工程复杂度上升一个数量级。
本文按「度量 → 网络 → 内存 → CPU → 通信 → 语言 → 硬件 → 时钟」的顺序展开。订单层面的算法问题在 订单管理与执行算法 中讨论,本文聚焦基础设施层。
目录
- 延迟的组成与度量
- 内核旁路:DPDK 与 Solarflare
- 用户态协议栈与忙轮询
- 无锁数据结构与内存布局
- CPU 亲和、NUMA 与缓存
- 共享内存与进程间通信
- 内存管理与确定性
- FPGA 与硬件加速
- 时间同步与延迟测量
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 | 专用网卡 API | 0.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 # 绑定进程到对应节点
三级缓存的开销对比:
| 层级 | 延迟 | 容量 |
|---|---|---|
| L1 | 1 ns | 32~64 KB |
| L2 | 4 ns | 256 KB~1 MB |
| L3 | 15 ns | 8~32 MB |
| 本地内存 | 80 ns | GB 级 |
| 远程 NUMA | 140 ns | GB 级 |
订单簿这种高频访问的结构应该尽量压进 L2/L3。手段包括:结构体紧凑化(用 int32 而不是 int64 存价格档位)、数组代替链表(连续内存预取友好)、热冷分离(把常用字段放一起)。
// 紧凑的价格档位:8 字节
struct alignas(8) PriceLevel {
int32_t price_ticks; // 价格以 tick 为单位(整数)
int32_t qty; // 数量
};
// 对比:用 double 价格 + int64 数量 = 16 字节,缓存效率减半
价格用整数 tick 而不是浮点,除了缓存效率,更重要的是避免浮点比较的精度问题。
6. 共享内存与进程间通信
策略进程与执行进程分离时,IPC 延迟成为瓶颈。几种方式的延迟对比:
| 方式 | 延迟 | 说明 |
|---|---|---|
| TCP loopback | 5~20 μs | 内核协议栈 |
| Unix socket | 3~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 μs | 100~300 ns |
| 策略计算(简单) | 0.5~2 μs | 50~200 ns |
| 报单编码 | 0.5~1 μs | 50~100 ns |
| 端到端(简单策略) | 3~10 μs | 0.5~1.5 μs |
FPGA 的实现方式是流水线化:行情包进入后,逐级经过解码、策略、风控、编码,全程不落到 CPU:
[网卡 PHY] → [包解析] → [订单簿更新] → [策略逻辑] → [风控] → [报单生成] → [MAC]
↑ 全部在 FPGA 内的流水线上,单周期推进
代价是策略必须能被硬件化:只能实现查表、比较、加减这类简单逻辑,无法跑复杂模型。FPGA 适合做市、套利这类规则简单的策略。开发成本极高,一个中等复杂度的 FPGA 交易系统需要数人年的投入。
9. 时间同步与延迟测量
没有精确时钟,前面所有优化都无法验证。三个层次的方案:
| 方案 | 精度 | 部署 |
|---|---|---|
| NTP | 1~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 ms | 100 μs~1 ms | < 10 μs |
| 成本 | 低 | 中 | 极高 |
| 开发周期 | 周 | 月 | 年 |
| 可维护性 | 高 | 中 | 低 |
| 团队要求 | 普通后端 | 系统工程师 | 内核 + 硬件 |
延迟投入的决策依据是策略的边际收益曲线。做市策略在 10 微秒和 100 微秒之间的收益差可能是年化 20%,值得投入;中频策略在这个区间的收益差接近零,投入就是浪费。
技术选型上的取舍也很明确:DPDK 通用但延迟不如 ef_vi,ef_vi 快但绑定单一厂商网卡,FPGA 最快但开发成本极高且无法迭代策略。大多数团队的最优解是 ef_vi + 用户态协议栈 + 无锁数据结构,把延迟做到 2~5 微秒,性价比最高。
常见坑清单
- 在关键路径上打日志:一次
printf就是几十微秒,必须异步日志或干脆不打。 - 忽略伪共享:共享变量落在同一缓存行,多线程性能暴跌数倍。
- 跨 NUMA 访问:网卡在节点 0、内存在节点 1,延迟翻倍。
- 运行时 malloc:glibc malloc 有锁,最坏阻塞几十微秒。
- 没做 CPU 隔离:交易线程被其他进程抢占,延迟抖动到毫秒级。
- 用平均值评估延迟:掩盖了尾部问题,必须看 P99.9。
- 时间不同步:测量结果包含时钟误差,优化方向完全错误。
- 中断没关:网卡中断、定时器中断打断轮询循环。
- 缺页中断:首次访问内存触发缺页,用
mlockall预锁定。 - 只优化单点:行情 1 微秒、报单 50 微秒,整体仍受最慢环节制约。
小结
低延迟系统的核心原则是减少不确定性而不是减少平均值。一个平均 2 微秒、P99 20 微秒的系统,远不如平均 5 微秒、P99 6 微秒的系统稳定。所有优化的目标都是把尾部压平:CPU 隔离、忙轮询、预分配内存、无锁通信,本质上都是在消除「什么时候会被打断」的不确定性。
判断是否需要低延迟架构的标准很简单:如果信号在 1 毫秒后衰减不到 10%,就不要做低延迟。低延迟是手段而不是目的,为不需要延迟的策略做延迟优化是最常见的工程浪费。
下一步建议阅读 撮合引擎设计 ,看交易所在收到你的报单后如何撮合;如果你关心报单侧的状态管理,可以回到 订单管理与执行算法 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。