TCP 与 UDP
问题
TCP 和 UDP 有什么区别?TCP 的三次握手和四次挥手是怎样的过程?
答案
TCP(传输控制协议)和 UDP(用户数据报协议)是传输层的两种核心协议。
对比概览
| 特性 | TCP | UDP |
|---|---|---|
| 连接 | 面向连接 | 无连接 |
| 可靠性 | 可靠传输 | 不可靠传输 |
| 顺序 | 保证顺序 | 不保证顺序 |
| 流量控制 | 有 | 无 |
| 拥塞控制 | 有 | 无 |
| 头部大小 | 20-60 字节 | 8 字节 |
| 传输效率 | 较低 | 较高 |
| 适用场景 | 文件传输、网页 | 视频、游戏、DNS |
TCP 三次握手
建立 TCP 连接需要三次握手(Three-way Handshake)。
握手过程
详细解释
| 步骤 | 发送方 | 内容 | 说明 |
|---|---|---|---|
| 第一次 | 客户端 | SYN=1, seq=x | 请求建立连接 |
| 第二次 | 服务器 | SYN=1, ACK=1, seq=y, ack=x+1 | 确认并请求连接 |
| 第三次 | 客户端 | ACK=1, seq=x+1, ack=y+1 | 确认连接 |
为什么是三次?
// 两次握手的问题:
// 1. 客户端发送 SYN(可能延迟)
// 2. 服务器收到 SYN,发送 SYN+ACK,建立连接
// 3. 客户端收到,建立连接
// 问题:延迟的 SYN 到达服务器,服务器会再次建立连接(资源浪费)
// 三次握手解决:
// 延迟的 SYN 到达 → 服务器 SYN+ACK → 客户端忽略(因为已关闭)
// 服务器收不到第三次 ACK → 不会建立连接
核心目的:
- 确认双方的发送能力和接收能力
- 同步初始序列号(ISN)
- 防止历史连接的初始化
TCP 四次挥手
关闭 TCP 连接需要四次挥手(Four-way Handshake)。
挥手过程
详细解释
| 步骤 | 发送方 | 内容 | 说明 |
|---|---|---|---|
| 第一次 | 客户端 | FIN=1 | 请求关闭连接 |
| 第二次 | 服务器 | ACK=1 | 确认收到(可能还有数据) |
| 第三次 | 服务器 | FIN=1 | 数据发完,请求关闭 |
| 第四次 | 客户端 | ACK=1 | 确认关闭 |
为什么是四次?
// TCP 是全双工通信,双方都可以发送数据
// 关闭连接需要双方分别关闭自己的发送通道
// 第一次:客户端 → 我不发了
// 第二次:服务器 → 知道了(但我可能还要发)
// 第三次:服务器 → 我也不发了
// 第四次:客户端 → 知道了,再见
TIME_WAIT 状态
// 客户端在发送最后一个 ACK 后,进入 TIME_WAIT 状态
// 等待 2MSL(Maximum Segment Lifetime,最大报文生存时间)
// 原因:
// 1. 确保最后一个 ACK 到达服务器
// 如果丢失,服务器会重发 FIN,客户端可以重发 ACK
// 2. 确保本次连接的所有报文都从网络中消失
// 防止延迟报文影响新连接
TCP 可靠传输机制
1. 序列号与确认号
// 每个字节都有序列号
// 接收方通过 ACK 确认收到的数据
// 发送: seq=1000, len=100 (字节 1000-1099)
// 确认: ack=1100 (期望下一个字节)
2. 超时重传
// 发送数据后启动定时器
// 超时未收到 ACK → 重传
// RTT (Round-Trip Time): 往返时间
// RTO (Retransmission Timeout): 重传超时时间
// RTO 通常根据 RTT 动态计算
3. 滑动窗口
// 窗口大小决定一次可以发送多少数据
// 不需要等待每个包的 ACK,提高吞吐量
// 窗口滑动:收到 ACK 后,窗口向右滑动
4. 流量控制
// 接收方告知发送方自己的接收能力
// 通过 TCP 头部的 Window 字段
// 接收方缓冲区满 → Window = 0 → 发送方停止发送
// 接收方处理数据 → Window > 0 → 发送方继续发送
5. 拥塞控制
| 算法 | 说明 |
|---|---|
| 慢启动 | 指数增长拥塞窗口 |
| 拥塞避免 | 线性增长拥塞窗口 |
| 快重传 | 收到 3 个重复 ACK 立即重传 |
| 快恢复 | 拥塞窗口减半后线性增长 |
UDP 特点
报文结构
0 7 8 15 16 23 24 31
+--------+--------+--------+--------+
| 源端口号 | 目的端口号 |
+--------+--------+--------+--------+
| 长度 | 校验和 |
+--------+--------+--------+--------+
| 数据 |
+--------+--------+--------+--------+
特点
// 1. 无连接
// 发送前不需要建立连接,直接发送
// 2. 不可靠
// 不保证送达,不保证顺序
// 3. 面向报文
// 应用层交下来的报文,UDP 原样发送
// 不拆分也不合并
// 4. 支持多播和广播
// 一对多通信
适用场景
| 场景 | 原因 |
|---|---|
| DNS 查询 | 请求小,不需要连接开销 |
| 视频直播 | 实时性要求高,允许丢帧 |
| 在线游戏 | 低延迟,丢包可容忍 |
| VoIP 通话 | 实时性优先 |
| QUIC | 在 UDP 上实现可靠传输 |
常见面试问题
Q1: TCP 和 UDP 的区别?
答案:
| 维度 | TCP | UDP |
|---|---|---|
| 连接 | 面向连接 | 无连接 |
| 可靠性 | 可靠(重传、确认) | 不可靠 |
| 顺序 | 保证有序 | 不保证 |
| 速度 | 较慢 | 较快 |
| 头部 | 20-60 字节 | 8 字节 |
| 流控/拥塞 | 有 | 无 |
| 传输方式 | 字节流 | 数据报 |
Q2: 为什么 TCP 握手是三次,挥手是四次?
答案:
三次握手:
- 目的是确认双方的发送和接收能力
- 两次不够:无法防止历史连接
- 四次浪费:三次已经够用
四次挥手:
- TCP 是全双工,需要双方分别关闭
- 服务器收到 FIN 后可能还有数据要发送
- 所以 ACK 和 FIN 分开发送
// 握手时:SYN + ACK 可以合并
// 挥手时:ACK 和 FIN 不能合并(中间可能还有数据)
Q3: TIME_WAIT 状态的作用?
答案:
TIME_WAIT 持续 2MSL(约 4 分钟)的原因:
-
确保最后的 ACK 到达
- 如果丢失,服务器会重发 FIN
- 客户端可以重发 ACK
-
确保旧连接的报文消失
- 防止延迟报文被新连接接收
- 新连接可能使用相同的端口号
Q4: TCP 如何保证可靠传输?
答案:
- 序列号 + 确认号:追踪每个字节
- 超时重传:丢包后重新发送
- 滑动窗口:流水线传输,提高效率
- 流量控制:防止接收方被淹没
- 拥塞控制:防止网络过载
Q5: HTTP/3 为什么使用 UDP?
答案:
HTTP/3 基于 QUIC 协议,QUIC 使用 UDP 的原因:
- 避免队头阻塞:TCP 一个包丢失会阻塞所有流
- 更快的握手:0-RTT 建立连接
- 连接迁移:IP 变化不影响连接
// QUIC 在 UDP 基础上实现了:
// - 类似 TCP 的可靠传输
// - 类似 TLS 的加密
// - 多路复用
// - 快速握手
Q6: TCP 的拥塞控制算法(慢启动、拥塞避免、快重传、快恢复)
答案:
TCP 的拥塞控制机制用于防止发送方向网络注入过多数据导致网络拥塞。核心思想是维护一个拥塞窗口(cwnd),动态调整发送速率。四个经典算法协同工作:
四个算法的关系
1. 慢启动(Slow Start)
连接刚建立时,不知道网络容量,从小的 cwnd 开始指数增长:
// 慢启动过程(简化描述)
let cwnd = 1; // 初始拥塞窗口:1 MSS(最大报文段大小)
let ssthresh = 64; // 慢启动阈值
// 每收到一个 ACK,cwnd 增加 1 MSS
// 实际效果:每经过一个 RTT,cwnd 翻倍
// RTT 1: cwnd = 1 → 发送 1 个包
// RTT 2: cwnd = 2 → 发送 2 个包
// RTT 3: cwnd = 4 → 发送 4 个包
// RTT 4: cwnd = 8 → 发送 8 个包
// ...指数增长,直到 cwnd >= ssthresh
2. 拥塞避免(Congestion Avoidance)
当 cwnd 达到慢启动阈值后,切换为线性增长,谨慎探测网络容量:
// 拥塞避免过程
// 当 cwnd >= ssthresh 时进入拥塞避免
// 每经过一个 RTT,cwnd 增加 1 MSS(而不是翻倍)
// RTT 1: cwnd = 64 → 发送 64 个包
// RTT 2: cwnd = 65 → 发送 65 个包
// RTT 3: cwnd = 66 → 发送 66 个包
// ...线性增长,缓慢探测网络容量上限
3. 快重传(Fast Retransmit)
不等超时,收到 3 个重复 ACK 就立即重传丢失的报文段:
// 快重传场景
// 发送: pkt1, pkt2, pkt3, pkt4, pkt5
// pkt2 丢失
// 接收方收到 pkt3 → 发送 ACK2(重复)
// 接收方收到 pkt4 → 发送 ACK2(重复)
// 接收方收到 pkt5 → 发送 ACK2(重复)
// 发送方收到 3 个重复 ACK2 → 立即重传 pkt2(不等超时)
1-2 个重复 ACK 可能只是网络乱序(包到达顺序不对),不一定是丢包。3 个重复 ACK 大概率说明包确实丢了,所以立即重传。
4. 快恢复(Fast Recovery)
配合快重传使用,避免像超时那样将 cwnd 直接降到 1:
// 快恢复过程
let cwnd = 32;
let ssthresh: number;
// 检测到丢包(3 个重复 ACK)时:
ssthresh = Math.floor(cwnd / 2); // ssthresh = 16
cwnd = ssthresh + 3; // cwnd = 19(加 3 是因为已收到 3 个重复 ACK)
// 之后进入拥塞避免阶段,cwnd 线性增长
// 对比:如果是超时丢包
// ssthresh = Math.floor(cwnd / 2); // ssthresh = 16
// cwnd = 1; // cwnd 重置为 1,重新慢启动
完整流程图
| 事件 | ssthresh 变化 | cwnd 变化 | 进入阶段 |
|---|---|---|---|
| 连接建立 | 初始值(较大) | 1 MSS | 慢启动 |
| cwnd >= ssthresh | 不变 | 继续增长 | 拥塞避免 |
| 3 个重复 ACK | cwnd / 2 | ssthresh + 3 | 快恢复 → 拥塞避免 |
| 超时 | cwnd / 2 | 1 MSS | 慢启动 |
Q7: TCP 粘包和拆包问题怎么解决?
答案:
什么是粘包和拆包?
TCP 是面向字节流的协议,没有消息边界的概念。发送方写入的多个数据包可能被合并成一个 TCP 段发送(粘包),或一个大数据包被拆分成多个 TCP 段发送(拆包)。
严格来说,"粘包"不是 TCP 的 bug,而是 TCP 面向字节流的设计特性。应用层需要自己定义消息边界。
产生原因
// 粘包的原因:
// 1. 发送方:Nagle 算法将小包合并发送,减少网络开销
// 2. 接收方:TCP 接收缓冲区中多个包堆积,应用层一次读取了多个包
// 拆包的原因:
// 1. 发送的数据超过 MSS(最大报文段大小),TCP 会拆分
// 2. 发送的数据超过发送缓冲区剩余空间
解决方案
有三种常见的应用层解决方案:
方案 1:固定长度协议
// 每个消息固定长度,不足的部分用特定字符填充
const FIXED_LENGTH = 128;
// 发送方:填充到固定长度
const encodeFixedLength = (message: string): Buffer => {
const buf = Buffer.alloc(FIXED_LENGTH, 0); // 用 0 填充
buf.write(message, 'utf-8');
return buf;
};
// 接收方:按固定长度切割
const decodeFixedLength = (data: Buffer): string[] => {
const messages: string[] = [];
for (let i = 0; i < data.length; i += FIXED_LENGTH) {
const msg = data.subarray(i, i + FIXED_LENGTH)
.toString('utf-8')
.replace(/\0+$/, ''); // 去除填充字符
if (msg) messages.push(msg);
}
return messages;
};
方案 2:分隔符协议
// 用特殊分隔符标记消息边界(如 \n、\r\n、自定义分隔符)
class DelimiterDecoder {
private buffer = '';
private delimiter: string;
constructor(delimiter = '\n') {
this.delimiter = delimiter;
}
// 接收数据,返回完整的消息数组
decode(chunk: string): string[] {
this.buffer += chunk;
const messages: string[] = [];
let index: number;
// 按分隔符切割
while ((index = this.buffer.indexOf(this.delimiter)) !== -1) {
messages.push(this.buffer.substring(0, index));
this.buffer = this.buffer.substring(index + this.delimiter.length);
}
return messages;
}
}
// 使用示例
const decoder = new DelimiterDecoder('\n');
// 假设收到粘包数据:"hello\nworld\nfoo"
const msgs = decoder.decode('hello\nworld\nfoo');
// msgs = ['hello', 'world'],'foo' 留在 buffer 中等待下次拼接
方案 3:长度前缀协议(最常用)
// 消息格式:[4字节消息长度][消息内容]
// 这是最常用的方案,HTTP 的 Content-Length 就是这个思路
class LengthPrefixCodec {
private buffer: Buffer = Buffer.alloc(0);
private readonly HEADER_SIZE = 4; // 4 字节存储消息长度
// 编码:在消息前加上长度头
encode(message: string): Buffer {
const body = Buffer.from(message, 'utf-8');
const header = Buffer.alloc(this.HEADER_SIZE);
header.writeUInt32BE(body.length, 0); // 大端序写入长度
return Buffer.concat([header, body]);
}
// 解码:从缓冲区中提取完整消息
decode(chunk: Buffer): string[] {
this.buffer = Buffer.concat([this.buffer, chunk]);
const messages: string[] = [];
while (this.buffer.length >= this.HEADER_SIZE) {
const bodyLength = this.buffer.readUInt32BE(0);
// 数据不够一个完整消息,等待更多数据
if (this.buffer.length < this.HEADER_SIZE + bodyLength) {
break;
}
// 提取完整消息
const body = this.buffer.subarray(
this.HEADER_SIZE,
this.HEADER_SIZE + bodyLength
);
messages.push(body.toString('utf-8'));
// 移除已处理的数据
this.buffer = this.buffer.subarray(this.HEADER_SIZE + bodyLength);
}
return messages;
}
}
三种方案对比
| 方案 | 优点 | 缺点 | 适用场景 |
|---|---|---|---|
| 固定长度 | 实现简单 | 浪费空间,不灵活 | 消息长度固定的场景 |
| 分隔符 | 灵活,无浪费 | 消息内容不能包含分隔符 | 文本协议(如 Redis RESP) |
| 长度前缀 | 最灵活,支持二进制 | 实现稍复杂 | 最通用(HTTP、Protobuf) |
Q8: WebRTC 为什么用 UDP 而不是 TCP?
答案:
WebRTC(Web Real-Time Communication)是用于浏览器之间实时音视频通信的技术。它选择 UDP 作为底层传输协议,主要有以下几个原因:
核心原因:实时性优先
原因 1:TCP 的重传机制导致延迟不可控
// TCP 丢包时的行为:
// 1. 检测到丢包(超时或 3 个重复 ACK)
// 2. 重传丢失的包
// 3. 后续的包必须等待丢失的包到达后才能交付给应用层(队头阻塞)
// 对于视频通话:
// - 一帧视频的延迟 = 33ms (30fps)
// - TCP 重传可能需要 100-300ms+
// - 等重传完成时,这帧视频已经过期了,用户看到的是卡顿
// UDP 的行为:
// - 丢了就丢了,跳过这帧
// - 后续帧正常播放
// - 用户感知:偶尔画面略有抖动,但不会卡顿
原因 2:TCP 的拥塞控制过于保守
// TCP 检测到丢包时:
// - 慢启动:cwnd 降到 1,从头开始
// - 快恢复:cwnd 降到一半
// → 带宽急剧下降,视频画质突然变差
// WebRTC 使用自己的拥塞控制算法(GCC/BBR):
// - 更适合实时媒体的特点
// - 可以根据延迟变化(而非丢包)来调整码率
// - 平滑降级,而不是断崖式降级
原因 3:TCP 的连接建立耗时长
// TCP + TLS 连接建立:
// TCP 三次握手: 1 RTT
// TLS 握手: 1-2 RTT
// 总计: 2-3 RTT(可能 200-600ms)
// WebRTC 使用 ICE + DTLS + SRTP:
// ICE 连接检查(并行探测多条路径)
// DTLS 握手(基于 UDP 的 TLS,通常 1 RTT)
// 并且支持 ICE restart,网络切换更快
WebRTC 的协议栈
WebRTC 并不是裸用 UDP,而是在 UDP 之上构建了一套完整的协议栈:
| 协议 | 作用 | 基于 |
|---|---|---|
| SRTP | 加密传输音视频数据,支持丢包容忍 | UDP |
| SCTP | 可靠/不可靠传输数据通道消息 | DTLS over UDP |
| DTLS | 基于 UDP 的 TLS,提供加密 | UDP |
| ICE/STUN/TURN | NAT 穿越,建立 P2P 连接 | UDP |
WebRTC 的 DataChannel 使用 SCTP 协议,它支持可靠传输和不可靠传输两种模式:
- 可靠模式:类似 TCP,保证送达和顺序(用于聊天消息、文件传输)
- 不可靠模式:类似 UDP,允许丢包(用于游戏状态同步)
总结对比
| 维度 | TCP | UDP (WebRTC) |
|---|---|---|
| 延迟 | 重传导致不可控延迟 | 丢包跳过,延迟稳定 |
| 队头阻塞 | 有(一个包丢失阻塞所有后续包) | 无 |
| 拥塞控制 | 保守(丢包 → 大幅降速) | 灵活(GCC 算法,按延迟调整) |
| 连接建立 | 2-3 RTT | ICE + DTLS 并行探测 |
| 可靠性 | 强制可靠 | 按需选择(SRTP 不可靠/SCTP 可选) |
| NAT 穿越 | 困难 | ICE/STUN/TURN 专门解决 |
可以补充提到 HTTP/3 的 QUIC 协议也是基于 UDP 的,原因类似——避免 TCP 的队头阻塞和连接建立延迟。但 QUIC 和 WebRTC 的设计目标不同:QUIC 追求可靠的网页加载,WebRTC 追求低延迟的实时通信。