site logo

Marico's space

使用 Rust 和 构建低延迟 Webhook 入口网关

Others 2026-11-11 20:56:26 9

最近折腾了一下 Rust 搭配 io_uring 的低延迟 Webhook 入口网关,踩了几个坑,这篇把问题说清楚:传统 epoll 模式的耗时到底在哪里,io_uring 能省掉什么,零拷贝接收又需要哪些硬件和内核条件。

Webhook 承载着现代集成系统里的各种事件:支付通知、CI/CD 触发器、聊天平台回调和设备遥测。入口网关通常挡在这些服务前面,需要承受小规模 HTTP(超文本传输协议)请求的突发流量,完成身份认证,再把请求转交给下游队列,同时不能自己变成瓶颈。

在请求速率很高时,跨越操作系统边界的成本会在单请求预算中占据很大比例。本文会拆解 Linux io_uring 和 Rust 如何降低这部分开销,零拷贝接收真正需要哪些条件,以及如何围绕它们搭一个网关骨架。同时也会说明,这项技术在哪些场景下值得用,哪些场景下并不划算。

1. 传统 Webhook 入口的时间都花在哪里

传统的就绪通知型服务器,比如基于 epoll 构建的服务,处理一个进来的请求大致会经历下面几步:

  1. DMA(直接内存访问)和中断处理:网卡通过 DMA 把数据包写入内核持有的接收缓冲区,并向 CPU 发出信号。随后内核网络栈开始处理,通常是在软中断中完成。
  2. TCP(传输控制协议)处理:内核校验报文段,执行 TCP 状态机,并把有效载荷放进 socket 的接收队列。
  3. 就绪通知:阻塞在 epoll_wait() 中的线程被唤醒,因为此时 socket 已经可读。
  4. 拷贝到用户空间:应用调用 read() 或 recv(),内核把有效载荷从 socket 缓冲区拷贝到用户空间缓冲区。

这些系统调用都会跨越用户态和内核态边界,而有效载荷进入应用时还会经历一次拷贝。Spectre 和 Meltdown 漏洞之后引入的 CPU(中央处理器)安全缓解措施,在部分硬件上会进一步提高这些状态切换的成本。具体开销受 CPU 型号、内核版本和缓解配置影响很大,别直接照搬某个固定数字,应该在自己的机器上实测。

对于处理海量小请求的网关来说,单请求的系统调用开销和拷贝次数会不断累积。业务逻辑往往反而不是主要成本。

2. io_uring 改变了什么

io_uring 在 Linux 5.1 中引入。应用和内核不再为每个操作单独发起一次系统调用,而是在内存中共享两个环形缓冲区:

  • 提交队列(Submission Queue,SQ):应用写入提交队列项(Submission Queue Entry,SQE),描述 accept、recv 或 send 等操作。
  • 完成队列(Completion Queue,CQ):操作完成后,由内核写入完成队列项(Completion Queue Entry,CQE)。

应用可以一次排队多个操作,再通过一次 io_uring_enter 调用统一提交。这个调用也可以等待完成事件,因此相比“等待就绪再读取”的循环,系统调用次数会明显下降。

Application (user space) writes SQEs ──► [ Submission Queue ] ──┐ │ shared memory rings reads CQEs ◄── [ Completion Queue ] ◄─┘ │
Kernel: io_uring subsystem executes the operations
Enter fullscreen mode Exit fullscreen mode

入口网关真正用得上的能力

  • 多次接收(Multishot receive):从 Linux 6.0 开始,一个接收请求可以随着数据不断到达持续产生完成事件,不必每次读取都重新提交 SQE。多次接收使用预置缓冲区:先注册一个缓冲区组,内核每次完成接收时都会从组里选择一个缓冲区。
  • IORING_SETUP_SINGLE_ISSUER:声明只有一个线程会向环形缓冲区提交请求,内核因此可以跳过部分同步操作。
  • IORING_SETUP_DEFER_TASKRUN:把完成处理推迟到应用显式进入内核等待事件时再执行。liburing 文档建议将它作为“一线程一环形缓冲区”这类新应用的默认配置,同时它依赖 SINGLE_ISSUER。
  • 零拷贝接收(IORING_OP_RECV_ZC):在 Linux 6.15 中合入,后面会详细说明。

3. 零拷贝接收是什么,又需要什么

io_uring 的零拷贝接收(ZC Rx)去掉了接收路径上的内核到用户态拷贝,数据包有效载荷直接进入用户空间内存。数据包头部仍由内核 TCP 栈按常规流程处理,因此它不像 DPDK(数据平面开发套件)这类内核旁路框架,依然使用内核现成的 TCP 实现。

这不是打开一个开关就能生效的功能。按照内核文档,它依赖下面所有条件:

  • Linux 6.15 或更高版本。该特性在这个版本中合入。
  • 网卡硬件特性。网卡必须支持头部和数据分离,让头部进入内核内存、有效载荷进入用户内存。同时还要支持流导向和 RSS(接收方缩放),保证只有选定的流量进入零拷贝专用硬件队列,其他流量全部避开它。最初合入时,内核不会替你配置网卡,需要在系统外部完成。规划方案前,先检查网卡和驱动的文档。
  • 特定的环形缓冲区初始化标志。创建环形缓冲区时,必须设置 IORING_SETUP_SINGLE_ISSUER、IORING_SETUP_DEFER_TASKRUN,并启用大尺寸完成项,也就是 IORING_SETUP_CQE32,或者在较新内核中使用 IORING_SETUP_CQE_MIXED。
  • 已注册内存和补充环形缓冲区。注册一块用户空间内存区域,并把它绑定到指定硬件接收队列。处理完成后,通过共享的补充队列把缓冲区还给内核。缓冲区生命周期由 io_uring 管理,其规则和普通网络缓冲区不同。

按照内核文档给出的示例,网卡配置大致如下:

# Make sure there are at least two hardware queues
sudo ethtool -L eth0 combined 2 # Enable header/data split
sudo ethtool -G eth0 tcp-data-split on # Reserve half of the queues for zero-copy using RSS
sudo ethtool -X eth0 equal 1 # Steer the flows you want into the zero-copy queue (queues are 0-indexed)
sudo ethtool -N eth0 flow-type tcp4 dst-port 8443 action 1
Enter fullscreen mode Exit fullscreen mode

网卡名称、队列数量和流规则都要按实际硬件调整。

别对收益有不切实际的预期

零拷贝接收节省的是每个有效载荷的一次内存拷贝。Webhook 载荷通常很小,往往只有几百字节到几 KB,因此拷贝本身一般只占成本的一小部分。对于小请求来说,io_uring 的大部分收益来自更少的系统调用、批量提交和多次接收,而不是零拷贝。只有当载荷大小和带宽继续增长时,零拷贝接收的价值才会明显上升。建议先使用可移植的特性,只有性能分析证明拷贝确实占比明显,而且硬件也满足要求时,再引入 ZC Rx。

4. Rust 入口引擎怎么设计

tokio-rs 组织维护的 io-uring crate,是 Rust 对 io_uring 接口的低层绑定。tokio-uring 等上层 crate 则在其基础上实现了兼容 Tokio(异步运行时)的运行时。tokio-uring 运行的是当前线程运行时,其文档建议通过启动多个线程、每个线程各自持有一个运行时来实现并发。下面这套架构正是基于这个思路。

架构蓝图

  1. 每个 CPU 核心一个线程。每个核心运行一个工作线程,每个线程拥有自己的环形缓冲区,并通过 core_affinity 之类的 crate 绑定 CPU 核心。这样每个线程的数据都能留在自己的缓存中,而且因为只有一个线程访问对应环形缓冲区,SINGLE_ISSUER 也能自然成立。
  2. 分发监听套接字。每个工作线程创建自己的监听套接字,并通过 SO_REUSEPORT 绑定,内核便可将新连接分发到不同工作线程,不需要共享 accept 队列。
  3. 预置缓冲区与多次接收。为每个环形缓冲区注册一个缓冲区组,再使用多次接收,让一个 SQE 持续产生完成事件。
  4. 批量提交。一次排队多个 SQE 并统一提交,而不是每个操作都调用一次内核。
  5. 限制热路径工作量。I/O 线程只做解析、认证和入队,把耗时更长的任务交给其他线程。

5. 实现草图

下面的代码片段展示核心引擎的大致结构,只能用于说明思路,还达不到生产可用标准。务必固定 crate 版本,检查 docs.rs 上最新的 io-uring API(应用程序接口),并在启动时运行操作码探测,确认当前内核支持所使用的功能。

Cargo 依赖

[dependencies]
io-uring = "0.7"
libc = "0.2"
core_affinity = "0.8"
ring = "0.17"
Enter fullscreen mode Exit fullscreen mode

创建环形缓冲区

use io_uring::IoUring;
use std::io; pub fn create_ingress_ring(entries: u32) -> io::Result<IoUring> { // One ring per worker thread. SINGLE_ISSUER declares that only this // thread submits; DEFER_TASKRUN requires it and defers completion work // until this thread enters the kernel to wait for events. IoUring::builder() .setup_single_issuer() .setup_defer_taskrun() .build(entries)
}
Enter fullscreen mode Exit fullscreen mode

提交多次接收请求

多次接收会从已注册的缓冲区组里选择缓冲区。必须先向内核提供这个组,可以使用 ProvideBuffers,也可以注册一个通过缓冲区环形队列 API 映射到环形缓冲区的缓冲区组。

use io_uring::{opcode, types, IoUring};
use std::io;
use std::os::unix::io::RawFd; pub fn submit_multishot_recv( ring: & mut IoUring, fd: RawFd, buf_group: u16, user_data: u64,
) -> io::Result<()> { // One SQE; the kernel keeps posting CQEs as data arrives, picking a // buffer from `buf_group` each time. Multishot recv needs Linux 6.0+. let sqe = opcode::RecvMulti::new(types::Fd(fd), buf_group) .build() .user_data(user_data); // Safety: the fd and buffer group must stay valid while the operation is in flight. unsafe { ring.submission() .push(&sqe) .map_err(|_| io::Error::new(io::ErrorKind::Other, "submission queue full"))?; } Ok(())
}
Enter fullscreen mode Exit fullscreen mode

多次接收请求结束时,最后一个 CQE 不会设置 IORING_CQE_F_MORE 标志。需要检查这个标志,一旦消失,就重新挂载接收请求。缓冲区组耗尽时就会发生这种情况。处理完数据后,要把缓冲区归还给缓冲区组。

如果还要进一步使用零拷贝接收,就需要注册接口队列、内存区域,以及第 3 节介绍的补充环形缓冲区。内核文档使用 C 语言版 liburing API 展示这个过程。真正落地前,先确认 Rust 绑定的当前状态;如果 crate 尚未覆盖相关能力,可以考虑调用 liburing,或者直接使用原始注册接口。

6. 安全与背压

Webhook 网关必须验证载荷身份,同时在高压流量下保护自身。

签名验证

很多服务提供方会使用 HMAC-SHA256(带密钥的哈希消息认证码)对原始请求体进行签名。例如,GitHub 会在 X-Hub-Signature-256 请求头中发送签名,格式为 sha256= 加上一段十六进制编码的摘要。务必基于收到的原始字节计算 HMAC(带密钥的哈希消息认证码),必须先完成验证,再进行 JSON(JavaScript 对象表示法)解析或重新序列化,比较时使用恒定时间算法。

ring crate 的 hmac::verify 会使用恒定时间完成比较:

use ring::hmac; /// `payload` is the raw request body; `sig_hex` is the header value
/// with the `sha256=` prefix already stripped.
pub fn verify_webhook_signature(key: &hmac::Key, payload: &[u8], sig_hex: &[u8]) -> bool { // Decode hex into a fixed-size stack buffer (SHA-256 = 32 bytes). let mut tag = [0u8; 32]; if sig_hex.len() != 64 || hex_decode(sig_hex, &mut tag) .is_err() { return false; } hmac::verify(key, payload, &tag) .is_ok()
} fn hex_decode( src: &[u8], dst: & mut [u8; 32],
) -> Result<(), ()> { fn nibble(b: u8) -> Result<u8, ()> { match b { b'0'..=b'9' => Ok(b - '0'), b'a'..=b'f' => Ok(b - 'a' + 10), b'A'..=