# TCP协议
# 一、TCP三次握手
Q1:请详细说明TCP三次握手的过程及其必要性。
参考答案:
# 三次握手过程
客户端 服务端
| |
| 1. SYN=1, seq=x |
|------------------------------------>| (SYN_SENT)
| |
| 2. SYN=1, ACK=1, seq=y, ack=x+1 |
|<------------------------------------| (SYN_RCVD)
| |
| 3. ACK=1, seq=x+1, ack=y+1 |
|------------------------------------>| (ESTABLISHED)
| |
步骤说明:
第一次握手:客户端发送 SYN 报文,进入 SYN_SENT 状态
- SYN=1 表示建立连接
- seq=x 为初始序列号
第二次握手:服务端收到 SYN,回复 SYN+ACK,进入 SYN_RCVD 状态
- SYN=1, ACK=1 表示确认并同意建立连接
- seq=y 为服务端初始序列号
- ack=x+1 确认收到客户端的 SYN
第三次握手:客户端收到 SYN+ACK,发送 ACK,进入 ESTABLISHED 状态
- ACK=1 表示确认
- seq=x+1, ack=y+1 确认收到服务端的 SYN
# 为什么是三次而不是两次?
防止已失效的连接请求报文段突然又传送到了服务端
场景:客户端发送的第一个连接请求报文段在网络中滞留,客户端超时重发,建立连接后完成通信关闭连接。之后滞留的旧报文段到达服务端。
- 两次握手:服务端收到旧报文段,误认为是新连接请求,建立连接等待客户端数据,造成资源浪费
- 三次握手:服务端收到旧报文段回复 SYN+ACK,客户端根据状态拒绝,避免错误连接
# 二、TCP四次挥手
Q2:为什么TCP断开连接需要四次挥手?
参考答案:
# 四次挥手过程
客户端 服务端
| |
| 1. FIN=1, seq=u |
|------------------------------------>| (FIN_WAIT_1)
| |
| 2. ACK=1, seq=v, ack=u+1 |
|<------------------------------------| (CLOSE_WAIT)
| |
| 3. FIN=1, seq=w |
|<------------------------------------| (LAST_ACK)
| |
| 4. ACK=1, seq=u+1, ack=w+1 |
|------------------------------------>| (TIME_WAIT)
| |
# 为什么是四次?
TCP是全双工通信,每个方向的连接需要单独关闭
- 第一次挥手:客户端发送 FIN,表示不再发送数据(但可接收)
- 第二次挥手:服务端回复 ACK,确认收到 FIN
- 此时服务端可能还有数据要发送给客户端
- 客户端进入 FIN_WAIT_2 状态,等待服务端发送剩余数据
- 第三次挥手:服务端发送 FIN,表示数据发送完毕
- 第四次挥手:客户端回复 ACK,确认收到 FIN
# 为什么客户端要等待 2MSL?
MSL(Maximum Segment Lifetime)= 报文最大生存时间
目的:
保证客户端发送的最后一个 ACK 到达服务端
- 如果 ACK 丢失,服务端会重发 FIN
- 客户端在 2MSL 时间内可以收到重发的 FIN 并重发 ACK
等待网络中所有旧的报文段消失
- 防止旧连接的报文段干扰新连接
# 三、TCP拥塞控制
Q3:TCP的拥塞控制机制有哪些?请详细说明。
参考答案:
TCP 拥塞控制包含四个核心算法:
# 1. 慢启动(Slow Start)
目的:在连接建立初期,探测网络承载能力
算法:
- 初始拥塞窗口 cwnd = 1 MSS
- 每收到一个 ACK,cwnd 加倍(指数增长)
- 当 cwnd 达到慢启动阈值 ssthresh,转为拥塞避免
RTT 1: cwnd = 1
RTT 2: cwnd = 2
RTT 3: cwnd = 4
RTT 4: cwnd = 8
# 2. 拥塞避免(Congestion Avoidance)
目的:避免 cwnd 增长过快导致拥塞
算法:
- 每个 RTT,cwnd 增加 1 MSS(线性增长)
- 更加保守的增长策略
# 3. 快速重传(Fast Retransmit)
触发条件:收到 3 个重复 ACK
算法:
- 不等待超时,立即重传丢失的报文段
- ssthresh = cwnd / 2
- cwnd = ssthresh + 3(加 3 是因为收到 3 个重复 ACK)
# 4. 快速恢复(Fast Recovery)
算法:
- 进入快速恢复状态
- 每收到一个重复 ACK,cwnd 增加 1
- 收到新的 ACK,cwnd = ssthresh,转入拥塞避免
# 拥塞控制状态转换图
┌─────────────────┐
│ 慢启动 │
│ (指数增长) │
└────────┬────────┘
│ cwnd >= ssthresh
▼
┌─────────────────┐
│ 拥塞避免 │
│ (线性增长) │
└────────┬────────┘
│ 3个重复ACK
▼
┌─────────────────┐
│ 快速重传/恢复 │
└─────────────────┘
# 四、TCP与UDP对比
Q4:TCP和UDP有什么区别?各自适用什么场景?
参考答案:
| 特性 | TCP | UDP |
|---|---|---|
| 连接性 | 面向连接 | 无连接 |
| 可靠性 | 可靠传输(确认、重传) | 不可靠传输 |
| 有序性 | 有序(序号机制) | 无序 |
| 流量控制 | 有(滑动窗口) | 无 |
| 拥塞控制 | 有(慢启动、拥塞避免) | 无 |
| 传输效率 | 较低(首部20字节) | 较高(首部8字节) |
| 应用场景 | 文件传输、邮件、HTTP | 视频直播、DNS、游戏 |
# TCP 适用场景
- 文件传输:FTP、HTTP
- 邮件传输:SMTP、POP3、IMAP
- 远程登录:SSH、Telnet
- 需要可靠性的场景
# UDP 适用场景
- 实时音视频:视频直播、VoIP
- DNS 查询:快速响应优先
- 在线游戏:实时性优先于可靠性
- 广播/多播:DHCP、IGMP
# 五、TCP粘包/拆包
Q5:什么是TCP粘包/拆包?如何解决?
参考答案:
# 粘包/拆包现象
粘包:多个小数据包被合并成一个大数据包发送
拆包:一个大数据包被拆分成多个小数据包发送
# 产生原因
发送方:
- Nagle 算法:合并小数据包提高效率
接收方:
- 应用层读取速度慢,多个包在缓冲区堆积
# 解决方案
# 1. 固定长度
每个消息固定长度,不足补齐
// 每个消息固定100字节
byte[] buffer = new byte[100];
while (true) {
int len = inputStream.read(buffer);
if (len == 100) {
// 处理完整消息
}
}
# 2. 分隔符
使用特殊字符分隔消息
// 使用换行符分隔
BufferedReader reader = new BufferedReader(
new InputStreamReader(inputStream)
);
String line;
while ((line = reader.readLine()) != null) {
// 处理一行消息
}
# 3. 长度字段
消息头包含消息长度
// 消息格式:[长度(4字节)][内容]
DataInputStream dis = new DataInputStream(inputStream);
while (true) {
int length = dis.readInt(); // 读取长度
byte[] data = new byte[length];
dis.readFully(data); // 读取内容
// 处理消息
}
# 4. Netty 解决方案
// 使用 LengthFieldPrepender 编码器
pipeline.addLast(new LengthFieldPrepender(4));
// 使用 LengthFieldBasedFrameDecoder 解码器
pipeline.addLast(new LengthFieldBasedFrameDecoder(
1024, // 最大帧长度
0, // 长度字段偏移
4, // 长度字段长度
0, // 长度调整值
4 // 剥离长度字段
));
# 六、TCP保活机制
Q6:TCP的Keep-Alive机制是如何工作的?
参考答案:
# Keep-Alive 参数
# Linux 默认参数
net.ipv4.tcp_keepalive_time = 7200 # 2小时无数据后开始探测
net.ipv4.tcp_keepalive_intvl = 75 # 探测间隔75秒
net.ipv4.tcp_keepalive_probes = 9 # 探测次数9次
# 工作流程
- 连接空闲超过
tcp_keepalive_time(2小时) - 发送 Keep-Alive 探测包(ACK包,序列号为当前序列号减一)
- 收到 ACK 响应,重置计时器
- 未收到响应,每隔
tcp_keepalive_intvl(75秒)重发 - 连续失败
tcp_keepalive_probes(9次)后,关闭连接
# 应用层 Keep-Alive
TCP Keep-Alive 的局限性:
- 默认时间太长(2小时)
- 不能携带应用层数据
- 中间设备可能不支持
应用层实现:
// 心跳机制
public class HeartbeatHandler extends ChannelInboundHandlerAdapter {
@Override
public void userEventTriggered(ChannelHandlerContext ctx, Object evt) {
if (evt instanceof IdleStateEvent) {
IdleStateEvent event = (IdleStateEvent) evt;
if (event.state() == IdleState.READER_IDLE) {
// 读超时,关闭连接
ctx.close();
} else if (event.state() == IdleState.WRITER_IDLE) {
// 写超时,发送心跳
ctx.writeAndFlush(new HeartbeatMessage());
}
}
}
}
# 七、TCP滑动窗口
Q7:TCP滑动窗口机制的作用是什么?
参考答案:
# 滑动窗口的作用
- 流量控制:防止发送方发送过快导致接收方缓冲区溢出
- 提高效率:允许发送方连续发送多个报文段,无需等待每个ACK
# 窗口大小计算
发送窗口 = min(拥塞窗口 cwnd, 接收窗口 rwnd)
# 三种窗口
| 窗口类型 | 作用 | 控制方 |
|---|---|---|
| 发送窗口 | 限制发送方发送速率 | 发送方 |
| 接收窗口 | 接收方缓冲区大小 | 接收方 |
| 拥塞窗口 | 网络拥塞程度 | 发送方 |
# 滑动过程
发送方窗口:
+----------+----------+----------+----------+
| 已发送ACK | 已发送未ACK | 可发送 | 不可发送 |
+----------+----------+----------+----------+
↑ ↑
已确认 窗口前沿
收到 ACK 后窗口右移:
+----------+----------+----------+----------+
| 已发送ACK | 已发送未ACK | 可发送 | 不可发送 |
+----------+----------+----------+----------+
↑ ↑
已确认 窗口前沿
# 八、TCP延迟确认与Nagle算法
Q8:TCP的延迟确认和Nagle算法是什么?
参考答案:
# 延迟确认(Delayed ACK)
目的:减少 ACK 报文数量
规则:
- 收到数据后,等待 200-500ms 再发送 ACK
- 如果在此期间有数据要发送,ACK 捎带在数据包中
- 如果收到两个连续的数据包,立即发送 ACK
问题:
- 与 Nagle 算法交互可能导致额外延迟
# Nagle 算法
目的:减少小包数量
规则:
- 如果数据小于 MSS,且连接中有未确认的数据,缓存数据
- 当收到 ACK 或缓存数据达到 MSS,发送数据
伪代码:
if (数据长度 >= MSS || 连接中没有未确认数据) {
立即发送数据
} else {
缓存数据,等待ACK或积累到MSS
}
# Nagle + Delayed ACK 问题
场景:
客户端 服务端
| |
| 1. 发送小数据包1 |
|---------------------------------->|
| | 等待200ms(延迟ACK)
| 缓存数据包2(Nagle算法) |
| |
| 2. 延迟ACK(200ms后) |
|<----------------------------------|
| |
| 3. 收到ACK,发送数据包2 |
|---------------------------------->|
延迟:200ms(延迟ACK)+ RTT
解决方案:
// 禁用 Nagle 算法
socket.setTcpNoDelay(true);
// 或禁用延迟ACK(Linux)
echo 1 > /proc/sys/net/ipv4/tcp_delack_min
# 九、TCP TIME_WAIT 状态
Q9:为什么TIME_WAIT状态需要等待2MSL?
参考答案:
详见"TCP四次挥手"章节。
补充:TIME_WAIT 过多的问题及解决方案
# TIME_WAIT 过多的影响
- 占用端口资源(客户端)
- 占用内存和连接表项
- 新连接可能失败(端口耗尽)
# 解决方案
# 1. 设置 SO_REUSEADDR
ServerSocket serverSocket = new ServerSocket();
serverSocket.setReuseAddress(true); // 允许重用处于TIME_WAIT的端口
serverSocket.bind(new InetSocketAddress(8080));
# 2. 调整系统参数
# 允许TIME_WAIT sockets快速回收
net.ipv4.tcp_tw_recycle = 1 # 已废弃(Linux 4.12+)
# 允许TIME_WAIT sockets重用
net.ipv4.tcp_tw_reuse = 1
# 减少TIME_WAIT时间
net.ipv4.tcp_fin_timeout = 30
# 3. 使用长连接
避免频繁创建和关闭连接
# 十、TCP校验和
Q10:TCP校验和如何计算?
参考答案:
# 计算范围
TCP 校验和覆盖三部分:
伪首部(12字节)
- 源IP地址(4字节)
- 目的IP地址(4字节)
- 保留(1字节,置0)
- 协议类型(1字节,TCP=6)
- TCP长度(2字节)
TCP首部(20字节+选项)
TCP数据
# 计算步骤
// 伪代码
sum = 0;
// 1. 计算伪首部
sum += 源IP地址
sum += 目的IP地址
sum += 协议类型
sum += TCP长度
// 2. 计算TCP首部和数据(按16位累加)
for (每16位) {
sum += data[i]
}
// 3. 处理进位
while (sum >> 16) {
sum = (sum & 0xFFFF) + (sum >> 16)
}
// 4. 取反
checksum = ~sum & 0xFFFF
# 特点
- 端到端校验:每个路由器不会重新计算
- 弱校验:只能检测部分错误
- 必须计算:IPv4 中可选,IPv6 中强制
# 参考资料
- 《TCP/IP详解 卷1:协议》
- RFC 793: Transmission Control Protocol
- RFC 6298: TCP Retransmission Timeout