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 防护)。