主题
计算机网络:沿着一次请求,把协议各自负责的事分清
请求超时,可能是域名没解析出来、连接没建立、证书没通过,也可能是请求已经到达服务端,只是响应还没回来。把这些情况都叫“网络不好”,很难知道下一步该查哪里。
本文沿着“找到服务器 → 建立传输 → 保护数据 → 交换请求 → 分发流量”这条路,讲清 TCP、UDP、HTTP、TLS、DNS、WebSocket、CDN 和负载均衡。目标是能解释一个现象,并选对验证办法。协议规则以相应 RFC 为准;文中的程序实测来自 Go 1.27.2、Linux/amd64,只在本机回环接口运行。没有把回环测试写成公网性能结论。
一次 HTTPS 请求里,谁在解决什么问题
| 要解决的问题 | 主要机制 | 不负责什么 |
|---|---|---|
| 域名对应哪些地址或其他记录 | DNS | 不证明拿到地址的服务器值得信任 |
| 把数据交给目标主机 | IP | 不保证应用完整、有序地收到数据 |
| 连续字节可靠、有序地到达 | TCP | 不划分业务消息,不证明业务已提交 |
| 一条数据报交给对方端口 | UDP | 不提供重传、顺序和拥塞控制 |
| 对端身份、机密性、完整性 | TLS | 不保证已认证的服务没有业务漏洞 |
| 方法、状态码、字段、请求与响应 | HTTP | 不代替数据库事务 |
| 用 UDP 承载可靠多流传输和安全握手 | QUIC | 不等于“没有可靠性成本的 UDP” |
这些分工分别来自 TCP §2.2、UDP、TLS §1 和 HTTP 语义 §3。分层不是每次访问都重新走一遍:连接可能复用,DNS 和内容可能命中缓存。
正在绘制图示…
查看图源代码
flowchart TB
U["应用:访问 https://example.test/items"] --> D["DNS:获得候选地址"]
D --> H1["HTTP/1.1 或 HTTP/2"]
D --> H3["HTTP/3"]
H1 --> T["TLS:认证对端、保护记录"]
T --> TCP["TCP:有序字节流"]
H3 --> Q["QUIC:多流、重传、拥塞控制<br/>使用 TLS 1.3 握手"]
Q --> UDP["UDP 数据报"]
TCP --> IP["IP:寻址与转发"]
UDP --> IP
图里 DNS 是连接前可能发生的查询,不是承载 HTTP 的一层。HTTP/3 则使用 QUIC 集成的 TLS 握手和包保护,不能画成普通的“TLS 记录套在 UDP 上”。QUIC-TLS §4
TCP:可靠的是字节顺序,消息边界要自己定义
先看最容易写错的 Read
客户端依次写入 "go" 和 "网络",服务端并不能通过“调用两次 Read”还原两条消息。TCP 可以把多次写入中的字节一起交付,也可以把一次写入拆成多次读取。“粘包”“拆包”描述的是应用期待的边界与读取结果不同,不是 TCP 把数据弄坏了。TCP §2.2、§3.7
常用的应用层分帧办法有三种:
| 方法 | 例子 | 必须补上的约束 |
|---|---|---|
| 定长 | 每条记录固定 32 字节 | 版本扩展和变长字段需要另外安排 |
| 分隔符 | 每条记录以换行结束 | 内容里的换行如何转义;单条最大长度 |
| 长度前缀 | 先读 4 字节长度,再读消息体 | 字节序、长度上限、截断处理 |
下面采用第三种。长度表示字节数,所以 "网络" 是 6,而不是 2。io.ReadFull 会继续读到缓冲区填满,或者遇到错误;普通 Read 没有填满保证。Go io.ReadFull
go
var h [4]byte
if _, err := io.ReadFull(r, h[:]); err != nil { return "", err }
n := binary.BigEndian.Uint32(h[:])
if n > maxFrame { return "", fmt.Errorf("frame too large: %d", n) }
b := make([]byte, int(n))
_, err := io.ReadFull(r, b)
return string(b), err验证时,把两条消息串在一个缓冲区里,再用 iotest.OneByteReader 强制每次底层读取只返回一个字节。实际输出:
text
wire=00000002676f00000006e7bd91e7bb9c
frame="go" bytes=2
frame="网络" bytes=6
oversize=frame too large: 1025前四个字节 00000002 是第一条长度,后面 676f 是 go;下一条前缀是 00000006。程序没有依赖一次读取拿到多少,因此即使每次只来一个字节也能解码。这是确定性的短读测试,不是声称抓到了某种 TCP 分段方式。
长度上限要在分配内存前检查。只写 make([]byte, n) 会把对方声明的长度变成自己的内存开销。正文上限取 1024 字节是教学选择;生产协议还要定超时、并发连接数和总内存预算。若多人并发写同一连接,虽然 Go 的连接方法允许并发调用,整条“头 + 体”的业务写入仍应串行化,否则不同消息的写入步骤可能交错。net.Conn
三次握手:让两边都确认起始序号
TCP 按字节编号,用确认号说明“下一字节希望从哪里开始”。假设客户端起始序号为 x、服务端为 y,通常建立连接的过程如下。x、y 是示意,不是本机抓包值。TCP §3.4、§3.5
正在绘制图示…
查看图源代码
sequenceDiagram
participant C as 客户端
participant S as 服务端
C->>S: SYN seq=x
Note over C: SYN-SENT
S->>C: SYN+ACK seq=y ack=x+1
Note over S: SYN-RECEIVED
C->>S: ACK ack=y+1
Note over C,S: 通常双方进入 ESTABLISHED
SYN 本身占用一个序号。第二步同时完成“确认客户端序号”和“给出服务端序号”,第三步让服务端知道自己的序号也被确认。握手还用于协商 MSS、窗口缩放等选项;不能把它理解为三次无内容的 ping。TCP §3.1、§3.2
对应用而言,连接成功仅表示传输连接建立。TCP ACK 是传输层确认,不等于远端业务代码已经读完,更不等于数据库已提交。因此“写请求成功,随后读超时”属于结果未知:服务端可能没执行,也可能已经执行但响应丢失。涉及扣款或创建订单时,需要应用层请求标识、幂等约束和状态查询;这是从传输确认范围推出的工程要求,不能由 TCP 单独解决。
滑动窗口和拥塞控制,分别保护谁
如果每发一个字节都等确认,长链路上的时间大多会花在等待。滑动窗口允许多个尚未确认的字节在途中,确认前移后再发送新字节。接收方通过接收窗口 rwnd 告诉发送方还能接收多少;发送方还维护拥塞窗口 cwnd,限制向网络中投入的数据。TCP §3.8.6、拥塞控制 §2
| 限制 | 谁决定 | 观察到限制时该怀疑什么 |
|---|---|---|
| rwnd,接收窗口 | 接收端 | 接收应用读得慢、接收缓冲空间不足 |
| cwnd,拥塞窗口 | 发送端拥塞控制算法 | 丢包、拥塞反馈、启动阶段 |
| 应用供给和发送节奏 | 应用与实现 | 没有足够数据可发,或正在限速 |
简化地看,允许在途的数据受 min(rwnd, cwnd) 约束,扣除已在途数据后才是新增发送空间。不能只看“机器网卡是 10Gbps”就断言单连接一定跑满。一个教学估算:RTT 为 100ms、在途上限只有 64KiB 时,即使忽略其他限制,吞吐量级也只有 64KiB / 0.1s = 640KiB/s。这是假设模型的计算,不是本机测速。
经典 Reno 框架包含慢启动、拥塞避免、快速重传和快速恢复。慢启动随 ACK 较快增大窗口;达到阈值后,拥塞避免更谨慎地增加。重复 ACK 可以提示后续字节到了、前面有缺口,快速重传不必一直等重传计时器;发生拥塞后又要降低发送压力。这是在“尽量利用链路”和“不要把队列持续塞满”之间调整。RFC 5681 §3
这些阶段帮助理解机制,却不是所有系统当前使用的完整算法。具体主机可能采用 CUBIC 等算法;不能把某一个 Reno 的窗口变化公式当作 TCP 协议唯一实现。丢包检测、重传和拥塞控制也不是同一个问题:发现哪个数据需要补发,与决定接下来允许发送多少数据,需要配合但职责不同。TCP §3.8.2
四次挥手、半关闭和 TIME_WAIT
TCP 是双向的:客户端不再发送,不代表服务端也没有数据要回。常见关闭过程里,客户端先发 FIN,服务端确认,等自己的数据发完再发 FIN,客户端再确认。确认与 FIN 可能合并,所以“四次挥手”是便于理解的典型过程,不保证抓包一定恰好四个包。TCP §3.6
正在绘制图示…
查看图源代码
sequenceDiagram
participant A as 主动关闭方
participant B as 被动关闭方
A->>B: FIN:我这边不再发送
B->>A: ACK
Note over B: CLOSE-WAIT,应用仍可发送剩余响应
B->>A: FIN:我这边也结束
A->>B: ACK
Note over A: TIME-WAIT,随后 CLOSED
Go 的 TCPConn.CloseWrite() 只关闭写方向。测试客户端写出 hello 后调用它,服务端读到 EOF,再回响应;客户端仍然能读到:
text
after CloseWrite: "received=hello"这同时解释了一个死等:服务端用 io.ReadAll 等请求结束,客户端又保持写方向打开,等服务端先响应。双方都在等。改成明确分帧,或在确实以 EOF 为边界的单次协议里半关闭,才能打破等待。CloseWrite
TIME_WAIT 用来在连接结束后继续处理最后确认丢失等情况,并使旧重复报文从网络中消失。标准状态机使用 2MSL 等待,MSL 是最大报文生存时间;具体操作系统计时不能从“2MSL”直接推出一个跨平台固定秒数。主动关闭方通常进入 TIME_WAIT;同时关闭等路径要看完整状态机。TCP §3.3.2、§3.10.7
大量 TIME_WAIT 可能与大量短连接有关,先查连接复用和新建速率。大量 CLOSE_WAIT 则值得检查本地应用是否在对端结束发送后迟迟没有关闭。后者是排查线索,不能仅凭计数就断定代码泄漏。贸然修改内核回收参数,可能掩盖问题并改变协议保护条件。
UDP:有数据报边界,但没有替你承担可靠性
UDP 交付的是数据报;每个数据报带端口、长度和校验信息。协议不承诺送达、顺序或去重,也没有 TCP 那样的连接建立过程。应用在某些 API 中对 UDP 调用 connect,通常是在本地关联默认对端,并不意味着完成 TCP 式握手。RFC 768、Linux udp(7)
这适合能容忍少量过期数据丢失的实时业务,也适合像 QUIC 那样自行实现传输机制的协议。成本没有消失:如果业务需要可靠传输,就必须在更高层处理丢失、重排、拥塞与重试。把文件简单切成 UDP 包发送,不会自动成为“更快的 TCP”。
UDP 还有与 TCP 不同的缓冲区陷阱。Linux 上一次接收只取一个数据报;缓冲区太小,超出的部分被丢弃,不能在下一次读取中补回来。实际发送 hello、bye 两条数据报,第一次只用两字节缓冲区,结果为:
text
udp small buffer="he"
udp next datagram="bye"llo 没有成为第三条数据。需要检测截断时,选能返回消息标志的接口并检查相应标志;不能从“小于预期的字符串”猜测是否完整。udp(7) DESCRIPTION、ReadMsgUDP
HTTP 的版本差别,主要落在消息如何共用连接
HTTP 的 GET、POST、状态码和字段语义由 HTTP 语义规范统一描述。1.1、2、3 主要改变如何编码和承载这些消息,升级传输版本不会自动修正不合理的业务重试。HTTP §1
| 版本 | 消息组织 | 复用方式 | 关键代价与边界 |
|---|---|---|---|
| HTTP/1.1 | 文本起始行与字段,消息体另定长度 | 持久连接;流水线响应仍须按请求顺序 | 前一响应慢会挡住后续流水线响应 |
| HTTP/2 | 二进制帧、流标识、HPACK 字段压缩 | 多条流的帧交错在同一 TCP 连接 | TCP 缺失一段字节仍会影响连接上的所有流 |
| HTTP/3 | HTTP 帧映射到 QUIC 流,QPACK 字段压缩 | 多条 QUIC 流 | 单流丢失不强迫其他流等待其有序交付;仍共享拥塞资源 |
依据:HTTP/1.1 §6、§9.3、HTTP/2 §4—5、HTTP/3 §2。
HTTP/1.1:连接能复用,前提是知道这一条在哪里结束
HTTP/1.1 默认支持持久连接。若一条响应没有可靠的消息长度界定,客户端就无法分清“这一条的尾巴”和“下一条的开头”。常见边界来自 Content-Length、分块传输编码,以及某些响应由关闭连接界定;还有 HEAD、特定状态码等没有消息体的规则。HTTP/1.1 §6.3、§7.1
分块编码把未知总长度的内容分段,每块带长度,最后用零长度块结束。它解决边发边生成的问题,不代表服务端可以省略协议边界校验。请求里长度相关字段相互矛盾时,中间代理与后端若采取不同解释,就可能把后续字节看成另一条请求;实现应严格遵循规范处理,不能自行“宽容兼容”。HTTP/1.1 §11.2
Go 客户端要关闭响应体;若希望底层持久连接有机会复用,通常还需读到 EOF。响应体巨大或不可信时,又不能为复用而无限读完,应该给大小和时间设边界。这是资源取舍。http.Client.Do
HTTP/2:消除 HTTP 层排队,并没有消除 TCP 有序交付
HTTP/2 把一条请求或响应分成帧,并给流编号。大响应的一帧发送完后,可以发送另一条响应的一帧,不再要求整个大响应结束后才轮到另一条。流级和连接级流量控制约束 DATA 帧,避免单个发送方无限占用接收缓冲。HTTP/2 §5.1—5.2
然而 TCP 看到的仍是一串字节。前面的 TCP 数据丢失时,即使后到字节里装着另一条 HTTP/2 流的帧,也不能越过缺口按普通字节流交给 HTTP/2 层。这就是传输层队头阻塞。连接复用同时减少了建连成本,也把多条请求放进同一个连接故障域。HTTP/3 §1.1
本机使用 httptest 创建两个 TLS 测试服务,一个关闭 HTTP/2,一个开启。输出分别为:
text
http=HTTP/1.1 tls13=true alpn="" body=HTTP/1.1
http=HTTP/2.0 tls13=true alpn="h2" body=HTTP/2.0h2 是 TLS ALPN 协商的应用协议名称。第一行 ALPN 为空是这个测试配置的结果,不是所有 HTTP/1.1 都必须没有 ALPN。测试证明这里实际协商了两个版本,没有测出谁在真实负载下更快。HTTP/2 §3.2
HTTP/3 与 QUIC:把可靠性放到各条流上
QUIC 在 UDP 上实现连接、多条可靠有序流、流控、丢包恢复和拥塞控制。不同流有各自的字节偏移,所以某一流缺了一段,不要求另一流等待它补齐;同一流内部仍然需要有序交付。QUIC §2、§4
这项设计对多请求、丢包链路有意义,但“没有队头阻塞”说得太满。多个流共享连接的拥塞预算,丢包导致降速仍可能影响它们;QPACK 解码也可能因动态表依赖而等待。QUIC 还需要加密、计时器、状态和资源管理,HTTP/3 是否更快要看网络、实现和负载。QUIC 拥塞控制 §7、QPACK §2.1.2
QUIC 的连接 ID 让连接身份不必只绑定当前地址与端口,迁移机制可处理某些网络路径变化;但路径仍要验证,不能据此保证任意移动网络切换无感。QUIC §9。本文没有部署 HTTP/3 服务、制造丢包或测试迁移,这一节是规范解释。
TLS 握手:既要算出密钥,也要知道在和谁说话
只把流量加密还不够。如果客户端与冒充服务器的人协商了密钥,流量同样能被那个冒充者解密。TLS 1.3 常见的证书认证握手同时解决密钥协商、对端认证和握手完整性问题。TLS §2
正在绘制图示…
查看图源代码
sequenceDiagram
participant C as 客户端
participant S as 服务端
C->>S: ClientHello:版本、算法、key_share、ALPN 等
S->>C: ServerHello:选择参数与 key_share
Note over C,S: 派生握手流量密钥
S->>C: 加密的扩展、Certificate、CertificateVerify、Finished
Note over C: 验证信任链、目标身份、签名与 Finished
C->>S: Finished
Note over C,S: 使用应用流量密钥交换数据
图是无客户端证书、无 HelloRetryRequest 的典型路径,不覆盖 PSK 恢复等所有分支。服务端证书把公钥与身份联系起来,CertificateVerify 证明握手方掌握对应私钥,Finished 校验握手过程和密钥是否一致;密钥协商不等于简单地“用证书公钥把全部业务数据加密”。TLS §4.4
| 经常混淆的内容 | 实际职责 | 出错时的后果 |
|---|---|---|
| 证书链与主机身份检查 | 判断是否信任这个目标 | 跳过检查会失去对中间人攻击的重要防护 |
| SNI | ClientHello 中表达目标服务器名称,帮助选证书或虚拟主机 | 自身不构成身份认证 |
| ALPN | 协商 h2 等应用协议 | 加密成功不代表双方选中了同一 HTTP 版本 |
| TLS 会话恢复、0-RTT | 减少重新连接的等待 | 0-RTT 数据存在重放风险,需要应用约束 |
依据:TLS §4.2、0-RTT §8、SNI RFC 6066 §3。在 Go 中遇到证书错误,不应该把 InsecureSkipVerify: true 当作正式修复;测试程序通过 httptest 提供的客户端信任测试证书,没有关闭验证。tls.Config
端到端延迟也因此不能只数一个握手。首次访问可能包含 DNS、TCP 建连、TLS、请求往返和服务端处理;复用已有连接会跳过其中几项。QUIC 把安全与传输建立结合,恢复握手又是另一条路径。讨论“几次 RTT”时,必须先说明是否首次访问、是否缓存命中、是否重试和是否恢复,不能用一个固定数字概括所有 HTTPS。
DNS:分布式记录查询,缓存会改变你看到的答案
DNS 把名字组织成层次空间,由不同区域的权威服务器负责记录。客户端通常问递归解析器;缓存未命中时,解析器沿根、顶级域、权威服务器获得委派和答案,再把结果返回。缓存命中可以省去后面的查询。DNS §4.3
正在绘制图示…
查看图源代码
flowchart LR
A["应用或系统解析接口"] --> R["递归解析器"]
R --> C{"缓存可用?"}
C -->|是| A
C -->|否| ROOT["根:顶级域的委派"]
ROOT --> TLD["顶级域:目标区域的委派"]
TLD --> AUTH["权威:记录或进一步别名"]
AUTH --> R
| 记录或参数 | 常见用途 | 排错时要注意 |
|---|---|---|
| A / AAAA | IPv4 / IPv6 地址 | 多地址、地址族选择可能影响连接结果 |
| CNAME | 一个名字指向另一个规范名字 | 别名目标还要继续解析,不是 HTTP 重定向 |
| NS | 区域的权威服务器 | 委派与最终业务地址是不同记录 |
| TTL | 缓存存活时间上限的关键参数 | 改权威记录不会立即清掉已有缓存 |
| NXDOMAIN / NODATA | 名字不存在 / 名字存在但没有所问类型 | 不要与超时或 SERVFAIL 混成一种失败 |
记录类型见 RFC 1035 §3.2,否定缓存见 RFC 2308 §2、§5。TTL 到期后可重新查询,并不保证客户端在那个瞬间主动刷新,更不保证所有中间缓存同一时刻更新。
DNS 经常使用 UDP,但绝不是“只用 UDP”。完整实现需要支持 TCP;响应截断、较大响应等情况可能转用 TCP,现代部署也可能使用加密的 DNS 传输。RFC 7766 §5。当“浏览器能访问,服务连不上”时,要核对两边用的解析器、缓存、代理和地址族,而不是先改服务代码。本文没有对真实域名做传播时延实验。
WebSocket:一条长连接上的双向消息协议
HTTP 请求响应很适合客户端询问、服务器回答;聊天、实时状态推送还需要服务器主动发消息。WebSocket 在经典 HTTP/1.1 路径上先做 Upgrade 握手,成功返回 101 后,双方按 WebSocket 帧格式交换数据。WebSocket §1.3、§4
和 TCP 不同,WebSocket 定义消息边界,但一条消息仍可拆成多个帧;控制帧如 Ping、Pong、Close 有单独规则。客户端到服务端的帧需要掩码,掩码不是加密;需要保密和认证时使用 wss 对应的 TLS 保护。WebSocket §5
应用因此省掉一部分分帧工作,却必须管理长连接成本:连接状态、消息大小、慢客户端积压、心跳与空闲超时、关闭握手、断线后如何恢复。比如“每次重连补发全部消息”会产生重复,业务仍要设计消息编号和确认范围。协议本身不承诺断线自动重连,也不承诺业务消息恰好执行一次。WebSocket §7
代理也要认识相应握手和连接生命周期。HTTP/2、HTTP/3 上承载 WebSocket 有另外的扩展,不能把 HTTP/1.1 的 Upgrade 原样套过去。RFC 8441、RFC 9220。本文未引入 WebSocket 库,未做握手或长连接负载实测。
CDN 与负载均衡:把流量放在哪里、交给谁
CDN:先判断内容是否能共用
CDN 把内容放在边缘节点,用户请求命中边缘缓存时就近返回,未命中时再向源站取内容。它能减少重复回源和远距离传输,但效果受缓存命中率、节点位置、对象大小和更新频率影响;不是给所有接口加上 CDN 都会更快。CloudFront 请求流程
正在绘制图示…
查看图源代码
flowchart LR
U["用户请求"] --> E["边缘节点"]
E --> K{"缓存键对应对象可复用?"}
K -->|命中且可用| U
K -->|未命中或需验证| O["源站"]
O -->|响应与缓存策略| E
真正容易出事故的是缓存键和可缓存性。若响应因用户身份、语言或请求字段而不同,却被不同用户共用同一个缓存对象,可能返回错误内容甚至泄露信息。Vary 指示缓存还需考虑哪些请求字段;private 限制共享缓存存储,no-store 要求不存储,no-cache 则允许存储但要求复用前验证,三者不能互换。缓存 §4.1、§5.2
静态带版本文件适合较长缓存寿命;随用户变化的内容要认真设计缓存键和授权边界。这是依据缓存语义作出的设计建议。刷新或失效操作也不是“全球立刻变更”的抽象保证,应查实际服务的行为;本文没有配置真实 CDN。
四层和七层负载均衡:可见信息不同,连接责任也不同
| 维度 | 四层负载均衡 | 七层负载均衡 |
|---|---|---|
| 主要决策依据 | 地址、端口、连接或流信息 | HTTP 主机名、路径、字段等应用信息 |
| 适用场景 | 任意 TCP/UDP 服务、希望保留加密透传 | 按域名或路径路由、HTTP 级观测与策略 |
| TLS | 可以透传;也有终止或其他部署形态 | 要读取加密的 HTTP 内容,通常需要在此前解密 |
| 成本 | 仍可能维护连接状态或代理连接 | 需要解析协议,管理前后端连接与应用规则 |
这里“层”说明主要处理的信息,不保证某种设备绝不解析额外字段。以 HAProxy 为例,mode tcp 与 mode http 的处理能力不同;TCP 模式也可配置检查某些握手信息。不要把“L4 必然只做简单转发,L7 必然很慢”当作性能定律。HAProxy 3.0 mode
轮询按次序分配,适合后端能力相近、请求成本接近的起点;最少连接利用连接数估计忙闲,但一条连接承载多个 HTTP/2 请求时,连接数并不等于工作量;基于键的哈希利于局部性,却可能让热点集中。这些策略需要和健康检查、容量、长连接行为一起看。HAProxy 3.0 balance
代理常把“客户端到代理”和“代理到后端”变成两段连接。客户端看到连接正常,不证明后端正常;代理重试也可能重复已经执行的请求。服务端超时、代理超时、客户端超时需要有明确关系,同时约束重试范围。比如非幂等请求不能只因为“连接断了”就盲目自动重放,HTTP 规范对此有专门约束。HTTP §9.2.2
遇到超时,按停在哪一步取证
| 现象 | 下一项证据 | 暂时不要下的结论 |
|---|---|---|
| 域名报错 | 解析器、错误类型、A/AAAA、TTL | “服务端一定宕机” |
| 连接超时或拒绝 | 目标地址端口、连接状态、网络路径 | “业务处理太慢” |
| TLS 错误 | 证书身份、信任链、时间、协商参数 | “关验证即可解决” |
| 等响应头超时 | 请求是否到达、服务耗时、代理日志 | “没有返回就没有执行” |
| 读一半结束 | 长度与帧校验、读错误、对端关闭原因 | “再读一次一定能补齐” |
| 高延迟伴随大量新连接 | 复用率、握手耗时、连接 churn | “只需要调大线程数” |
这是本文各层职责汇总出的排查顺序,不是只凭一个指标就能诊断故障的公式。先把“在哪一步”等待说清,再决定抓哪一段连接、看哪一方的日志,往往比先换协议有用。
依据与验证范围
- TCP:RFC 9293,重点为 §2.2 字节流,§3.1 序号与确认号,§3.3.2 状态机,§3.5 建连,§3.6 关闭,§3.7 分段,§3.8.6 窗口;RFC 5681 §2—3 经典拥塞控制。
- UDP:RFC 768;Linux
udp(7)的接收数据报和截断规则。 - HTTP:RFC 9110 §9.2.2 幂等与重试;RFC 9112 §6、§7、§9.3、§11.2;RFC 9113 §3—5;RFC 9114 §1—2;RFC 9204 §2.1.2。
- QUIC:RFC 9000 §2、§4、§9;RFC 9001 §4;RFC 9002 §7。
- TLS:RFC 8446 §2、§4.2、§4.4、§8;RFC 6066 §3。
- DNS:RFC 1034 §4.3;RFC 1035 §3.2;RFC 2308 §2、§5;RFC 7766 §5。
- WebSocket:RFC 6455 §4—7;HTTP/2、HTTP/3 扩展分别为 RFC 8441 和 RFC 9220。
- 缓存与分流:RFC 9111 §4.1、§5.2;上文 CloudFront 官方流程文档;HAProxy 3.0 官方配置手册
mode、balance。 - Go API:上文固定 Go 1.27.2 文档,以及本机同版本
go doc。所有关键行为旁均已链接对应章节。
实测包含长度前缀、单字节短读、截断错误、最大帧拒绝、TCP 半关闭、UDP 缓冲截断、TLS 1.3 上 HTTP/1.1 与 HTTP/2 协商。go test -race -v . 和 go vet . 通过。没有抓公网包,没有验证拥塞算法表现、TIME_WAIT 具体秒数、DNS 传播、WebSocket、HTTP/3、CDN 或负载均衡性能。示意图来自规范流程,不是抓包截图。
完整可复现代码
以下文件放在同一目录。使用 Go 1.27.2;测试只监听 127.0.0.1 随机端口,所有网络读写设置了有限等待,不依赖外部服务。复现命令:
sh
go version
go run .
go test -race -v .
go vet .go.mod
go
module example.com/foundations/network
go 1.27.0main.go
代码块 · 189 行 · 点击收起 / 展开
go
package main
import (
"bytes"
"crypto/tls"
"encoding/binary"
"fmt"
"io"
"net"
"net/http"
"net/http/httptest"
"os"
"testing/iotest"
"time"
)
const maxFrame = 1024
func writeFrame(w io.Writer, s string) error {
if len(s) > maxFrame {
return fmt.Errorf("frame too large: %d", len(s))
}
var h [4]byte
binary.BigEndian.PutUint32(h[:], uint32(len(s)))
if _, err := w.Write(h[:]); err != nil {
return err
}
_, err := io.WriteString(w, s)
return err
}
func readFrame(r io.Reader) (string, error) {
var h [4]byte
if _, err := io.ReadFull(r, h[:]); err != nil {
return "", err
}
n := binary.BigEndian.Uint32(h[:])
if n > maxFrame {
return "", fmt.Errorf("frame too large: %d", n)
}
b := make([]byte, int(n))
_, err := io.ReadFull(r, b)
return string(b), err
}
func framing(out io.Writer) error {
var b bytes.Buffer
for _, s := range []string{"go", "网络"} {
if err := writeFrame(&b, s); err != nil {
return err
}
}
fmt.Fprintf(out, "wire=%x\n", b.Bytes())
r := iotest.OneByteReader(&b)
for i := 0; i < 2; i++ {
s, err := readFrame(r)
if err != nil {
return err
}
fmt.Fprintf(out, "frame=%q bytes=%d\n", s, len(s))
}
var big [4]byte
binary.BigEndian.PutUint32(big[:], maxFrame+1)
_, err := readFrame(bytes.NewReader(big[:]))
fmt.Fprintf(out, "oversize=%v\n", err)
return nil
}
func tcpHalfClose(out io.Writer) error {
l, err := net.Listen("tcp", "127.0.0.1:0")
if err != nil {
return err
}
defer l.Close()
done := make(chan error, 1)
go func() {
c, e := l.Accept()
if e != nil {
done <- e
return
}
defer c.Close()
if e = c.SetDeadline(time.Now().Add(3 * time.Second)); e != nil {
done <- e
return
}
b, e := io.ReadAll(c)
if e == nil {
_, e = fmt.Fprintf(c, "received=%s", b)
}
done <- e
}()
c, err := net.DialTimeout("tcp", l.Addr().String(), 3*time.Second)
if err != nil {
return err
}
defer c.Close()
if err = c.SetDeadline(time.Now().Add(3 * time.Second)); err != nil {
return err
}
if _, err = io.WriteString(c, "hello"); err != nil {
return err
}
if err = c.(*net.TCPConn).CloseWrite(); err != nil {
return err
}
b, err := io.ReadAll(c)
if err != nil {
return err
}
if err = <-done; err != nil {
return err
}
fmt.Fprintf(out, "after CloseWrite: %q\n", b)
return nil
}
func udp(out io.Writer) error {
r, err := net.ListenPacket("udp", "127.0.0.1:0")
if err != nil {
return err
}
defer r.Close()
w, err := net.DialTimeout("udp", r.LocalAddr().String(), 3*time.Second)
if err != nil {
return err
}
defer w.Close()
if err = w.SetWriteDeadline(time.Now().Add(3 * time.Second)); err != nil {
return err
}
if err = r.SetReadDeadline(time.Now().Add(3 * time.Second)); err != nil {
return err
}
for _, s := range []string{"hello", "bye"} {
if _, err = io.WriteString(w, s); err != nil {
return err
}
}
b := make([]byte, 2)
n, _, err := r.ReadFrom(b)
if err != nil {
return err
}
fmt.Fprintf(out, "udp small buffer=%q\n", b[:n])
b = make([]byte, 10)
n, _, err = r.ReadFrom(b)
if err != nil {
return err
}
fmt.Fprintf(out, "udp next datagram=%q\n", b[:n])
return nil
}
func httpVersions(out io.Writer) error {
for _, h2 := range []bool{false, true} {
s := httptest.NewUnstartedServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { fmt.Fprint(w, r.Proto) }))
s.EnableHTTP2 = h2
s.TLS = &tls.Config{MinVersion: tls.VersionTLS13}
s.StartTLS()
client := s.Client()
client.Timeout = 3 * time.Second
resp, err := client.Get(s.URL)
if err != nil {
s.Close()
return err
}
b, err := io.ReadAll(resp.Body)
closeErr := resp.Body.Close()
s.Close()
if err != nil {
return err
}
if closeErr != nil {
return closeErr
}
fmt.Fprintf(out, "http=%s tls13=%t alpn=%q body=%s\n", resp.Proto, resp.TLS.Version == tls.VersionTLS13, resp.TLS.NegotiatedProtocol, b)
}
return nil
}
func run(out io.Writer) error {
for _, f := range []func(io.Writer) error{framing, tcpHalfClose, udp, httpVersions} {
if err := f(out); err != nil {
return err
}
}
return nil
}
func main() {
if err := run(os.Stdout); err != nil {
fmt.Fprintln(os.Stderr, err)
os.Exit(1)
}
}main_test.go
代码块 · 42 行 · 点击收起 / 展开
go
package main
import (
"bytes"
"errors"
"io"
"strings"
"testing"
)
func TestRoundTrip(t *testing.T) {
for _, s := range []string{"", "go", "网络", strings.Repeat("x", maxFrame)} {
var b bytes.Buffer
if err := writeFrame(&b, s); err != nil {
t.Fatal(err)
}
got, err := readFrame(&b)
if err != nil || got != s {
t.Fatalf("got %q %v", got, err)
}
}
}
func TestTruncated(t *testing.T) {
for _, b := range [][]byte{{0, 0}, {0, 0, 0, 2, 'x'}} {
_, err := readFrame(bytes.NewReader(b))
if !errors.Is(err, io.ErrUnexpectedEOF) {
t.Fatalf("got %v", err)
}
}
}
func TestOversize(t *testing.T) {
if err := writeFrame(io.Discard, strings.Repeat("x", maxFrame+1)); err == nil {
t.Fatal("accepted oversize")
}
}
func TestDemo(t *testing.T) {
var out bytes.Buffer
if err := run(&out); err != nil {
t.Fatal(err)
}
t.Log("\n" + out.String())
}