Skip to content
Go back

一文串起 TCP:从头部字段、三次握手到滑动窗口与快速恢复

Edit page

很多人第一次系统学习网络时,会把 TCPUDPICMP、三次握手、黏包、 滑动窗口、快速重传、DDoS 混成一团。单个概念拆开看都不难,但一旦放到同一条链路里,就容易只记住名词,不知道它们为什么会一起出现。

本文尝试沿着一条更工程化的主线来整理这些概念:先说明 TCPUDPICMP 各自解决什么问题,再进入 TCP 头部字段、三次握手、连接关闭、字节流与黏包、 滑动窗口、拥塞控制,最后再解释 UDP FloodDDoS 为什么常被拿来和 UDPICMP 放在一起讨论。

文中对 TCP 的基本语义主要参考 RFC 9293(2022)与拥塞控制相关的 RFC 5681(2009)。不同操作系统和内核实现会有细节差异,但主干理解基本一致。

一、先把分工看清:TCP、UDP、ICMP 各负责什么

从传输和网络层的视角看,这三个协议关注点不同:

  • TCP:面向连接、可靠传输、按序到达、字节流语义
  • UDP:无连接、尽力而为、不保证可靠与顺序
  • ICMP:传递差错报告和控制信息,不承载业务会话

工程上可以这样理解:

  • 当你希望数据可靠送达,并且希望应用只面对一条连续的字节流时,常用 TCP
  • 当你更在意低开销、低时延,或者上层协议自己处理可靠性时,常用 UDP
  • 当网络需要反馈“目标不可达”“超时”“回显应答”这类信息时,会用 ICMP

例如:

  • 浏览器访问传统 HTTPS 站点时,底层通常是 TCP
  • DNS、语音视频、游戏实时通道常会使用 UDP
  • ping 使用的是 ICMP

这一层分工理解清楚后,再看 TCP 里的各种字段和状态,才不会失去上下文。

二、TCP 头部字段到底在干什么

TCP 头部最少 20 字节,常见字段可以分成四组:

  • 标识通信双方:源端口、目的端口
  • 保证可靠传输:序列号、确认号、校验和
  • 控制连接状态:SYNACKFINRST
  • 调节发送速度:窗口大小、选项字段

最需要先掌握的是这些字段:

1. 端口号

源端口和目的端口各占 16 bit,用于标识通信两端的应用进程。

2. 序列号与确认号

  • Sequence Number:本报文段中数据的起始字节编号
  • Acknowledgment Number:期望收到对方下一个字节的编号

TCP 是面向字节流的协议,确认的不是“第几个包”,而是“哪一段字节已经收到”。

3. 标志位

常见标志位包括:

  • SYN:发起连接,同步初始序列号
  • ACK:确认号字段有效
  • FIN:发送方向对方声明“我没有更多数据了”
  • RST:强制复位连接
  • PSH:提示接收方尽快把数据交给应用层
  • URG:紧急指针有效

4. 窗口大小

头部里的 Window 字段对应接收窗口 rwnd,全称是 Receive Window。它表示接收方当前还能再接收多少字节,用于流量控制。

5. 选项字段

常见选项包括:

  • MSSMaximum Segment Size,最大报文段长度
  • Window Scale:窗口扩大选项
  • SACK:选择确认
  • Timestamp:时间戳

头部字段不是为了“记忆表格”而存在,而是在为三件事服务:建立连接、可靠传输、控制发送节奏。

三、三次握手里,SYN、ACK、ISN 分别是什么关系

三次握手的目标不是“打招呼”这么简单,而是让双方确认:

  • 对端可达
  • 双向收发能力存在
  • 双方各自的初始序列号已经同步

这里的 ISNInitial Sequence Number,也就是初始序列号。它不是一个单独字段,而是三次握手时写在 Sequence Number 字段里的初始值。

典型过程如下:

Client                              Server
  | ---- SYN, seq = x ------------> |
  | <--- SYN+ACK, seq = y, ack=x+1 -|
  | ---- ACK, ack = y+1 ----------> |

这里要特别注意两点:

第一,xy 没有计算关系。客户端选择自己的 ISN = x,服务端独立选择自己的 ISN = yy 不是 x + 某个长度 算出来的。

第二,为什么服务端确认的是 x + 1 而不是 x?因为 SYN 本身要占用一个序号。

同理,客户端最后确认的是 y + 1,因为服务端发出的 SYN 也占用了一个序号。

四、为什么 SYN 和 FIN 各占一个序号,但 ACK 不占

这个问题的关键不在“是不是带了一个标志位”,而在于它是否代表了一个需要被可靠确认的控制事件。

  • SYN 表示连接建立阶段的同步动作,需要被确认,因此占一个序号
  • FIN 表示字节流结束标记,也需要被确认,因此占一个序号
  • ACK 只是对已有内容的确认回执,不引入新的字节流边界或连接状态事件,因此不单独占序号

可以把它们理解成:

  • SYN:连接开始标记
  • FIN:连接结束标记
  • ACK:回执

所以:

  • SYN, seq = x 收到后,对方回 ack = x + 1
  • FIN, seq = n 收到后,对方回 ack = n + 1
  • 一个纯 ACK 报文不会因为自己“发出了确认”而消耗新的序号

五、RST 是什么,和 FIN 有什么区别

RSTReset,表示复位连接。它和 FIN 的核心差别是:

  • FIN:正常关闭,走完剩余数据和挥手流程
  • RST:异常或强制中止,连接立即作废

常见触发场景包括:

  • 访问一个没有进程监听的目标端口
  • 一方认为连接已经不存在,另一方却还在发数据
  • 应用程序或内核直接强制终止连接

收到 RST 后,应用层通常会看到类似 connection reset by peer 的错误。它更像“立刻挂断”,而不是“我这边数据发完了,你也收尾吧”。

六、为什么会有黏包:TCP 只认字节流,不认消息边界

所谓 TCP 黏包,本质上不是传输层“包真的粘住了”,而是应用层误把 TCP 当成了有消息边界的协议。

TCP 只保证:

  • 字节可靠到达
  • 字节按序到达

它不保证:

  • 你调用一次 send,对方就正好一次 recv 收到
  • 每次读取都刚好对应一条业务消息

因此发送两条消息:

Hello
World

接收端可能读到:

  • HelloWorld
  • HelloWorld
  • 前一条消息尾部加后一条消息头部

这也是为什么常说黏包和拆包总是一起出现。根因是:TCP 是面向字节流,而不是面向消息。

解决思路也都在应用层定义“边界”:

  • 固定长度协议
  • 分隔符协议,例如 \n
  • 长度字段 + 消息体

工程实践里最常见的是”长度前缀 + 负载”的协议格式。

用 Go 实现”长度前缀 + 消息体”协议

下面用一个示例展示如何用 4 字节长度前缀来解决黏包问题。 协议格式为:[4 字节长度][消息体]

协议编解码工具

// protocol.go
package main

import (
encoding/binary
io
)

const maxMessageSize = 1024 * 1024 // 限制最大 1MB,防止恶意包

// EncodeMessage 将消息编码为 [长度][消息体] 格式
func EncodeMessage(msg []byte) []byte {
	buf := make([]byte, 4+len(msg))
	binary.BigEndian.PutUint32(buf[:4], uint32(len(msg)))
	copy(buf[4:], msg)
	return buf
}

// ReadMessage 从连接中读取一条完整消息
func ReadMessage(r io.Reader) ([]byte, error) {
	// 先读 4 字节长度
	lenBuf := make([]byte, 4)
	if _, err := io.ReadFull(r, lenBuf); err != nil {
		return nil, err
	}
	length := binary.BigEndian.Uint32(lenBuf)

	// 安全检查
	if length > maxMessageSize {
		return nil, io.ErrShortBuffer
	}

	// 再读消息体
	body := make([]byte, length)
	if _, err := io.ReadFull(r, body); err != nil {
		return nil, err
	}
	return body, nil
}

服务端代码

// server.go
package main

import (
fmt
io
log
net
)

func handleConn(conn net.Conn) {
	defer conn.Close()
	fmt.Printf(“客户端已连接: %s\n”, conn.RemoteAddr())

	for {
		msg, err := ReadMessage(conn)
		if err != nil {
			if err != io.EOF {
				log.Printf(“读取错误: %v”, err)
			}
			break
		}
		fmt.Printf(“收到 (%d 字节): %s\n”, len(msg), string(msg))

		// Echo: 编码后返回
		reply := EncodeMessage(msg)
		conn.Write(reply)
	}
	fmt.Printf(“客户端断开: %s\n”, conn.RemoteAddr())
}

func main() {
	listener, err := net.Listen(“tcp”, “:8080”)
	if err != nil {
		log.Fatal(err)
	}
	defer listener.Close()
	fmt.Println(“服务端启动监听 :8080”)

	for {
		conn, err := listener.Accept()
		if err != nil {
			log.Printf(“接受连接失败: %v”, err)
			continue
		}
		go handleConn(conn)
	}
}

客户端代码

// client.go
package main

import (
bufio
fmt
io
log
net
os
)

func main() {
	conn, err := net.Dial(“tcp”, “127.0.0.1:8080”)
	if err != nil {
		log.Fatal(err)
	}
	defer conn.Close()
	fmt.Println(“已连接到服务端”)

	// 发送协程:读取标准输入,编码后发送
	go func() {
		scanner := bufio.NewScanner(os.Stdin)
		for scanner.Scan() {
			line := scanner.Text()
			encoded := EncodeMessage([]byte(line))
			conn.Write(encoded)
		}
		conn.Close()
	}()

	// 接收协程:按协议解码
	for {
		msg, err := ReadMessage(conn)
		if err != nil {
			if err != io.EOF {
				log.Printf(“读取响应错误: %v”, err)
			}
			break
		}
		fmt.Printf(“服务端返回: %s\n”, string(msg))
	}
	fmt.Println(“连接已关闭”)
}

运行方式

# 终端 1:启动服务端
go run protocol.go server.go

# 终端 2:启动客户端
go run protocol.go client.go

为什么能解决黏包

假设客户端连续发送两条消息 ”Hello””World”,编码后的字节流是:

[00 00 00 05] [H e l l o] [00 00 00 05] [W o r l d]
 长度=5       消息体       长度=5       消息体

即使这两条数据被合并成一个 TCP 段发送,或者被拆成多个段,接收方只需:

  1. 先读 4 字节,得到消息长度
  2. 再读对应长度的消息体
  3. 循环处理下一条消息

消息边界由长度字段明确标识,不再依赖 TCP 的分段行为。

七、滑动窗口怎么理解:rwnd 管接收能力,cwnd 管网络承受能力

如果没有滑动窗口,发送方每发一个报文段都要停下来等确认,链路利用率会很低。滑动窗口机制允许发送方在未收到全部确认前,先把一段范围内的数据连续发出去。

可以把窗口理解成“当前允许飞在网络中的未确认数据量”。

acked | sent_not_acked | can_send | cannot_send_yet

窗口之所以能“滑动”,是因为新的 ACK 到达后,左边已经确认的数据出窗,右边新的可发送范围进窗。

这里有两个很容易混淆的概念:

  • rwndReceive Window,接收方还能收多少
  • cwndCongestion Window,发送方认为网络还能承受多少

两者作用不同:

  • rwnd 关注接收端缓冲区,会做流量控制
  • cwnd 关注网络拥塞状态,会做拥塞控制

发送方真正能发出的窗口通常受两者共同限制:

send_window = min(rwnd, cwnd)

所以“窗口大小”不是一个来源。一个是接收方告诉你的上限,一个是你根据网络状况给自己的上限。

八、什么是拥塞窗口,以及它为什么会变

拥塞窗口 cwnd 的全称是 Congestion Window。它不是固定值,而是发送方根据当前网络反馈动态调整出来的。

它的核心目标不是“让自己发得越快越好”,而是:

  • 尽量提高吞吐量
  • 但不要把网络直接压垮

典型规律是:

  • 刚开始时较小,先试探网络容量
  • 如果确认持续正常返回,就逐步增大
  • 如果出现丢包、超时或重复确认,说明可能拥塞,就收缩窗口

这也是为什么说 rwndcwnd 分别对应两类不同限制:

  • 接收方来不及收
  • 网络来不及送

九、MSS、快速重传和快速恢复如何串起来理解

MSSMaximum Segment Size,即单个 TCP 报文段中“数据部分”的最大长度,不包含 IPTCP 头部。

它影响的是单个段里能装多少数据,而快速重传、快速恢复关注的是“丢包后怎么尽快恢复”。

1. 快速重传

当某个报文段丢失,但后面的报文段先到了接收方,接收方会持续回复同一个确认号,表示自己还在等中间缺失的那段数据。

例如发送方发出 1 2 3 4 5,其中 3 丢了,接收方可能连续回复:

ACK 3
ACK 3
ACK 3

发送方收到 3 个重复 ACK 后,通常认为某个报文段大概率丢失,于是不等超时计时器到期,直接重传。这就是快速重传。

2. 快速恢复

如果一看到丢包就把拥塞窗口打回初始状态,吞吐量会下降得很厉害。快速恢复的思路是:

  • 出现 3 个重复 ACK,说明网络仍然在传,只是局部丢包
  • 因此应该收缩窗口,但没必要像超时那样退回起点

典型做法是:

  • 先把慢启动阈值 ssthresh 下调
  • 触发快速重传补发丢失段
  • 然后进入拥塞避免,而不是重新从很小的窗口慢启动

所以可以这样概括:

  • 快速重传:尽快补发丢失的数据
  • 快速恢复:补发之后,不要把发送速率降得过于激进

十、UDP Flood、ICMP 和 DDoS 为什么常一起出现

UDP Flood 是一种典型的拒绝服务攻击。攻击者向目标持续发送大量 UDP 报文,消耗带宽、CPU、队列或应用处理能力,导致正常请求无法得到响应。

它之所以常见,是因为 UDP

  • 无连接,发包成本低
  • 不需要像 TCP 那样先建立连接
  • 某些场景下还容易被用来做反射或放大

如果这些流量来自单个来源,通常叫 DoS;如果来自大量被控制主机,就叫 DDoS,全称是 Distributed Denial of Service

ICMP 在这里常出现,是因为它承担网络控制和错误反馈的角色。例如把 UDP 包发到一个没有服务监听的端口时,对端可能返回 ICMP Port Unreachable。但在洪泛攻击场景下,系统也可能因为处理大量异常报文而进一步消耗资源。

所以它们之间的关系不是“同一层面的同类协议”,而是:

  • UDP:攻击流量可能借用的传输协议
  • ICMP:网络反馈和差错报告协议
  • DDoS:一种攻击方式
  • UDP FloodDDoS/DoS 的一种常见实现形态

十一、把这些概念收束成一张脑图

如果把本文内容压缩成最需要记住的几句话,大致是:

  • TCP 面向字节流,所以会有黏包和拆包,消息边界要由应用层自己定义
  • 三次握手里,ISN 是写在序列号字段里的初始值,双方各自独立生成
  • SYNFIN 各占一个序号,因为它们代表需要被确认的控制事件;ACK 只是回执,不单独占序号
  • rwnd 是接收窗口,cwnd 是拥塞窗口,真正发送窗口常取两者较小值
  • 快速重传解决“别傻等超时”,快速恢复解决“丢包后别退得太狠”
  • RST 是强制中止连接,FIN 是正常关闭连接
  • UDP Flood 是攻击流量形态,DDoS 是攻击组织方式,ICMP 则负责网络控制和错误反馈

理解到这里,再回头看 TCP 头部里的各个字段,就不会只剩下“背位宽”和“背缩写”了。

参考资料


Edit page