Old-School Networking / 2026 ・ No.06

古法网络入门

第6讲 TCP ── 端口号、三次握手、重发、窗口与拥塞

发布

Wireshark 的界面。上方列表排着一次申请的 10 个数据包:SYN、SYN, ACK、ACK、POST /api/expenses、ACK、201 Created、ACK、两个 FIN, ACK、最后的 ACK。选中第 1 个 SYN,展开详情里的 Transmission Control Protocol,可以看到 Flags: 0x002 (SYN)、Window: 64240,以及 Options 中的 Maximum segment size: 1460 bytes、SACK permitted、Timestamps、Window scale: 10 (multiply by 1024)
一次申请的 TCP 往来。展开第 1 个 SYN(“我想连接”的招呼),里面写着自己能接收的大小(MSS 1460 字节)和窗口倍率(1024 倍)等希望(在笔者于云端搭建的练习用网络中,于 2026 年 10 月 25 日记录,用 Wireshark 4.2.2 打开后截图。界面保持英文。窗口上方的标题栏和小标题是为了说明而后加的)

第 5 讲里,包靠目的地址在路由器之间传递。可是途中有时会丢,有时顺序会乱。从这一讲开始进入第 4 层,传输层。
丢了、乱了、接收方慢、路上堵,TCP 都要把申请可靠地送到。我们做一条故意丢包的“捣乱线”,逐一确认它的机制。

一

房间号这一层

新井上一讲 NAT 表里的 40418 和 3000,您说是房间号对吧。

古井对。靠地址能送到大楼。再往里,交给哪个房间的哪个程序,就是 TCP 的工作之一。

新井“之一”,也就是说还有别的?

古井丢包的路由器、打乱顺序的路、读得慢的服务器、拥挤的线路。IP 一概不管,全由 TCP 接手。今天把这些都故意制造一遍。

全 12 讲路线图(当前:第 6 讲)

1 全景图 2 物理层 3 数据链路层 4 IP 地址 5 路由器 6 TCP 7 UDP・套接字 8 TLS 9 DNS 10 HTTP 11 开发者的网络 12 故障排查 当前位置 全景图 第1层 第2层 第3层 第4层 第5・6层 第7层 实战
  • 端口号 ── 地址是大楼,端口是房间。一条连接由 4 个值决定
  • 招呼与编号 ── 三次握手、Seq 与 Ack、被拒绝与被晾着(实测)
  • 重发 ── 丢一个包、打乱顺序(实测)
  • 窗口与拥塞 ── 不读数据的接收方、拥挤线路上包数怎样变化(实测)
  • 道别 ── FIN、TIME-WAIT、RST

这一讲的练习网络,是在电脑(192.168.1.10)和服务器(203.0.113.20)之间夹了一个笔者写的小程序。它一边中转,一边可以延迟、限速、丢掉指定的包、打乱顺序,我们叫它“捣乱线”。如无特别说明,单程设为 20 毫秒(往返 40 毫秒)。为了集中话题,省去了第 5 讲的路由器和 NAT。

二

端口号

地址是大楼,端口号是房间

电脑 192.168.1.10 35594 申请页面 35596 列表页面 41230 其他应用 服务器 203.0.113.20 3000 申请 API 4000 慢服务器 22 ssh 一条连接=两端“地址:端口”的 4 个值 192.168.1.10 : 35594 ⇄ 203.0.113.20 : 3000 即使是同一个对方的同一个端口,电脑一侧端口不同就是不同的连接

端口号是 16 位,从 0 到 65535 的数。服务器的程序在固定的端口监听(LISTEN),电脑一侧每次连接时借用一个空闲的端口。

# 服务器上正在监听的端口(LISTEN)
srv$ ss -tln
    State Recv-Q Send-Q Local Address:Port Peer Address:PortProcess
    LISTEN 0 128 0.0.0.0:4000 0.0.0.0:*
    LISTEN 0 511 0.0.0.0:3000 0.0.0.0:*
# 电脑一侧使用的端口范围(Linux 默认)
pc$ sysctl net.ipv4.ip_local_port_range
    net.ipv4.ip_local_port_range = 32768 60999

服务器上,申请 API 的 Node.js 在 3000 号,为这一讲准备的“读得慢的服务器”在 4000 号等待。电脑一侧的端口,Linux 从 32768〜60999 中选(Windows 和 Mac 是 49152〜65535)。这次申请用的是 35594 号。

端口常见用途
22SSH
80 / 443HTTP / HTTPS(第 8 讲、第 10 讲)
53DNS(第 9 讲,主要用 UDP)
3306 / 5432 / 6379MySQL / PostgreSQL / Redis
3000 / 5173 / 8080开发中的服务器常用的端口(并非规定)

一条连接由两端的地址和端口这 4 个值来区分。从同一台电脑开两个浏览器标签页连接同一台服务器的 3000 号,因为电脑一侧的端口不同,就是两条不同的连接。开发时遇到“Address already in use”,说明已经有程序在这个端口监听了。

三

从打招呼开始

TCP 在发送数据之前,先用三次招呼(三次握手)建立连接:“我想连接(SYN)”“请,我也想连接(SYN+ACK)”“明白(ACK)”。下面是一次申请的记录。

# 一次申请的往来(单程 20 ms 的线。Seq 和 Ack 与 Wireshark 一样,从 0 开始计数)
    0.000  pc  → srv  SYN      Seq=0    Ack=0    Len=0
    0.040  srv → pc   SYN+ACK  Seq=0    Ack=1    Len=0
    0.041  pc  → srv  ACK      Seq=1    Ack=1    Len=0
    0.041  pc  → srv  PSH+ACK  Seq=1    Ack=1    Len=198  POST /api/expenses
    0.081  srv → pc   ACK      Seq=1    Ack=199  Len=0
    0.090  srv → pc   PSH+ACK  Seq=1    Ack=199  Len=203  201 Created
    0.090  pc  → srv  ACK      Seq=199  Ack=204  Len=0
    0.090  pc  → srv  FIN+ACK  Seq=199  Ack=204  Len=0
    0.132  srv → pc   FIN+ACK  Seq=204  Ack=200  Len=0
    0.132  pc  → srv  ACK      Seq=200  Ack=205  Len=0
# 开头 3 个是打招呼,最后 3 个是道别

一次申请:打招呼、申请与回复、道别(实测时刻)

电脑 服务器 SYN 0 SYN+ACK 40 ms ACK, POST (198 B) ACK 81 ms 201 (203 B) 90 ms FIN FIN+ACK 132 ms ACK 打招呼 1 个往返 申请与回复 1 个往返+处理 9 ms 道别 1 个往返

封面照片展开的就是最初的 SYN。SYN 里还写着希望。MSS 1460 是“一个包的内容请不要超过 1460 字节”,即第 2 讲的 MTU 1500 减去 IP 和 TCP 头部各 20 字节。此外,能否使用细致告知已到达范围的 SACK、时间戳(Timestamps)、窗口倍率(Window scale)也在这里商定。

# curl 测得的时间:连接用 1 个往返,到回复的第一个字节用 2 个往返
pc$ curl -w '…' -X POST … http://203.0.113.20:3000/api/expenses
    connect 0.0407 s   first byte 0.0900 s   total 0.0901 s   local port 35594
# 先关闭的电脑一侧,会留下 60 秒的 TIME-WAIT
pc$ ss -tano dst 203.0.113.20
    TIME-WAIT 0 0 192.168.1.10:35594 203.0.113.20:3000 timer:(timewait,59sec,0)

看 curl 的数字,连接用了 1 个往返(0.041 秒),收到回复的第一个字节用了 2 个往返(0.090 秒)。光打招呼就要 1 个往返。如果对方往返 100 毫秒,光这一步就是 0.1 秒。所以,重复使用一条连接会更快。

# 两次申请:每次重新连接
    0.0827 s + 0.0824 s = 0.1652 s
# 两次申请:重复使用一条连接(第 2 次 connects 0)
    0.0825 s + 0.0421 s = 0.1246 s
POINT

第二次申请,复用连接是 0.042 秒,重新连接是 0.082 秒,相差正好一个往返。HTTP 的 keep-alive 和数据库连接池,就是为了省掉这次招呼(第 10 讲)。

四

给字节编号

新井记录里有 Seq=199、Ack=204,是第 199 个包的意思吗?

古井编号的不是包,而是字节。TCP 不把数据当成一串包,而是当成一条长长的字节流。

Seq 是“这段数据从第几号字节开始”,Ack 是“下一个想要第几号”

电脑发送的字节编号 0 SYN POST /api/expenses(198 字节) 1 198 199 FIN Seq Ack=200(FIN 也算一个)→ 读法 Seq=1 Len=198 从 1 号开始发送 198 字节 Ack=199 收到 198 号为止,下一个从 199 号开始 Ack=204 服务器一侧的编号也一样(回复 203 字节) SYN 和 FIN 各占一个编号。真正的编号不是从 0,而是从随机值开始

电脑的 POST 是 198 字节,编号 1 到 198。服务器的 Ack=199 意思是“198 号为止都收到了,下一个从 199 号开始”。服务器回复的 203 字节在服务器一侧另有编号,电脑回复 Ack=204。两个方向各有一条编号序列。

Wireshark 为了好看把开头当作 0,真正的编号从随机值开始(照片详情里的 Sequence Number (raw))。这是为了防止旧连接的包混进来,也防止别人猜中编号、插入伪造的包。

NOTE

TCP 不记得分界。即使一次发送 198 字节,也不能保证接收方读一次就正好拿到 198 字节,可能被拆开,也可能和后面的数据粘在一起。HTTP 用 Content-Length 写明长度就是这个原因。自己使用套接字时,要事先约定长度或分隔符(第 7 讲)。

五

被拒绝,被晾着

招呼不成功有两种情况。连接没人监听的端口,对方的操作系统会回复 RST(那里没人)。而如果 SYN 在途中被丢掉,就什么也收不到。我们让捣乱线丢掉发往 3002 号的 SYN,比较一下。

# 发往关闭的端口 3001:服务器回复 RST(没人),1 个往返就被拒绝
pc$ curl http://203.0.113.20:3001/
    curl: (7) Failed to connect to 203.0.113.20 port 3001 after 40 ms: Couldn't connect to server
# 发往没有回应的端口 3002(线把 SYN 丢掉):一直重发,20 秒后 curl 放弃
pc$ curl --connect-timeout 20 http://203.0.113.20:3002/
    curl: (28) Failed to connect to 203.0.113.20 port 3002 after 20002 ms: Timeout was reached
# 发送 SYN 的时刻(秒)
    0.0  1.0  2.0  3.0  4.1  5.1  7.1  11.3  19.5

被拒绝只需一瞬间,没有回应就得一直等

关闭的端口(3001):立刻被拒绝 0.041 秒收到 RST(那里没人) 没有回应的端口(3002):一直等 0 s 5 s 10 s 15 s 20 s curl 放弃(20 秒) SYN 重发:每隔 1 秒 5 次,之后间隔翻倍为 2、4、8 秒 “Connection refused”说明到达了对方。“timed out”说明在途中或对方的防火墙消失了 Linux 默认约 2 分钟(131 秒)后放弃

被拒绝的一方 0.041 秒,也就是 1 个往返就结束了。没有回应的一方,电脑每隔 1 秒重发 SYN,之后把间隔翻倍为 2 秒、4 秒、8 秒。这台 Linux(6.18)的设置是前几次每隔 1 秒(tcp_syn_linear_timeouts = 4),默认约 2 分钟后放弃。

POINT

Connection refused 说明已经到达对方的机器,只是那个端口上没有程序在运行。timed out 说明在途中或对方的防火墙消失了。错误信息会告诉你该去哪里查(第 12 讲)。

六

丢了就重发

我们模拟给申请附上 1 MB 收据图片的场景,把捣乱线限速到 10 Mbit/s,只丢掉电脑发出的第 40 个包。

# 上传 1 MB 收据图片的途中,线丢掉电脑发出的第 40 个包(10 Mbit/s,单程 20 ms)
wire: up drop data#40
# 电脑一侧的记录(编号是 Wireshark 的序号)
    58  0.1282  pc → srv  Seq=55169 Len=1448
   123  0.1759  srv → pc  Ack=55169  SACK 56617-58065
   128  0.1771  srv → pc  Ack=55169  (Dup ACK #1)
   130  0.1783  srv → pc  Ack=55169  (Dup ACK #2)
   131  0.1784  pc → srv  Seq=55169 Len=1448  (Fast Retransmission)
# 耗时:不丢包的 3 次,与丢包的 1 次
    1.007 s  1.007 s  1.006 s   /   1.008 s
把 Wireshark 数据包列表的两处上下并排。上面是第 55〜61 个,选中第 58 个 Seq=55169 的行。下面是第 122〜140 个,服务器发来的 TCP Dup ACK 123#1 到 #7 以黑底红字排列,其间第 131 个选中的是 TCP Fast Retransmission 的 Seq=55169
上传收据图片途中,用捣乱线只丢掉第 58 个包。服务器反复回复同样的 Ack=55169,电脑在第 131 个重发了同样的 Seq=55169(在笔者于云端搭建的练习用网络中,于 2026 年 10 月 25 日记录,用 Wireshark 4.2.2 打开后截图。界面保持英文。窗口上方的标题栏和小标题是为了说明而后加的)

同样的 Ack 接连出现,就判断为丢失并重发

电脑 服务器 在线上丢失 Seq=55169 Seq=56617 Seq=58065 Seq=59513 Ack=55169 + SACK Dup ACK #1 Dup ACK #2 Seq=55169 提早重发(Fast Retransmission) 同样的 Ack 连续出现 3 个,就判断为“丢了”,不等超时就重发 实测:从丢失到重发 0.050 秒(约 1 个往返)。总耗时与不丢包时一样,都是 1.01 秒

Seq=55169 没有到达,所以后面的包每到一个,服务器就回复同样的 Ack“55169 起还没收到”,并用 SACK 附上已到达的范围。同样的 Ack 凑齐 3 个时,电脑不等超时,只重发了 55169(Fast Retransmission)。从丢失到重发 0.050 秒,几乎正好一个往返。总耗时 1.008 秒,与不丢包的 3 次(1.006〜1.007 秒)几乎没有差别。

一个包是 1448 字节,是 MSS 1460 减去时间戳(Timestamps)12 字节的结果。如果后面没有包,凑不齐同样的 Ack,就要等超时(RTO)再重发。Linux 的超时最短也有 0.2 秒,这种情况会明显变慢。

顺序乱了的话

# 让线把第 40 个包往后挪一个。服务器一侧的记录:
    76  0.1375  pc → srv  Seq=56617 Len=1448  (跳过 55169 先到)
    77  0.1376  srv → pc  Ack=55169  SACK 56617-58065
    78  0.1388  pc → srv  Seq=55169 Len=1448  (Out-Of-Order)
# 后一个包先到,服务器回复“55169 起还没收到,56617〜58065 已经有了”。紧接着 55169 到达,空洞填上
# 电脑的重发次数 0

这次不丢第 40 个包,而是往后挪一个。服务器把先到的 56617 先存进缓冲,回复“55169 还没到”,但 55169 马上就到了,空洞被填上。电脑重发 0 次。应用(Node.js)只会收到按编号重新排好的字节流,根本察觉不到顺序乱过。

七

接收方的情况:窗口

新井发送方拼命发,接收方跟不上,会怎样?

古井接收方每次都在回复里写着“还能接收多少”,这就是窗口。我做了一个偷懒不读数据的服务器。

# 向接收后 3 秒内不读取的服务器(接收缓冲 32 KB)发送 1 MB
    48  0.163  srv → pc  Win=0  Ack=40689  (ZeroWindow)
# 缓冲区满了,窗口为 0。电脑隔一段时间探问“好了吗?”
    49  0.414  pc → srv  Len=0  (probe)
    51  0.966  pc → srv  Len=0  (probe)
    53  2.022  pc → srv  Len=0  (probe)
# 3 秒后服务器开始读取,窗口打开
    55  3.082  srv → pc  Win=36200  (Window Update)
    curl: total 4.188 s
Wireshark 的数据包列表。第 46 个是 TCP Window Full,选中的第 48 个是 TCP ZeroWindow(Win=0),之后电脑发出的 TCP Keep-Alive 与服务器的 TCP ZeroWindow 在 0.41 秒、0.97 秒、2.02 秒交替出现,第 55 个在 3.08 秒出现 TCP Window Update(Win=36200),随后数据开始流动
向读取很慢的服务器发送 1 MB。第 48 个时窗口变成 0,电脑隔一段时间探查窗口(Wireshark 显示为 Keep-Alive,其实是确认窗口是否打开的探查)。第 55 个窗口打开(在笔者于云端搭建的练习用网络中,于 2026 年 10 月 25 日记录,用 Wireshark 4.2.2 打开后截图。界面保持英文。窗口上方的标题栏和小标题是为了说明而后加的)

接收缓冲满了,窗口就变成 0

服务器的接收缓冲(32 KB) 满了(40,688 字节) 到达 应用不读取 窗口=缓冲的空余。现在是 0(“别再发了”) 窗口 0 0.41 0.97 2.02 3.08 0.16 窗口打开 点=电脑的探查(秒) 接收窗口是接收方每次告知“还能接收多少”的数字 窗口为 0 时发送方等待。这是保护慢接收方不被快发送方淹没的机制(流量控制)

4000 号的服务器连上后 3 秒内完全不读取。接收缓冲(32 KB)在 40,688 字节时装满,0.163 秒时窗口变成 0(ZeroWindow)。电脑停止发送,以 0.41 秒、0.97 秒、2.02 秒逐渐拉长的间隔探问“窗口开了吗?”。3.08 秒服务器开始读取,窗口打开,发送恢复。

窗口字段是 16 位,原样只能写到 64 KB。所以在 SYN 里约定倍率(封面照片的 Window scale: 10 是 1024 倍)。有了这种流量控制,快的发送方就不会把慢的接收方淹没。Node.js 的 write() 返回 false,追根溯源也和这个窗口有关。

八

路上的拥堵

窗口是接收方的情况。另外还有途中道路的情况。线路很细却发得太多,就会从路由器前的队列里溢出丢失。TCP 根据丢包推测道路的拥挤程度,自己调节发送量(拥塞控制)。

把捣乱线设为 10 Mbit/s、队列最多 30 个,从服务器下载 2 MB 的 CSV。下图是根据服务器一侧的记录,每 10 毫秒统计一次的“已发送但尚未确认的包数”。

发送量成倍增加,溢出后减少,再慢慢增加(实测)

0 25 50 75 100 0.0 s 0.5 s 1.0 s 1.5 s 线路和队列能容纳的量(约 65 个) 慢启动 每个往返翻倍(10→20→40) 溢出丢了 4 个 重发期间数字暂时变大 减到约七成,再慢慢增加 cubic 拥塞控制 已发送但尚未确认的包数 10 Mbit/s、单程 20 ms 的线。根据服务器一侧的记录每 10 ms 统计(2026-10-25)
# 下载 2 MB 的 CSV(10 Mbit/s,单程 20 ms,线前最多排 30 个)
# 服务器每个往返(41 ms)发出的包数
     10   20   40   51   36   36   35   36  …
# 途中服务器的状态(ss -ti。cwnd 是包的个数)
    cubic  mss:1448  cwnd:56  ssthresh:46  rtt:66.713 ms  minrtt:40.738 ms
    queue drops 4  retransmissions 4   curl: 1.884 s, 8.90 Mbit/s

第一个往返发了 10 个,接着 20 个,再接着 40 个。每个往返翻一倍,这叫慢启动(最初的 10 个是 RFC 6928 的值)。当线路和队列装不下时溢出了 4 个,重发丢失的部分后,把发送量减到约七成(ssthresh 46)。之后再慢慢增加。这是 Linux 默认的 cubic 方式。

也请注意途中状态里 rtt:66.7 和 minrtt:40.7 的差。在队列里等待让往返延长了 26 毫秒。队列太深导致延迟变大的状态叫缓冲膨胀(bufferbloat)。也有像 Google 的 BBR 那样,不等丢包、而是根据往返时间的增长来判断拥塞的方式。

九

道别的方式

申请记录的最后 3 个就是道别。电脑先发 FIN(没有要发的了),服务器也回 FIN,最后电脑用 ACK 收尾。因为两个方向要分别关闭,本来是 4 次,这里服务器的 ACK 和 FIN 合成了一个。

先关闭的一方会留下 60 秒的 TIME-WAIT 状态(前面的 timer:(timewait,59sec))。这是为了在最后的 ACK 丢失时能重新回应,也是为了防止迟到的旧包混进使用相同端口的新连接。大量建立短连接的程序,可能会堆积几万个 TIME-WAIT,导致电脑一侧的端口不够用。这也是复用连接的理由之一。

不打招呼的强制结束是 RST。除了连接没人监听的端口(第五章),对方程序崩溃时也会收到。Connection reset by peer 就是收到了对方的 RST。

十

在自己的电脑上确认

想查的内容MacWindowsLinux
当前的连接与状态netstat -an -p tcpnetstat -anss -tan
占用该端口的程序lsof -i :3000netstat -ano | findstr :3000ss -tlnp
能否连上该端口nc -vz 目的地 443Test-NetConnection 目的地 -Port 443nc -vz 目的地 443
连接耗时curl -o /dev/null -s -w '%{time_connect} %{time_starttransfer}\n' URL

在浏览器里开着几个网站时看 netstat -an,应该会看到一排发往 443 号的 ESTABLISHED,刚关闭的则是 TIME_WAIT。

结 — 一页总结与下一讲

  1. 端口号是房间。一条连接由两端的地址和端口这 4 个值决定。电脑一侧的端口在 Linux 上是 32768〜60999。
  2. 招呼是 SYN、SYN+ACK、ACK,连接要 1 个往返。复用连接后,第二次申请从 0.082 秒变成 0.042 秒。
  3. Seq 和 Ack 是字节的编号。Ack 表示“下一个想要第几号”。TCP 不记得分界。
  4. 关闭的端口 0.041 秒就被 RST 拒绝;SYN 被丢掉时,每隔 1 秒重发,一直被晾着。
  5. 丢一个包,同样的 Ack 接连出现,约 1 个往返后重发,总耗时几乎不变。顺序乱了则重发 0 次。
  6. 不读数据的接收方会让窗口变成 0。发送方等待、探查,窗口打开后恢复。
  7. 发送量按 10、20、40 翻倍增加,溢出后减到约七成。队列让往返延长了 26 毫秒。

新井TCP 真是太周到了。全都用它不就好了?

古井有时周到反而碍事。视频通话里,把 1 秒前的画面重发过来也没用吧。下一讲讲既不打招呼也不重发的 UDP,以及程序使用它们的窗口:套接字。

参考

记录来自笔者在云端 Linux(Ubuntu 24.04,内核 6.18)上用网络命名空间搭建的练习网络,于 2026 年 10 月 25 日抓取。电脑和服务器之间的“捣乱线”是一个在两个接口之间中转帧的 Python 小程序(使用 AF_PACKET),可以指定延迟、带宽、队列长度、要丢的包和要打乱的包。服务器是 Node.js,读得慢的服务器是 Python,拥塞控制为 cubic,为了让记录更易读,关闭了收发分段功能(TSO/GRO)。表中的数字都是从这些记录和当时命令的输出读出的实际数值。登场人物均为虚构。

古法网络入门 第6讲 — TCP / 2026.10

← 返回文章列表