HiHuo
首页
博客
手册
工具
关于
首页
博客
手册
工具
关于
  • 代理技术全栈手册

    • 代理技术全栈手册 - HiHuo
    • 原理篇

      • 第01章 代理是什么:正向 / 反向 / 透明 / 隧道的统一模型
      • 第02章 代理与网络层级:L3 / L4 / L5 / L7 在哪里截断流量
      • 第03章 一个请求穿过代理的一生:连接生命周期全景
    • 协议篇

      • 第04章 HTTP 代理协议:绝对 URI、CONNECT 隧道、转发头与连接池
      • 第05章 HTTPS 与 TLS 代理:终止 / 透传 / MITM / SNI / mTLS
      • 第06章 SOCKS 协议:SOCKS4/4a/5 与 UDP ASSOCIATE 报文级解析
      • 第07章 HTTP/2、gRPC 与 HTTP/3(QUIC) 代理的挑战
      • 第08章 代理自动配置:PAC / WPAD / 系统代理 / NO_PROXY
    • 层级与转发篇

      • 第09章 L4 代理:TCP/UDP 转发与连接级负载均衡
      • 第10章 L7 代理:协议感知与基于内容的路由
      • 第11章 透明代理:iptables REDIRECT/DNAT、TPROXY 与 eBPF 劫持
      • 第12章 数据搬运的艺术:splice / sendfile / 零拷贝 / io_uring
    • 组件横评篇

      • 第13章 Nginx / OpenResty:反向代理、upstream 与 Lua 可编程
      • 第14章 HAProxy:L4/L7、ACL、健康检查与 stick table
      • 第15章 Envoy:xDS 动态配置与 filter chain,为何是云原生数据面
      • 第16章 Traefik / Caddy:自动服务发现与自动 HTTPS
      • 第17章 Squid 与正向/缓存代理:企业出网、缓存与审计
      • 第18章 mitmproxy:抓包、改包、脚本化调试
      • 第19章 内网穿透与隧道:frp / gost / SSH 隧道 / ngrok
      • 第20章 科学上网生态的技术原理(技术中立)
    • 多语言手写篇

      • 第21章 Go:100 行手写 HTTP/CONNECT + SOCKS5 代理
      • 第22章 Rust:基于 tokio 的高性能 TCP 代理
      • 第23章 Python:asyncio 实现,适合调试与脚本
      • 第24章 C:epoll 裸写与零拷贝,及语言选型对比
    • 容器与K8s篇

      • 第25章 Docker 里的代理:HTTP_PROXY、build/pull 与 daemon 配置
      • 第26章 Sidecar 与流量劫持:Istio init-container 的 iptables 原理
      • 第27章 Ingress 与南北流量:Ingress-nginx 与 Gateway API
      • 第28章 Egress 与出网治理:出口网关、registry mirror、审计
      • 第29章 Service Mesh 数据面:Envoy Sidecar 全链路
    • 进阶篇

      • 第30章 可编程代理:Lua / Wasm / eBPF / xDS,代理的"软件定义"
      • 第31章 性能调优:并发模型、连接池、超时与重试、压测
      • 第32章 排错决策树:502 / 504 / 握手失败 / 环路 / 泄漏
      • 第33章 代理安全:开放代理、SSRF、凭证泄漏与攻击面
    • 底层机制篇

      • 第34章 代理的背压与流控:一个代理最难的部分
      • 第35章 socket 与 TCP 状态机:半关闭、超时、连接生命周期
      • 第36章 HTTP/2 帧、流控与 HPACK:h2 代理的内部机制
      • 第37章 负载均衡算法推导与韧性状态机
      • 第38章 Capstone:把玩具代理改造成生产级骨架
    • 综合实战篇

      • 第39章 企业多跳转发链:拓扑、协议矩阵与贯穿性难题
      • 第40章 端到端实战:把 6 类流量全代理通
      • 第41章 更刁钻的流量:gRPC、长轮询、WebRTC、大文件、双向流
      • 第42章 可落地完整参考实现:一套能跑的多协议转发栈
    • 附录

      • 附录 A:代理协议报文速查(HTTP / SOCKS / PAC / PROXY protocol)
      • 附录 B:组件选型决策树
      • 附录 C:抓包与命令速查

第12章 数据搬运的艺术:splice / sendfile / 零拷贝 / io_uring

学习目标

  • 看清代理的本质开销:字节在两个 socket 间搬运时,到底发生了几次拷贝、几次系统调用
  • 掌握零拷贝技术谱系:sendfile、splice、MSG_ZEROCOPY、io_uring、eBPF sockmap
  • 理解 splice 为何是 TCP 代理零拷贝的关键,以及 Go 的 io.Copy 如何自动用它
  • 想清楚一个反直觉的事实:L7 代理为什么享受不到零拷贝

前置知识

  • 第21章 Go 手写代理(io.Copy 自动 splice 的伏笔)
  • 第09章 L4 代理、系统手册 · DPDK 与用户态网络栈

原理

代理的本质开销:朴素搬运有多贵

一个 L4 代理的核心就是"把 socket A 的数据搬到 socket B"。最朴素的写法(read 进用户缓冲再 write 出去)代价惊人:

朴素 read()/write() 单个方向:

  网卡 ──DMA──▶ 内核 socket 接收缓冲
                      │  ① CPU 拷贝(copy_to_user)
                      ▼
                  用户态 buffer        ← read() 系统调用 #1
                      │  ② CPU 拷贝(copy_from_user)
                      ▼
              内核 socket 发送缓冲      ← write() 系统调用 #2
                      │
                  ──DMA──▶ 网卡

  代价:2 次 CPU 拷贝 + 2 次系统调用(用户/内核态切换)/ 每个方向
       代理是双向的,再 ×2

数据只是路过代理,根本不需要进用户态——这两次 CPU 拷贝和两次上下文切换纯属浪费。零拷贝技术就是来消除它们的。

零拷贝技术谱系

技术能做什么适用代理场景
sendfile(2)文件 → socket,省一次拷贝反向代理回源/发静态文件
splice(2)socket ↔ socket(经内核管道),数据不进用户态TCP 代理零拷贝的核心
tee(2)复制管道数据(不消费)流量镜像
MSG_ZEROCOPYsend() 时不拷贝用户缓冲大块发送
io_uring异步批量 IO,减少系统调用高并发代理新范式
eBPF sockmap/sk_msg内核里把 socket A 数据直接 redirect 到 socket B同机 sidecar 加速(Cilium)
DPDK / 用户态栈完全绕过内核栈,轮询收包极致性能、特种场景

splice:TCP 代理零拷贝的关键

splice 在两个文件描述符间移动数据,要求其中一端是管道(pipe)。socket→socket 因此需要一个内核管道做中转,但数据始终在内核空间,不进用户态:

splice 搬运 socket A → socket B:

  socket A ──splice──▶ [内核管道 pipe] ──splice──▶ socket B
                     全程在内核,0 次 CPU 拷贝,0 次 copy_to/from_user

  代理只是用系统调用"指挥"内核搬数据,自己碰不到字节

这就是 L4 代理高性能的秘密。Go 的 io.Copy(tcpDst, tcpSrc) 在 Linux 上会自动走 splice(TCPConn.ReadFrom → poll.Splice,Go 1.11+),所以 第21章 那行 io.Copy 不只是简洁,还顺带零拷贝——你白嫖了内核优化。

深一层:splice 为什么是"零拷贝"——页引用级真相

"数据不进用户态"还不够精确,真相在**页(page)**这一层:

  • Linux 的管道内部是一圈 struct pipe_buffer,每个指向一个内核内存页 struct page,而不是自己存字节。
  • splice 移动的是页的引用(refcount),不是拷贝页里的数据。socket 收到的数据本就在内核 sk_buff(skb)的页里:splice(socket→pipe) 把 skb 的页"挂"到 pipe_buffer(加引用),splice(pipe→socket) 再把页引用挂到对端发送队列——CPU 全程没碰过 payload 字节。
  skb 页 ─splice→ pipe_buffer 指向同一页 ─splice→ 发送 skb 指向同一页
         移动的是"指针 + refcount",不是 4KB 数据本身

这也解释了 splice 的前提与退化:

  • 数据必须已在内核页里(来自 DMA 的 skb / page cache)——所以 socket↔socket、file→socket 适用
  • skb 若非线性/分片,可能要先线性化(产生一次拷贝)
  • 小包下 per-splice 的系统调用 + 页管理固定开销占比高,未必比 read/write 划算(见本章排错表)

sendfile:文件 → socket 的零拷贝

sendfile 专为"把文件内容发到 socket"优化(如 Nginx 发静态文件、反向代理回传文件):

传统:read(file)→user buf→write(socket)   2 次拷贝
sendfile:file ──内核直送──▶ socket        省去用户态往返

注意 sendfile 的源端必须是文件(可 mmap),不能 socket→socket——那是 splice 的活。Nginx 的 sendfile on; 即用它。Kafka 的高吞吐也大量依赖 sendfile(见 Kafka 手册)。

io_uring:用异步批量摊薄系统调用

零拷贝省的是"拷贝",io_uring 省的是"系统调用次数"。传统每次 IO 一个 syscall,高并发下上下文切换成为瓶颈。io_uring 用一对内核/用户共享的环形队列(提交队列 SQ + 完成队列 CQ),批量提交、批量收割,甚至能配合零拷贝:

用户态:往 SQ 塞一批 IO 请求 ──┐
                              ├─ 一次 io_uring_enter 提交 N 个
内核:处理完写入 CQ ──────────┘
用户态:从 CQ 一次收割 N 个结果

新一代高性能代理/网络框架(部分 Rust 生态、实验性 Nginx 模块)正在拥抱 io_uring。注意它对内核版本敏感(5.1+,越新越好),且早期有过安全 CVE,生产需谨慎评估。

eBPF sockmap:同机两 socket 直连

当代理和后端在同一台机器(典型:sidecar 与业务容器),数据本可以不走完整 TCP/IP 栈。eBPF 的 sockmap/sk_msg 能在内核里把 socket A 收到的数据直接 redirect 到 socket B,短路掉本机回环的协议栈处理:

普通:业务容器 socket → 本机 TCP/IP 栈 → sidecar socket
sockmap:业务容器 socket ──eBPF 内核直转──▶ sidecar socket   (短路协议栈)

这是 Cilium 给 Service Mesh sidecar 加速的手段之一(第11章、网络手册 · eBPF 深度实践)。

反直觉:L7 代理为什么享受不到零拷贝

零拷贝的前提是数据只是路过、不需要看也不需要改。但 L7 代理的整个价值就在于解析和改写应用层内容(第10章)——它必须把字节读进用户态才能解析 HTTP、改 Header、做路由。

L4 透传:字节路过 → splice 零拷贝 ✅
L7 解析:字节必须进用户态解析/改写 → 无法零拷贝 ❌(这是 L7 慢的根因之一)

所以存在一个性能与能力的根本权衡(呼应 第02章):

  • 要零拷贝的极致性能 → L4 透传,但看不懂内容
  • 要 L7 的路由/改写能力 → 放弃零拷贝,数据必进用户态

折中:有些代理在"不需要改 body"时,对 body 部分用 splice(只把 header 读进用户态解析),body 零拷贝直通——Envoy/Nginx 在特定路径上有类似优化。


️ 实现 / 命令

实验一:strace 抓出 Go 代理在用 splice

# 跑第21章的 Go TCP 代理,过一笔流量,strace 看系统调用
strace -f -e trace=splice,read,write -p $(pgrep -f http_proxy) 2>&1 | head
# 你会看到大量 splice(...) 调用,而不是 read()/write() 搬运 body
# splice(5, NULL, 7, NULL, 65536, SPLICE_F_MOVE|SPLICE_F_NONBLOCK) = 65536

看到 splice 而非成对的 read/write,就证明零拷贝生效了。

实验二:对比 sendfile 开关对 Nginx 的影响

# 发静态文件,对比 sendfile on/off 的 CPU 与吞吐
location /static/ {
    sendfile on;          # 文件→socket 零拷贝
    tcp_nopush on;        # 配合 sendfile,攒满一个包再发
    root /data;
}
# 压测对比(sendfile on vs off),看 CPU sys% 差异
sendfile on:  吞吐更高、sys CPU 更低
sendfile off: 多了 read/write 往返,sys CPU 上升

实验三:观察零拷贝退化

# TLS 终止后,数据必须解密进用户态 → splice 失效,退回 read/write
# 验证:对一个做 TLS 终止的 nginx strace,看不到 body 的 splice
strace -f -e trace=splice,read,write -p $(pgrep nginx) 2>&1 | head
# TLS 终止路径上是 read/write(要解密),印证"看内容就没法零拷贝"

排错

现象根因解决
期望零拷贝但 CPU 仍高路径上有 TLS 终止/内容改写改写就没法零拷贝;能透传就透传
splice 不生效非 socket→socket、或平台不支持确认是 TCP↔TCP 透传场景
小包场景 splice 反而慢小包下 splice 固定开销占比高小包用普通 read/write 或合并
io_uring 崩溃/不可用内核太老或被安全策略禁用升级内核(5.10+),评估 CVE
sendfile 发的文件被改写后不更新页缓存/sendfile 与 mmap 一致性大文件谨慎,必要时关 sendfile
sidecar 本机转发延迟高走了完整回环协议栈用 eBPF sockmap 短路(Cilium)

本章小结

  • 朴素 read/write 搬运每方向要 2 次 CPU 拷贝 + 2 次系统调用,数据本不必进用户态。
  • splice 让 socket↔socket 在内核零拷贝,是 L4 代理高性能的核心;Go 的 io.Copy 自动用它。
  • sendfile 优化文件→socket;io_uring 摊薄系统调用;eBPF sockmap 短路同机两 socket。
  • L7 代理享受不到零拷贝——因为解析/改写内容必须把字节读进用户态,这是"能力 vs 性能"的根本权衡。

层级与转发篇至此完结——你已经从 L4/L7 的能力差异,一路深入到内核搬运字节的最底层。下一篇 第四篇·组件横评 进入实战:把 Nginx、HAProxy、Envoy、Squid、mitmproxy、frp、Clash 等主流代理逐一拆解,每个都给最小可跑配置。

Prev
第11章 透明代理:iptables REDIRECT/DNAT、TPROXY 与 eBPF 劫持