1. 逐笔行情接入链路端到端延迟基线测量

为什么先测“端到端”,而不是先测解析耗时

逐笔行情的性能优化最容易犯的错误,是直接去测某个函数或某个线程的耗时。但策略真正关心的是:一笔委托从交易所撮合系统产生,到本地策略进程拿到这条消息,中间过了多久。这个时间由网关排队、网络传输、内核协议栈、用户态收包、解析、线程间传递等多段组成,任何一段的抖动都会体现在最终分布上。因此第一步不是优化,而是建立一条可重复、可对比的端到端延迟基线。

端到端延迟无法在本地单点测得,必须依赖跨机器的时间参照。工程上常用两种口径:

  • 交易所时间戳口径:行情报文自带交易所网关打的时间戳(如沪深 Level-2 逐笔委托中的时间字段,精度通常为毫秒或更细)。本地收到时用本地时钟减去该字段,得到“交易所→本地”的近似延迟。缺点是交易所时钟与本地时钟存在偏移,且字段精度有限。
  • 探针回环口径:在行情源侧(或仿真源)与策略进程各部署一个探针,用同一时钟源(PTP/NTP 或硬件时间戳)对齐,消息携带发送时刻,接收端计算差值。精度更高,但需要可控的源端。

本例采用第二种口径的可控版本:用一台仿真行情源按真实逐笔节奏发包,包内写入源端发送时刻(纳秒),策略侧探针在应用层收到消息的第一时间打接收时刻,两者相减即为该笔消息的端到端延迟。这样既避免了交易所时钟不可控的问题,又能完整复现“网关→网络→内核→应用”的链路。

探针部署位置与测量链路

探针必须放在应用层拿到完整消息之后、任何业务处理之前。如果放在解析之后,测到的是“解析+业务”延迟;如果放在内核收包处,测到的是“网络+内核”延迟。我们要的是策略进程视角的端到端,所以接收时间戳应紧贴消息出队、进入策略逻辑的那一行代码。

仿真行情源 逐笔委托/成交报文 写入 t_send(纳秒) 探针 A:发送时刻 网络与内核 交换机 / 网卡 / 协议栈 收包队列 → 用户态 (此段不单独打点) 策略进程(应用层) 消息出队 t_recv 打点 解析 / 业务处理 (基线测量不包含) 探针 B:接收时刻 延迟 = t_recv − t_send t_send t_recv 端到端延迟样本 统计口径:P50 / P90 / P99 / P99.9 / 最大值,按固定窗口(如 10 万笔)滚动输出,作为后续优化对比基准

探针实现:最小侵入的纳秒打点

探针本身要足够轻,否则测量行为会污染被测对象。核心要求:用单调时钟(避免系统时间跳变导致负延迟)、只记录两个 64 位整数、不在热路径做格式化或加锁。下面是一段可直接编译运行的 C++ 示例,模拟“源端写时间戳 → 接收端打点 → 计算延迟并统计分位数”的完整闭环。

#include <cstdio>
#include <cstdint>
#include <vector>
#include <algorithm>
#include <chrono>
#include <thread>
#include <random>

// 单调时钟纳秒,源端与接收端必须使用同一时钟源(本例同机演示)
static inline uint64_t now_ns() {
    return std::chrono::duration_cast<std::chrono::nanoseconds>(
        std::chrono::steady_clock::now().time_since_epoch()).count();
}

struct TickMsg {
    uint64_t seq;      // 消息序号
    uint64_t t_send;   // 源端发送时刻(ns)
    // 真实场景此处还有价格、数量、方向等字段
};

int main() {
    const int N = 100000;              // 样本数
    std::vector<uint64_t> latencies;   // 每笔延迟(ns)
    latencies.reserve(N);

    std::mt19937_64 rng(42);
    // 模拟网络+内核+调度的随机抖动:基础 20us,叠加 0~80us 抖动
    std::uniform_int_distribution<uint64_t> jitter(0, 80000);

    for (int i = 0; i < N; ++i) {
        TickMsg m;
        m.seq = i;
        m.t_send = now_ns();           // 探针 A:源端打点

        // 模拟链路耗时(真实环境由网络与内核产生)
        std::this_thread::sleep_for(std::chrono::microseconds(20));
        volatile uint64_t spin = 0;
        uint64_t target = jitter(rng);
        while (spin < target) ++spin;  // 模拟抖动

        uint64_t t_recv = now_ns();    // 探针 B:应用层收到即打点
        latencies.push_back(t_recv - m.t_send);
    }

    std::sort(latencies.begin(), latencies.end());
    auto pct = [&](double p) {
        size_t idx = static_cast<size_t>(p * (latencies.size() - 1));
        return latencies[idx];
    };

    printf("样本数: %zu\n", latencies.size());
    printf("P50   : %8.2f us\n", pct(0.50) / 1000.0);
    printf("P90   : %8.2f us\n", pct(0.90) / 1000.0);
    printf("P99   : %8.2f us\n", pct(0.99) / 1000.0);
    printf("P99.9 : %8.2f us\n", pct(0.999) / 1000.0);
    printf("Max   : %8.2f us\n", latencies.back() / 1000.0);
    return 0;
}

这段代码的关键点不在统计,而在打点位置:t_send 在消息构造完成、即将进入发送路径的瞬间;t_recv 在消息从接收队列取出、尚未做任何解析的瞬间。两者之间恰好覆盖了“发送→网络→内核→应用出队”的完整链路。真实部署时,源端与接收端分属两台机器,必须用 PTP 或带硬件时间戳的网卡保证时钟同步,否则测出的延迟会混入时钟偏移误差。

若源端与接收端时钟不同步,延迟分布会出现整体平移甚至负值。上线前务必用 ptp4l 或 chronyc tracking 确认偏移量在微秒级以内,并在报告中注明同步精度。

基线报告应包含什么

单次测量的平均值没有意义,逐笔行情的瓶颈几乎总在尾部。基线报告至少应固定以下字段,便于后续案例逐项对比:

字段示例值说明
样本窗口10 万笔固定笔数或固定时长,保证可比
P5024.6 μs中位延迟,反映常态
P9978.3 μs尾部延迟,策略最敏感
P99.996.1 μs极端抖动,暴露调度/GC/中断问题
最大值142.7 μs单笔最坏情况
时钟同步精度±1.2 μsPTP 偏移,决定测量可信度
环境CPU 型号/核数/网卡/内核版本复现前提
上表数值为示例假设,用于说明报告结构,不代表任何真实环境实测结果。实际基线必须在你自己的硬件与网络条件下测得,并连同环境信息一起归档,否则后续优化无法判断改善是否真实。

结果判读与常见陷阱

  • 负延迟:几乎总是时钟不同步或打点顺序错误(先打接收后打发送)。先查时钟,再查代码。
  • P50 正常但 P99 异常高:说明存在偶发长尾,常见于内核收包软中断集中、CPU 被其他进程抢占、或接收线程被调度延迟。这正是后续 CPU 亲和性绑定要解决的问题。
  • 整体延迟偏高且平稳:多为链路固有开销(交换机缓冲、协议栈处理),优化空间在架构层而非代码层。
  • 样本量不足:几千笔无法稳定估计 P99.9,建议至少 10 万笔或覆盖一个完整交易时段。

完成本步后,你手上应有一份带环境信息、时钟精度和分位数分布的基线档案。后续任何优化——线程绑定、零拷贝、批处理——都必须回到同一口径重新测量,用 P99 与 P99.9 的变化来判断是否真正改善了策略可见的延迟,而不是只看平均值或吞吐。