UDP与传输基础 展示了传输层的最简方案:8 字节头部,不建立连接,不确认送达,不保证顺序——一切交给应用。UDP 的”极简”换来了低延迟和低开销,但也意味着丢包、乱序、重复全部需要应用自己处理。对于 Web 浏览、文件传输、邮件收发这些场景,应用层自己实现可靠性代价太高,需要传输层直接提供可靠、有序、按流的字节传输——这就是 TCP。
TCP 做了 UDP 不做的一切:建立连接、确认收据、重传丢失、按序交付、流量控制、拥塞感知。这些能力不是免费的——三次握手增加延迟,状态机增加复杂度,TIME_WAIT 占用资源。本章聚焦 TCP 连接的建立与释放:报文格式、三次握手、四次挥手、状态机、异常处理。至于 TCP 如何保证数据可靠传输、如何控制发送速率,留给 TCP流控与拥塞控制。
一、TCP的设计目标
1.1 从UDP到TCP:可靠性从何而来
UDP 把数据包扔给 IP 就不管了——到了没有、先来后到、重复与否,一概不问。这对 DNS 查询、实时音视频够用,但对大多数应用远远不够。TCP 要在 IP 的不可靠交付之上,构建一个可靠、有序、字节流式的传输服务。
| 特性 | UDP | TCP |
|---|---|---|
| 连接 | 无连接 | 面向连接(三次握手建立) |
| 可靠性 | 不确认、不重传 | 确认 + 重传 + 超时 |
| 有序性 | 不保证 | 按序交付(序号 + 排序) |
| 传输方式 | 数据报(保留边界) | 字节流(无边界) |
| 流量控制 | 无 | 滑动窗口 |
| 拥塞控制 | 无 | 慢启动 / 拥塞避免 / BBR |
| 头部开销 | 8 字节 | 20 字节(不含选项) |
| 适用场景 | 低延迟小包、实时音视频 | 可靠数据传输、Web/文件/邮件 |
1.2 TCP的五大核心能力
TCP 的设计围绕五个目标展开:
- 可靠交付:每个字节都有序号,接收方确认已收到的数据,发送方超时重传未确认的数据
- 按序到达:数据可能乱序到达,接收方根据序号重新排序后再交给应用
- 字节流:应用写入的是连续的字节流,TCP 不保留应用层写入的边界——两次 write 可能被合并为一段发送,一次 write 也可能被拆成多段
- 流量控制:接收方通过窗口字段告诉发送方”我还能接收多少字节”,防止发送方淹没接收方
- 拥塞感知:发送方感知网络拥塞,主动降低发送速率——这是 TCP流控与拥塞控制 的主题
字节流是 TCP 最容易误解的特性。send() 两次 100 字节,对端可能一次 recv() 收到 200 字节,也可能分三次收到 50+80+70 字节——TCP 不保证应用层消息边界。如果你需要保留消息边界,要么在应用层加长度前缀,要么用 UDP。
二、TCP报文段格式
2.1 头部字段详解
TCP 报文段由头部(最小 20 字节)和数据组成。头部是理解 TCP 一切行为的钥匙:
关键字段解读:
- 序号(seq):指向该报文段数据部分第一个字节的编号。初始序号(ISN)不是从 0 或 1 开始,而是随机生成
- 确认号(ack):期望收到的下一个字节的编号。ack = N 意味着”编号 N 之前的所有字节都已收到”
- 数据偏移:头部长度,以 4 字节为单位,最小 5(20 字节),最大 15(60 字节)
- 标志位:6 个控制位,每个 1 bit,决定了报文段的类型
- 窗口大小:接收方的可用缓冲区空间,用于流量控制
- 选项:最常见的是 Maximum Segment Size(MSS)、窗口缩放(Window Scale)、SACK Permitted、时间戳
2.2 六个标志位
| 标志 | 全称 | 含义 |
|---|---|---|
| URG | Urgent | 紧急指针有效(几乎不用) |
| ACK | Acknowledgment | 确认号有效(连接建立后始终置 1) |
| PSH | Push | 接收方应尽快交给应用层 |
| RST | Reset | 重置连接(异常终止) |
| SYN | Synchronize | 同步序号(握手阶段使用) |
| FIN | Finish | 发送方数据已结束(挥手阶段使用) |
2.3 常见选项字段
# 用 tshark 查看 TCP 选项tshark -i eth0 -f "tcp" -Y "tcp.flags.syn == 1" \ -T fields -e tcp.options.mss_val \ -e tcp.options.wscale.shift \ -e tcp.options.sack_perm \ -e tcp.options.timestamp.tsval| 选项 | 长度 | 作用 |
|---|---|---|
| MSS | 4 字节 | 通告本端最大段长,避免 IP 分片 |
| Window Scale | 3 字节 | 窗口缩放因子,16 bit 窗口最大 64KB,缩放后可达 1GB |
| SACK Permitted | 2 字节 | 声明支持选择性确认 |
| Timestamps | 10 字节 | 发送时间戳 + 回显时间戳,用于 RTT 测量和 PAWS |
三、三次握手
3.1 握手过程
TCP 建立连接需要三次报文交换——这就是著名的”三次握手”:
逐步拆解:
- SYN:客户端发送 SYN 报文段,seq = x(ISN),进入 SYN_SENT 状态
- SYN+ACK:服务器回复 SYN+ACK,seq = y(服务器 ISN),ack = x+1,进入 SYN_RCVD 状态
- ACK:客户端发送 ACK,seq = x+1,ack = y+1,双方进入 ESTABLISHED 状态
第三次握手可以携带数据——因为此时客户端已经处于 ESTABLISHED 状态,知道对方的 ISN 和窗口信息。
3.2 为什么不是两次?
两次握手的问题在于无法防止历史重复 SYN 导致的脏连接:
假设只有两次握手:客户端发送 SYN(seq=100),因网络延迟未到达服务器。客户端超时重发 SYN(seq=200),服务器响应,连接建立并传输数据后正常关闭。此时第一个 SYN(seq=100) 终于到达服务器,服务器回复 SYN+ACK,以为客户端要建立新连接——但客户端根本不想连。如果只有两次握手,服务器已经分配了资源等待数据,造成资源浪费。
三次握手时,服务器回复 SYN+ACK 后不会进入 ESTABLISHED,必须等客户端的第三次 ACK。客户端收到 SYN+ACK 后发现”我没发过这个 SYN”,发送 RST 拒绝,服务器就不会错误建立连接。
3.3 ISN的生成
初始序号(ISN)不是从 0 或 1 开始,而是每 4 微秒加 1 的计数器(约 2^32 / 4μs ≈ 4.55 小时循环)。随机 ISN 的目的是防止序号预测攻击——如果攻击者猜到 ISN,就能伪造 TCP 报文段注入数据。
# Linux ISN 生成算法(简化)# ISN = 随机偏移 + 基于连接四元组的哈希 + 时间递增计数器# 查看 ISN 相关参数cat /proc/sys/net/ipv4/tcp_isn
# 查看当前连接的序号ss -ti dst 93.184.216.343.4 SYN Cookie与SYN Flood防御
SYN Flood 是经典的 DoS 攻击:攻击者发送大量伪造源 IP 的 SYN,服务器为每个 SYN 分配 TCB(Transmission Control Block)并回复 SYN+ACK,等待永远不会来的第三次 ACK——资源很快耗尽。
| 对比项 | 正常握手 | SYN Cookie |
|---|---|---|
| 服务器状态 | 收到 SYN 即分配 TCB | 收到 SYN 不分配任何状态 |
| SYN+ACK 构造 | 正常回复 | 将状态编码进 ISN(时间戳 + MSS + 四元组哈希) |
| 第三次 ACK | 查找 TCB 验证 | 从 ack 值反算验证 ISN 合法性 |
| 优势 | 支持所有 TCP 选项 | 不消耗内存,免疫 SYN Flood |
| 劣势 | 消耗内存 | 无法携带 TCP 选项(MSS 除外),加密计算开销 |
# 启用 SYN Cookie(Linux 默认已启用)sysctl -w net.ipv4.tcp_syncookies=1
# 查看 SYN Cookie 统计cat /proc/net/netstat | grep Syncookies
# 调整 SYN 队列长度(增加半连接容量)sysctl -w net.ipv4.tcp_max_syn_backlog=8192
# 减少 SYN+ACK 重试次数(快速释放半连接)sysctl -w net.ipv4.tcp_synack_retries=2
# 查看当前半连接数量ss -n state syn-recv | wc -lSYN Cookie 不是万能药。它牺牲了 TCP 选项协商能力(窗口缩放、SACK、时间戳在 SYN Cookie 模式下无法使用),在高延迟网络中可能导致性能下降。生产环境应结合防火墙限速、SYN 代理等方案综合防御。
四、四次挥手
4.1 挥手过程
TCP 关闭连接需要四次报文交换——“四次挥手”:
逐步拆解:
- FIN:主动关闭方发送 FIN,表示”我没有数据要发了”,进入 FIN_WAIT_1
- ACK:被动关闭方确认收到 FIN,进入 CLOSE_WAIT——但被动方可能还有数据没发完
- FIN:被动关闭方数据发完后发送自己的 FIN,进入 LAST_ACK
- ACK:主动关闭方确认收到 FIN,进入 TIME_WAIT,等待 2MSL 后关闭
4.2 为什么不是三次?
关键在于 FIN 和 ACK 不能合并——被动关闭方收到 FIN 后,可能还有数据要发送。TCP 是全双工的,一个方向关闭不代表另一个方向也要关闭。只有当被动方也发完数据后,才发送自己的 FIN。
如果被动方恰好没有额外数据要发,ACK 和 FIN 确实可以合并成一次发送——这就是”三次挥手”的场景,但这是优化而非标准流程,不能依赖。
4.3 TIME_WAIT的作用
TIME_WAIT 是 TCP 最常被问到的状态,也是最容易出问题的状态。主动关闭方在发送最后一个 ACK 后,不立即进入 CLOSED,而是进入 TIME_WAIT,等待 2MSL(Maximum Segment Lifetime,报文最大生存时间)。
TIME_WAIT 存在的三个理由:
- 确保最后一个 ACK 到达:如果最后一个 ACK 丢失,被动方会重发 FIN。如果主动方已经 CLOSED,会回复 RST,被动方收到 RST 会报错。TIME_WAIT 期间可以重发 ACK
- 让网络中残留的报文消亡:2MSL 确保本连接的所有报文段在网络中消失,不会干扰同四元组的新连接
- 防止旧连接的报文被新连接误收:如果新连接使用了相同的四元组,旧连接的延迟报文可能被新连接错误接受
# 查看 TIME_WAIT 时长(Linux 默认 60 秒,即 2 * MSL=30s)cat /proc/sys/net/ipv4/tcp_fin_timeout
# 调整 TIME_WAIT 时长sysctl -w net.ipv4.tcp_fin_timeout=30
# 查看 TIME_WAIT 连接数量ss -n state time-wait | wc -l
# 允许 TIME_WAIT 连接被新连接复用(用于短连接高并发场景)sysctl -w net.ipv4.tcp_tw_reuse=1
# 查看连接状态分布ss -tan | awk 'NR>1{state[$1]++}END{for(s in state)print s, state[s]}'4.4 TIME_WAIT vs CLOSE_WAIT
这两个状态是排查连接泄漏时的重点:
| 对比项 | TIME_WAIT | CLOSE_WAIT |
|---|---|---|
| 谁进入 | 主动关闭方 | 被动关闭方 |
| 触发条件 | 发送最后一个 ACK 后 | 收到对方 FIN 后 |
| 等待什么 | 2MSL 超时 | 应用层调用 close() |
| 持续时间 | 内核控制(tcp_fin_timeout) | 应用控制(不调 close 就永远不退出) |
| 大量堆积原因 | 短连接高频创建关闭 | 应用 bug——忘记关闭套接字 |
| 解决思路 | 连接池、长连接、tcp_tw_reuse | 修复应用层 close() 逻辑 |
大量 CLOSE_WAIT 几乎一定是应用 bug——收到对方 FIN 后应用没有调用 close()。大量 TIME_WAIT 则是短连接高频创建关闭的正常现象,但可能耗尽端口资源。
五、TCP状态机
5.1 完整状态转换图
TCP 连接从建立到释放,经历一系列状态转换。理解状态机是排查连接问题的关键:
5.2 关键状态解读
- CLOSED:起始和终止状态,没有连接
- LISTEN:服务器等待连接,已绑定端口但还没收到 SYN
- SYN_SENT:客户端发了 SYN 等待回复,超时后回到 CLOSED
- SYN_RCVD:服务器收到 SYN 并回复了 SYN+ACK,等待第三次 ACK
- ESTABLISHED:连接建立,可以传输数据——这是”正常”状态
- FIN_WAIT_1 / FIN_WAIT_2:主动关闭方发了 FIN 后的等待状态
- CLOSE_WAIT:被动关闭方收到 FIN 后等待应用层 close()
- LAST_ACK:被动关闭方发了 FIN 等待最后一个 ACK
- TIME_WAIT:主动关闭方等待 2MSL,确保连接干净关闭
# 按状态统计 TCP 连接ss -tan | awk 'NR>1{state[$1]++}END{for(s in state)print s, state[s]}'
# 查看 ESTABLISHED 连接的详细信息ss -ti state established
# 查看 TIME_WAIT 连接ss -tn state time-wait
# 查看 CLOSE_WAIT 连接(排查泄漏)ss -tn state close-wait
# 查看特定端口的连接状态ss -tan '( sport = :80 or dport = :80 )'六、连接异常处理
6.1 RST报文
RST(Reset)是 TCP 的”紧急制动”——收到 RST 的一端必须立即关闭连接,不发送任何回复。RST 用于以下场景:
- 连接不存在时收到报文:端口未监听、连接已关闭,回复 RST
- 异常终止:应用调用
SO_LINGER设置 linger=0 后 close(),发送 RST 而非 FIN - 半打开连接检测:一端崩溃重启后收到旧连接的报文,回复 RST
- 防火墙拒绝:中间盒直接发送 RST 断开连接
# 用 scapy 发送 RST 报文(需要 root 权限)# 注意:仅用于学习实验,不要在生产环境使用python3 -c "from scapy.all import *rst_pkt = IP(dst='10.0.0.1')/TCP(sport=12345, dport=80, flags='R', seq=1000)send(rst_pkt)print('RST sent')"6.2 同时打开
两端同时向对方发送 SYN——虽然罕见,但 TCP 协议支持这种场景。双方同时从 SYN_SENT 跳到 SYN_RCVD,再各自发送 ACK 进入 ESTABLISHED。四次交换代替了三次,但最终都能建立连接。
6.3 同时关闭
两端同时发送 FIN——比同时打开更常见。双方都从 ESTABLISHED 进入 FIN_WAIT_1,收到对方 FIN 后进入 CLOSING,再收到 ACK 后进入 TIME_WAIT。最终两端都经历 TIME_WAIT 后关闭。
6.4 半关闭
TCP 支持半关闭(Half-Close):一方发送 FIN 后,另一方还可以继续发送数据。应用层通过 shutdown(fd, SHUT_WR) 实现半关闭——关闭写端但保留读端。这在 HTTP/1.0 中很常见:客户端发送完请求后关闭写端,服务器读取完请求后发送响应。
# Python 半关闭示例import socket
sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)sock.connect(('example.com', 80))
# 发送请求sock.sendall(b'GET / HTTP/1.0\r\nHost: example.com\r\n\r\n')
# 半关闭:关闭写端,通知服务器"我没有更多数据了"sock.shutdown(socket.SHUT_WR)
# 仍然可以读取服务器响应response = b''while True: chunk = sock.recv(4096) if not chunk: break response += chunk
sock.close()6.5 Keepalive
TCP 连接建立后,如果双方都不发数据,连接会一直处于 ESTABLISHED——即使中间路由器已经崩溃、对端主机已经断电。Keepalive 机制定期探测连接是否仍然存活:
# Linux TCP Keepalive 参数# 探测间隔(秒):最后一次数据交换后多久开始探测sysctl net.ipv4.tcp_keepalive_time# 默认 7200 秒(2 小时)
# 探测间隔(秒):每次探测之间的间隔sysctl net.ipv4.tcp_keepalive_intvl# 默认 75 秒
# 探测次数:连续失败多少次后认为连接死亡sysctl net.ipv4.tcp_keepalive_probes# 默认 9 次
# 调整 Keepalive 参数(更积极地探测)sysctl -w net.ipv4.tcp_keepalive_time=600sysctl -w net.ipv4.tcp_keepalive_intvl=30sysctl -w net.ipv4.tcp_keepalive_probes=3
# 应用层设置 Keepalive(C 语言示例)# int keepalive = 1;# int keepidle = 60; // 60 秒后开始探测# int keepintvl = 10; // 每 10 秒探测一次# int keepcnt = 3; // 3 次失败后断开# setsockopt(fd, SOL_SOCKET, SO_KEEPALIVE, &keepalive, sizeof(keepalive));# setsockopt(fd, IPPROTO_TCP, TCP_KEEPIDLE, &keepidle, sizeof(keepidle));# setsockopt(fd, IPPROTO_TCP, TCP_KEEPINTVL, &keepintvl, sizeof(keepintvl));# setsockopt(fd, IPPROTO_TCP, TCP_KEEPCNT, &keepcnt, sizeof(keepcnt));七、动手实践:抓包观察TCP连接生命周期
7.1 完整抓包实验
用一个 HTTP 请求观察 TCP 从握手到挥手的完整过程:
# 终端 1:抓包sudo tcpdump -i eth0 -w tcp_lifecycle.pcap \ 'host 93.184.216.34 and tcp port 80'
# 终端 2:发起 HTTP 请求curl -s -o /dev/null http://example.com/
# 终端 1:停止抓包(Ctrl+C),分析7.2 用tshark分析握手与挥手
# 查看完整的 TCP 标志位序列tshark -r tcp_lifecycle.pcap -Y "tcp.flags.syn == 1 || tcp.flags.fin == 1 || tcp.flags.reset == 1" \ -T fields -e frame.number -e ip.src -e ip.dst \ -e tcp.srcport -e tcp.dstport -e tcp.flags \ -e tcp.seq -e tcp.ack
# 预期输出类似:# 1 10.0.0.1 93.184.216.34 52840 80 0x0002 (SYN) seq=0# 2 93.184.216.34 10.0.0.1 80 52840 0x0012 (SYN+ACK) seq=0 ack=1# 3 10.0.0.1 93.184.216.34 52840 80 0x0010 (ACK) seq=1 ack=1# ... 数据传输 ...# N 10.0.0.1 93.184.216.34 52840 80 0x0011 (FIN+ACK)# N+1 93.184.216.34 10.0.0.1 80 52840 0x0010 (ACK)# N+2 93.184.216.34 10.0.0.1 80 52840 0x0011 (FIN+ACK)# N+3 10.0.0.1 93.184.216.34 52840 80 0x0010 (ACK)# 查看 SYN 报文中的 TCP 选项tshark -r tcp_lifecycle.pcap -Y "tcp.flags.syn == 1" \ -T fields -e ip.src -e tcp.options.mss_val \ -e tcp.options.wscale.shift -e tcp.options.sack_perm
# 查看 RTT(基于时间戳选项)tshark -r tcp_lifecycle.pcap -Y "tcp.analysis.ack_rtt" \ -T fields -e frame.number -e tcp.analysis.ack_rtt7.3 用ss观察连接状态
# 实时观察连接状态变化watch -n 0.5 'ss -tan | awk "NR>1{state[\$1]++}END{for(s in state)print s, state[s]}"'
# 在另一个终端发起连接curl -s http://example.com/ &
# 查看特定连接的详细信息ss -ti dst 93.184.216.34
# 输出包含:rtt、cwnd、mss、pacing 等内核参数7.4 Wireshark显示过滤器
在 Wireshark 图形界面中,以下过滤器有助于分析 TCP 连接生命周期:
# 仅显示握手和挥手报文tcp.flags.syn == 1 || tcp.flags.fin == 1 || tcp.flags.reset == 1
# 仅显示特定四元组的流量ip.addr == 93.184.216.34 && tcp.port == 80
# 显示重传tcp.analysis.retransmission
# 显示零窗口(流控问题)tcp.window_size_value == 0
# 显示 TCP Keepalive 探测tcp.keep_alive
# 跟踪完整的 TCP 流tcp.stream eq 0八、本章小结
| 概念 | 要点 |
|---|---|
| TCP 设计目标 | 可靠、有序、字节流、流量控制、拥塞感知——与 UDP 的极简形成对比 |
| 报文段格式 | 20 字节基础头部:seq/ack 实现可靠传输,标志位控制连接状态,窗口实现流控 |
| 三次握手 | SYN→SYN+ACK→ACK,三次交换确保双方确认彼此 ISN,防止历史 SYN 建立脏连接 |
| ISN 生成 | 随机化防止序号预测攻击,SYN Cookie 将状态编码进 ISN 抵御 SYN Flood |
| 四次挥手 | FIN→ACK→FIN→ACK,TCP 全双工导致每个方向需要单独关闭 |
| TIME_WAIT | 2MSL 等待确保最后 ACK 到达、残留报文消亡;大量堆积用 tcp_tw_reuse 缓解 |
| CLOSE_WAIT | 被动方收到 FIN 后等待应用 close(),大量堆积一定是应用 bug |
| TCP 状态机 | CLOSED→SYN_SENT→ESTABLISHED→FIN_WAIT→TIME_WAIT→CLOSED,11 个状态 |
| RST | 紧急终止连接,端口未监听、异常关闭、防火墙拒绝都会触发 |
| Keepalive | 定期探测连接存活,默认 2 小时开始探测,生产环境应缩短 |
UDP 把可靠性全部甩给应用,TCP 把可靠性全部打包给你——三次握手确保双方准备好通信,序号和确认保证数据不丢不乱,四次挥手确保双方都体面退出。但 TCP 的可靠性不是免费的:握手增加延迟,状态机增加复杂度,TIME_WAIT 占用资源。理解这些代价,才能在需要时做出正确的选择——比如短连接场景用连接池减少握手开销,高并发场景用 tcp_tw_reuse 缓解端口耗尽。
连接建立之后,数据如何可靠、高效地传输?发送方怎么知道该发多快?接收方怎么告诉发送方”慢一点”?网络拥塞时 TCP 如何反应?下一章 TCP流控与拥塞控制 将深入滑动窗口、慢启动、拥塞避免、快速重传与 BBR——TCP 性能调优的核心知识。
支持与分享
如果这篇文章对你有帮助,欢迎支持作者或分享给更多人
部分信息可能已经过时






