网络面试精讲:TCP三次握手四次挥手、TIME_WAIT、粘包拆包
网络面试精讲:TCP三次握手四次挥手、TIME_WAIT、粘包拆包
适读人群:Java后端开发 | 难度:★★★★☆ | 出现频率:极高
开篇故事
面了十几年技术,TCP相关的网络问题是我最爱考的话题之一。
有次面试一个五年工作经验的候选人,我问他:"TIME_WAIT状态是什么,为什么要等待2MSL?"
他说:是挥手之后的等待状态。
我追问:为什么要等2MSL,而不是等1秒或者立刻关闭?
他答:防止对方没有收到最后一个ACK……然后就说不下去了。
能说到这一步的候选人其实不少,但能说清楚"如果不等2MSL,新连接可能收到旧连接的延迟数据包"这层逻辑的,寥寥无几。
网络知识对Java后端工程师来说是基础能力,今天把TCP的核心面试考点全部讲清楚。
一、高频考点拆解
TCP这道题考察三个维度:
第一维:连接管理——三次握手和四次挥手的详细过程,每一步的状态 第二维:TIME_WAIT——为什么存在、为什么是2MSL、TIME_WAIT过多怎么处理 第三维:粘包/拆包——TCP字节流特性导致的问题,以及Netty等框架的解决方案
二、深度原理分析
2.1 TCP三次握手
为什么是三次,不是两次?
两次握手无法防止"历史连接"的影响。如果客户端发出的第一个SYN因为网络延迟很久才到服务端(此时客户端已经超时重发了新的SYN并完成了连接),服务端会为这个旧的SYN创建连接,但客户端并没有对应的连接,导致资源浪费甚至通信混乱。三次握手通过客户端的第三次确认,确保双方都认可的是同一次连接,过滤掉了历史连接的干扰。
为什么不是四次?
三次已经足够让双方都确认对方的发送能力和接收能力了:
- 第1次:客户端确认自己能发,服务端能收
- 第2次:服务端确认自己能收、能发,客户端能收
- 第3次:客户端向服务端确认接收到了第2次
四次是多余的,没有新信息。
2.2 TCP四次挥手
为什么是四次,不是三次?
因为TCP是全双工的,关闭连接需要双方各自关闭各自方向的发送。客户端发FIN表示"我不再发数据了",但服务端可能还有数据要发,所以服务端的ACK和FIN是分两次发的(先确认客户端的FIN,等自己的数据发完后,再发自己的FIN)。如果服务端没有数据要发,ACK和FIN可以合并,变成三次挥手。
2.3 TIME_WAIT的原因和意义
TIME_WAIT状态:主动关闭方(通常是客户端)在发出最后一个ACK后,进入TIME_WAIT状态,等待2MSL(Maximum Segment Lifetime,最大报文生存时间,通常是60秒或2分钟)后才真正关闭连接。
等待2MSL的两个原因:
原因1:保证最后一个ACK能到达对方。
原因2:让旧连接的数据包在网络中消散(防止数据包污染新连接)。
如果立刻关闭连接,立刻用同样的四元组(源IP:端口,目的IP:端口)建立新连接,旧连接网络中残留的数据包可能被新连接接收,导致数据错乱。等待2MSL,旧连接的所有数据包都已经超时丢弃了,新连接不会受到干扰。
TIME_WAIT过多的问题和解决:
# 查看TIME_WAIT数量
netstat -an | grep TIME_WAIT | wc -l
# 服务端大量TIME_WAIT,说明服务端主动关闭了大量连接(通常不正常)
# 客户端大量TIME_WAIT,是正常的(短连接场景)
# Linux内核参数调整(对TIME_WAIT过多的优化)
# 开启TIME_WAIT快速回收(不推荐,有安全风险)
net.ipv4.tcp_tw_reuse = 1 # 允许将TIME_WAIT socket重用于新的TCP连接
# net.ipv4.tcp_tw_recycle = 1 # 已废弃,JDK8后不再有效,不要使用
# 更好的解决方案:使用长连接(HTTP Keep-Alive,连接池)减少连接建立/断开次数三、粘包和拆包
3.1 什么是粘包/拆包
TCP是字节流协议,没有消息边界的概念。应用层发送的"一条消息",可能被TCP分成多个数据包发送(拆包),也可能多条消息合并成一个TCP数据包发送(粘包)。
3.2 解决方案
方案1:固定长度:每条消息固定N字节,不够的用0补齐。 缺点:浪费带宽(消息长度不一致时),不灵活。
方案2:分隔符:每条消息以特定字符(如换行符\n)结束。 适合:文本协议(HTTP、Redis的RESP协议)。 缺点:消息内容中不能包含分隔符(需转义)。
方案3:长度字段:消息头包含一个固定字节数的"消息长度"字段。 最通用:先读N字节获取消息长度,再读消息长度个字节获取消息体。
// Netty的LengthFieldBasedFrameDecoder:自动处理粘包/拆包
// 消息格式:[4字节长度][消息体]
pipeline.addLast(new LengthFieldBasedFrameDecoder(
65535, // maxFrameLength: 最大帧长度
0, // lengthFieldOffset: 长度字段的偏移量
4, // lengthFieldLength: 长度字段的字节数
0, // lengthAdjustment: 长度调整值
4 // initialBytesToStrip: 解码后去掉的字节数(去掉4字节长度字段)
));
// 对应的编码器(发送时自动加上长度字段)
pipeline.addLast(new LengthFieldPrepender(4)); // 在消息前加4字节长度四、标准答案 + 代码验证
4.1 用Netty实现处理粘包拆包
import io.netty.bootstrap.ServerBootstrap;
import io.netty.channel.*;
import io.netty.channel.nio.NioEventLoopGroup;
import io.netty.channel.socket.SocketChannel;
import io.netty.channel.socket.nio.NioServerSocketChannel;
import io.netty.handler.codec.LengthFieldBasedFrameDecoder;
import io.netty.handler.codec.LengthFieldPrepender;
import io.netty.handler.codec.string.StringDecoder;
import io.netty.handler.codec.string.StringEncoder;
import java.nio.charset.StandardCharsets;
public class NettyServer {
public static void main(String[] args) throws Exception {
EventLoopGroup bossGroup = new NioEventLoopGroup(1);
EventLoopGroup workerGroup = new NioEventLoopGroup();
try {
ServerBootstrap bootstrap = new ServerBootstrap();
bootstrap.group(bossGroup, workerGroup)
.channel(NioServerSocketChannel.class)
.childHandler(new ChannelInitializer<SocketChannel>() {
@Override
protected void initChannel(SocketChannel ch) {
ChannelPipeline pipeline = ch.pipeline();
// 解决粘包拆包:基于长度字段的帧解码器
// 消息格式:[4字节长度][消息体]
pipeline.addLast(new LengthFieldBasedFrameDecoder(
65535, 0, 4, 0, 4));
// 发送时加长度前缀
pipeline.addLast(new LengthFieldPrepender(4));
// 字符串编解码
pipeline.addLast(new StringDecoder(StandardCharsets.UTF_8));
pipeline.addLast(new StringEncoder(StandardCharsets.UTF_8));
// 业务处理器
pipeline.addLast(new SimpleChannelInboundHandler<String>() {
@Override
protected void channelRead0(ChannelHandlerContext ctx, String msg) {
System.out.println("收到消息: " + msg);
ctx.writeAndFlush("已收到: " + msg);
}
});
}
});
ChannelFuture future = bootstrap.bind(8080).sync();
System.out.println("Server started on port 8080");
future.channel().closeFuture().sync();
} finally {
bossGroup.shutdownGracefully();
workerGroup.shutdownGracefully();
}
}
}4.2 模拟TIME_WAIT和连接状态查看
import java.net.ServerSocket;
import java.net.Socket;
import java.io.IOException;
public class TcpStateDemo {
// 服务端:接收连接,处理后关闭
public static void startServer() throws IOException {
try (ServerSocket serverSocket = new ServerSocket(9090)) {
System.out.println("服务端监听9090");
Socket client = serverSocket.accept();
System.out.println("客户端连接: " + client.getRemoteSocketAddress());
// 读取一些数据后关闭
client.close(); // 服务端主动关闭 → TIME_WAIT在服务端
}
}
// 观察连接状态
// netstat -an | grep 9090
// 可以看到:
// tcp 0 0 127.0.0.1:9090 127.0.0.1:12345 TIME_WAIT
}五、面试官追问
追问1:TCP如何实现可靠传输?
我的回答:TCP可靠传输通过四个机制保证。第一,序号和确认应答:每个字节都有序号,接收方确认已接收的序号,发送方知道哪些需要重传。第二,超时重传:发送方设定超时时间,超时未收到ACK则重传,超时时间(RTO)会根据网络RTT动态调整。第三,流量控制:接收方通过窗口大小通知发送方自己的接收缓冲区剩余容量,发送方不超过这个窗口发送,防止接收方缓冲区溢出。第四,拥塞控制:通过慢启动、拥塞避免、快速重传、快速恢复算法,感知网络拥塞并降低发送速率,避免加剧拥塞。
追问2:HTTP/1.1的Keep-Alive和HTTP/2的多路复用有什么区别?
我的回答:HTTP/1.1的Keep-Alive(持久连接)让一个TCP连接可以发送多个HTTP请求,避免了每次请求都建立/断开连接的开销(减少TIME_WAIT)。但HTTP/1.1的请求是串行的(一个请求处理完才发下一个),或者需要多个连接并行(浏览器一般限制6个并行连接)。HTTP/2的多路复用在一个TCP连接上可以并行发送多个请求,每个请求是一个Stream,Stream之间独立,互不阻塞,真正解决了HTTP/1.1的队头阻塞问题(但TCP层仍然存在队头阻塞)。HTTP/3改用QUIC(基于UDP),彻底解决了TCP层的队头阻塞,连接迁移也更顺畅。
追问3:如何用Netty实现一个高性能的长连接服务器?
我的回答:Netty的Reactor模型:一个或多个bossGroup线程负责Accept新连接,workerGroup线程(通常是CPU核数)负责处理IO事件(read/write)。核心是ChannelPipeline,将编解码、业务处理等逻辑拆分成独立的ChannelHandler,职责单一,复用性好。关键配置:SO_BACKLOG(TCP连接等待队列大小)、TCP_NODELAY(禁用Nagle算法,降低延迟)、SO_KEEPALIVE(TCP心跳,检测失活连接)。心跳检测用IdleStateHandler,设置读超时和写超时,超时后发心跳包,一定次数无响应就关闭连接,避免死连接占用资源。
六、同类题目举一反三
UDP和TCP的区别,什么场景用UDP?
TCP:面向连接,可靠传输,流量控制,拥塞控制,数据有序,适合文件传输、HTTP、数据库等需要可靠性的场景。UDP:无连接,不可靠,无拥塞控制,数据可能乱序丢失,但延迟低、效率高,适合实时性要求高且可以容忍少量丢包的场景:DNS查询(单次请求响应,丢了就重试)、视频直播/游戏(宁愿丢帧,不能卡顿)、QUIC(HTTP/3,在UDP上实现可靠传输)。
七、踩坑实录
坑一:服务端出现大量TIME_WAIT,排查发现是服务端主动关闭连接
一个API网关,代理大量短连接请求,请求完成后由服务端主动调用close()。大量TIME_WAIT占用端口,导致新连接无法建立(端口耗尽)。解决方案:改为客户端主动关闭(让TIME_WAIT在客户端),同时为上游连接开启Keep-Alive(长连接复用)。修复后TIME_WAIT数量大幅下降。
坑二:Netty业务处理器中做了阻塞IO,导致worker线程阻塞
有个同事在Netty的ChannelHandler里直接做了数据库查询(阻塞操作),导致workerGroup的线程全部阻塞在IO等待上,其他连接的IO事件无法处理,服务性能急剧下降。正确做法:在ChannelHandler中只做轻量操作,耗时业务逻辑提交给独立的业务线程池执行,不阻塞Netty的IO线程。
坑三:未处理粘包导致消息解析错误
自研了一个内部RPC框架,用TCP传输消息,但消息处理器里直接read()然后解析,没有考虑粘包。高并发时多条消息合并成一个TCP包,read()返回的数据包含了多条消息,按单条消息解析就出错了。加上长度字段前缀,配合Netty的LengthFieldBasedFrameDecoder,问题解决。
八、总结
TCP面试三大考点:
三次握手:确保双方建立可靠连接,防止历史连接的干扰;三次是充要条件,两次不够,四次多余。
四次挥手:全双工连接需要双方各自关闭发送方向;TIME_WAIT等待2MSL,确保最后ACK能到达对方,同时让旧数据包在网络中消散。
粘包/拆包:TCP是字节流,应用层需要自己定义消息边界;常用方案:长度字段前缀(最通用)、分隔符(文本协议);Netty提供了完善的编解码器处理这个问题。
