java-websocket 中 Websocket Server 关闭后端口不会及时释放的解决方法
问题描述
前几天在使用java-websocket
包创建Websocket服务器时,遇到了一个奇怪的现象。具体表现如下:
- Websocket Server 启动后,如果未建立过ws连接,在后台关闭 ws server,该server在关闭后可以立即重新启动
- Websocket Server 启动后,如果建立过ws连接,在后台关闭 ws server, 这时再立即启动 websocket server会提示 ‘端口已占用’。但此时通过lsof查看相应的端口占用情况可以看到并没有进程在占用ws端口
问题原因
询问通义千问后,得到回应:
问题分析:
1. 当WebSocket服务器关闭时,操作系统会将端口置于TIME_WAIT状态
2. TIME_WAIT是TCP连接关闭过程中的正常状态,持续时间为2MSL(通常为30-120秒)
3. 在TIME_WAIT状态下,端口不能被立即重新绑定
4. lsof看不到端口占用是因为端口确实没有被进程占用,而是处于TCP协议栈的等待状态
5. 等待一段时间后可以正常启动,这符合TIME_WAIT状态的特征
解决方案
- 在创建WebSocketServer时设置
SO_REUSEADDR
套接字选项,即在构造方法中添加:this.setReuseAddr(true)
其他 Q&A
注意: 下方回答为AI生成
为什么要给出一个这样的TIME_WAIT状态?
TCP协议中设计TIME_WAIT状态是出于网络通信可靠性的考虑,主要有以下几个关键原因:
- 确保连接可靠关闭
- 当主动关闭- 方发送最后一个ACK后,需要等待一段时间以确保被动关闭方收到了这个ACK
- 如果最后一个ACK丢失,被动关闭方会重发FIN,主动关闭方需要在TIME_WAIT状态下能够重发ACK
- 没有这个等待期,连接可能无法完全、可靠地关闭
- 防止旧连接的数据包干扰新连接
- 网络中可能存在延迟传输的报文段(在网络设备中排队或路由)
- TIME_WAIT等待2倍MSL(Maximum Segment Lifetime,通常为30-120秒)
- 确保:所有旧连接的报文在网络中完全消失,避免这些"迷路"的报文被误认为是新连接的数据
- 保证TCP协议的可靠性
- TCP设计原则是"可靠性优先于效率"
- MSL代表报文在网络中存活的最长时间,2MSL是经过计算的安全等待时间:
- 第一个MSL:确保最后一个ACK或FIN能够到达对方
- 第二个MSL:确保如果对方没收到ACK而重发FIN,我们能再次响应
- 在WebSocket场景中的体现
- 当你的WebSocket服务器主动关闭连接时,它会进入TIME_WAIT状态
- 即使程序已退出,操作系统仍会保持端口在TIME_WAIT状态一段时间
- 这就是为什么立即重启时会遇到"地址已在使用"错误,而等待一段时间后可以正常启动
上述便是为什么即使关闭逻辑执行正确,端口仍需要一段时间才能被重新使用。通过设置SO_REUSEADDR选项,我们告诉操作系统允许在TIME_WAIT状态下重用端口,但不会影响TCP协议的可靠性,因为新连接会有不同的序列号。
为什么允许系统在TIME_WAIT状态下重用端口不会影响TCP协议的可靠性?连接的序列号又是指?
TCP序列号详解
TCP序列号(Sequence Number)是TCP协议中确保可靠传输的核心机制:
- 定义:每个传输的字节都有一个32位的序列号,用于标识数据在字节流中的位置
- 初始序列号(ISN):每个新TCP连接开始时,双方会协商一个随机的初始序列号
- 作用:
- 确保数据按正确顺序重组
- 检测数据包丢失(通过序列号缺口)
- 防止重复数据包被错误处理
- 识别属于哪个连接的数据(关键点!)
为什么SO_REUSEADDR不影响可靠性
设置SO_REUSEADDR后,虽然端口可以立即重用,但TCP协议的可靠性不会受到影响,原因如下:
- 连接的唯一标识是四元组,而非单一端口
- TCP连接由四元组唯一标识:(源IP, 源端口, 目标IP, 目标端口)
- 即使服务器端口相同,只要四元组中至少有一个元素不同,就是不同的连接
- 每个连接有独立的序列号空间
- 新连接的初始序列号(ISN)是随机生成的,与之前连接完全不同
- 操作系统能通过序列号区分新旧连接的数据包
- 网络中延迟的旧连接数据包会被新连接自动丢弃,因为:
- 序列号不在新连接的接收窗口内
- TCP校验和可能不匹配
- 连接状态不匹配
- TIME_WAIT的原始目的已通过其他机制保障
- TIME_WAIT的主要目的是确保网络中延迟的数据包不会干扰新连接
- 但现代操作系统通过序列号随机化和连接状态跟踪已经解决了这个问题
- SO_REUSEADDR只是允许端口重用,不会改变TCP协议处理数据包的方式
实际示例
假设你的WebSocket服务器在端口29610:
- 第一次连接:
- 客户端A: 192.168.1.10:50000 → 服务器: 29610
- 序列号空间: 1000-5000
- 关闭后立即重启:
- 客户端B: 192.168.1.11:50001 → 服务器: 29610(端口重用)
- 新的序列号空间: 3000000-3000500(完全不同的范围)
即使网络中有延迟的旧数据包(序列号1000-5000),服务器也会将其丢弃,因为:
- 这些序列号不在新连接的接收窗口内
- 四元组可能不匹配(客户端IP/端口不同)
- TCP头部的其他字段(如校验和)不匹配
因此,SO_REUSEADDR只是解决了端口绑定的问题,而TCP协议的核心可靠性机制(包括序列号)仍然完整工作,不会导致数据混淆或可靠性下降。
既然是服务端丢弃了旧数据包,那如果这个数据包是客户端因最后一个ACK丢失而重发的FIN呢?客户端是否会因此无法正常关闭?
让我们详细分析当设置了SO_REUSEADDR
后,客户端因最后一个ACK丢失而重发FIN时会发生什么:
实际情况分析
-
TIME_WAIT状态由内核维护,与进程无关
- 即使服务器进程退出,内核仍会维护TCP连接的TIME_WAIT状态
SO_REUSEADDR
只影响端口绑定规则,不改变内核的TCP状态机
-
正确的FIN重传处理流程
- 当客户端重发FIN时(因ACK丢失)
- 内核识别这是TIME_WAIT连接的数据包(通过四元组匹配)
- 内核重新发送ACK响应,确保连接正常关闭
- 客户端收到ACK后完成关闭流程
-
SO_REUSEADDR的真实作用
- 仅允许新套接字绑定到处于TIME_WAIT状态的端口
- 不影响现有TIME_WAIT连接的处理逻辑
- 新旧连接通过序列号等机制完全隔离
为什么不会影响客户端关闭?
关键点:TIME_WAIT是内核级状态,不是进程级状态
- 即使服务器进程重启,内核中的TCP栈仍然存在
- TIME_WAIT计时器由内核维护,不受进程生命周期影响
- 新启动的服务器进程与内核中的TIME_WAIT状态机并行工作
- 客户端重发的FIN由内核TCP栈处理,与新服务器进程无关
实际验证方法
你可以通过以下方式验证:
# 查看TCP连接状态(包括TIME_WAIT)
netstat -an | grep TIME_WAIT
# 或
ss -tan state time-wait
即使在服务器进程重启后,你仍然能看到原来的连接处于TIME_WAIT状态,证明内核仍在维护这些连接。
总结
设置SO_REUSEADDR
不会影响客户端的正常关闭,因为:
- TIME_WAIT状态由内核维护,与用户进程无关
- 内核会正确处理所有TCP状态转换,包括FIN重传
SO_REUSEADDR
只是解决了"地址已在使用"的绑定问题- TCP协议的可靠性机制完全不受影响
这是一个经过充分验证的解决方案,在各类网络服务中广泛使用。
许可协议:
CC BY-NC-SA 4.0