很多人第一次系统学习网络时,会把 TCP、UDP、ICMP、三次握手、黏包、
滑动窗口、快速重传、DDoS 混成一团。单个概念拆开看都不难,但一旦放到同一条链路里,就容易只记住名词,不知道它们为什么会一起出现。
本文尝试沿着一条更工程化的主线来整理这些概念:先说明 TCP、UDP、ICMP
各自解决什么问题,再进入 TCP 头部字段、三次握手、连接关闭、字节流与黏包、
滑动窗口、拥塞控制,最后再解释 UDP Flood 和 DDoS 为什么常被拿来和
UDP、ICMP 放在一起讨论。
文中对 TCP 的基本语义主要参考 RFC 9293(2022)与拥塞控制相关的
RFC 5681(2009)。不同操作系统和内核实现会有细节差异,但主干理解基本一致。
一、先把分工看清:TCP、UDP、ICMP 各负责什么
从传输和网络层的视角看,这三个协议关注点不同:
TCP:面向连接、可靠传输、按序到达、字节流语义UDP:无连接、尽力而为、不保证可靠与顺序ICMP:传递差错报告和控制信息,不承载业务会话
工程上可以这样理解:
- 当你希望数据可靠送达,并且希望应用只面对一条连续的字节流时,常用
TCP - 当你更在意低开销、低时延,或者上层协议自己处理可靠性时,常用
UDP - 当网络需要反馈“目标不可达”“超时”“回显应答”这类信息时,会用
ICMP
例如:
- 浏览器访问传统
HTTPS站点时,底层通常是TCP DNS、语音视频、游戏实时通道常会使用UDPping使用的是ICMP
这一层分工理解清楚后,再看 TCP 里的各种字段和状态,才不会失去上下文。
二、TCP 头部字段到底在干什么
TCP 头部最少 20 字节,常见字段可以分成四组:
- 标识通信双方:源端口、目的端口
- 保证可靠传输:序列号、确认号、校验和
- 控制连接状态:
SYN、ACK、FIN、RST - 调节发送速度:窗口大小、选项字段
最需要先掌握的是这些字段:
1. 端口号
源端口和目的端口各占 16 bit,用于标识通信两端的应用进程。
2. 序列号与确认号
Sequence Number:本报文段中数据的起始字节编号Acknowledgment Number:期望收到对方下一个字节的编号
TCP 是面向字节流的协议,确认的不是“第几个包”,而是“哪一段字节已经收到”。
3. 标志位
常见标志位包括:
SYN:发起连接,同步初始序列号ACK:确认号字段有效FIN:发送方向对方声明“我没有更多数据了”RST:强制复位连接PSH:提示接收方尽快把数据交给应用层URG:紧急指针有效
4. 窗口大小
头部里的 Window 字段对应接收窗口 rwnd,全称是 Receive Window。它表示接收方当前还能再接收多少字节,用于流量控制。
5. 选项字段
常见选项包括:
MSS:Maximum Segment Size,最大报文段长度Window Scale:窗口扩大选项SACK:选择确认Timestamp:时间戳
头部字段不是为了“记忆表格”而存在,而是在为三件事服务:建立连接、可靠传输、控制发送节奏。
三、三次握手里,SYN、ACK、ISN 分别是什么关系
三次握手的目标不是“打招呼”这么简单,而是让双方确认:
- 对端可达
- 双向收发能力存在
- 双方各自的初始序列号已经同步
这里的 ISN 是 Initial Sequence Number,也就是初始序列号。它不是一个单独字段,而是三次握手时写在 Sequence Number 字段里的初始值。
典型过程如下:
Client Server
| ---- SYN, seq = x ------------> |
| <--- SYN+ACK, seq = y, ack=x+1 -|
| ---- ACK, ack = y+1 ----------> |
这里要特别注意两点:
第一,x 和 y 没有计算关系。客户端选择自己的 ISN = x,服务端独立选择自己的 ISN = y。y 不是 x + 某个长度 算出来的。
第二,为什么服务端确认的是 x + 1 而不是 x?因为 SYN 本身要占用一个序号。
同理,客户端最后确认的是 y + 1,因为服务端发出的 SYN 也占用了一个序号。
四、为什么 SYN 和 FIN 各占一个序号,但 ACK 不占
这个问题的关键不在“是不是带了一个标志位”,而在于它是否代表了一个需要被可靠确认的控制事件。
SYN表示连接建立阶段的同步动作,需要被确认,因此占一个序号FIN表示字节流结束标记,也需要被确认,因此占一个序号ACK只是对已有内容的确认回执,不引入新的字节流边界或连接状态事件,因此不单独占序号
可以把它们理解成:
SYN:连接开始标记FIN:连接结束标记ACK:回执
所以:
SYN, seq = x收到后,对方回ack = x + 1FIN, seq = n收到后,对方回ack = n + 1- 一个纯
ACK报文不会因为自己“发出了确认”而消耗新的序号
五、RST 是什么,和 FIN 有什么区别
RST 是 Reset,表示复位连接。它和 FIN 的核心差别是:
FIN:正常关闭,走完剩余数据和挥手流程RST:异常或强制中止,连接立即作废
常见触发场景包括:
- 访问一个没有进程监听的目标端口
- 一方认为连接已经不存在,另一方却还在发数据
- 应用程序或内核直接强制终止连接
收到 RST 后,应用层通常会看到类似 connection reset by peer 的错误。它更像“立刻挂断”,而不是“我这边数据发完了,你也收尾吧”。
六、为什么会有黏包:TCP 只认字节流,不认消息边界
所谓 TCP 黏包,本质上不是传输层“包真的粘住了”,而是应用层误把
TCP 当成了有消息边界的协议。
TCP 只保证:
- 字节可靠到达
- 字节按序到达
它不保证:
- 你调用一次
send,对方就正好一次recv收到 - 每次读取都刚好对应一条业务消息
因此发送两条消息:
Hello
World
接收端可能读到:
HelloWorldHel和loWorld- 前一条消息尾部加后一条消息头部
这也是为什么常说黏包和拆包总是一起出现。根因是: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 段发送,或者被拆成多个段,接收方只需:
- 先读
4字节,得到消息长度 - 再读对应长度的消息体
- 循环处理下一条消息
消息边界由长度字段明确标识,不再依赖 TCP 的分段行为。
七、滑动窗口怎么理解:rwnd 管接收能力,cwnd 管网络承受能力
如果没有滑动窗口,发送方每发一个报文段都要停下来等确认,链路利用率会很低。滑动窗口机制允许发送方在未收到全部确认前,先把一段范围内的数据连续发出去。
可以把窗口理解成“当前允许飞在网络中的未确认数据量”。
acked | sent_not_acked | can_send | cannot_send_yet
窗口之所以能“滑动”,是因为新的 ACK 到达后,左边已经确认的数据出窗,右边新的可发送范围进窗。
这里有两个很容易混淆的概念:
rwnd:Receive Window,接收方还能收多少cwnd:Congestion Window,发送方认为网络还能承受多少
两者作用不同:
rwnd关注接收端缓冲区,会做流量控制cwnd关注网络拥塞状态,会做拥塞控制
发送方真正能发出的窗口通常受两者共同限制:
send_window = min(rwnd, cwnd)
所以“窗口大小”不是一个来源。一个是接收方告诉你的上限,一个是你根据网络状况给自己的上限。
八、什么是拥塞窗口,以及它为什么会变
拥塞窗口 cwnd 的全称是 Congestion Window。它不是固定值,而是发送方根据当前网络反馈动态调整出来的。
它的核心目标不是“让自己发得越快越好”,而是:
- 尽量提高吞吐量
- 但不要把网络直接压垮
典型规律是:
- 刚开始时较小,先试探网络容量
- 如果确认持续正常返回,就逐步增大
- 如果出现丢包、超时或重复确认,说明可能拥塞,就收缩窗口
这也是为什么说 rwnd 和 cwnd 分别对应两类不同限制:
- 接收方来不及收
- 网络来不及送
九、MSS、快速重传和快速恢复如何串起来理解
MSS 是 Maximum Segment Size,即单个 TCP 报文段中“数据部分”的最大长度,不包含 IP 和 TCP 头部。
它影响的是单个段里能装多少数据,而快速重传、快速恢复关注的是“丢包后怎么尽快恢复”。
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 Flood:DDoS/DoS的一种常见实现形态
十一、把这些概念收束成一张脑图
如果把本文内容压缩成最需要记住的几句话,大致是:
TCP面向字节流,所以会有黏包和拆包,消息边界要由应用层自己定义- 三次握手里,
ISN是写在序列号字段里的初始值,双方各自独立生成 SYN和FIN各占一个序号,因为它们代表需要被确认的控制事件;ACK只是回执,不单独占序号rwnd是接收窗口,cwnd是拥塞窗口,真正发送窗口常取两者较小值- 快速重传解决“别傻等超时”,快速恢复解决“丢包后别退得太狠”
RST是强制中止连接,FIN是正常关闭连接UDP Flood是攻击流量形态,DDoS是攻击组织方式,ICMP则负责网络控制和错误反馈
理解到这里,再回头看 TCP 头部里的各个字段,就不会只剩下“背位宽”和“背缩写”了。