TheUnknownBlog

Back

一个程序想和另一个程序通信

很多网络入门材料一上来就是一堆术语:IP、TCP、UDP、DNS、HTTP、TLS、NAT、socket。我觉得这样很难学。在记住一个答案之前,我得先理解:究竟是什么问题,让人们需要发明它?

所以,我们先从一个熟悉的例子开始:

curl https://example.com/index.html
sh

curl 是一个传输数据的命令行工具。这里,我们让它从一个网站获取资源。你在浏览器里输入 URL 时,浏览器也会做类似的事,然后再把结果展示出来。

从程序的角度看,任务似乎很简单:

我们的程序  <-->  Web 服务器
text

但计算机怎么理解 example.com?字节如何到达那里?到达之后,该由哪个程序接收,那个程序又怎么知道我们想要什么?返回的结果可信吗?中途出错了怎么办?

这些问题构成了我们理解网络的一条路线:

问题会用到的概念
我想和谁通信?名字、DNS、IP 地址、端口、socket
我的字节怎么到达那里?路由、下一跳、本地链路
多个会话如何共享网络?封装、复用、解复用
接收方如何理解我的字节?协议、HTTP、消息边界
我能信任对端吗?HTTPS、TLS、证书
流量经过中间人转发时,会发生什么?SOCKS5、代理、NAT、VPN、CDN
数据包丢失或乱序时怎么办?TCP、UDP、缓冲、超时

只要有基本的编程知识,就可以继续读下去。文中的 shell 示例适用于 macOS、Linux 或 WSL 中的 Unix shell。有些实验需要 dignslookuplsoftraceroutenc(netcat),可以通过系统的包管理器安装。例如,Debian 和 Ubuntu 的 dnsutils 包提供了 dignslookup。本地实验还会用到 Python 3。

如果想结合图示阅读,也可以下载我的 Network Principles for Programmers 幻灯片(英文 PDF,100 页,2 MB)。

网络组成的网络

互联网连接着不同组织运营的网络:家庭、大学、公司、互联网服务提供商,等等。没有一个运营者管理着从你的电脑到远程服务器的整条路径。这就是互联网的联邦式结构(federated)

这些网络还可以采用不同的技术。你的电脑可能通过 Wi-Fi 开始通信,之后的路程则经过以太网或光纤链路。我们需要一种共同的方式,把数据送过这些不同的网络。这就是 IP,也就是互联网协议的职责。

这个设计还带来了两个有用的概念:

  • 端到端(end-to-end): 需要理解完整会话才能完成的工作,例如确认应用操作是否成功,应由端点负责。中间设备帮助传送流量,却无法提供应用需要的全部保证。
  • 尽力而为(best effort): IP 不承诺数据一定送达、按顺序送达、不重复,或在固定时间内送达。上层按需补充相应的行为。

分层有什么用?

你可能见过七层 OSI 模型。它的实际价值是帮助我们区分职责。对这篇文章来说,一张更小的地图就够了:

层次职责例子
应用层赋予数据含义HTTP、DNS、SOCKS5
传输层在通信端点之间传递数据TCP、UDP
网络层跨网络寻址和转发数据包IP
链路层在本地链路上传递帧以太网、Wi-Fi
物理层传输信号铜线、光纤、无线电

OSI 还定义了会话层和表示层。在日常开发中,对应的工作经常由库和应用协议完成:维护登录会话、把数据编码成 JSON,或协商加密。理解这些职责很有帮助,但真实软件不一定整齐地分成七个盒子。

分层让 Web 应用可以运行在各种网络之上。路由器不必实现你的应用 API,也能转发 IP 包。后面每当分层能解释一个具体决策时,我们就会回到这张地图。

我想和谁通信?

我们的 URL 包含几部分信息:

https://example.com/index.html
  |          |          |
 协议方案    主机名      路径
text

协议方案选择了 HTTPS,其默认端口是 443。主机名给服务命名,路径标识该服务上的资源。这几部分会在通信的不同阶段发挥作用。

DNS:从名字找到地址

即使背后的机器发生变化,域名也可以保持不变。一个名字可以对应多个地址,一个地址也可以服务于多个名字。这就是名字和地址需要分开的原因。

**DNS(域名系统)**让程序能够查询与名字关联的记录:

记录类型描述的内容
AIPv4 地址
AAAAIPv6 地址
CNAME另一个名字的别名
NS某个区域的权威域名服务器
MX某个域名的邮件服务器
TXT文本元数据

建立 Web 连接时,我们首先关心地址记录。试试看:

nslookup example.com
dig example.com A
dig example.com AAAA
sh

观察答案部分,以及是哪台服务器回答的。结果可能包含多个地址,也可能随着网络位置或时间而变化。教程里出现的地址,并不是那个域名永久不变的属性。

谁来回答 DNS 查询?

通常,你的机器会询问一个递归解析器(recursive resolver)。它可能由当前网络提供,也可能配置在系统或应用中。你的机器请求一个完整答案;解析器可以使用缓存,也可以沿着委派关系继续查找。

在没有可用缓存的情况下,一次查询大致如下:

你的机器 -> 递归解析器
                |
                +-> 根服务器:谁负责 .com?
                +-> .com 服务器:谁负责 example.com?
                +-> 权威服务器:它的 A 记录是什么?
                |
你的机器 <- 地址答案
text

根服务器并不保存每个网站的地址。它把解析器引向 .com 这样的顶级域名服务器,后者再将解析器引向 example.com 的权威服务器。权威服务器保存着自己负责的那部分命名空间,也就是**区域(zone)**中的记录。

从你的机器看,请求是递归的:“请帮我找到答案。”解析器与其他服务器之间的交互通常是迭代的:“请给我答案,或者告诉我下一步问谁。”这是 DNS 解析模型中的职责划分。

你可以自己查看这些委派关系:

dig +trace example.com A
sh

这里是 dig 自己沿着委派关系查询,并不是在回放你平时使用的解析器做过什么。只想观察根服务器的回答,可以运行:

dig @a.root-servers.net example.com A +norecurse
sh

寻找指向 .com 服务器的 NS 记录,通常还会附带帮助你联系这些服务器的地址记录。具体服务器名字可能变化。有些网络限制直接向外部服务器发送 DNS 查询,所以即使通过默认解析器查询正常,+trace 也可能失败。

缓存:为什么不用每次都从根开始?

DNS 记录有一个 TTL(time to live,生存时间),限制它通常可以被缓存多久。例如,下面是一个示意答案,并不是 example.com 的实际地址:

example.com.  300  IN  A  203.0.113.10
               |
          TTL,单位为秒
text

解析器可以重复使用这条记录最多 300 秒,之后通常需要刷新。重复执行 dig example.com A,你可能看到缓存的剩余 TTL 逐渐减少。不过,共享解析器的内部结构和刷新行为,也会让数字不那么规律。

缓存解释了为什么修改 DNS 后,并非所有人都会立刻看到变化。不同查询也可以复用委派链中不同部分的缓存。

后面的示意图用 203.0.113.10 代表服务器。这是文档专用地址;运行命令时,请使用命令里的域名或回环地址。

IP 地址:数据包可以被路由到哪里?

IP 地址给了网络一个可以路由的目标。IPv4 地址有 32 位,IPv6 地址有 128 位:

IPv4: 203.0.113.10
IPv6: 2001:db8::10
text

“IP 标识一台机器”可以作为最初的理解。更准确地说,地址与网络接口相关,也可以代表虚拟服务或共享基础设施。一台机器可以有多个地址。

一个数据包携带源地址和目的地址。路由器根据目的地址做转发决策。普通的 IP 转发不需要知道最初使用的域名。

端口:那台机器上的哪个端点?

一台机器可以同时运行 Web 服务器、SSH 服务器和许多其他程序。光靠 IP 地址,操作系统还不知道该把数据交给哪个通信端点。

TCP 和 UDP 加入了端口:取值从 0 到 65535 的 16 位数字。常见约定包括:

服务约定的服务器端口
SSHTCP 22
DNSUDP 或 TCP 53
HTTPTCP 80
HTTPSTCP 443;HTTP/3 使用 UDP 443

这些是约定,并不意味着操作系统会自动识别协议。向端口 80 发送任意字节,并不会让它们变成 HTTP。TCP 和 UDP 的端口空间也相互独立:TCP 53 和 UDP 53 可以对应不同的 socket。

客户端也有端口。客户端建立 TCP 连接时,操作系统通常会选择一个临时端口(ephemeral port)

电脑                            Web 服务器
192.168.1.23:52001  ---------->  203.0.113.10:443
192.168.1.23:52001  <----------  203.0.113.10:443
text

响应发回客户端的 52001 端口。浏览器访问 HTTPS 网站时,通常是连接 443 端口,并不需要自己监听 443 端口。浏览器标签页也不是每个都分配一个固定端口;浏览器会管理连接,并在内部把数据关联到相应请求。

Socket:程序手中的句柄

Socket 是操作系统中表示一个通信端点的对象。在 Unix 类系统中,程序通过文件描述符访问它;编程语言的库通常会再把描述符包装成一个对象。

TCP 客户端创建 socket,将它连接到一个地址和端口,然后发送和接收字节。名字解析可能由库在连接前完成;底层连接操作使用的是地址。

TCP 服务器的初始化过程可以写成下面这段 Python:

import socket

server = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
server.bind(("127.0.0.1", 9330))
server.listen()

conn, addr = server.accept()
# 使用 conn 读写;再次调用 accept() 接受下一个客户端。
python

这里有两个不同的 socket。server 是等待连接的监听 socketaccept() 返回的 conn 是面向某个特定对端的已连接 socket。监听 socket 仍然可以接受更多连接,而程序需要安排如何处理这些连接,例如使用线程或事件循环。

可以把它想成前台把每位来电者交给单独的接线员,自己继续接听新来电。我在 What Is a Socket? 中更详细地讨论了操作系统句柄;理解这里的区别,就足够继续看后面的例子。

绑定 127.0.0.1 会创建一个 IPv4 回环服务,可以从同一台机器访问。绑定 0.0.0.0 则监听所有本地 IPv4 地址;其他机器能否访问,仍然取决于路由和防火墙。0.0.0.0 是绑定时的通配地址,不是远程客户端应该连接的地址。

动手试试:找到 Socket 的所有者

在一个终端中,创建一个临时页面并启动服务:

network_demo_dir=$(mktemp -d)
printf 'Hello World!' > "$network_demo_dir/index.html"
python3 -m http.server 9330 --bind 127.0.0.1 --directory "$network_demo_dir"
sh

让这个终端继续运行。在另一个终端执行:

curl --noproxy '*' -v http://127.0.0.1:9330/index.html
lsof -nP -iTCP:9330
sh

响应正文应该是 Hello World!--noproxy '*' 让 curl 在这次实验中直接连接,即使环境变量配置了代理。在 lsof 的结果中,寻找 Python 进程、它的 PID、FD 列中的 socket 描述符,以及 LISTEN 状态。

请求可能结束得太快,以至于你看不到 ESTABLISHED 状态。想保持一个连接,可以在第三个终端运行 nc 127.0.0.1 9330,先不输入请求,再执行一次 lsof。这时应该既能看到监听 socket,也能看到已接受的连接。用 Ctrl-C 关闭 nc。保留 Python 服务器,后面的 HTTP 和 TCP 实验还会用到它。

我的字节怎么到达那里?

现在有了端点,还需要一条连接它们的路径。

路由表选择下一跳

你的电脑通常不会计算出通往网站的每一台路由器。它查询自己的路由表,选择一个出站接口,并在需要时选择下一跳路由器。之后每台路由器都会重复这个决策。

电脑 -> 家庭路由器 -> ISP 路由器 -> ... -> 目标网络 -> 服务器
text

查看你自己的机器会采用什么路由:

# Linux / WSL
ip route get 1.1.1.1

# macOS
route -n get 1.1.1.1
sh

一个示意性的 Linux 结果是:

1.1.1.1 via 192.168.1.1 dev wlan0 src 192.168.1.23
text

意思是:使用源地址 192.168.1.23,通过 wlan0 发出,先把数据包交给 192.168.1.1。你的接口名和地址会不同,尤其是在 WSL 中或使用 VPN 时。

路由通常描述称为**前缀(prefix)**的地址范围。例如,192.168.1.0/24 覆盖从 192.168.1.0192.168.1.255 的地址;/24 表示前 24 位固定。一张简单的路由表可能包含:

目的地址发往哪里
192.168.1.0/24直接在本地网络上发送
10.8.0.0/16通过 VPN 接口发送
0.0.0.0/0通过默认网关发送

在基于目的地址的路由中,最具体的匹配前缀优先。一个匹配的 /24 会优先于 /0 默认路由。默认网关是兜底选择,并不一定是所有非本地地址的下一跳。

有些路由来自直连网络或本地配置。路由器还可以通过路由协议学习可达性。关于路由表如何建立,我在 IP 与路由入门文章中有更详细的介绍。跟随一个数据包时,关键是理解:每台设备独立决定自己的下一跳。

本地投递:先到达下一跳

假设目的 IP 是 203.0.113.10,下一跳却是路由器 192.168.1.1。你的电脑怎样把包交给这台路由器?

在以太网链路上,它把 IP 包放进一个帧(frame),帧的目的地址填写路由器的 MAC 地址,也就是链路层地址。以太网和 Wi-Fi 都使用 MAC 地址进行本地投递,但帧格式不同。MAC 地址也不一定是永久不变的出厂身份,软件可以设置或随机化它。

IP 目的地址:      203.0.113.10    (远程服务器)
下一跳 IP:       192.168.1.1     (本地路由器)
以太网目的地址:   路由器的 MAC    (这条链路上的接收者)
text

IPv4 使用 ARP 把链路上的 IP 地址映射到 MAC 地址:“谁拥有 192.168.1.1?”路由器回应,电脑缓存这个结果。IPv6 则使用**邻居发现(Neighbor Discovery)**完成这项工作。

查看本地邻居信息:

# Linux
ip neigh

# macOS:IPv4 ARP 缓存
arp -a
sh

这里应该看到的是本地邻居,而不是互联网另一端服务器的 MAC 地址。如果目的主机就在本地链路上,帧会直接发给它,而不是网关。

在路由器处,入站的链路层封装被移除,IP 包再使用适合出站链路的封装转发出去。在不涉及地址转换的普通路由中,目的 IP 一直是服务器的地址,而本地投递地址会在经过路由的每一跳发生变化。

动手试试:观察一些跳数

traceroute example.com
sh

Traceroute 发送 IP TTL 逐渐增加的探测包;IPv6 中对应字段叫 Hop Limit。路由器递减这个值,当它变为零时,路由器可以发回 ICMP Time Exceeded 响应。这些响应帮助我们发现中间的跳点。

这里的 TTL 限制包能经过多少跳,与控制缓存时间的 DNS TTL 不同。同一个缩写,在不同协议中有不同用途。

* 表示某次探测没有及时收到响应,不代表那台路由器一定无法转发流量。路由器可能过滤或限制回复,路径也可能变化,返回路径还可能与去程不同。Traceroute 提供的是关于这些探测包的证据,并不是所有后续 Web 请求必经路径的保证。

共享链路与尽力投递

来自不同会话的包共享链路和路由器队列,这就是分组交换(packet switching)。一个 TCP 连接不会在存在期间独占一条物理线缆。

共享会带来延迟和失败:繁忙的队列可能让包等待,也可能因溢出而丢包。包还可能乱序或重复。IP 不会替应用修复所有这些问题。等理解了应用究竟要交换什么,我们再讨论可靠性选择。

多个会话如何共享网络?

浏览器、编辑器、SSH 会话和视频通话可能都使用同一个 Wi-Fi 连接。**复用(multiplexing)**把它们的流量放到共享资源上;**解复用(demultiplexing)**则拆分收到的流量,把各部分交给正确的接收者。

让这一切成为可能的标签,就放在协议头部中。

封装:每一层加入自己的信息

对于通过 TCP 和以太网传输的简单 HTTP/1.1 交互,嵌套关系是这样的:

以太网帧
└── IP 包
    └── TCP 段
        └── HTTP 会话中的一部分字节
text

每一层把上层数据视为载荷,再加入自己的信息,这就是封装(encapsulation)。接收方逐层移除封装并解读对应字段。一条应用消息可以跨越多个包,所以这个嵌套关系并不意味着一个 TCP 段对应一个 HTTP 请求。

信息帮助做出的决策
以太网目的 MAC本地链路上的哪个接收者?
以太网 EtherType载荷是 IPv4、IPv6、ARP,还是其他协议?
IP 目的地址是交给本机,还是继续转发到哪里?
IPv4 Protocol / IPv6 Next Header后面跟着什么,例如 TCP、UDP 或 ICMP?
TCP 或 UDP 的地址和端口应该交给哪个传输端点?
应用字段涉及哪个网站、资源或逻辑请求?

普通的 IP 转发决策不需要 HTTP 解析器。防火墙等中间设备也可能查看更多字段,所以分层描述的是职责,并不保证没有设备会检查更深层的内容。

一个服务器端口,多个 TCP 连接

Web 服务器可以在 443 端口接受成千上万个连接:

客户端 A  192.168.1.10:52001 -> 203.0.113.10:443
客户端 B  192.168.1.11:52002 -> 203.0.113.10:443
客户端 C  192.168.1.12:52003 -> 203.0.113.10:443
text

这里展示的是可能发生地址转换之前的端点。在 TCP 内,一个连接由四元组标识:

源 IP + 源端口 + 目的 IP + 目的端口
text

只有目的端口是不够的。每个已接受的 socket 都有特定的对端,即使它们共享服务器上的同一个本地端口。操作系统根据连接信息把数据交给对应的 socket。这属于传输层解复用。

跨协议描述流量时,工具通常会使用五元组,再加上传输协议。这样就能区分地址和端口数字相同的 TCP 流与 UDP 流。

一个连接里的多个请求

共享也会发生在 TCP 之上。HTTP/2 可以在一个 TCP 连接中承载多个应用流。它的帧包含流标识符,让客户端和服务器能够交错处理多个请求:

一个 TCP 连接
├── HTTP/2 流 1:/index.html
├── HTTP/2 流 3:/style.css
└── HTTP/2 流 5:/app.js
text

操作系统把 TCP 字节流交给 socket。HTTP/2 实现解析帧,再根据流 ID 把数据关联到请求。这是两个不同的解复用决策。HTTP/2 规范描述了这种流模型

所以,标签页、请求、socket 和 TCP 连接的数量并不需要相等。不同层次可以各自共享资源。

接收方如何理解我的字节?

把字节送到正确的 socket,只完成了通信的一部分。看看这段数据:

47 45 54 20 2f 20 48 54 54 50 2f 31 2e 31
text

这些十六进制值是 GET / HTTP/1.1 的 ASCII 编码。接收者需要一个共同约定,才能把它理解成请求,而不是任意字符串。

**协议(protocol)**提供这个约定:消息格式、字段含义、允许的交互过程,以及数据无效时应如何处理。

HTTP:方法、资源、元数据、正文

HTTP/1.1 的请求行和头部使用文本,很适合观察这种约定:

GET /index.html HTTP/1.1
Host: example.com
User-Agent: curl
Accept: */*
Connection: close
http

第一行给出方法、请求目标和 HTTP 版本。GET 请求获取 /index.html 对应资源的一种表示。Host 头指定目标主机;多个网站共享一个地址时,它就很重要。其他头部携带元数据。

实际传输时,这些行以 \r\n 结尾,也就是回车和换行。一个空行标记头部结束。这个请求没有正文,其他请求则可以携带正文,例如提交给 API 的数据。HTTP/1.1 要求包含 Host,符合规范的服务器会拒绝缺少它的请求。这些语法规则定义在 HTTP/1.1 规范中。

消息在哪里结束?

下面是一个简单响应,正文恰好 12 字节,末尾没有额外换行:

HTTP/1.1 200 OK
Content-Type: text/plain
Content-Length: 12

Hello World!
http

空行结束头部,Content-Length 指明正文的字节数。接收者读完 Hello World!,就能判断这个正文完整了。

其他 HTTP 响应可能使用分块传输编码等不同的分帧规则,有些响应则没有正文。某些响应还可以用连接关闭来标记结束。关键是:消息边界由协议定义,既不是数据包边界,也不是一次 recv() 的返回边界。HTTP/1.1 明确规定了正文长度的判断规则

动手试试:不用 HTTP 客户端也能说 HTTP

保持前面 socket 实验中的 Python 服务器运行,用 netcat 发送请求:

printf 'GET /index.html HTTP/1.1\r\nHost: localhost:9330\r\nConnection: close\r\n\r\n' | nc 127.0.0.1 9330
sh

你应该看到状态行、包含 Content-Length: 12 的头部、一个空行,以及 Hello World!。Python 的基础服务器默认通常用 HTTP/1.0 回复,这里是正常现象。Connection: close 请求服务器在响应后关闭连接,让这个小实验有明确的终点。

/index.html 改成 /missing 再试一次。连接依然成功,但响应应该变成 404 Not Found:传输已经完成,应用告诉你请求的资源不存在。

也可以向 example.com 的 80 端口发送同样形式的请求,将 Host 改成 example.com。公网服务器可能返回重定向或其他内容。去掉 Host 可以测试服务器对 HTTP/1.1 的校验;宽松的教学服务器可能仍然接受请求,即使规范要求拒绝。

无状态协议如何记住你?

HTTP 本身不会把一系列请求自动识别为同一个已登录用户的操作。应用可以通过 cookie 或令牌建立这种联系:

Cookie: session_id=abc123
http

服务器可能用这个值查找保存在其他地方的会话状态。另一个应用则可能要求 Authorization: Bearer ... 头,并验证其中的令牌。

这里又出现了一种名字:应用层的用户或会话身份。它与 IP 地址、TCP 连接的生命周期相互独立。新建一个连接不一定会让你退出登录,共享一个 IP 地址也不会让两个人变成同一个用户。

我能信任对端吗?

我们已经能到达服务,并交换有意义的请求。但收到一个答案,并不能证明它来自预期的服务。

明文 HTTP 没有提供密码学保护,无法阻止路径上的中间人读取或修改内容。HTTPS 使用 **TLS(传输层安全协议)**保护 HTTP。对于这里讨论的 HTTP/1.1 和 HTTP/2 连接,关系是:

HTTP -> TLS -> TCP -> IP -> 本地链路
text

TLS 提供三个不同的属性:

属性回答的问题
机密性外部观察者能读到受保护的数据吗?
完整性外部参与者能不被发现地篡改数据吗?
身份认证对端是我原本打算联系的那个身份吗?

只有加密还不够。连接到冒充者的加密连接,仍然把数据送给了错误的人。TLS 将数据保护与对端认证结合起来;浏览网页时通常认证服务器,客户端证书认证则是可选的。参见 TLS 的安全模型

证书把名字与密钥联系起来

证书将公钥与某个身份,例如域名,关联起来。正常建立 HTTPS 连接时,客户端会检查:

  • 证书是否覆盖它原本想访问的主机名。
  • 证书是否处于有效期内。
  • 证书链是否能够连接到该客户端信任的根证书。
  • 握手是否证明对端持有相应的私钥。

受信任根证书来自客户端的信任配置,可能由操作系统、浏览器或应用提供。证书颁发机构,也就是 CA,参与签发构成证书链的证书。

DNS 帮助找到连接地址,证书验证则在安全连接中认证预期的主机名。即使 DNS 把你指向别处,也不会仅凭这一点就让那个目的地拥有客户端愿意接受的、适用于原主机名的证书。

这种认证不证明网站内容或经营行为诚实;它是在客户端的信任模型下确认对端身份。

动手试试:比较 HTTP 和 HTTPS

curl --noproxy '*' -v --max-time 15 http://example.com/
curl --noproxy '*' -v --max-time 15 https://example.com/
sh

在详细输出中,两者都可以观察地址解析和连接建立。HTTPS 的输出还可以看到 TLS 协商和证书验证信息。具体文字取决于 curl 的 TLS 后端和版本。Curl 可能协商使用 HTTP/2;如果想与前面的文本例子保持一致,可以加上 --http1.1

Curl 能显示请求和响应,是因为它本身就是端点,能够访问明文。在 curl 输出中看到这些文字,并不意味着网络上的观察者也能读到。

中间人什么时候能检查 HTTPS?

普通路由器或转发程序无需解密 HTTP,也能转发加密流量。它仍然可以观察地址、时序和数据大小等元信息,也可以干扰投递。

HTTPS 检查代理则会在两侧分别终止 TLS:

客户端 <== TLS 连接 1 ==> 检查代理 <== TLS 连接 2 ==> 服务器
text

代理解密客户端的数据,可以查看或修改,再为另一条连接加密。调试工具通常通过让客户端信任它的 CA 来做到这一点:它可以签发客户端会接受的证书。凭据泄露或验证机制失效也可能破坏认证;安装 CA 是一种机制,并不是唯一的失败方式。

应该追问的是:经过认证的 TLS 连接在哪里结束?一个设备位于传输路径上,并不自动意味着它是 TLS 端点。

流量经过中间人转发时,会发生什么?

许多连接使用间接通信(indirection):借助中间设备到达目标或提供服务。这样可以访问另一个网络、分配负载、执行策略,或返回缓存内容。

正向代理把这个额外参与者明确地放在图中:

客户端 <--> 代理 <--> 目标服务器
text

SOCKS5:“请替我连接”

SOCKS5 代理实现了一种让客户端请求网络操作的协议。对于其中的 TCP CONNECT 操作,过程如下:

  1. 客户端与代理建立 TCP 连接。
  2. 双方协商认证方法,并在需要时完成认证。
  3. 客户端提供目标地址和端口。
  4. 代理连接目标,报告成功或失败。
  5. 成功后,代理在两个方向转发数据。

目标可以用 IPv4 地址、IPv6 地址或域名表示。使用域名时,由代理一侧完成解析。SOCKS5 还定义了 UDP 关联等其他操作;这里讨论的是 TCP 转发。这些操作定义在 SOCKS 第五版规范中。

现在有两个 TCP 连接:

客户端:52001  <-->  代理:1080
代理:53002    <-->  目标:443
text

服务器看到的 TCP 对端是代理发起的出站连接,途中还可能发生进一步的地址转换。代理把接受到的客户端 socket 与连接目标的出站 socket 配成一对。服务多个客户端时,正确维护这些配对关系,是操作系统完成 socket 解复用之后,应用层仍要承担的职责。

SOCKS5 转发与 HTTPS 检查代理

通过普通 SOCKS5 TCP 代理传输 HTTPS 时,TCP 和 TLS 的边界不同:

TCP:客户端 <----> SOCKS5 代理 <----> 服务器
TLS:客户端 <=====================> 服务器
text

客户端通过代理与目标进行 TLS 握手,并验证目标的证书。代理复制的是加密字节。把 TCP 分成两个连接,并不自动把 TLS 也分成两个会话。

如果使用的是检查代理,TLS 的关系就变成:

TLS:客户端 <====> 检查代理 <====> 服务器
text

这个区别解释了为什么加上 SOCKS5 代理,并不会自动让代理读到 HTTPS 请求。它能看到请求的目的地和转发元信息,却不能直接解密 HTTP 内容。SOCKS5 本身也不会为普通转发流量提供加密:如果没有其他保护,明文 HTTP 仍然是明文。

动手试试:选择由谁解析域名

如果你已经有一个 SOCKS5 代理监听在 127.0.0.1:1080,可以比较:

# 直接连接
curl --noproxy '*' -v --max-time 15 https://example.com/

# 通过 SOCKS5,由 curl 在本地解析目标域名
curl --noproxy '' --socks5 127.0.0.1:1080 -v --max-time 15 https://example.com/

# 通过 SOCKS5,由代理解析目标域名
curl --noproxy '' --socks5-hostname 127.0.0.1:1080 -v --max-time 15 https://example.com/
sh

这些命令使用已经运行的代理,并不会启动代理。如果该地址没有监听服务,连接代理这一步就会失败。

观察最初到 1080 端口的连接、SOCKS 协商,以及之后与目标进行的 TLS 交互。--noproxy '' 清空这些实验中的代理绕过规则。Curl 文档解释了 --socks5--socks5-hostname 在域名解析位置上的区别。

改变解析位置,可能影响可达性,以及哪个解析器能观察到查询。但在这两种代理方式下,HTTPS 原本要认证的身份仍然是 example.com

其他间接通信形式

这些系统改变了路径中的不同部分:

机制做了什么
NAT改写 IP 地址,通常也改写端口,让多个私有网络客户端共享公网地址
VPN把选定的流量通过隧道送到另一个网络或出口
CDN从分布式边缘服务器提供网站内容,通常会缓存源站响应
负载均衡器把流量分配给后端服务器;可以转发包,也可以终止连接
反向代理代表服务接受请求,再转发给后端服务器

它们并不都等同于 SOCKS5 转发。普通 NAT 转换数据包,却不成为 TCP 端点。VPN 可以隧道化 IP 包,而不终止其中的 TCP 连接;分流 VPN 只承载选定路由的流量。CDN 则可能作为网站授权的端点,有意终止 TLS。

理解一个具体部署时,画出实际连接,并标注 TCP、TLS 和应用协议在哪里结束。这比把所有中间的盒子都叫作“代理”更有帮助。

数据包丢失、乱序或数据被拆分时怎么办?

到目前为止,我们描述会话时,好像字节总能顺利到达。但底层网络仍然只提供尽力投递。应用需要选择自己想要哪些保证,以及哪些失败由自己处理。

TCP:可靠、有序的字节流

TCP 在端点之间提供可靠、有序的字节流。几个机制一起完成这件事:

机制用途
序列号跟踪字节在流中的位置
确认报告已经收到的数据
重传恢复被认为已经丢失的数据
流量控制限制发送量,使其不超出接收方的承受能力
拥塞控制根据网络状况调整发送行为

流量控制和拥塞控制解决的是不同的瓶颈:接收端,以及网络路径。TCP 处理重传和排序,但连接仍然可能失败。它不承诺每次传输尝试最终都会成功。TCP 规范描述了这些职责。

写入操作不是消息边界

假设发送者成功写入这些字节:

sock.sendall(b"hello")
sock.sendall(b"world")
python

接收者可能看到:

recv() -> b"helloworld"
text

也可能看到:

recv() -> b"hel"
recv() -> b"lowor"
recv() -> b"ld"
text

两种情况下,把收到的字节拼起来都是 helloworld。TCP 保留的是顺序,不是发送方调用的边界。接收缓冲区大小只是这次调用最多读取多少字节,并不是要求读取一条多长的消息。

这也解释了前面 HTTP 正文长度规则的重要性。接收者需要一个累积字节并识别完整消息的解析器。常见方案包括固定长度、分隔符和长度前缀:

[ 长度 = 5 ][ hello ][ 长度 = 5 ][ world ]
text

连长度字段本身都可能跨越多次读取。程序要保留不完整数据,等收到足够字节后,再解析下一部分。SOCKS5 问候消息、TLS 记录和 HTTP 响应,都在 TCP 字节流之上定义了各自的分帧规则。

动手试试:每次只读十个字节

保持本地 Python HTTP 服务器运行,把下面的代码保存为 read_stream.py,再执行 python3 read_stream.py

import socket

request = (
    b"GET /index.html HTTP/1.1\r\n"
    b"Host: localhost:9330\r\n"
    b"Connection: close\r\n"
    b"\r\n"
)

with socket.create_connection(("127.0.0.1", 9330), timeout=5) as sock:
    sock.sendall(request)
    chunks = []
    while True:
        data = sock.recv(10)
        if not data:
            break
        print(repr(data))
        chunks.append(data)

print("\nReassembled response:")
print(b"".join(chunks).decode("utf-8"))
python

每次打印的片段最多十字节。一行状态、一条头部或一段正文,都可能跨越多次读取。把片段拼起来,就重建了响应。这些分块边界来自应用读取操作,不是在观察网络数据包的边界。

sendall() 替这个小型阻塞示例处理部分写入。recv(10) 可以返回不足十字节;返回 b"" 表示缓冲中的数据读完后,对端已经关闭其发送方向。超时设置避免示例无限等待。这些行为记录在 Python socket API 文档中。

这里一直读到连接关闭,是因为请求明确要求服务器关闭连接。通用 HTTP 客户端必须解析 HTTP 分帧,不能假设每个响应都以断开连接结束。现在可以用 Ctrl-C 停止本地服务器了。

包、段和消息有不同的边界

链路有一个 MTU(最大传输单元),限制它能承载的数据包大小。普通以太网的 IP MTU 经常是 1500 字节,其中包含 IP 头部。TCP 根据路径把字节流划分成合适大小的段,载荷需要给相关头部留出空间。

这种分段与 IP 分片不同,后者拆分的是一个 IP 包。IPv4 可以允许路由器执行分片,而 IPv6 路由器不执行分片。这两种机制都不定义应用所看到的消息。

要区分三个单位:IP 传递包,TCP 暴露字节流,应用协议定义消息。一条消息可以跨越许多包,一次读取也可以包含多条消息的字节。

UDP:保留数据报边界,提供更少保证

UDP 传递数据报(datagram)。收到的数据报会保留各自的边界,但 UDP 没有内置的连接握手、重传或顺序保证。应用可以对 UDP socket 调用 connect() 来指定对端,不过这不会建立 TCP 式握手或可靠连接。

接收缓冲区必须足够大:在常见的 socket API 中,缓冲区太小可能导致数据报被截断。应用也不能假设任意大的数据报都能顺利通过网络路径。

合适的选择取决于应用需求:

需求可能采用的方式
SSH 或文件传输需要有序字节流TCP
简短的 DNS 查询与响应经常使用 UDP,由应用重试;DNS 也使用 TCP 和加密传输
及时送达的媒体或游戏更新经常使用基于 UDP 的协议,自行处理丢失与时序
不通过 TCP 提供可靠流在 UDP 之上构建传输协议,例如 QUIC

使用 UDP 不意味着应用必须放弃可靠性。QUIC 在 UDP 之上构建安全、可靠的流,HTTP/3 就使用这种传输。因此,“Web 一定使用 TCP”和“UDP 应用一定不可靠”都只是过度简化。

程序还需要自己决定什么?

即使使用 TCP,程序仍然要处理超时、部分读取、写入失败、关闭和连接重置。成功写入 socket,只说明本地网络栈接受了字节,不证明远程应用已经处理请求。即使收到 TCP 确认,也不等于收到应用层的成功响应。

假设你提交一个操作,但在响应到来之前连接断开。服务器可能已经完成操作,也可能根本没收到。设计重试策略时,必须考虑重复执行是否安全,可能需要应用定义的请求标识符来避免重复工作。

代理还需要处理背压(backpressure)。如果代理持续从快速客户端读取,而目标接收得很慢,无限缓存最终会耗尽内存。它需要限制缓冲区大小,并在出站端跟不上时暂停读取。转发字节不仅要配对 socket,也要管理速率和生命周期。

从头到尾跟随一次请求

回到最初的命令。为了明确路径,我们直接请求 HTTP/1.1:

curl --noproxy '*' --http1.1 -v --max-time 15 https://example.com/index.html
sh

对于一次全新的连接,在没有现成连接可复用时:

  1. Curl 解析 URL:HTTPS、主机名 example.com、默认端口 443、路径 /index.html
  2. 名字解析提供候选地址,也可能直接使用缓存。Curl 选择一个地址尝试连接。
  3. 操作系统选择源地址、本地端口、出站接口和路由。在本地以太网或 Wi-Fi 链路上,如果下一跳链路地址尚未缓存,邻居解析会补齐它。
  4. TCP 建立连接。相关数据包经过本地投递和逐跳路由;远程操作系统把连接关联到服务器的监听端点。
  5. Curl 与对端协商 TLS,并根据预期主机名和信任配置验证证书。
  6. Curl 在 TLS 中发送 HTTP 请求。服务器读取请求,根据主机、路径、方法和其他字段决定如何响应。
  7. 响应经网络返回。TCP 按顺序提供字节,TLS 验证并解密,HTTP 解析则区分头部和正文,并判断响应是否完整。
  8. Curl 展示结果。连接可以根据协议和客户端行为关闭或被复用。

缓存、连接复用、代理和 HTTP/3 会改变其中一些步骤。只要找出发生变化的是哪项职责,就可以继续分析。

这也给了我们排查问题的方法:

观察到的现象接下来检查什么
域名查询失败解析器配置、DNS 答案、解析器可达性
连接被拒绝目标地址和端口、监听服务、防火墙是否主动拒绝
连接超时路由、过滤、可达性、服务器状态;没有回应本身不足以定位原因
TLS 验证失败预期主机名、证书链、有效期、客户端信任配置
HTTP 返回 404请求的主机和路径、应用路由
程序收到部分字节后一直等待消息分帧、缓冲逻辑,是否错误地等待关闭或假设一次读取包含完整消息
代理只有使用远程 DNS 时才能工作客户端和代理端在解析结果或可达性上的差异

当我在网络问题中迷路时,我会回到那个正在等待的程序:它已经知道什么,想完成什么,下一块信息应该由哪一层提供?带着这个问题去观察一次查询、一个 socket 或一条请求,那些术语就容易用起来了。

Networking 101:写给程序员的网络入门指南
https://20051110.xyz/blog/networking-101/zh
Author TheUnknownThing
Published at June 24, 2026
Comment seems to stuck. Try to refresh?✨
浙ICP备2025146421号-1 浙公网安备33010502012185号