Skip to main content

同一专线、同一秒、延迟不一样——从一次排查看网卡中断与核心隔离的本质

·11 mins

本文从一个真实的加密货币交易系统延迟异常出发,逐层拆解网卡中断机制、多队列架构、PPS 瓶颈、Buffer Bloat、网卡隔离与 CPU 核组规划,帮助读者建立从物理网卡到 CPU 处理的完整认知。


一、问题现象 #

在一台 AWS c8g.metal-48xl(Graviton4,192 核,裸金属)上,同一条专线接入了多个交易所的行情。某天观察到以下现象:

时间BN 延迟GATE 延迟间隔
17:33:36248ms20ms同秒,相隔 7ms
17:35:4673-82ms(连续 8 包)20ms同秒,相隔 170ms
17:41:1786ms20ms同秒,相隔 142ms

关键矛盾:如果是专线本身的问题(光纤故障、中继设备拥塞),同一时刻所有流量都应该受影响。但 GATE 始终稳定在 20ms,只有 BN 在波动。

结论:问题出在 TY 端到 BN 的独有路径上。 但这次排查引出了一系列关于网卡架构和 CPU 资源分配的深层问题,值得系统梳理。


二、中断到底是什么:一次中断,两个角色 #

很多人把"网卡中断"和"CPU 中断"当作两种不同的中断,但实际上它们描述的是同一次中断的发起方和执行方

打个比方:有人按了你家门铃(网卡发中断),你听到铃声后起身去开门、收快递、拆包裹(CPU 处理中断)。门铃和你的行动不是两件独立的事,而是同一件事的两端。

网卡中断(发起方):网卡通过 DMA 将数据写入 Ring Buffer 后,通过 PCIe 总线向 CPU 的中断控制器发送一个消息写入(MSI-X,Message Signaled Interrupt)——本质是一次 PCIe 内存写事务,而非传统 INTx 那样的专用电气信号线——含义是"Queue 5 有包到了,请处理"。这个动作本身几乎没有开销,网卡发完就结束了。

CPU 中断处理(执行方):CPU 收到信号后的一系列动作——暂停当前任务、保存上下文、执行硬中断处理函数、调度软中断(softirq)、NAPI 轮询收包、协议栈处理、放入 socket buffer。所有计算开销全在 CPU 侧。

一句话总结:网卡只负责喊一声,CPU 负责跑全程。 人们说"网卡中断"时侧重"谁触发的",说"CPU 中断"时侧重"谁在干活"。


三、一张网卡的内部结构:多队列、Ring Buffer 与 IRQ #

3.1 多队列与 RSS #

现代网卡并不是一个"单通道"设备。一张网卡内部有多个独立的收发队列(RX/TX Queue),每个队列有自己独立的 Ring Buffer 和专属的 IRQ 编号(IRQ,全称 Interrupt Request,中断请求)。

多队列解决了一个根本问题:如果只有一个队列,所有包只能由一个 CPU 核顺序处理,吞吐量受限于单核性能。多队列让不同的包分散到不同队列,由不同 CPU 核并行处理,从而线性提升收包吞吐量。

但"把包分到哪个队列"需要一套确定性的分流机制——这就是 RSS(Receive Side Scaling,接收端扩展)

RSS 的工作机制 #

RSS 是网卡硬件内部的包分流引擎,在包进入 Ring Buffer 之前就完成分流决策,不消耗任何 CPU 资源。整个过程分三步:

第一步:提取哈希输入。 网卡从包头中提取四元组(或二元组)作为哈希输入:

  • TCP/UDP 包:{src_ip, dst_ip, src_port, dst_port}(四元组)
  • 非 TCP/UDP 的 IP 包:{src_ip, dst_ip}(二元组)
  • 非 IP 包:通常落入默认队列(Queue 0)

第二步:计算 Toeplitz 哈希。 网卡使用 Toeplitz 哈希算法对提取的字段计算 32-bit hash 值。Toeplitz 哈希的特点是:计算快(纯位移和异或运算,适合硬件实现)、分布均匀、且哈希密钥可配置。密钥(hash key)是一个 40 字节的随机数,可通过 ethtool -X 查看和修改:

# 查看当前 RSS 配置(hash key + indirection table)
ethtool -x enP11p4s0

# 修改 hash key(改变包到队列的映射关系)
ethtool -X enP11p4s0 hkey <hex_string>

第三步:查 Indirection Table 得到队列号。 计算出的 hash 值并不直接对队列数取模,而是用 hash 低位(通常低 7 位)作为索引,查找 Indirection Table(RETA,Redirection Table)。RETA 是一张由网卡硬件维护的映射表,每个条目存放一个目标队列号。

hash = Toeplitz(src_ip, dst_ip, src_port, dst_port)
queue = RETA[hash & 0x7F]   // 取低 7 位,索引 128 条目的 RETA

RETA 的好处是灵活:默认按轮转(round-robin)填充,效果接近 hash % N,但也可以做非均匀分配(比如让热门队列分到更多 RETA 条目),且支持在线修改而无需更换 hash key:

# 将所有流量集中到队列 0 和 1(修改 indirection table)
ethtool -X enP11p4s0 equal 2

# 自定义权重:队列 0 占 3/4,队列 1 占 1/4
ethtool -X enP11p4s0 weight 3 1

整个 RSS 流程在网卡硬件中完成,示意如下:

一张网卡内部结构
┌─────────────────────────────────────────────────┐
│              RSS 引擎(硬件)                     │
│                                                  │
│  ① 提取四元组 {src_ip, dst_ip, src_port, dst_port}│
│  ② Toeplitz 哈希 → 32-bit hash                  │
│  ③ RETA[hash & 0x7F] → 目标队列号                │
└─────────────────┬───────────────────────────────┘
                  │
    ┌─────────────┼─────────────┬──────────┐
    ▼             ▼             ▼          ▼
  Queue 0       Queue 1      Queue 2   ... Queue 31
  Ring Buffer   Ring Buffer  Ring Buffer   Ring Buffer
  IRQ 132       IRQ 133      IRQ 134       IRQ 163

RSS 的关键特性 #

同一条 TCP 连接(四元组固定)的所有包,hash 值相同,始终落入同一个队列。这保证了单连接内的包顺序,同时让不同连接的处理可以并行——这是 RSS 设计的核心约束:在保序的前提下最大化并行度

3.2 IRQ 与 CPU 的绑定关系 #

每个 Queue 的专属 IRQ number 决定了"谁来喊",而 IRQ 到 CPU core 的绑定关系决定了"谁来干活"。

IRQ 被分配到哪个 core,那个 core 就负责处理这个 Queue 的 Ring Buffer 里的包——包括硬中断、softirq、NAPI 轮询和协议栈处理,全部在这个 core 上完成。

绑定方式有两种:

  • irqbalance 自动分配:系统守护进程动态调整 IRQ 到 core 的映射,目标是均衡负载。但动态迁移会导致 cache 失效和延迟突刺,对低延迟系统有害。
  • 手动绑定:通过 smp_affinity_list 将 IRQ 固定到指定 core,确保不漂移。
# 将 IRQ 132(Queue 0)固定由 CPU 5 处理
echo 5 > /proc/irq/132/smp_affinity_list

# 之后网卡 Queue 0 每次发 IRQ 132,都只有 CPU 5 响应

3.3 32 个 Queue 不一定需要 32 个独占的 core #

一张网卡有 32 个 Queue 就有 32 个 IRQ,每个都需要绑定到某个 core。但这不意味着需要 32 个独占的 core。

实际操作中,行情连接可能只命中其中几个 Queue(因为 RSS 按四元组 hash,连接数有限),大部分 Queue 可能几乎没有流量。合理的做法是:

  1. 先看哪些 Queue 实际有流量:cat /proc/interrupts | grep enP11p4s0,找出中断计数高的 Queue。
  2. 热门 Queue 一对一绑定独立 core。
  3. 冷门 Queue 可以多个共享同一个 core,反正没什么流量不会互相影响。

或者更直接:减少队列数。BN 行情可能就几条到十几条连接,4 个 Queue 足够覆盖,只需要绑 4 个 core,管理简单,资源不浪费。

# 把队列数从 32 降到 4
ethtool -L enP11p4s0 combined 4

3.4 共享资源的边界:NIC On-Chip Buffer #

每个 Queue 有独立的 Ring Buffer(位于主存)和 IRQ,但所有 Queue 共享网卡芯片内部的物理硬件资源。理解这些共享资源,才能理解为什么多 Queue 不等于完全隔离。

NIC 内部的 On-Chip Buffer #

在数据包从网线到达到 DMA 写入主存之间,还有一个常被忽略的环节:NIC 芯片内部的 SRAM

网线信号到达
     │
     ▼
  PHY 层:电信号/光信号 → 数字比特流
     │
     ▼
  MAC 层:帧校验(CRC)
     │
     ▼
  ★ NIC On-Chip SRAM ★  ← 包暂存在这里,所有 Queue 共享
     │
     ▼
  RSS 引擎:算四元组哈希,选目标 Queue
     │
     ▼
  DMA 引擎:从 On-Chip SRAM → 通过 PCIe → 写入主存中该 Queue 的 Ring Buffer

包通过 MAC 校验后,不是直接 DMA 到主存,而是先暂存在网卡芯片上的 SRAM 中。原因是 DMA 不一定能立即执行——DMA 引擎可能正在搬上一个包、PCIe 总线可能正忙、目标 Ring Buffer 的描述符可能还没准备好。所以包先在 NIC 内部 SRAM 排队等待。

这块 On-Chip SRAM 的关键特性:

  • 容量有限:通常几百 KB 到几 MB(远小于主存中的 Ring Buffer)
  • 所有 Queue 共享:不是每个 Queue 有自己的 SRAM 分区,而是所有 Queue 的包都在同一块 SRAM 中排队
  • 满了直接丢包:当 SRAM 满时,新到达的包被静默丢弃(tail drop),这是比 Ring Buffer 溢出更底层的丢包点ethtool -S 中的 rx_fifo_errors 或厂商特定计数器可以反映这类丢包

共享资源全景 #

┌─────────────────────────────────────────────────────┐
│  NIC 芯片内部(所有 Queue 共享)                       │
│                                                      │
│  ┌──────────────┐  ┌──────────────┐  ┌────────────┐ │
│  │ On-Chip SRAM │  │  RSS 引擎    │  │ DMA 控制器  │ │
│  │ (包暂存区)    │  │ (哈希+分流)  │  │            │ │
│  └──────────────┘  └──────────────┘  └────────────┘ │
│                                                      │
│  ┌──────────────┐  ┌───────────────────────────────┐│
│  │ PCIe 接口    │  │ 固件调度逻辑 / MAC / PHY       ││
│  └──────────────┘  └───────────────────────────────┘│
└─────────────────────────────────────────────────────┘
                         │
           ══════════════╪══════════════  PCIe 总线(共享带宽)
                         │
    ┌────────────────────┼────────────────────┐
    ▼                    ▼                    ▼
  主存 Ring Buffer 0   主存 Ring Buffer 1   主存 Ring Buffer 2    ← 这里才是 per-Queue 独立的

正常流量下这些共享资源不会成为瓶颈。但极端突发时(比如某个交易所瞬间推送大量行情),On-Chip SRAM 拥塞、DMA 引擎排队、PCIe 带宽饱和,都会连锁影响同一张网卡上所有 Queue 的收包延迟。这也是第七节"为什么要分网卡"的硬件层面根因。

延伸阅读:本文假设读者对网卡收包路径有基本了解。如需回顾从物理层到用户态的完整七阶段流程,参见《从网线到策略引擎:Linux 网络收包全路径深度解析》


四、硬中断与 softirq:谁轻谁重 #

理解了中断的基本概念后,需要进一步区分硬中断和 softirq,因为这直接决定了"CPU 时间到底花在哪"。

4.1 硬中断:只喊不干 #

硬中断处理函数必须极快(微秒级),因为在硬中断上下文中所有本地中断被屏蔽。所以 Linux 网络子系统的设计哲学是:硬中断做最少的事,把真正的工作推迟到 softirq。

硬中断只做两件事:

  1. 关闭该队列的中断(防止同一队列反复触发中断风暴)
  2. 调度 NAPI 轮询(注册一个待处理的 NET_RX_SOFTIRQ

然后就结束了,几百纳秒的事。

4.2 softirq:真正干活的地方 #

CPU 退出硬中断后,检查到有待处理的 NET_RX_SOFTIRQ,开始执行 softirq。网络收包的所有重活都在这里:

  1. NAPI poll——从 Ring Buffer 批量取包(每次最多 64 个)
  2. 填充 skb 元数据——协议类型、校验和、时间戳
  3. GRO 合并——将同一 TCP 流的小包合并为大包(可关闭)
  4. IP 层处理——头校验、Netfilter 规则、路由查找
  5. TCP/UDP 层处理——序列号校验、窗口更新、socket 查找
  6. 放入 socket buffer——数据入队,唤醒用户进程

每个包几微秒,几十个包下来就是几百微秒。这才是 CPU 时间的大头。

4.3 softirq 是抢占式的,不是时间片调度 #

一个容易混淆的概念:softirq 的排队和用户态进程的时间片调度是两回事。

时间片是进程调度器(CFS)的概念,用于用户态进程之间的轮转。而硬中断的优先级比任何用户进程都高——中断来了直接打断当前任务,不需要等时间片。softirq 则在硬中断返回路径上执行(irq_exit()__do_softirq()),此时仍优先于用户态代码。

不过需要注意:__do_softirq() 有保护机制——如果 softirq 处理时间超过 2ms 或循环超过 10 次MAX_SOFTIRQ_RESTART),内核会停止在中断上下文中继续处理,转而唤醒 ksoftirqd 内核线程。ksoftirqdSCHED_NORMAL 优先级的普通内核线程,需要通过 CFS 调度器调度运行。在微突发场景下,softirq 工作量很容易超过阈值,后续包的处理会落入 ksoftirqd,延迟特性会发生变化——这是低延迟系统需要关注的。

所以当多个 Queue 的 IRQ 绑在同一个 core 上时,不是"时间片被挤压",而是 NAPI 实例在同一个 softirq 内串行轮询——同一个 CPU 上只有一个 NET_RX_SOFTIRQ,触发后由 net_rx_action() 依次轮询 poll_list 中所有已调度的 NAPI 实例。BN 队列的 NAPI poll 执行期间,GATE 队列的 NAPI poll 必须等待,因为它们在同一次 net_rx_action() 调用中被顺序处理。

4.4 softirq 排队会造成多大延迟 #

单个包的 softirq 处理只要几微秒,正常情况下不会有感知。

但微突发时不一样。比如 BN 突然 10ms 内来了几千个包,NAPI 会连续轮询:取 64 个包处理完,队列里还有,再取 64 个,再处理……这一轮 softirq 可能持续几百微秒甚至几毫秒。在这段时间内,同一个 core 上其他 Queue 的 softirq 确实要等。

不过,几毫秒的 softirq 排队解释不了本文开头图中 80-248ms 的延迟。那个量级的延迟更可能源于 TY 到 BN 的中间链路设备(交换机、路由器)本身引入的 Buffer Bloat——包在某个交换机端口的缓冲队列里排了上百毫秒。

所以回到最初的判断:本案例的根因在网络路径上,不在本机 softirq 抢占。 但本机的网卡隔离和核组规划是预防性优化,防止未来本机侧也出现类似的延迟传导。


五、带宽没满也会卡:PPS 才是小包场景的真瓶颈 #

5.1 带宽与 PPS 的换算 #

网卡的带宽(如 50Gbps)标的是比特传输速率(Gbps = Gigabit per second,每秒十亿比特)。但每个包不管多小,在线路上都有固定开销:

最小以太网帧 64 字节 + 前导码 8 字节 + 帧间隔 12 字节 = 84 字节 = 672 bit

以不同带宽为例(假设 100 字节的行情包,线路开销后 960 bit/包):

带宽理论最大 PPS
50 Mbps~52,000
1 Gbps~1,040,000
50 Gbps~52,000,000

加密货币 BBO 行情消息通常只有几十到几百字节。在低带宽专线(如 50Mbps)上,带宽还没满,PPS 就可能先到上限。

5.2 网卡 PPS vs CPU PPS #

网卡硬件的 PPS 上限(DMA + 描述符处理)通常在百万级以上,很难打满。

真正的瓶颈在 CPU 每秒能从 Ring Buffer 取走并处理完的包数。每个包都需要经历完整的 softirq 流程:NAPI poll → 协议栈解析 → socket buffer 入队。这套流程的 CPU 开销是固定的。

普通云服务器(有虚拟化开销)通常在几万到十几万 PPS;裸金属服务器(如 c8g.metal-48xl)可以到几百万 PPS。

5.3 微突发:平均不高,瞬时要命 #

加密货币行情的 PPS 有强烈的突发特征。平时可能只有几千 PPS,但行情剧烈波动时(插针、大单成交),交易所对大量币对同时推送更新,10ms 内堆积上万包完全现实。

这就是微突发(microburst)——平均流量远低于上限,但瞬时到达速率可能超过 CPU 处理速率。


六、Buffer 能救命吗:缓冲 ≠ 消化 #

6.1 三层 Buffer,各司其职 #

收包路径上有三层不同的 Buffer,位于不同阶段,行为各异:

网卡 DMA 写入 → Ring Buffer → CPU softirq 取走处理 → Socket Buffer → 用户 recv() 拷贝 → 用户 Buffer
                   ①                                      ②                                  ③

① 网卡 Ring Buffer:位于主内存中,由网卡驱动初始化。网卡通过 DMA 把包写进来,CPU 通过 NAPI 轮询取走。满了的话网卡硬件直接丢包,软件完全不知道(rx_missed_errors 计数增加,但没有任何通知机制)。Ring Buffer 的大小可以调整:

# 查看当前值和硬件支持的最大值
ethtool -g enP11p4s0

# 设置 Ring Buffer 大小(每个队列的描述符数量)
ethtool -G enP11p4s0 rx 4096

同样的取舍:设大了能扛更长的突发不丢包,但包在队列里排队时间更长,延迟更高。

② Socket Buffer(sk_receive_queue):位于内核空间,协议栈处理完后把数据放进来,等用户进程 recv() 取走。满了之后的行为取决于协议:TCP 会通告窗口为 0,让对端停止发送(不丢包但阻塞对端);UDP 直接丢包,无任何通知。用 ss -tnpm 查看使用量。Socket Buffer 的大小可以通过系统参数调整:

# 单个 socket 接收缓冲区的全局上限和默认值
sysctl -w net.core.rmem_max=26214400
sysctl -w net.core.rmem_default=26214400

# TCP 专属的自动调优范围:最小值、默认值、最大值
sysctl -w net.ipv4.tcp_rmem="4096 87380 26214400"

也可以在代码里对单个 socket 设置:setsockopt(fd, SOL_SOCKET, SO_RCVBUF, &buf_size, sizeof(buf_size))。但注意:Buffer 设大了能扛更长的突发,代价是 Buffer Bloat 更严重——数据在内核里排队更久才触发流控,延迟更高。对交易系统来说,宁可早点流控甚至丢包,也不要让过期行情在 buffer 里排几百毫秒才送到应用层,所以不是越大越好。

③ 用户进程 Buffer:位于用户空间,就是 recv(fd, buf, len) 里的那个 buf。数据从内核 Socket Buffer 拷贝到这里后,由应用逻辑消费。如果应用处理不过来(比如 JSON 解析太慢),数据在用户 Buffer 里堆积,表现为应用层延迟增长。

6.2 每一层都能缓冲,但都不能消化 #

三层 Buffer 共享同一个本质:只能缓冲一时的突发,消化速度跟不上就出问题。 但出问题的表现各不相同:

层级满了之后感知度
Ring Buffer网卡硬件静默丢包最低——只能通过 ethtool -S 计数器发现
Socket BufferTCP 流控阻塞 / UDP 丢包中等——TCP 表现为吞吐下降,UDP 表现为数据丢失
用户 Buffer应用层延迟堆积最高——直接体现在业务指标上

6.3 Buffer Bloat:缓冲区越大,延迟越高 #

Buffer Bloat(缓冲区膨胀) 是网络领域一个经典且反直觉的问题:缓冲区越大,延迟反而越高。

为什么缓冲区大了反而有害 #

直觉上,缓冲区越大越安全——能吸收更多突发,不丢包。但问题在于 TCP 的拥塞控制机制依赖丢包作为"网络拥塞了"的反馈信号。整个恶性循环是这样展开的:

1. 发送端全速发送数据
2. 中间设备(交换机/路由器)的大缓冲区吸收了所有突发流量,不丢包
3. TCP 没收到丢包信号 → 认为网络没有拥塞 → 继续全速发送甚至加速(慢启动/拥塞避免)
4. 缓冲区里的包越排越多,每个包的排队时间越来越长
5. 延迟从毫秒级飙到几百毫秒甚至秒级
6. 但吞吐量看起来完全正常,丢包率为零——所有传统监控指标都是绿的

如果缓冲区小一点,步骤 2 就会丢包,TCP 在步骤 3 就会降速,延迟反而保持在低位。大缓冲区用高延迟换取了零丢包,而这个交换对低延迟系统来说是致命的。

Buffer Bloat 发生在哪里 #

Buffer Bloat 可以发生在数据路径上的任何一层

位置缓冲区典型场景
中间链路设备交换机/路由器的端口出口队列多条流汇聚到同一个出口端口,队列深度配置过大
本机网卡Ring Bufferethtool -G rx 8192 设太大,NAPI 来不及消费
本机内核Socket Bufferrmem_max 设太大,应用层来不及 recv()
ISP / 云网关运营商设备内部队列专线接入点的流量整形设备缓冲过深

本案例中 BN 延迟 80-248ms 而 GATE 正常,最可能的位置是 TY 到 BN 的中间链路设备——某台交换机或流量整形设备的出口队列配置了过深的缓冲,BN 突发流量被缓冲而非丢弃,导致排队延迟。

为什么 Buffer Bloat 比丢包更危险 #

对 HFT 系统来说,丢包其实不可怕——TCP 会重传,应用层能检测序列号跳变,丢包率是标准监控指标。但 Buffer Bloat 的表现是:

  • 不丢包——所有数据最终都会送到,监控上丢包率为零
  • 吞吐量正常——带宽利用率可能还很高,看起来一切健康
  • 延迟悄悄升高——除非你主动测量每包延迟,否则根本发现不了
  • 过期数据被当作最新数据处理——行情消息在缓冲区排了 200ms 才到达应用层,策略引擎基于 200ms 前的"旧行情"做决策,但它以为这是最新的

HFT 系统的应对策略 #

Buffer Bloat 的根本解法是让缓冲区保持小,宁可早丢包也不要攒包:

  • Ring Buffer 不要设太大:够扛正常突发即可,过大只会增加排队延迟(见 6.1 节)
  • Socket Buffer 不要设太大:对行情连接,宁可流控也不要让过期行情排队几百毫秒(见 6.1 节)
  • 关闭中断合并rx-usecs 0 rx-frames 1,每包立即处理,不在网卡侧攒包(见 9.5.4 节)
  • 应用层丢弃过期数据:即使数据到了应用层,如果时间戳表明它已经过期,直接丢弃不处理
  • 对中间链路设备:要求网络供应商在专线接入设备上配置较小的队列深度,或启用 AQM(Active Queue Management,如 CoDel / fq_codel)算法,在队列开始膨胀时主动丢包

回到本案例 #

排查中观察到 BN 的延迟从正常飙到 80-248ms,而 GATE 始终稳定在 20ms。这不是丢包(TCP 连接正常),而是典型的 Buffer Bloat——包在 TY 到 BN 路径上的某台设备的缓冲队列里排了上百毫秒。对低延迟交易系统来说,这比丢包更隐蔽也更致命:丢包至少能被检测到,而 Buffer Bloat 看起来一切正常,只是"慢了一点"。

延伸阅读:关于 Socket Buffer 与 TCP 窗口调优的更多细节,参见《网络 Buffer 与 UDP 丢包排查》;Ring Buffer 与网卡队列的内核视角,参见《网卡多队列与中断优化》


七、为什么要分网卡:从逻辑隔离到物理隔离 #

7.1 同一张网卡:逻辑隔离的局限 #

如果 BN 和 GATE 的行情走同一张网卡,RSS 会根据四元组 hash 把它们分到不同的 Queue。这是逻辑隔离——不同的 Queue、不同的 Ring Buffer、不同的 IRQ 编号,看似各走各路。

但实际上:

  1. 共享网卡内部硬件资源:RSS 引擎、DMA 控制器、PCIe 接口、固件调度逻辑。正常流量下无感,极端突发时可能互相影响。
  2. softirq 层面互相竞争:不同 Queue 的 IRQ 如果被分配到相同的 CPU core,它们的 softirq 处理会串行排队。BN 微突发时大量 softirq 占据 CPU,同一核上 GATE 的 softirq 被延迟。

7.2 分网卡:物理隔离的本质 #

不同网卡的队列完全独立。比如 enP11p4s0 有 Queue 0-31(IRQ 132-163),enP11p8s0 也有自己的 Queue 0-31,但对应完全不同的 IRQ 编号、完全不同的 Ring Buffer 内存区域、完全不同的 DMA 通道。它们之间没有任何共享。

分网卡的本质是把逻辑隔离升级为物理隔离。逻辑隔离(同卡不同 Queue)在正常流量下够用,但扛不住微突发时的连锁影响;物理隔离(不同卡)在任何情况下都互不干扰。

将不同业务分到不同网卡后,隔离是从硬件到软件全链路的:

独立的物理网卡硬件
  → 独立的 Ring Buffer
    → 独立的 DMA 通道
      → 独立的 IRQ 编号
        → 独立的 CPU 核组(通过 IRQ 亲和性绑定)
          → 独立的 softirq 处理
            → 独立的协议栈处理

7.3 IRQ 集合绑定到独立的 CPU 核组 #

分网卡方案中最关键的一步:每张网卡的所有队列的 IRQ,固定分配给一组专用的 CPU 核。

enP11p4s0 (BN 行情)    → Queue 0-31 的 IRQ → 绑定 CPU 0-31
enP11p8s0 (GATE 行情)   → Queue 0-31 的 IRQ → 绑定 CPU 32-63
enP11p5s0 (TD 成交回报)  → Queue 0-31 的 IRQ → 绑定 CPU 64-95

BN 突发时,硬中断、softirq、协议栈处理全部在 CPU 0-31 上跑。CPU 32-63 和 CPU 64-95 完全不知道发生了什么,GATE 和 TD 的延迟纹丝不动。

如果不分网卡,所有业务的 IRQ 混在同一套 Queue 里,很难做到这种干净的 CPU 核组隔离。

7.4 代价:总中断数可能增加 #

将流量从 1 张网卡分散到多张网卡后,总中断数可能反而增加。

原因在于 NAPI 的轮询机制。流量集中在一张网卡时,每个队列的包到达密集,NAPI 进入轮询后一次能捞出大量包,中断被长时间抑制。分散后每个队列的到达密度下降,NAPI 更频繁地遇到"队列空了"退出轮询、重开中断,下一个包又触发新中断。

但对交易系统来说,这个代价完全值得。我们关心的不是"总共产生了多少次中断",而是"某个业务的延迟会不会因为另一个业务的突发而被拖累"。硬件级流量隔离带来的确定性,远比节省几次中断更有价值。


八、收包核 vs 计算核:两种哲学的取舍 #

分完网卡和 IRQ 核组之后,还有一个关键决策:IRQ 处理和应用进程应该在同一个 core 上,还是分开?

8.1 同核方案:延迟极低,但抖动大 #

如果策略引擎绑在 CPU 5,该连接对应的 IRQ 也绑在 CPU 5。每次来包:

  1. CPU 5 正在跑策略计算
  2. 中断来了,CPU 5 被迫暂停策略计算
  3. 执行硬中断 + softirq + 协议栈(几微秒到几十微秒)
  4. 数据直接在 CPU 5 的 L1 cache 中,recv() 时延迟极低
  5. 回到策略计算

好处是数据路径最短——从 softirq 处理到用户态 recv() 全在同一个 core 的 L1 cache 中,零跨核开销。代价是策略计算被频繁打断,执行时间变得不确定。微突发时 softirq 密集触发,策略引擎可能被连续打断好几百微秒都跑不了。

8.2 分核方案:抖动小,延迟略高 #

将 IRQ 处理和应用进程分到不同的 core:

CPU 0-31   → 专门处理 IRQ + softirq + 协议栈(收包核)
CPU 32-63  → 专门跑应用进程(计算核)

收包核把数据处理完放进 socket buffer,计算核通过 recv() 取走数据安心计算,不会被中断打断。

代价是数据要跨核传递。数据在收包核的 L1/L2 cache 中,计算核读取需要走 L3/SLC(Graviton4 的 System Level Cache,同 socket 内约 25ns)甚至跨 socket 缓存一致性访问(c8g.metal-48xl 是双 socket,跨 socket 约 139ns)。比同核方案多了几十到上百纳秒延迟。(延迟数据来源:Chips and Cheese - Arm’s Neoverse V2, in AWS’s Graviton 4,使用 pointer chasing 实测)

8.3 怎么选 #

维度同核分核
延迟极低(L1 cache 命中)略高(跨 SLC ~25ns / 跨 socket ~139ns)
抖动大(中断随时打断计算)小(计算核不受中断干扰)
适用场景极致延迟优先、流量稳定确定性优先、流量有突发

大多数交易系统选分核,因为确定性比极致延迟更重要。一个稳定的 10μs 比一个平均 5μs 但偶尔飙到 500μs 的系统更可靠。

如果选分核方案,IRQ core 和应用进程 core 至少应该在**同一个 socket(即同一个 NUMA 节点)**内。c8g.metal-48xl 双 socket 架构下,同 socket 内的 SLC 访问约 25ns,跨 socket 则飙到约 139ns,差距巨大。

延伸阅读:关于 isolcpus、housekeeping 核规划、中断核与计算核分离的系统性方案,参见《低延迟系统的 CPU 核心规划:从 isolcpus 到 Busy Poll 的内核级实战》


九、实战配置:原理与调优 #

9.1 分网卡策略(以 6 ENI 为例) #

原理:同一张网卡的所有队列共享 RSS 引擎、DMA 控制器、PCIe 接口和固件调度逻辑(见 3.4 节)。正常流量下这些共享资源不是瓶颈,但微突发时一个业务的流量风暴会挤占共享资源,连带影响同卡上的其他业务。将不同业务分到不同物理网卡,把共享资源变为独占资源,消除硬件层面的交叉影响。

分配原则:中断量最大的业务独占一张卡;延迟最敏感的业务独占一张卡;发送为主的业务(如信号转发)独立出去,避免 TX 中断干扰 RX 收包;管理流量(SSH、监控)走单独的卡,防止运维操作影响交易路径。

enP11p4s0 → BN 行情(中断量最大,独占一张卡)
enP11p8s0 → OKX 行情
enP11p5s0 → GATE 行情 + 成交回报(延迟最敏感)
enP11p6s0 → 信号转发(UDP,发送为主)
enP11p7s0 → 管理流量(SSH、监控)
enP11p9s0 → 备用

9.2 精简队列数 #

原理:RSS 按四元组哈希分流,同一条连接的包始终落入同一个队列。如果 BN 行情只有 8 条 WebSocket 连接,那最多只有 8 个队列有流量(实际可能更少,因为不同连接的哈希值可能碰撞到同一个队列)。剩下的 24 个队列完全空闲,却各占一个 IRQ 编号,需要绑定 core、占用描述符内存。

减少队列数的好处:(1)IRQ 数量减少,绑核配置更简单;(2)每个队列分到的描述符更多(Ring Buffer 总大小不变时),单队列抗突发能力更强;(3)RETA 条目集中到更少的队列,哈希分布更均匀。

# 把队列数从 32 降到 4
ethtool -L enP11p4s0 combined 4

9.3 停用 irqbalance,手动绑定 IRQ #

原理irqbalance 是 Linux 的中断负载均衡守护进程,它周期性地(默认每 10 秒)检查各核的中断负载,将 IRQ 从繁忙的核迁移到空闲的核。对通用服务器这是合理的,但对低延迟系统有三个致命问题:

  1. Cache 冷启动:IRQ 从 CPU A 迁移到 CPU B 后,CPU B 的 L1/L2 cache 里没有 NAPI 结构体、skb 内存池、socket 哈希表等热数据,前几次中断处理需要从 L3 甚至内存重新加载,延迟突增几百纳秒到几微秒。
  2. 不可预测性:迁移时机取决于 irqbalance 的采样周期和算法决策,你无法预知 IRQ 什么时候会漂移。在行情微突发的关键时刻发生迁移,会叠加出极端延迟。
  3. 违反 NUMA 亲和性:irqbalance 可能把 IRQ 迁移到跨 NUMA 节点的核上,引入跨 socket 访问延迟(~139ns)。

手动绑定通过 smp_affinity_list 将 IRQ 固定到指定核,确保处理路径永远"热"在同一个核的 cache 中。

# 停止 irqbalance
systemctl stop irqbalance
systemctl disable irqbalance

# 将 enP11p4s0 的 Queue 0-3 绑定到 CPU 0-3
for i in $(seq 0 3); do
  echo $i > /proc/irq/$((132 + i))/smp_affinity_list
done

# 将 enP11p8s0 的队列绑定到 CPU 4-7
# (IRQ 编号需根据实际 /proc/interrupts 查询)

9.4 收包核与计算核分离 #

原理:硬中断是抢占式的——无论计算核当前在做什么(策略计算、风控逻辑),中断来了必须立即响应。一次硬中断 + softirq 处理耗时几微秒到几十微秒,微突发时可能连续打断几百微秒。将 IRQ 处理和应用进程绑到不同核,计算核上永远不会有中断打断,执行时间完全可预测。

代价是跨核数据传递的额外延迟(同 socket 内约 25ns,见 8.2 节),但对大多数交易系统来说,确定性比极致延迟更重要

# 应用进程绑到计算核(避免被 IRQ 打断)
taskset -c 64-95 ./binance-md
taskset -c 96-127 ./okx-md

# 确保计算核不处理任何 IRQ
# 通过上面的 smp_affinity_list 已经实现:
# IRQ 只在低编号 core,应用只在高编号 core

9.5 关闭增加延迟的内核特性 #

收包路径上有多个默认开启的内核特性,它们为通用场景优化吞吐量,但每一个都在延迟上做了不利于 HFT 的取舍。

9.5.1 关闭 GRO(Generic Receive Offload) #

原理:GRO 在 softirq 阶段(NAPI poll 之后、协议栈之前)拦截属于同一条 TCP 流的多个小包,将它们合并成一个大的"super-skb"再送入协议栈。这样协议栈只处理一个包而不是多个,减少了 per-packet 的头解析、Netfilter 遍历、socket 查找等开销,对大流量下载/流媒体等场景能显著提升吞吐量。

为什么 HFT 要关闭:GRO 的合并需要"等"——收到第一个包后不立即上送,而是暂存起来看后续有没有同流的包可以合并。这个等待窗口虽然很短(通常在同一次 NAPI poll 内),但对行情数据来说,每条消息都是独立的、需要立即处理的事件。GRO 把第一条行情消息扣住等第二条来合并,白白增加了第一条的处理延迟。

ethtool -K enP11p4s0 gro off

9.5.2 关闭 conntrack(连接跟踪) #

原理nf_conntrack 是 Netfilter 的连接跟踪模块,它为每个经过的连接维护一个状态机(NEW → ESTABLISHED → RELATED → …),存储在一个全局哈希表中。每个包在进入协议栈时都要执行:哈希计算 → 查表 → 匹配连接 → 更新状态 → 引用计数操作。这是有状态防火墙(如 -m state --state ESTABLISHED -j ACCEPT)和 NAT 的基础。

为什么 HFT 要关闭:每个包的 conntrack 查找和更新大约增加 100-500ns 的开销(取决于哈希表大小和 cache 命中率)。交易系统不需要有状态防火墙和 NAT——行情连接是简单的长连接,安全策略通过 VPC Security Group 在网络层实现,不需要主机级的状态跟踪。

rmmod nf_conntrack

9.5.3 清空 iptables 规则 #

原理:Linux 内核的 Netfilter 框架在收包路径上注册了多个 hook 点(PREROUTING、INPUT 等)。即使 iptables 规则为空(只有默认 ACCEPT 策略),每个包仍然要遍历 hook 链表、执行函数调用、检查规则。每个 hook 点的遍历开销约 50-100ns

为什么 HFT 要关闭:卸载 nf_conntrack 并清空 iptables 后,Netfilter 的 hook 注册被移除,内核在收包路径上直接跳过整个 Netfilter 子系统,相当于砍掉了协议栈中一段不必要的处理路径。

iptables -F

9.5.4 关闭中断合并(Interrupt Coalescing) #

原理:网卡默认开启中断合并(Interrupt Coalescing),即收到包后不立即触发中断,而是等待一段时间(rx-usecs)或累积一定数量的包(rx-frames)后才触发一次中断。自适应模式(adaptive-rx on)会根据当前流量动态调整这两个阈值——流量大时多攒、流量小时少攒。

这样做的目的是减少中断次数、提升吞吐量。但代价是每个包都要额外等待——第一个到达的包可能等了几十微秒才触发中断开始处理。

为什么 HFT 要关闭adaptive-rx off rx-usecs 0 rx-frames 1 的含义是:关闭自适应、不等待任何时间、每收到 1 个包就立即触发中断。每条行情到达后零延迟进入处理流程。代价是中断频率更高,但在行情 PPS(通常几千到几万)的量级下,CPU 完全承受得住。

ethtool -C enP11p4s0 adaptive-rx off rx-usecs 0 rx-frames 1

9.6 本文未展开的关键调优手段 #

以下调优手段与本文主题密切相关,但涉及更深的内核机制,在系列的其他文章中有详细展开。此处给出原理概述和调优方向。

9.6.1 Busy Poll:跳过中断,用户态直接收包 #

原理:常规收包路径是"中断驱动"的——网卡触发中断 → softirq 处理 → 数据放入 socket buffer → 唤醒睡眠的用户进程。Busy Poll 改变了这个模型:用户线程调用 recv() / epoll_wait() 时,不睡眠等待唤醒,而是在内核态直接调用网卡驱动的 NAPI poll 函数,主动从 Ring Buffer 取包、跑协议栈、放入 socket buffer,然后立即返回数据。

整条路径从"中断 → softirq → 唤醒 → 系统调用 → 拷贝"缩短为"系统调用 → 直接 poll → 拷贝",省掉了中断触发、softirq 调度和进程唤醒三个环节,延迟可从 5-10μs 降到 1-2μs。

代价是用户线程在没有数据时会在内核态空转(spin),持续消耗 CPU。对行情处理线程来说这通常是值得的——反正这些核也不会跑别的任务。

# 全局启用:epoll_wait / recv 内核态轮询 50μs
sysctl -w net.core.busy_poll=50
sysctl -w net.core.busy_read=50

# 或单个 socket 设置
setsockopt(fd, SOL_SOCKET, SO_BUSY_POLL, &val, sizeof(val));

深入阅读:Busy Poll 的内核实现细节(sk_busy_loop() 函数路径、与 NAPI 的交互、与用户态 busy loop 的本质区别),参见《低延迟系统的 CPU 核心规划:从 isolcpus 到 Busy Poll 的内核级实战》 第五节。

9.6.2 CPU 隔离:isolcpus / nohz_full / rcu_nocbs #

原理:即使将 IRQ 和应用进程分到了不同核,计算核上仍然会被以下内核活动打断:

  • 调度器负载均衡:内核每隔几毫秒检查各核负载,可能把其他核上的任务迁移过来。isolcpus 将指定核从调度域中移除,阻止任何任务被自动迁移到这些核上。
  • 定时器中断(tick):默认每个核每秒产生 250-1000 次时钟中断(HZ=250/1000),用于时间片轮转和定时器到期检查。nohz_full 在核上只有一个 runnable 任务时关闭 tick,消除周期性打断。
  • RCU 回调:Read-Copy-Update 机制的回调函数默认在所有核上执行,可能在任意时刻打断用户线程。rcu_nocbs 将指定核的 RCU 回调卸载到专门的内核线程上,由 housekeeping 核处理。

三者配合使用,将计算核变成"几乎只跑用户代码"的干净环境。

# 内核启动参数(/etc/default/grub)
isolcpus=managed_irq,domain,6-95
nohz_full=6-95
rcu_nocbs=6-95

深入阅读:isolcpus 的 managed_irqdomain 标志的区别、Housekeeping 核的规划、192 核系统的完整分配方案,参见《低延迟系统的 CPU 核心规划》 第一、二、六节。

9.6.3 C-state / P-state 锁定:消除 CPU 唤醒延迟 #

原理:CPU 空闲时会进入低功耗状态(C-state)以省电。C-state 越深,功耗越低,但唤醒延迟越大

C-state典型唤醒延迟状态
C00正在执行指令
C1~1μs时钟门控,核心暂停
C610-100μs核心断电,需要重新上电和恢复状态

当一个处于 C6 状态的核突然收到中断,它需要 10-100μs 才能醒过来开始处理。这个唤醒延迟会叠加到包处理延迟上,且完全不可预测(取决于核在中断到达时碰巧处于哪个 C-state)。

processor.max_cstate=0 禁止 CPU 进入任何睡眠状态,idle=poll 让空闲核在 idle 循环中 spin 而不是调用 mwait 进入低功耗。代价是功耗增加,但在裸金属实例上电费不是瓶颈。

# 内核启动参数
processor.max_cstate=0 idle=poll

深入阅读:C-state 与完整的内核启动参数方案,参见《低延迟系统的 CPU 核心规划》 第 6.4 节。

9.6.4 硬件时间戳:纳秒级延迟测量 #

原理:排查延迟问题时,时间戳的精度决定了你能看到多细的粒度。Linux 提供三个层级的时间戳:

层级打戳位置精度开销
应用层时间戳gettimeofday() / clock_gettime()微秒级最低
内核软件时间戳softirq 处理包时内核打戳微秒级
网卡硬件时间戳网卡 MAC 层收到包的瞬间由硬件打戳纳秒级零(硬件完成)

硬件时间戳在包进入网卡的瞬间就被记录,不受中断延迟、softirq 排队、协议栈处理的影响。通过对比硬件时间戳和应用层时间戳,可以精确拆分出"网卡到应用"的每一段延迟。

// 开启硬件时间戳(需要网卡支持)
int flags = SOF_TIMESTAMPING_RX_HARDWARE | SOF_TIMESTAMPING_RAW_HARDWARE;
setsockopt(fd, SOL_SOCKET, SO_TIMESTAMPING, &flags, sizeof(flags));

// 通过 recvmsg() 的 cmsg 读取时间戳
struct msghdr msg = { ... };
recvmsg(fd, &msg, 0);
// 遍历 msg.msg_control 中的 SCM_TIMESTAMPING 消息
# 检查网卡是否支持硬件时间戳
ethtool -T enP11p4s0

延伸阅读:关于收包路径各阶段的完整时序和延迟分布,参见《从网线到策略引擎:Linux 网络收包全路径深度解析》

9.6.5 内核旁路:终极方案 #

原理:以上所有调优都是在 Linux 内核协议栈的框架内优化。内核旁路(Kernel Bypass)则跳出这个框架——用户态程序直接接管网卡,跳过整个内核收包路径:

常规路径:NIC → DMA → Ring Buffer → 硬中断 → softirq → IP → TCP → socket → recv()
           约 5-25μs

旁路路径:NIC → DMA → 用户态 Ring Buffer → 应用直接读取
           约 1-3μs

省掉了中断、softirq、整个协议栈和内核-用户态拷贝。延迟降低一个数量级,且完全消除了内核侧的不确定性。

主流方案:DPDK(完全接管网卡的用户态驱动框架)、AF_XDP(Linux 5.4+,利用 XDP 钩子将包零拷贝送到用户态)、Solarflare OpenOnload / ef_vi(硬件级用户态协议栈)。AWS ENA 网卡对旁路的支持有限,如果使用自建服务器 + Solarflare/Mellanox 网卡,旁路是 HFT 的标准选择。

深入阅读:内核旁路的架构对比和实战部署,参见《从网线到策略引擎》 第 10.6 节。

9.7 确认配置的关键命令 #

# 网卡驱动信息
ethtool -i enP11p4s0

# Ring Buffer 大小
ethtool -g enP11p4s0

# 队列数量
ethtool -l enP11p4s0

# 各队列丢包统计
ethtool -S enP11p4s0 | grep -i drop

# IRQ 亲和性
cat /proc/irq/132/smp_affinity_list

# 硬件时间戳支持
ethtool -T enP11p4s0

# 实例类型(EC2 IMDSv2)
TOKEN=$(curl -s -X PUT "http://169.254.169.254/latest/api/token" \
  -H "X-aws-ec2-metadata-token-ttl-seconds: 60")
curl -s -H "X-aws-ec2-metadata-token: $TOKEN" \
  http://169.254.169.254/latest/meta-data/instance-type

十、总结 #

回到最初的问题:同一条专线、同一秒内,为什么 BN 延迟 80-248ms 而 GATE 只有 20ms?

排除了专线本身的问题后,根因在 TY 端到 BN 的独有路径上——中间链路设备的 Buffer Bloat 导致了上百毫秒的排队延迟。本机 softirq 排队在微突发时最多造成几毫秒的延迟,解释不了这个量级的异常。

但这次排查揭示了一个更深层的架构问题:当多个业务共享同一张网卡和同一组 CPU 核时,微突发带来的 Buffer Bloat 和 softirq 竞争会在业务之间传导延迟。 即使本次根因在网络路径上,本机侧的隔离优化也是必要的预防措施。

解决方案的核心思想是三层隔离:

  1. 网卡隔离(逻辑隔离 → 物理隔离)——不同业务走不同物理网卡,Ring Buffer、DMA、IRQ 全部独立,硬件层面互不干扰。
  2. IRQ 核组隔离——每张网卡的 IRQ 绑定到专属 CPU 核组,softirq 处理互不抢占。精简队列数以匹配实际连接数,避免资源浪费。
  3. 收包核与计算核分离——IRQ 和 softirq 在收包核上处理,应用进程在计算核上运行,中断不打断策略计算,换取执行时间的确定性。

更新记录 #

日期变更
2026-04-10扩展 3.4 节"共享资源的边界",从一句话展开为完整的 NIC On-Chip Buffer 讲解:包括包从 PHY/MAC 到 DMA 之间在网卡片上 SRAM 暂存的完整流程图、SRAM 容量与共享特性、共享资源全景图,并阐明这是第七节"分网卡做物理隔离"的硬件层面根因

在 192 核、6 张 ENA 网卡的 c8g.metal-48xl 上,硬件资源是充裕的。瓶颈从来不是绝对性能,而是资源分配的合理性。