Switch-Router

一种网络报文Relay方案的设计与实现

Published at 2025-12-20 | Last Update 2025-12-20

互联网的单路径特性往往受到地理位置限制、网络拥塞和区域故障的影响。对于长距离报文传输, 易受公网波动的影响, 尤其对于像FPS游戏这类延迟和网络波动敏感的应用来说, 高延迟和高波动是影响游戏体验的元凶。

受到ECMP(Equal-Cost Multi-Path,等价多路径)思想的启发, 本文介绍一种网络报文Relay方案的设计与实现。

整个系统由控制中心 和 分布在全球的若干节点组成

控制中心 负责中心化控制所有节点, 为后者下发控制信息以及从其中收集必要的节点间的连接状态信息.

节点负责组成网络, 传递网络报文. 根据其所处的位置不同, 又可将其分类为 入口节点中继节点出口节点

一个典型的应用过程如下:

    1. 客户端连接区域控制中心获取节点列表以及节点间的关系(拓扑和延迟信息)
    1. 客户端选择入口节点出口节点
    1. 客户端自身规划多条始于入口节点, 终于出口节点的路径.
    1. 客户端连接入口节点的控制平面, 向其发送路径信息
    1. 入口节点收到路径信息后, 分配资源, 并将路径信息向后继节点传递, 后继节点重复该步骤
    1. 客户端将真正的应用数据封装后, 发送到入口节点.
    1. 入口节点将数据分多条路径重复向后继节点传递(重复冗余发送), 最终多条路径的数据汇聚于出口节点
    1. 出口节点始终仅将单份数据发送到原始的目的地址

相当于整个网络充当了一个代理, 最终目的地址看到的报文来源不是原始客户端地址, 而是最终目的地址.

节点整体设计

注: 本文只包括节点自身的设计与实现, 而控制中心客户端的相关实现不在本文探讨范围.

节点采用转发控制平面隔离的方式运行, 控制线程数量:转发线程数量 = 1:N, 转发平面支持水平扩展.

转发平面的设计

转发平面的目标是高效易扩展. 本文使用了以下几种技术方案: vectorization (向量化) + pipeline graph(流水线图) + AFXDP

vectorization (向量化)

转发平面遵循极致向量化的思想, 仅使用利用下标索引的数组, 而不使用链表.

以存放报文的内存为例: 为了避免频繁内存申请和释放, 程序预先为每个转发线程分配一定数量(如256个)个内存连续且大小固定(如4KB)的缓冲区, 用于存放报文内容, 缓冲区通过下标索引.

与此同时, 程序创建同等数量但大小较小内存用于存放报文的元数据, 通过下标方式与上面的缓冲区一一对应, 用于指示报文的有效内存返回以及其他信息 (如关联的连接跟踪信息)

pipeline graph(流水线图)

为了最大限度利用 CPU 的本地处理特性, 程序将转发平面功能切分成多个 node, 这些 node 通过逻辑上的箭头组织成一个有向图, 报文就在这幅图中加工和流转。

以单网卡(命名为 eth0)为例, 只考虑最基本的功能, 转发线程将构建如下样式的 pipeline graph

每个 node 维护了一个 bitmap, 长度与报文缓冲区数量一致(256个), 某位设置为 1 则表示这个下标指示的报文正等待这个 node 处理, 当此 node 处理完这个报文, 会将下一个 node 的 bitmap 的对应位置设置为 1

转发线程每次循环都会依次遍历所有 node, 每个 node 会处理完自身待处理的所有报文, 这样批量处理利用 CPU 的局部性原理, 并且每个 node 的功能可以更加专注, 比如这里ip-input就只完成 IP 报文的解析, 解析出上层协议后, 再将报文投递到 next node

node 的向性可以保证一个报文可以在一次循环中完成处理, 一个报文在 pipeline 的流转过程中, 无需与其他转发线程进行同步, 也就是不纯在加锁解锁之类的操作

AFXDP

AF_XDP 是 Linux 内核引入的一种高性能网络套接字地址族。它作为 XDP(eXpress Data Path)技术的用户空间接口,允许应用程序绕过内核网络协议栈,直接与网卡驱动层交互,从而实现极高的网络数据包处理性能。

本方案中, 需要为系统保留一段端口范围为本程序专用, 再加载一个 eBPF 程序将需要的网络报文截获到本进程的 AFXDP socket, 其余报文则还是上送内核协议栈.

eBPF 程序判断的依据是收到报文的 L4 层端口是否是保留端口. 这种做法可以使得本程序与系统中的其他程序共用同一个网卡。

本方案采用 AFXDP 的还有一个重要原因是希望维持数据流的【无状态】性. 传统的 TCP 代理与最终服务器是【有状态】, 当最终服务器关闭时, 由于代理的存在, 客户端并不能感知.

而本方案中, 发送的报文(无论是TCP/UDP/ICMP)不需要建立 socket 就可以向客户端/其他节点/最终服务器发送数据, 这样一来客户端可以对最终服务器进行感知.

控制平面的设计

控制平面需要完成的工作:

    1. 响应控制中心下发的控制信息, 例如组网拓扑信息。
    1. 维持与其他节点之间的拓扑组网关系, 定期测量与其他节点之间的网络连接状态(延迟与丢包率), 并上报给控制中心
    1. 响应客户端的连接请求, 为其分配连接资源, 并 relay 到后继节点.

1和2可以说的不多, 这里本文将详细介绍第3点中的连接资源. 它指的是一条路径上的所有节点, 为这条连接分配的连接跟踪记录.

在本方案中, 连接跟踪 记录由控制线程写入, 由转发线程进行读取. 为了转发线程使用时的效率考虑, 连接跟踪 同样采用向量化的设计方法.

每个转发线程预先创建连接跟踪数组, 初始化为空闲状态, 当入口节点收到客户端请求, 或是中继节点/出口节点收到 relay 请求时, 便会将各自的一条连接跟踪记录设置为使用中.

连接跟踪的结构大致如下:

 本节点 ct index           上游节点 ct index      下游节点 ct index         校验地址                   其他字段(如上下游地址等)
 
+----------------------+----------------------+----------------------+----------------------+--------------------------------------------+
|          0           |                      |                      |                      |                                            |
+----------------------+----------------------+----------------------+----------------------+--------------------------------------------+ 
|          1           |                      |                      |                      |                                            |
+----------------------+----------------------+----------------------+----------------------+--------------------------------------------+
|         ...          |                      |                      |                      |                                            |
+----------------------+----------------------+----------------------+----------------------+--------------------------------------------+
|         idx          |                      |                      |                      |                                            |
+----------------------+----------------------+----------------------+----------------------+--------------------------------------------+
|         ...          |                      |                      |                      |                                            |
+----------------------+----------------------+----------------------+----------------------+--------------------------------------------+

其中本节点ct_index是作为下标索引到具体的连接跟踪记录. 上游节点ct index下游节点ct index则是对应的上下游节点的连接跟踪所有, 初始状态为空.

下面使用一个例子展示各个节点的连接跟踪记录是如何建立的: 假设用户的地址是 sip:sport 要访问的最终服务器地址是 dip:dport, 中间不使用多径, 并且途径3个节点

  1. 客户端向入口节点发送接入请求. 其中包含了途径节点的信息;
  2. 入口节点A分配一个连接跟踪记录, 由于其无上游节点, 因此设置为空, 下游节点的ct index还未确定, 也先保留为空, 将客户端地址填如校验地址
  3. 入口节点A将连接请求relay到中继节点B
  4. 中继节点B分配一个连接跟踪记录, 记录上游节点的ct_index,下游节点的ct index还未确定, 也先保留为空
  5. 中继节点B将连接请求relay到出口节点C
  6. 出口节点C分配一个连接跟踪记录, 记录上游节点的ct_index, 下游节点保持为空
  7. 出口节点C回复中继节点B自己建立的连接跟踪 ct_index, 将最终服务器端地址填如校验地址
  8. 中继节点B收到后填入下游节点的ct_index
  9. 中继节点B回复入口节点A自己建立的连接跟踪 ct_index 10.入口节点A收到后填入下游节点的ct_index 11.入口节点A回复客户端自身的ct_index

后续客户端发送报文时, 会带上入口节点A的连接跟踪索引idx_a, 而在节点间传递时, 也会带上下游节点的ct_index, 转发线程根据这个索引即可快速找到对应的记录.

PS. 出口节点还需要一张额外的映射表, 可以将出口Port映射到连接跟踪表的索引, 原因是从服务器返回的报文是不会携带ct_index的, 转发线程需要额外进行一次转换.