8.1 TCP/IP 与三次握手

一句话

TCP 在 “尽力而为” 的 IP 之上,用三次握手换到一条双向可靠的字节流;代价是建连必付 1 个 RTT,且服务端要为 “已握手、但还没被应用取走” 的连接准备两条队列。

TCP/IP 是四层分工,不是四个协议

层 代表协议 管什么 不管什么
应用层 HTTP、DNS、gRPC、MQTT 业务语义、报文格式 丢包、乱序、重传
传输层 TCP、UDP、QUIC 进程到进程、可靠、有序、流量控制 走哪条路
网络层 IP、ICMP、路由协议 主机到主机、尽力转发 可靠、有序、不重
链路层 以太网、ARP、Wi-Fi 一跳之内把帧送到下一跳 跨网段

🔑 **IP 只承诺 “尽量送到”,TCP 承诺 “送到、按序、不重、不丢”。**所有的可靠性都是两端在端到端补出来的,中间路由器不参与,也不会替你重传。

一个 TCP 段里,六个字段决定一切

字段 长度 作用 出问题时你会看到
源 / 目的端口 各 16 bit 定位进程,与 IP 组成四元组 端口耗尽、连错实例
seq 序号 32 bit 本段首字节在字节流中的位置 乱序、重传、重复数据
ack 确认号 32 bit 期望收到的下一个字节 卡住、长尾、吞吐上不去
flags 9 bit SYN / ACK / FIN / RST / PSH / URG 建连失败、被重置、半关闭
window 窗口 16 bit 接收方还能收多少(流量控制) 吞吐被锁死(见 8.2 拥塞控制)
MSS 选项 SYN 中协商 单个段最大载荷 分片、PMTU 黑洞

💡 TCP 头最小 20 字节 (不含选项),最大 60 字节。MSS = MTU − IP 头 − TCP 头:以太网 1500 − 20 − 20 = 1460。带时间戳等 12 字节选项时,实际可用 1448。

三次握手:四个方向的能力,三个包确认

客户端                                     服务端
CLOSED                                     LISTEN
  │                                          │
  │────── SYN  seq=x  ──────────────────────▶│  客户端 → SYN_SENT
  │                                          │  服务端 → SYN_RCVD
  │◀───── SYN+ACK  seq=y, ack=x+1 ───────────│
  │                                          │
  │────── ACK  ack=y+1  ────────────────────▶│  双方 → ESTABLISHED
  │                                          │
ESTABLISHED                              ESTABLISHED
步骤 报文 序号关系 交换了什么信息
① SYN seq=x 客户端能发;请求建连,并告知自己的 MSS、窗口缩放因子
② SYN+ACK seq=y, ack=x+1 服务端收到了 x;服务端能发 y;回告自己的 MSS 与窗口
③ ACK ack=y+1 客户端收到了 y;服务端据此确认「双向都通」

三个容易记错的点:

  • SYN 占一个序号:所以确认号是 x+1 / y+1。第一个数据字节的序号是 x+1。
  • ACK 不占序号:纯 ACK 不消耗序号,所以第三个包可以直接捎带数据 (HTTP 请求的第一段就挂在这上面)。
  • ISN 不是 0:现代实现用 RFC 6528 的算法 (四元组 + 密钥 + 时钟) 随机化初始序号,防伪造、防旧报文串台。

为什么是三次,不是两次,也不是四次

两次握手省掉第三个包,会在三个场景翻车:

两次握手的场景 后果
服务端发完 SYN+ACK 就当连接建立 客户端没收到,服务端白建一条连接等数据 → 半开连接堆积,正好被 SYN flood 放大
客户端重发的旧 SYN 迟到 服务端建了一条「客户端毫不知情」的连接,同一业务重复建连
服务端无法确认客户端收不收得到 服务端开始推数据,全打水漂,只能靠 RTO 超时发现

四次也没必要:

  • SYN 和 ACK 可以合并——ACK 只确认 x,SYN 只声明 y,两件事互不依赖,合成一个包不丢信息。
  • “对方能收” 这件事,第三次 ACK 已经一次覆盖了双向。

🔑 一句话记:**三次 = 双方各发一次、各确认一次的最小值。**少一次,总有一方不知道 “对方到底能不能收”。

握手完成 ≠ 应用能处理:两条队列

内核在收完第三个 ACK 后就进入 ESTABLISHED,但此时应用可能还没调 accept()。连接会先待在全连接队列里:

队列 存什么 上限 满了会怎样
SYN 队列(半连接) 收到 SYN、还没收到第三个 ACK net.ipv4.tcp_max_syn_backlog 丢弃 SYN,客户端重传(1s、2s、4s…)
accept 队列(全连接) 已完成握手、等应用 accept() min(backlog, net.core.somaxconn) 默认丢弃第三个 ACK;tcp_abort_on_overflow=1 时回 RST

backlog 是应用传给 listen() 的值,不等于队列上限——内核取 min(backlog, somaxconn)。Go 的 net.Listen 直接把 backlog 传成 somaxconn,所以上限实际由 somaxconn 决定。

看一眼队列水位:

# Linux:Recv-Q = 当前排队数,Send-Q = 队列上限
ss -lnt '( sport = :8080 )'

# 溢出计数:ListenOverflows = accept 队列溢出,TCPReqQFullDrop = SYN 队列丢弃
nstat -az TcpExtListenOverflows TcpExtListenDrops TcpExtTCPReqQFullDrop

🔑 **握手成功只说明内核收下了连接,不说明你的进程能处理它。**accept 队列打满时,客户端看到的是 “连接超时 / 连接被重置”,而服务端 CPU 可能一点都不高——这类故障最容易被误判成 “网络抖动”,实际原因是 accept 太慢或 worker 不够。

SYN flood 与 SYN cookie:攻击者伪造源 IP 猛发 SYN,把 SYN 队列塞满,真用户进不来。防御是 net.ipv4.tcp_syncookies=1——队列满时不建连接状态,把必要信息编码进 ISN,等第三个 ACK 回来再还原。代价是 SYN 里的选项 (窗口缩放、SACK) 会丢,高带宽场景吞吐下降。

MSS、MTU 与分片

  • 应用层写 2MB,TCP 不会发一个 2MB 的包:内核按 MSS 切成 1460 字节的段,逐段编号、逐段确认。
  • 路径 MTU 发现 (PMTUD):靠 IP 头的 DF 位 + ICMP “需要分片” 消息。ICMP 被防火墙拦掉 → PMTU 黑洞,症状是 “小请求正常,大响应永远卡住”。
  • IPv6 不允许中间路由器分片,最小 MTU 1280 → MSS 1220,PMTUD 必须能通。
  • 抓包里看到 MSS=1460 是正常值;看到 MSS=536 多半是走了 PPPoE / VPN / 隧道,封装又吃了一层头。

关闭:四次挥手,谁先关谁进 TIME_WAIT

主动关闭方                              被动关闭方
  │────── FIN  seq=u ──────────────────▶│  ESTABLISHED → CLOSE_WAIT
  │◀───── ACK  ack=u+1 ─────────────────│   (半关闭:还能发数据)
  │◀───── FIN  seq=w ───────────────────│  CLOSE_WAIT → LAST_ACK
  │────── ACK  ack=w+1 ────────────────▶│  CLOSED
  │
TIME_WAIT(等 2×MSL)
  • 半关闭:shutdown(SHUT_WR) 只关发送方向,仍可收;close() 是双向关。
  • CLOSE_WAIT 堆积 = 代码没 close (本端没调 close()),是应用 bug,不是网络问题。
  • TIME_WAIT 堆积 = 协议正常,但量太大。2×MSL 的等待、端口耗尽、怎么治,全部放在 8.3。

实测:握手就是 1 个 RTT

package main

import (
	"fmt"
	"io"
	"net"
	"time"
)

func main() {
	ln, err := net.Listen("tcp", "127.0.0.1:0")
	if err != nil {
		panic(err)
	}
	defer ln.Close()

	// 服务端:收 1 字节,原样回写
	go func() {
		for {
			c, err := ln.Accept()
			if err != nil {
				return
			}
			go func(c net.Conn) {
				defer c.Close()
				buf := make([]byte, 1)
				for {
					if _, err := c.Read(buf); err != nil {
						return
					}
					c.Write(buf)
				}
			}(c)
		}
	}()

	const n = 20
	var dialSum, echoSum time.Duration
	for i := 0; i < n; i++ {
		t0 := time.Now()
		c, err := net.Dial("tcp", ln.Addr().String()) // 含三次握手
		t1 := time.Now()
		if err != nil {
			panic(err)
		}
		c.Write([]byte("x"))
		io.ReadFull(c, make([]byte, 1)) // 一次请求-响应 = 1 RTT
		t2 := time.Now()
		c.Close()

		dialSum += t1.Sub(t0)
		echoSum += t2.Sub(t1)
	}
	fmt.Printf("握手平均 %v,一次回环 echo 平均 %v\n", dialSum/n, echoSum/n)
}

本机回环实测 (Go 1.27,macOS,Apple M1):

握手平均 150.812µs,一次回环 echo 平均 91.681µs

回环上握手耗时约为一次 echo 的 1.6 倍 (多次运行在 1.6–1.8 倍之间)——多出来的是内核建连、分配 socket、状态机推进。真正要看的是外推:

链路 RTT 建连(握手) 一次请求-响应 新建连接的 HTTP 总开销
同机房 0.5 ms 0.5 ms 0.5 ms 1.5 ms(TCP + TLS1.3 + 请求)
同城 2 ms 2 ms 2 ms 6 ms
北京–上海 30 ms 30 ms 30 ms 90 ms
中美 180 ms 180 ms 180 ms 540 ms

🔑 表里最后一列就是 “短连接为什么致命” 的全部答案:在 30ms 的链路上,光协议开销就 90ms,且慢启动还没跑完连接就关了。连接复用 (8.3) 省的就是这三段。

观测命令 (可直接抄)

# 抓握手:-S 打印绝对序号,能看到 SYN 的 x 与 ACK 的 x+1
sudo tcpdump -i any -nn -S 'tcp port 8080 and (tcp[tcpflags] & (tcp-syn|tcp-ack) != 0)'

# macOS(无 any 网卡)
sudo tcpdump -i lo0 -nn -S 'tcp port 8080'

# 连接状态分布
ss -tan state established | wc -l        # Linux
netstat -an -p tcp | grep -c ESTABLISHED # macOS

# SYN 重传与拒绝
nstat -az TcpExtTCPSynRetrans TcpExtTCPReqQFullDrop
  • SYN 重传次数由 net.ipv4.tcp_syn_retries 决定,默认 6 次,RTO 按 1s/2s/4s/8s/16s/32s/64s 翻倍——建连失败要等约 127 秒。应用层不设 DialTimeout 就会一直卡到两分钟,这才是 “连接超时” 的真正默认值。
  • 客户端收到 RST 的两种情况:目标端口没有监听;连接已关闭后又有数据到达 (半开连接)。

反模式

反模式 后果
把「连接超时」直接归因于网络抖动,不看 accept 队列 真实原因是应用 accept 慢,扩容网络无效
只调大 somaxconn,不改应用 accept 速度 队列只是缓冲,积压到上限仍会丢包
把 tcp_abort_on_overflow 当优化打开 队列一满就对客户端回 RST,把「慢」变成「错」
依赖 ICMP 做 PMTUD,却在安全组封掉 ICMP 大包黑洞:小请求正常、大响应卡死
用 CLOSE_WAIT 多来判断网络故障 CLOSE_WAIT 是本端没 close,属于代码 bug
短连接跑跨地域调用 每次多付 1 RTT 握手 + 慢启动,P99 直接翻倍

自检

Q1 有人主张 “两次握手就够:客户端发 SYN,服务端回 SYN+ACK,连接就建立”。请举一个两次握手一定会出问题的具体场景。

▸ 答案:服务端回完 SYN+ACK 就认为连接存在,但它无法确认客户端是否收到。若该 SYN+ACK 丢失 (或客户端已经超时放弃),服务端就挂了一条永远不会有数据的连接,直到超时才释放——这正是 SYN flood 的放大器。另外,迟到的旧 SYN 会让服务端建立一条客户端毫不知情的连接,造成重复建连。第三次 ACK 的唯一作用就是让服务端确认 “客户端收到了我的 SYN”。

Q2 服务端 CPU 只有 10%,但客户端大量报 “连接超时”,ss -lnt 显示 Recv-Q 等于 Send-Q。问题在哪一层?怎么改?

▸ 答案:accept 队列 (全连接队列) 打满。三次握手已经由内核完成,但应用 accept() 太慢,连接堆在队列里;新来的第三个 ACK 被丢弃,客户端表现为建连超时。改法:查应用是否有阻塞操作卡住 accept 循环、加 accept 并发 / worker、再配合调大 backlog 与 net.core.somaxconn;同时看 TcpExtListenOverflows 是否在涨。

Q3 抓包看到服务端 SYN+ACK 里的 MSS=536,但你的服务是千兆内网,这意味着什么?

▸ 答案:536 是 “MTU 576 的老默认值”,通常说明 SYN 的 MSS 选项被中间设备改写或丢弃 (VPN、隧道、老防火墙、负载均衡的协议栈),也可能是走了 PPPoE。后果是每个段只能带 536 字节,同样的数据要发近 3 倍的包,吞吐和 CPU 都会变差。排查方向是链路 MTU、隧道封装开销和中间设备的 MSS 改写策略,而不是应用代码。

Q4 为什么握手是三次而不是两次?

▸ 答案:因为两次无法让双方都确认 “自己的发送与对方的接收都正常”,且无法排除 “历史重复连接”:一个陈旧的 SYN 到达后,若只两次握手,服务端就直接建立连接并分配资源,客户端却不会使用它。三次让客户端再确认一次,服务端才确认 “对方真的收到了我的 SYN-ACK”。

Q5 半连接队列(SYN queue)满了会怎样?

▸ 答案:新到的 SYN 会被丢弃,客户端重传后仍可能失败,表现为 “连接超时/偶发失败”。对策是调大 tcp_max_syn_backlog、开启 syncookies(不保存半连接状态),并排查是否有 SYN Flood 或客户端重试风暴(见 13.2 DDoS 防护)。