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:抓包与命令速查

第35章 socket 与 TCP 状态机:半关闭、超时、连接生命周期

学习目标

  • 理解代理必须同时管理两套独立 TCP 状态机,及它们如何耦合
  • 吃透半关闭:shutdown(SHUT_WR) vs close,及玩具代理为何会截断响应
  • 看懂 CLOSE_WAIT/TIME_WAIT 的来历,及它们如何导致连接泄漏与端口耗尽
  • 把超时分级(第31章)落到状态机的具体位置

前置知识

  • 第34章 背压、第21章 Go/第24章 C、第03章 生命周期
  • 网络手册 · TCP 与可靠传输

原理

代理 = 两套 TCP 状态机的协调者

一个代理连接其实是两条独立 TCP 连接(第01章),各有完整的状态机。代理的职责是把两套状态机的生命周期事件正确地传递:一端建连、关闭、半关闭、异常,要恰当地反映到另一端。做错了,就是截断、泄漏、或端口耗尽。

半关闭:玩具代理最容易错的地方

TCP 是全双工的,两个方向独立关闭。这对应两个系统调用:

shutdown(fd, SHUT_WR)  只关"写"方向 → 发 FIN,但仍能读   = "我说完了,但还听着"
shutdown(fd, SHUT_RD)  只关"读"方向
close(fd)              关整个 socket(双向)+ 释放 fd

为什么代理必须支持半关闭? 看一个真实场景(HTTP 上传、或任何"请求发完等响应"的协议):

客户端发完请求体 → shutdown(SHUT_WR) → 给代理发 FIN("我发完了")
                                       但客户端仍在等响应(读方向还开着)
代理收到客户端 FIN(客户端→源站方向 EOF)
   ✗ 错误:代理 close() 到源站的整条连接
        → 源站那边收到的是"双向都关了" → 源站还没发完的响应被截断!
   ✓ 正确:代理只 shutdown(SHUT_WR) 到源站(转发这个"发完了"的信号)
        → 源站继续发响应 → 经代理回到客户端 → 那个方向也 EOF 时才真正关闭

一句话:一个方向的 EOF 只应该关"对应方向的写",不能关掉整条连接。 否则你把另一个还在传输的方向腰斩了。

揭穿 第21章 的半关闭缺陷

第21章 的转发是这样:

func transfer(dst, src net.Conn) {
    defer dst.Close()      // ← 一个方向 EOF 就 Close 两端
    defer src.Close()
    io.Copy(dst, src)
}
// go transfer(dst, client); transfer(client, dst)

当 io.Copy(dst, src) 因为 src 发来 FIN 而返回时,defer 把 dst 和 src 双向 Close 了。对纯隧道(CONNECT,双向大致同时结束)通常无碍,但对半关闭语义的协议会截断另一方向。正确写法是传播半关闭:

func transfer(dst, src net.Conn) {
    io.Copy(dst, src)
    // src 读到了 EOF:只关 dst 的【写】方向,转发这个 FIN
    if c, ok := dst.(interface{ CloseWrite() error }); ok {
        c.CloseWrite()              // = shutdown(SHUT_WR),发 FIN,不动读方向
    }
}
// 两个方向各自跑;两个方向都 EOF 后,连接自然完全关闭

net.TCPConn.CloseWrite() 正是 shutdown(SHUT_WR)。生产级代理(含 第38章 capstone)都用半关闭传播,而非一刀 Close。

TCP 关闭状态机(代理视角)

四次挥手的状态转移,代理天天打交道:

主动关闭方(先发 FIN 的一方):
  ESTABLISHED ─发FIN→ FIN_WAIT_1 ─收ACK→ FIN_WAIT_2 ─收对方FIN→ TIME_WAIT ─2MSL→ CLOSED

被动关闭方:
  ESTABLISHED ─收FIN→ CLOSE_WAIT ─应用调close→ LAST_ACK ─收ACK→ CLOSED

两个状态是代理排错的高频主角:

CLOSE_WAIT = "收到了对方的 FIN,但我(应用)还没调用 close"。它停在这里,说明应用代码没正确关闭连接——这是连接/fd 泄漏的铁证。代理大量 CLOSE_WAIT 堆积 = 转发逻辑漏关了连接(呼应 第34章 的状态机要完整收尾)。

TIME_WAIT = 主动关闭方发完最后的 ACK 后等 2×MSL(防止旧报文串到新连接、确保对方收到 ACK)。问题在于:正向代理/做大量出向短连接的代理,会积累海量 TIME_WAIT,每个占一个本地端口(第31章 的端口耗尽)。这就是为什么代理偏爱连接池 + 长连接(少关少建,少 TIME_WAIT)。

超时分级落到状态机的哪里(呼应第31章)

第31章 列了超时种类,这里给它们在状态机上定位:

超时卡在哪个状态触发动作
connect timeout发了 SYN 等 SYN-ACK(SYN_SENT)放弃连接,快速失败(→ 502)
idle/keepalive timeoutESTABLISHED 上长期无数据主动关闭,回收僵尸连接
read/server timeoutESTABLISHED 等对端数据关闭,→ 504(第03章)

注意区分两种 keepalive:

  • TCP keepalive(SO_KEEPALIVE):内核级,默认 2 小时才探,太慢,且只能发现"对端死机",不能管业务超时
  • 应用层 idle timeout:代理自己计时,更短更可控——生产代理依赖应用层超时,TCP keepalive 仅作兜底

异常关闭:RST 与 SO_LINGER

  • RST(reset):异常终止,不走四次挥手,立即丢弃连接、对端收到"连接被重置"。代理在出错、超时、或要快速回收时可能发 RST。
  • SO_LINGER:控制 close 行为。l_onoff=1, l_linger=0 → close 立即发 RST 而非 FIN(abortive close),避免进入 TIME_WAIT,但会丢未发数据。某些高并发代理用它主动避免 TIME_WAIT 堆积,是把双刃剑。

连接的资源账(呼应第31章)

每条连接的成本:1 个 fd + 读缓冲 + 写缓冲(各可达数十 KB 到 MB)+ 应用层状态。所以代理的并发连接上限由 fd 上限(ulimit -n)和内存共同约束。连接池里的空闲连接处于 ESTABLISHED,但可能已被对端/中间设备静默断开(坏死连接),复用前需探活——否则复用一个死连接会失败重试。


️ 实现 / 命令

实验一:用 ss 看连接状态与定时器

# -o 显示定时器(TIME_WAIT 倒计时、keepalive、重传)
ss -tano
# State      ... Timer
# ESTAB      ... timer:(keepalive,118min,0)
# TIME-WAIT  ... timer:(timewait,52s,0)      ← 2MSL 倒计时
# CLOSE-WAIT ...                              ← 没有计时器 = 等应用 close(泄漏信号)

ss -tan state close-wait | wc -l    # CLOSE_WAIT 数量(持续增长 = 连接泄漏)
ss -tan state time-wait  | wc -l    # TIME_WAIT 数量(过多 = 端口压力)

实验二:复现半关闭截断

# 用一个"发完即半关闭、然后等响应"的客户端打代理
# nc -N 在发完 stdin 后会 shutdown(SHUT_WR)(发 FIN 但继续读)
printf 'GET /slow-response HTTP/1.0\r\n\r\n' | nc -N 代理 8080
# 玩具代理(双向 Close):收到客户端 FIN 就关了到源站的连接 → 响应被截断/不完整
# 修正代理(CloseWrite 传播):只转发 FIN,源站照常发完响应

实验三:观察 CLOSE_WAIT 泄漏

# 一个不正确关闭连接的代理,压一批请求后:
ss -tan state close-wait dst :后端端口 | wc -l
# 数字只增不减 = 代理收到后端 FIN 却没 close → fd 缓慢耗尽,最终 "too many open files"
lsof -p $(pgrep 代理) | grep -c CLOSE_WAIT

实验四:缓解 TIME_WAIT 端口耗尽

# 出向短连接多导致 TIME_WAIT 堆积、本地端口耗尽(第31章)
sysctl net.ipv4.tcp_tw_reuse=1                 # 允许复用 TIME_WAIT 端口(出向安全)
sysctl net.ipv4.ip_local_port_range="1024 65535"
# 根治:用连接池 + 长连接,从源头减少关闭/新建(少 TIME_WAIT)

排错

现象根因解决
响应被截断/不完整一个方向 EOF 就 close 了整条连接改用 CloseWrite()/shutdown(SHUT_WR) 传播半关闭
CLOSE_WAIT 持续增长、fd 耗尽收到 FIN 没 close(连接泄漏)转发收尾确保两端都正确关闭
TIME_WAIT 海量、端口耗尽出向短连接多连接池 + 长连接;tw_reuse;必要时 SO_LINGER
复用连接池连接偶发失败池里有坏死的 ESTABLISHED 连接复用前探活 / 设最大空闲时间
连不上后端但很久才报错没设 connect timeout(卡在 SYN_SENT)配 connect 超时快速失败
僵尸连接长期不释放只靠 TCP keepalive(2 小时)加应用层 idle timeout

本章小结

  • 代理协调两套独立 TCP 状态机,必须正确传递建连/关闭/半关闭/异常事件。
  • 半关闭是玩具代理头号坑:一个方向 EOF 只应 shutdown(SHUT_WR)(CloseWrite)传播 FIN,不能 close 整条连接,否则截断另一方向。
  • CLOSE_WAIT 堆积 = 连接泄漏(没 close);TIME_WAIT 堆积 = 出向短连接多(端口耗尽,靠连接池根治)。
  • 超时要落到状态机:connect(SYN_SENT)、idle(ESTABLISHED)、read(等数据→504);靠应用层超时而非 TCP keepalive。

下一章 第36章 HTTP/2 帧与流控、HPACK,把 第07章 的"多路复用很难"展开到帧、两级流控窗口、HPACK 上下文、h2↔h1 翻译的机制级。

Prev
第34章 代理的背压与流控:一个代理最难的部分
Next
第36章 HTTP/2 帧、流控与 HPACK:h2 代理的内部机制