📖 Day 8 / 15 · 《计算机网络:自顶向下方法》阅读笔记

每天 30 分钟,15 天读懂计算机网络。今天我们来深入互联网最重要的传输层协议——TCP。三次握手、四次挥手、可靠传输、流量控制、拥塞控制,一次讲透。

一、TCP 是什么?为什么这么重要?#

1.1 从 UDP 到 TCP#

前两天我们聊传输层的时候提过 UDP——那个「我发了,收到没?不关我事」的无忧无虑少年。UDP 很简单,但也太不靠谱了。

现实世界中,很多时候我们需要可靠的数据传输。你用 SSH 连你的 Mac mini,敲一个 ls,不可能出现「这个 ls 包丢了,你再来一次」的情况。你希望的是:每一条命令都准确无误地到达,而且按顺序到达

这就是 TCP(Transmission Control Protocol,传输控制协议)存在的意义。

🎯 一句话理解 TCP

TCP 是一个面向连接的、可靠的、基于字节流的传输层协议。它在不可靠的网络之上,为你搭建了一条看起来靠谱的「逻辑通道」。

1.2 TCP 的三大核心特征#

特征 含义 生活类比
面向连接 通信前先「建立连接」(三次握手),通信后「断开连接」(四次挥手) 打电话先拨号,对方接听后才开始聊
可靠传输 保证数据不丢、不重复、不乱序 快递员不仅要送到,还要按顺序送到,丢了就重新派件
字节流 数据被视为连续的字节流,没有消息边界 水龙头里流出来的水,不分「第几杯」,就是源源不断的流

1.3 TCP 报文段结构#

先来看看 TCP 数据包长什么样:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
 0                   1                   2                   3
0 1 2 3 4 5 6 7 8 9 0 1 2 3 4 5 6 7 8 9 0 1 2 3 4 5 6 7 8 9 0 1
+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
| Source Port | Destination Port |
+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
| Sequence Number |
+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
| Acknowledgment Number |
+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
| Data | |U|A|P|R|S|F| |
| Offset| Reserved |R|C|S|S|Y|I| Window |
| | |G|K|H|T|N|N| |
+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
| Checksum | Urgent Pointer |
+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
| Options (if any) |
+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+

几个关键字段速览:

  • 端口号(Port):源端口 + 目的端口,标识「这是谁发给谁的应用」。比如 SSH 是 22,HTTP 是 80
  • 序号(Sequence Number):这是 TCP 可靠传输的灵魂——每个字节都有编号,接收方靠它来排序和去重
  • 确认号(Acknowledgment Number):「我收到了你序号 N 之前的所有数据,下一个我等 N+1」
  • 标志位(Flags):ACK、SYN、FIN、RST 等,控制 TCP 状态机的运转
  • 窗口大小(Window):流量控制的核心,告诉对方「我还能接收多少数据」

💡 实战观察

你现在用 SSH 连着 Mac mini?那就有至少一条 TCP 连接活着。我们可以用 ss 命令看到它:

1
2
# 查看 established 的 TCP 连接
ss -tn state established

输出里你会看到源端口(你的终端,通常是随机的高位端口)和目的端口(22,SSH 默认端口),以及连接状态。


二、三次握手:TCP 连接是怎么建立的?#

2.1 为什么要「握手」?#

想象你给朋友打电话:

  1. 你拨号,说:「喂,听得到吗?」(试探一下线路通不通)
  2. 朋友说:「听到了,你能听到我吗?」(确认你能听到,同时反向试探)
  3. 你说:「能听到,那我们开始聊吧。」(确认双向都通了,开搞)

这就是三次握手的逻辑。TCP 要确保双方都能发送和接收数据,才正式建立连接。

2.2 三次握手详细过程#

1
2
3
4
5
6
7
8
9
10
11
12
客户端(主动打开)                          服务器(被动打开)
| |
| SYN, seq=x |
| ----------------------------------------> | SYN-RCVD
| |
| SYN+ACK, seq=y, ack=x+1 |
| <---------------------------------------- |
| |
| ACK, ack=y+1 |
| ----------------------------------------> | ESTABLISHED
| |
| (可以传数据了!) |

第一步:SYN(同步)

客户端选一个初始序号 x,发送 SYN 报文段给服务器。此时客户端进入 SYN_SENT 状态。

意思是:「嘿,我想跟你建连,我的初始序号是 x」

第二步:SYN+ACK(同步+确认)

服务器收到 SYN 后,自己也选一个初始序号 y,然后回复 SYN+ACK 报文段。确认号是 x+1(表示「x 之前的我都收到了,等你发 x+1」)。服务器进入 SYN_RCVD 状态。

意思是:「收到!我也想跟你建连,我的序号是 y,你发的 x 我收到了」

第三步:ACK(确认)

客户端收到 SYN+ACK 后,再发一个 ACK,确认号是 y+1。双方都进入 ESTABLISHED 状态。

意思是:「你的 y 我也收到了,咱俩正式开聊!」

2.3 为什么是三次,不是两次?#

这是一个经典面试题。

两次握手的问题:假设客户端发了一个 SYN,但因为网络延迟,这个 SYN 过了很久才到服务器。此时客户端早就放弃了这个连接(超时了),但服务器不知道,它会回复 SYN+ACK,然后就傻等。如果只有两次握手,服务器在发出 SYN+ACK 后就认为连接建立了,会一直等数据——白白浪费资源

三次握手中,服务器发了 SYN+ACK 后还要等客户端的第三个 ACK 才算建立连接。如果客户端没回应,服务器可以超时重发或放弃,不会一直傻等。

🍵 生活类比

两次握手就像你敲别人家的门,里面说「请进」,你就直接进去了——但如果这个「请进」是说给之前敲门的另一个人(延迟的旧敲门),那就尴尬了。三次握手就是你在门外面再确认一句「真的是请我进吗?」,里面说「是的」,你才进去。

2.4 实战:用 tcpdump 抓三次握手#

让我们实际抓一个 TCP 三次握手的包看看:

1
2
3
4
5
# 在一个终端窗口开始抓包(抓取 22 端口的包)
sudo tcpdump -i any -n 'port 22' -c 20 -S

# 在另一个终端窗口发起 SSH 连接
ssh localhost

你会看到类似这样的输出:

1
2
3
IP 127.0.0.1.54321 > 127.0.0.1.22: Flags [S], seq 1234567890, win 65535, ...
IP 127.0.0.1.22 > 127.0.0.1.54321: Flags [S.], seq 9876543210, ack 1234567891, win 65535, ...
IP 127.0.0.1.54321 > 127.0.0.1.22: Flags [.], ack 9876543211, win 65535, ...
  • 第一行 Flags [S] 就是 SYN
  • 第二行 Flags [S.] 就是 SYN+ACK(那个 . 代表 ACK 标志位)
  • 第三行 Flags [.] 就是最后的 ACK

三行,三次握手,完美。


三、四次挥手:TCP 连接是怎么断开的?#

3.1 为什么断开要四次?#

建立连接只需要三次握手,断开却要四次挥手。这是因为断开是单向的——A 可以停止发送数据,但 B 可能还有数据要发给 A。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
客户端                                      服务器
| |
| FIN, seq=u |
| ----------------------------------------> | CLOSE_WAIT
| |
| ACK, ack=u+1 |
| <---------------------------------------- |
| |
| (服务器可能还有数据要发) |
| data... |
| <---------------------------------------- |
| |
| FIN, seq=v |
| <---------------------------------------- | LAST_ACK
| |
| ACK, ack=v+1 |
| ----------------------------------------> |
| |
| [等待 2MSL] → CLOSED → CLOSED |

第一步:主动方发 FIN,表示「我没数据要发了」

第二步:被动方回 ACK,表示「知道了,你先别发了」。但被动方可能还有数据要发,所以这个阶段叫 CLOSE_WAIT

第三步:被动方数据发完了,也发 FIN,表示「我也没数据了」

第四步:主动方回 ACK,表示「收到,再见」

3.2 为什么要等待 2MSL?#

主动方在最后发完 ACK 后,不会立刻关闭,而是进入 TIME_WAIT 状态,等待 2 倍的 MSL(Maximum Segment Lifetime,报文最大生存时间,通常 2 分钟)。

原因有两个:

  1. 保证最后的 ACK 能到达对方:如果最后的 ACK 丢了,对方会超时重发 FIN,你还能再回一个 ACK。如果你直接关了,对方就永远等不到确认了。
  2. 让旧连接的报文消失:防止延迟的旧报文段被错误地接收为下一个新连接的数据。

🍵 生活类比

四次挥手就像两个人挂电话:

A:「我没啥事说了,先挂了?」(FIN)

B:「好的,但我还有两句。」(ACK)

B:「……(说完最后的话)……好了,我也挂了。」(FIN)

A:「好的,拜拜!」(ACK)

A 挂了之后还默默拿着电话等了一会儿(TIME_WAIT),怕 B 没听到「拜拜」又打过来。

3.3 实战:观察 TCP 状态#

1
2
3
4
5
6
7
# 查看所有 TCP 连接及其状态
ss -tn state time-wait
ss -tn state close-wait
ss -tn state established

# 统计各状态的连接数
ss -tan | awk '{print $1}' | sort | uniq -c | sort -rn

如果你的服务器跑了很多 Web 服务或者 frp 隧道,你很可能会看到大量的 TIME_WAIT 状态连接——这完全是正常的,是 TCP 在做它该做的事。


四、TCP 可靠传输:怎么保证数据不丢不错不乱?#

4.1 可靠传输的四大法宝#

TCP 的可靠性不是魔法,它靠的是四个核心机制:

① 序号与确认#

每个发送的字节都有序号。接收方通过确认号告诉发送方「我收到了哪些」。如果发送方发出数据后一段时间没收到确认,就认为丢了,重新发。

② 重传#

发送方每发一个数据段就启动一个定时器(重传定时器)。超时了还没收到 ACK?重传!

超时时间(RTO,Retransmission Timeout)不是固定的,TCP 会动态估算往返时间(RTT)来设置合理的超时值。

③ 流水线与滑动窗口#

一个一个发太慢了——发一个等一个 ACK,网络利用率极低。TCP 用滑动窗口机制,允许一次发多个数据段,不用等每个都确认。

④ 冗余 ACK 与快重传#

如果接收方收到一个乱序的段(比如缺了 3 号段),它会发一个冗余 ACK(重复确认之前收到的最后一段)。如果发送方收到 3 个冗余 ACK,就认为那个段丢了,立即重传——这就是快重传,不用等超时。

🍵 生活类比

想象你在网购,快递分 10 箱送过来:

  • 序号与确认:每箱都有编号,你签收时告诉卖家「1-5 箱收到了」
  • 重传:第 6 箱迟迟没到,你等了 3 天后联系卖家说「重发」
  • 滑动窗口:你不用一箱一箱收,可以同时有 5 箱在路上
  • 快重传:你收到了 7、8、9 箱但没收到 6 号,你连着催了 3 次「6 号呢?」,卖家立刻补发 6 号,不用等 3 天超时

4.2 TCP 的状态机#

TCP 连接的完整生命周期涉及多个状态。这里画一个简化的状态转移图:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
      被动打开
|
LISTEN
|
收到 SYN | 发送 SYN+ACK
v
SYN_RCVD
|
收到 ACK |
v
ESTABLISHED
|
发送 FIN | 收到 FIN
v
FIN_WAIT_1 CLOSE_WAIT
| |
v |
FIN_WAIT_2 |
| 收到 FIN |
v |
TIME_WAIT <---------- LAST_ACK
| |
2MSL 超时 | 收到 ACK |
v v
CLOSED CLOSED

不用担心记不住——实际开发中你很少需要手动跟踪这些状态。但理解了它,遇到网络问题时你就知道去哪里查。


五、流量控制:别淹死我!#

5.1 什么是流量控制?#

流量控制(Flow Control)解决的是:发送方发太快,接收方处理不过来怎么办?

接收方有一个接收缓存。如果发送方发得太快,缓存满了,后续的数据就会被丢弃。为了解决这个问题,TCP 让接收方在 ACK 中带上自己的接收窗口大小,告诉发送方「我还能吃多少」。

5.2 滑动窗口机制#

发送方维护一个滑动窗口,窗口大小 = 接收方通告的窗口大小。窗口内的数据可以连续发送,不用等确认。随着 ACK 到达,窗口向前「滑动」,露出新的可发送数据。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
发送窗口(Window = 400 bytes):
已确认 已发送未确认 可发送但未发 不可发送
+----------+------------------+--------------+------------------+
数据 | 0 ...99 | 100 ... 299 | 300 ... 499 | 500 ... |
+----------+------------------+--------------+------------------+
^ ^ ^
窗口左沿 已发送指针 窗口右沿

收到 ACK 200 后,窗口向右滑动 100 bytes:

已确认 已发送未确认 可发送但未发 不可发送
+--------------+------------------+--------------+----------+
数据 | 0 ... 199 | 200 ... 299 | 300 ... 599 | 600 ... |
+--------------+------------------+--------------+----------+

当接收方处理慢、缓存快满时,它会通告一个越来越小的窗口,甚至降到 0(零窗口)。发送方看到零窗口就暂停发送,等接收方缓出空间后再继续。

🍵 生活类比

流量控制就像你往浴缸里放水:

  • 水龙头(发送方)控制水流大小
  • 浴缸(接收缓存)有固定容量
  • 如果水放得太快,浴缸要溢出了,你得关小水龙头
  • 等水用了部分后,再把水龙头开大

接收方通告窗口大小,就是浴缸在喊「还能装多少水!」


六、拥塞控制:别把路堵了!#

6.1 流量控制 vs 拥塞控制#

很多人搞混这两个概念:

流量控制 拥塞控制
解决什么问题 发送方 vs 接收方 所有发送方 vs 网络
类比 别淹死浴缸 别堵死高速公路
依据 接收方的窗口通告 发送方自己的网络感知
控制者 接收方决定 发送方自己决定

流量控制是「点对点」的——发送方根据接收方的容量调速。

拥塞控制是「全局」的——发送方根据网络的整体拥堵情况调速。

6.2 拥塞控制的四大算法#

TCP 拥塞控制由四个经典算法组成,它们是 TCP 的灵魂。

① 慢启动(Slow Start)#

别被名字骗了——慢启动其实一点都不慢

连接刚建立时,发送方不知道网络能承受多少数据,所以从 cwnd = 1 MSS(最大报文段长度,通常约 1460 字节)开始。每收到一个 ACK,cwnd 加 1。

看起来每轮只加 1,但注意:每一轮的 ACK 数量等于这一轮发送的段数,所以 cwnd 实际上是指数增长的:

1
2
3
4
5
6
RTT 1: cwnd = 1   (发送 1 段)
RTT 2: cwnd = 2 (发送 2 段)
RTT 3: cwnd = 4 (发送 4 段)
RTT 4: cwnd = 8 (发送 8 段)
RTT 5: cwnd = 16 (发送 16 段)
...

1 → 2 → 4 → 8 → 16 → 32,蹭蹭蹭往上涨。直到达到一个阈值(ssthresh),就切换到拥塞避免模式。

🍵 生活类比

搬进新宿舍,不知道电线能承受多大功率。先开一盏灯(1),没问题?再开两盏(2),还是没事?开四盏(4)……一直翻倍试探,直到快跳闸了就停下来,改成一盏一盏地加。

② 拥塞避免(Congestion Avoidance)#

cwnd 达到 ssthresh 后,从指数增长切换为线性增长——每个 RTT 只增加 1 MSS。

这就像从「猛踩油门」切换到「轻踩油门」。因为已经接近网络容量了,要小心试探。

1
2
3
4
5
RTT 6: cwnd = 32  (ssthresh = 32)
RTT 7: cwnd = 33
RTT 8: cwnd = 34
RTT 9: cwnd = 35
...

③ 快重传(Fast Retransmit)#

之前提到了:如果收到 3 个冗余 ACK,说明那个段大概率丢了。TCP 不等超时,立刻重传丢失的段。

这比等超时(可能要等几秒)快多了。

④ 快恢复(Fast Recovery)#

快重传之后,TCP 不是从头开始慢启动,而是:

  1. ssthresh 设为当前 cwnd 的一半
  2. cwnd 设为新的 ssthresh
  3. 进入拥塞避免模式(线性增长)

也就是说,从一半的位置重新线性增长,而不是从 1 开始指数增长。这更合理,因为收到冗余 ACK 说明网络还在传输数据,只是有点堵,不是完全崩了。

但如果是超时丢包呢?(连冗余 ACK 都没收到,直接超时)

那就严重了:

  1. ssthresh = cwnd / 2
  2. cwnd = 1
  3. 回到慢启动模式

🍵 生活类比

你在高速上开车:

  • 慢启动:刚上高速,从低速开始,但很快加速(指数增长)
  • 拥塞避免:接近限速了,慢慢加(线性增长)
  • 快重传 + 快恢复:前面看到有几辆车急刹(冗余 ACK),你减速到一半,但没停车,继续慢慢加速
  • 超时:直接堵死了(超时),你只能从一档重新开始

6.3 完整的拥塞控制状态转移#

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
           cwnd = 1
|
慢启动(指数增长)
|
cwnd >= ssthresh
|
v
拥塞避免(线性增长)
/ \
/ \
3个冗余ACK 超时事件
/ \
v v
快重传 超时重传
ssthresh=cwnd/2 ssthresh=cwnd/2
cwnd=ssthresh cwnd=1
| |
快恢复(线性增长) 慢启动(指数增长)
| |
+--------------------+
|
继续循环...

6.4 TCP 变体:Reno vs Cubic vs BBR#

教科书上讲的是 TCP Reno(快重传+快恢复的发明者),但实际世界更丰富:

  • TCP Reno:经典版本,上面的算法就是它
  • TCP Cubic:Linux 默认(2008-至今),窗口增长函数是三次方程,在高带宽延迟网络中表现更好
  • TCP BBR:Google 开发(2016),不看丢包,而是根据 RTT 和带宽吞吐量来调整,在弱网环境下表现优异

你可以查看你的 Mac mini 当前用的是什么拥塞控制算法:

1
2
3
4
5
6
7
# macOS 查看TCP拥塞控制
sysctl net.inet.tcp.ecn_initiate
sysctl net.inet.tcp.sendspace

# 如果是 Linux:
# sysctl net.ipv4.tcp_congestion_control
# 通常输出 cubic 或 bbr

七、实战分析:你身边的 TCP#

7.1 SSH 连接中的 TCP#

你每天用 SSH 连 Mac mini,这就是 TCP 的典型场景:

1
2
3
4
5
6
# 查看 SSH 连接的完整信息
ss -tnp 'dport = :22 or sport = :22'

# 输出示例:
# Local Address:Port Peer Address:Port State
# 192.168.1.100:54321 192.168.1.200:22 ESTABLISHED

SSH 对可靠性要求极高——你不能容忍一个 rm 命令在传输中丢失然后没执行(或者误执行)。所以 SSH 用 TCP 是天经地义的选择。

7.2 frp 隧道中的 TCP#

你的 frp 内网穿透隧道,底层也是 TCP 连接:

  • frpc → frps:一条持久的 TCP 连接,所有流量复用这条连接
  • 用户 → frps → frpc → 内网服务:每次访问都可能有新的 TCP 连接建立和断开

这就是为什么你经常在 frp 日志里看到大量 TIME_WAIT 状态的连接——每个 HTTP 请求都对应一次 TCP 连接的生命周期。

7.3 实战:观察 TCP 重传#

1
2
3
4
5
6
7
8
9
10
# macOS 查看 TCP 统计信息
netstat -s -p tcp | head -40

# 重点关注这些指标:
# - packets sent
# - packets retransmitted (重传数)
# - connections established
# - connections closed

# 如果重传率高,说明网络质量不好或拥塞严重

7.4 实战:TCP 连接状态统计#

1
2
3
4
5
# 统计各状态的 TCP 连接数量
netstat -an -p tcp | awk '{print $6}' | sort | uniq -c | sort -rn

# 或者在 Linux 上使用 ss:
ss -tan | awk 'NR>1{print $1}' | sort | uniq -c | sort -rn

典型输出:

1
2
3
4
142 TIME_WAIT        # 正常,刚关闭的连接还在等 2MSL
35 ESTABLISHED # 活跃连接
3 LISTEN # 监听中的服务端口
1 CLOSE_WAIT # 有程序没正确关闭连接,值得留意

如果你看到大量 CLOSE_WAIT,通常意味着应用程序有 bug——收到对方的 FIN 后没有调用 close(),导致连接一直卡在 CLOSE_WAIT


八、TCP vs UDP:何时用谁?#

经过前面几天和今天的学习,我们来做个完整对比:

特性 TCP UDP
连接 面向连接(三次握手) 无连接
可靠性 可靠(重传、确认、序号) 不可靠(尽力而为)
顺序 有序到达 可能乱序
速度 较慢(开销大) 快(开销小)
头部大小 20 字节 8 字节
流量控制 有(滑动窗口)
拥塞控制 有(慢启动等)
适用场景 SSH、HTTP/1、文件传输 DNS、视频流、游戏、QUIC

值得注意的是,现代趋势是 UDP + 应用层可靠性 的组合。比如 HTTP/3(QUIC)就用 UDP 在应用层实现了类似 TCP 的可靠传输,同时避免了 TCP 的队头阻塞问题。这是后话了,等我们学到应用层进阶的时候再聊。


九、关键知识点回顾#

读完今天的内容,确认你掌握了以下要点:

  1. 三次握手:SYN → SYN+ACK → ACK,确保双向通信能力
  2. 四次挥手:FIN → ACK → FIN → ACK,允许半关闭(一方先停,另一方继续发)
  3. TIME_WAIT:等待 2MSL,保证最后的 ACK 到达 + 让旧报文消失
  4. 可靠传输四机制:序号与确认、超时重传、滑动窗口、冗余 ACK + 快重传
  5. 流量控制:接收方通过通告窗口大小控制发送方速度(点对点)
  6. 拥塞控制:发送方感知网络拥堵,调整发送速率(全局)
  7. 慢启动cwnd 从 1 开始,指数增长到 ssthresh
  8. 拥塞避免:超过 ssthresh 后线性增长
  9. 快重传:3 个冗余 ACK 触发立即重传,不等超时
  10. 快恢复:快重传后 cwnd 减半进入线性增长,不回 1
  11. 超时事件cwnd 回 1,ssthresh 减半,重新慢启动

🧠 记忆口诀

三握四挥建断连,
序号确认保可靠,
滑动窗口控流量,
慢启拥避快重恢。


十、Day 9 预告:进入网络层!#

今天我们彻底搞定了传输层。从明天开始,我们要往下钻一层,进入计算机网络的核心——网络层

Day 9 你将学到:

  • 🌐 网络层概述:数据平面 vs 控制平面,转发与路由的区别
  • 📦 数据平面:路由器是怎么读 IP 地址、查表、转发数据包的?
  • 🔧 路由器工作原理:输入端口、交换结构、输出端口, longest prefix matching(最长前缀匹配)

网络层是整个互联网的「骨架」——没有它,数据包就不知道该往哪走。

「TCP 像快递公司的调度系统,保证包裹送达。但包裹走哪条路?这是网络层的事。」

明天见!🚀


本文是《计算机网络:自顶向下方法》第 8 版读书笔记系列的第 8 篇。上一篇我们聊了传输层概述与 UDP,今天深入了 TCP 的方方面面。如果觉得有帮助,欢迎分享给也在学网络的朋友。