Skip to content

计算机网络:沿着一次请求,把协议各自负责的事分清 ​

请求超时,可能是域名没解析出来、连接没建立、证书没通过,也可能是请求已经到达服务端,只是响应还没回来。把这些情况都叫“网络不好”,很难知道下一步该查哪里。

本文沿着“找到服务器 → 建立传输 → 保护数据 → 交换请求 → 分发流量”这条路,讲清 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
放大图示

原始尺寸下可横向或纵向滚动;按 Escape 关闭。

正在绘制图示…

查看图源代码
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
放大图示

原始尺寸下可横向或纵向滚动;按 Escape 关闭。

正在绘制图示…

查看图源代码
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
放大图示

原始尺寸下可横向或纵向滚动;按 Escape 关闭。

正在绘制图示…

查看图源代码
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/3HTTP 帧映射到 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.0

h2 是 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: 使用应用流量密钥交换数据
放大图示

原始尺寸下可横向或纵向滚动;按 Escape 关闭。

正在绘制图示…

查看图源代码
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

经常混淆的内容实际职责出错时的后果
证书链与主机身份检查判断是否信任这个目标跳过检查会失去对中间人攻击的重要防护
SNIClientHello 中表达目标服务器名称,帮助选证书或虚拟主机自身不构成身份认证
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
放大图示

原始尺寸下可横向或纵向滚动;按 Escape 关闭。

正在绘制图示…

查看图源代码
flowchart LR
    A["应用或系统解析接口"] --> R["递归解析器"]
    R --> C{"缓存可用?"}
    C -->|是| A
    C -->|否| ROOT["根:顶级域的委派"]
    ROOT --> TLD["顶级域:目标区域的委派"]
    TLD --> AUTH["权威:记录或进一步别名"]
    AUTH --> R
记录或参数常见用途排错时要注意
A / AAAAIPv4 / 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
放大图示

原始尺寸下可横向或纵向滚动;按 Escape 关闭。

正在绘制图示…

查看图源代码
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.0

main.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())
}

保留各书来源与许可 · 示例运行条件以正文为准