01 TCP 协议深入
本篇定位:把 Modbus TCP / S7comm / MC 三条链路底下那层"看不见的契约"讲透——从 20 字节报文头的每一个比特位,到握手挥手状态机、可靠传输、滑动窗口、拥塞控制,再到工业采集真正会咬人的 Nagle + 延迟 ACK 40ms 问题、KeepAlive 为什么救不了断链、缓冲区与
send()语义、RST 与半开连接。读完你应该能对着ss -i的输出说出链路出了什么问题。报文边界与粘包/拆包的处理不在这里展开(详见02-粘包与拆包.md),本篇只从传输层解释"为什么会粘"。
本篇目录
- 1. TCP 在协议栈中的位置
- 2. TCP 报文头完整拆解
- 3. 三次握手:seq/ack、状态机、为什么不是两次
- 4. SYN Flood 与 SYN Cookie
- 5. 四次挥手与 TIME_WAIT:为什么是 2MSL
- 6. 可靠传输:序列号、超时重传、RTO 与 Karn 算法
- 7. 快速重传、SACK 与累计确认
- 8. 滑动窗口:零窗口与糊涂窗口综合症
- 9. 拥塞控制:慢启动、拥塞避免、CUBIC/BBR
- 10. Nagle 算法与延迟 ACK:工业采集的 40ms 陷阱
- 11. TCP KeepAlive:为什么它救不了你的断链
- 12. 缓冲区与 send() 的真实语义
- 13. 连接异常:RST、Broken pipe、半开连接
- 14. Socket 选项速查表
- 15. Java 实战:工业级 Socket 初始化
- 16. 现场排查:netstat / ss 输出解读
- 小结
- 常见误区
1. TCP 在协议栈中的位置
Modbus TCP(502)、S7comm(S7-1200/1500,TCP/102)、MC 协议(三菱,常见 5000~5002,需按机型确认)全部跑在 TCP 之上:
| 诉求 | TCP 提供 | UDP 需要自己做 |
|---|---|---|
| 车间丢包(电磁干扰、劣质网线) | 自动重传 | 应用层超时重传 + 去重 |
| 请求-响应严格配对 | 字节流 + 序号保证有序 | 自己编号、排序、去重 |
| 一帧 S7 读响应可能 > 1500 字节 | 自动分段重组 | 自己拆包重组(IP 分片极不可靠) |
| 跨网段/跨 VPN | 拥塞控制、窗口自适应 | 自己限速,否则一上来就把 PLC 打懵 |
| 防火墙穿透 | 面向连接、状态可追踪 | 无状态,很多工业网关不友好 |
代价是 TCP 是字节流协议,没有消息边界:
write(req1); write(req2)
→ 发送缓冲 |01 02 03 04 05 06 07 08 09 0A 0B 0C|
→ 网络 [seg1: 01..08] [seg2: 09..0C] (按 MSS / 窗口 / Nagle 切)
→ read() [01 02 03 04 05 06 07 08] ← 可能是"半个包"或"两个包"
一次 read() 的字节数与一次 write() 的字节数没有任何必然关系
"粘包"不是 TCP 的 bug,而是字节流的固有属性,解法只能在应用层(详见 02-粘包与拆包.md)。本篇负责讲清是什么机制决定了字节如何被切分(MSS、窗口、Nagle、拥塞窗口、缓冲区)。
2. TCP 报文头完整拆解
2.1 比特布局图
头最小 20 字节,最大 60 字节(选项最多 40 字节)。每格一个比特,每行 32 比特:
0 1 2 3
0 1 2 3 4 5 6 7 8 9 0 1 2 3 4 5 6 7 8 9 0 1 2 3 4 5 6 7 8 9 0 1
+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
| Source Port | Destination Port | +0
+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
| Sequence Number | +4
+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
| Acknowledgment Number | +8
+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
| Data | |N|C|E|U|A|P|R|S|F| |
|Offset | Rsvd |S|W|C|R|C|S|S|Y|I| Window Size | +12
| (4b) | (3b) | |R|E|G|K|H|T|N|N| (16b) |
+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
| Checksum | Urgent Pointer | +16
+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
| Options (0 ~ 40 字节, 4 字节对齐) | +20
+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
| Data ... |
+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
数据偏移单位是"32 位字",取值 5~15 → 头长 20~60 字节
2.2 逐字段说明
| 字段 | 偏移(字节) | 长度(bit) | 含义 | 工业关注点 |
|---|---|---|---|---|
| Source Port | 0 | 16 | 源端口 | 客户端临时端口来自 ip_local_port_range(Linux 常见 32768~60999,需实测确认) |
| Destination Port | 2 | 16 | 目的端口 | Modbus 502、S7 102、MC 5000~5002 |
| Sequence Number | 4 | 32 | 本报文第一个数据字节的序号 | SYN 报文里装的是 ISN;SYN 不携带数据也消耗 1 个序号 |
| Acknowledgment Number | 8 | 32 | 期望收到的下一个字节序号 | 仅 ACK=1 时有效 |
| Data Offset | 12(高 4 位) | 4 | 头长度(单位 4 字节) | 解析选项时必须用它定位数据起点,别硬编码 20 |
| Reserved | 12 | 3 | 保留,必须为 0 | — |
| NS | 12(最低位) | 1 | ECN 隐匿保护(RFC 3540) | 极少用到 |
| CWR | 13(bit7) | 1 | 拥塞窗口已减小 | 需 ECN 协商成功才有效 |
| ECE | 13(bit6) | 1 | 接收端通告网络拥塞 | 车间交换机多不支持 ECN |
| URG | 13(bit5) | 1 | 紧急指针有效 | 实践中已废弃,不要依赖 |
| ACK | 13(bit4) | 1 | 确认号有效 | 建连后所有报文都置 1 |
| PSH | 13(bit3) | 1 | 提示尽快上交应用层 | Java 无法直接控制;与 Nagle 无关 |
| RST | 13(bit2) | 1 | 重置连接 | 见第 13 节 |
| SYN | 13(bit1) | 1 | 同步序列号(建连) | 消耗 1 序号 |
| FIN | 13(bit0) | 1 | 发端数据发完 | 消耗 1 序号 |
| Window Size | 14 | 16 | 接收窗口(字节) | 实际窗口 = Window × 2^(Window Scale) |
| Checksum | 16 | 16 | 头+数据+伪首部校验和 | 网卡 checksum offload 时 Wireshark 常显示"错误",属正常 |
| Urgent Pointer | 18 | 16 | 紧急数据偏移,仅 URG=1 有效 | 不用 |
序号消耗规则(算 seq/ack 最容易错的地方):
SYN 占 1 个序号(哪怕没数据) | FIN 占 1 个序号 | 纯 ACK 不占序号
RST 实践中按不占处理 | 数据 N 字节 → 占 N 个序号
A: SYN seq=1000 → B: SYN+ACK seq=5000,ack=1001 → A: ACK seq=1001,ack=5001
A: 12B 请求 seq=1001,ack=5001 → B: 10B 响应 seq=5001,ack=1013
B: FIN seq=5011,ack=1013 → A: ACK seq=1013,ack=5012
2.3 常见选项
| Kind | 长度 | 名称 | 作用 | 工业场景 |
|---|---|---|---|---|
| 0 | 1 | EOL | 选项结束 | — |
| 1 | 1 | NOP | 填充对齐 | 抓包里到处都是 |
| 2 | 4 | MSS | 本端能接收的最大报文段长度 | 以太网 IPv4 通常 1460,握手时定下就不再变 |
| 3 | 3 | Window Scale | 窗口左移 0~14 位 | 高 BDP 链路(跨厂区专线)必须开 |
| 4 | 2 | SACK Permitted | 允许选择性确认 | 丢包重传效率关键 |
| 5 | 可变 | SACK | 报告已收到的乱序区间 | 丢包时可见 SLE/SRE |
| 8 | 10 | Timestamp | RTT 测量 + PAWS 防序号回绕 | 车上/长肥管道必需 |
选项 TLV 编码:Kind(1B) + Length(1B) + Value,Kind=0/1 无 Length 字段。
MSS 决定"你一次 write 会被切成几段":MSS = MTU - IP头 - TCP头,以太网 MTU 1500 → IPv4 1460、IPv6 1440;PPPoE/GRE/IPsec 隧道会让 MTU 变小(1492、1476…)。若 DF 位置位而 ICMP 不可达被防火墙丢掉 → PMTU 黑洞,症状是"握手成功、小包正常,一读 2000 字节的大 DB 块就卡死"。排查:Linux ping -M do -s 1472 <目标>,Windows ping -f -l 1472 <目标>。
3. 三次握手:seq/ack、状态机、为什么不是两次
客户端(采集器) 服务端(PLC/网关)
CLOSED | listen()
| v LISTEN
| ① SYN seq=x, SYN=1 [MSS=1460, WS=7, SACK_PERM, TS]
| -----------------------------------------------> | SYN_RCVD(半连接队列)
| ② SYN+ACK seq=y, ack=x+1 [MSS=1460, WS=7, SACK_PERM, TS]
| <----------------------------------------------- |
SYN_SENT |
| ③ ACK seq=x+1, ack=y+1 |
| -----------------------------------------------> |
v v
ESTABLISHED ESTABLISHED
- MSS / WS / SACK_PERM 在 SYN 中交换且是单向的(本端 MSS 只约束对端发来的段长)。
- 建连耗时 = 1 个 RTT。"每次采集都新建连接"的代价是
RTT(握手)+RTT(请求)+RTT(挥手);车间内 1ms 无所谓,跨 VPN 30ms 就很痛。这是必须用长连接的第一个理由。 - 状态机:客户端
CLOSED --发SYN--> SYN_SENT --收SYN+ACK/发ACK--> ESTABLISHED;服务端CLOSED --listen()--> LISTEN --收SYN/发SYN+ACK--> SYN_RCVD --收ACK--> ESTABLISHED。
为什么不是两次
理由一(工程):TCP 是全双工的,两次握手只能确认"A 的 ISN 被 B 收到",无法确认"B 的 ISN 被 A 收到"。
理由二(根本):两次握手会让"迟到的重复 SYN"建立幽灵连接:
① A 发 SYN(seq=90) 走丢,超时;A 重发 SYN(seq=100) → B 收到并回 SYN+ACK
② 两次握手 → 连接建立、用完关闭
③ 此时迟到的 SYN(seq=90) 到达 B
④ 两次握手:B 回 SYN+ACK 就单方面进入 ESTABLISHED → 死连接,资源泄漏
三次握手:B 处于 SYN_RCVD,需等 A 的 ACK;A 发现 ack 与期望不符 → 回 RST
一句话:第三次握手是让发起方拥有最后一次否决权,防止历史重复请求初始化错误连接。
4. SYN Flood 与 SYN Cookie
攻击者发大量 SYN 不回 ACK,服务端半连接队列被填满,真实用户的 SYN 被丢弃(表现为"连不上 PLC"):
攻击方 SYN(伪造IP) × N -------> 服务端 SYN_RCVD(每个占一个 TCB)
半连接队列满
真实用户 SYN -----------------> X 丢弃
两个队列要分清(Linux):
| 队列 | 参数 | 溢出表现 |
|---|---|---|
| 半连接队列(SYN queue) | net.ipv4.tcp_max_syn_backlog |
SYN 被丢,客户端 connect 超时重试 |
| 全连接队列(accept queue) | min(应用层 backlog, net.core.somaxconn) |
连接被丢,内核 ListenOverflows 计数增长 |
Java 侧:new ServerSocket(port, backlog) 的第二参数就是全连接队列长度;JDK 默认值较小(约 50,需实测确认)且会被 somaxconn 截断。自己做 TCP Server(如接收设备主动上报)时务必显式传 backlog。
SYN Cookie:收到 SYN 时不分配任何状态,把连接信息编码进 ISN:ISN = t(5b 时间计数) | m(3b MSS 索引) | s(24b hash(四元组,t,密钥));收到第 ③ 个 ACK 时由 ack-1 还原 ISN 并重算 hash 比对,一致才分配真正的 TCB。代价要如实知道:Cookie 只编码 MSS,不编码 Window Scale / SACK / Timestamp → Cookie 路径下这些能力失效。Linux 默认 tcp_syncookies=1 是混合模式,只在半连接队列将满时才启用。
5. 四次挥手与 TIME_WAIT:为什么是 2MSL
主动关闭方 A(采集器) 被动关闭方 B(PLC)
ESTABLISHED ESTABLISHED
| ① FIN seq=u, ack=v |
|------------------------------------------->|
v v
FIN_WAIT_1 CLOSE_WAIT ← B 的 read() 返回 -1
| ② ACK seq=v, ack=u+1 | B 必须调 close() 才发 FIN
|<-------------------------------------------|
v |
FIN_WAIT_2 | ← 这段可很长(半关闭)
| ③ FIN seq=w, ack=u+1 |
|<-------------------------------------------|
| v
v LAST_ACK
TIME_WAIT |
| ④ ACK seq=u+1, ack=w+1 |
|------------------------------------------->|
| v
| 等 2MSL(Linux 60s) CLOSED
v
CLOSED
双方同时关闭时都进入 CLOSING,收到对端 ACK 后转 TIME_WAIT。
| 状态 | 谁进入 | 含义 | 现场诊断 |
|---|---|---|---|
FIN_WAIT_1 |
主动方 | 已发 FIN,等 ACK | 堆积 → 对端不响应或链路断了 |
FIN_WAIT_2 |
主动方 | 等对端 FIN | 长期停留 → 对端没调 close() |
TIME_WAIT |
主动方 | 已发最后 ACK,等 2MSL | 短连接客户端常见,正常 |
CLOSE_WAIT |
被动方 | 等本端 close() | 大量堆积 = 应用 bug |
LAST_ACK |
被动方 | 已发 FIN,等 ACK | 短暂,堆积说明对端异常 |
CLOSING |
双方 | 同时关闭 | 罕见 |
5.1 为什么需要 TIME_WAIT,且是 2MSL
作用一:可靠终止——第 ④ 个 ACK 可能丢失,B 会重发 FIN。若 A 已 CLOSED,重发 FIN 会收到 RST,B 就以异常方式结束。
作用二:让迷途报文消散——若 A 关闭后立即用相同四元组建新连接,旧连接迟到的数据段可能被新连接误收。
最坏推演:A 的最后 ACK 最多存活 MSL;若丢失,B 最晚在 MSL 时刻重发 FIN,
该 FIN 最多再存活 MSL 到达 A → A 必须保持状态 ≥ 2×MSL 才能接住它
| 系统 | MSL | 2MSL | 备注 |
|---|---|---|---|
| RFC 793 建议 | 2 分钟 | 4 分钟 | 规范值 |
| Linux | 30 秒 | 60 秒(TCP_TIMEWAIT_LEN 硬编码,sysctl 改不了,需实测确认) |
最常见 |
| Windows | 120 秒(注册表 TcpTimedWaitDelay,可调,需实测确认) |
240 秒 | 开发机上注意 |
5.2 现场堆积说明什么
CLOSE_WAIT 堆积:对端已发 FIN,本端应用没调 close()。排查 ss -tnp state close-wait → 拿 PID → jstack 找卡住的线程栈。工业采集典型成因:① read() 阻塞住走不到 finally(必须有 SO_TIMEOUT + 业务超时,超时后主动 close 且不放回连接池);② 连接被放回池里复用但 PLC 已单方面断开(半开连接,见第 13 节);③ Netty 的 ByteBuf 没 release。
TIME_WAIT 堆积:本端是主动关闭方,属正常现象,危害是占四元组(采集器固定连同一 PLC 的 502 端口时会耗尽本地临时端口)。对策按推荐顺序:① 治本——长连接 + 连接池;② net.ipv4.tcp_tw_reuse=1(只对新出站连接生效,依赖 TCP timestamp);③ 调大 ip_local_port_range;④ tcp_tw_recycle 已在 Linux 4.12+ 移除,NAT 下曾造成大规模连接失败,别用;⑤ SO_LINGER=0 强制 RST——见第 14 节,别乱用。
6. 可靠传输:序列号、超时重传、RTO 与 Karn 算法
A 发送 B 回应
seq=1, len=1000 -------------> 收到 1~1000 ack=1001
seq=1001, len=1000 -------------> 收到 1001~2000 ack=2001
seq=2001, len=500 ----丢失----> (无)
seq=2501, len=500 -------------> 乱序 ack=2001 (dup #1)
seq=3001, len=500 -------------> 乱序 ack=2001 (dup #2)
ack=2001 (dup #3) → 快速重传
seq=2001, len=500 -------------> 补齐 ack=3501(累计确认,一次补齐)
累计确认:ack=N 表示"N 之前的所有字节都收到了",不表示"只收到 N-1"。
6.1 RTO 计算(Jacobson)
首次采样 R: SRTT=R, RTTVAR=R/2, RTO=SRTT+max(G, 4×RTTVAR)
后续采样 R: RTTVAR = (1-β)×RTTVAR + β×|SRTT-R| β=1/4
SRTT = (1-α)×SRTT + α×R α=1/8
RTO = SRTT + max(G, 4×RTTVAR)
约束:RTO 初值 1s(RFC 6298,老实现 3s)
RTO 最小 200ms(Linux tcp_rto_min,可按路由改)
RTO 最大 120s(常见值,需实测确认)
6.2 Karn 算法
报文重传后回来的 ACK 到底是确认"第一次发送"还是"重传"?无法区分 → 重传报文的 RTT 样本一律不采信;同时 RTO 指数退避(常见 ×2,上限 tcp_rto_max);只有收到未重传报文的 ACK 才恢复 RTO 计算。
初始 RTO=200ms 时:0.2s → 0.6s → 1.4s → 3.0s → 6.2s → 12.6s → 25.4s → 51s …
Linux tcp_retries2 默认 15 次 → 总时长可达十几分钟(常引用约 924s,需实测确认)
→ 结论:绝不能靠 TCP 重传超时来发现"PLC 断电了"。
7. 快速重传、SACK 与累计确认
快速重传:收到 3 个重复 ACK(即第 4 个相同 ACK)立即重传,不等 RTO。取 3 是为了把"乱序"与"丢包"分开(1~2 个 dup ACK 大概率只是乱序)。
**SACK(RFC 2018)**补上累计确认的信息缺口:
无 SACK:A 以为 2001 之后全丢 → 重发 2001/2501/3001(后两段白发)
有 SACK:B 回 ack=2001, SACK[2501,3500) → A 只重发 2001~2500
选项编码:Kind=5, Length=8n+2,每块 8 字节(Left Edge / Right Edge 各 32b)
受 40 字节选项空间限制,最多约 4 个块
**D-SACK(RFC 2883)**报告"重复收到的段",用来区分真丢包 / 乱序 / ACK 丢失。抓包看到 D-SACK 说明网络有重排。
可观测性建议:定期看 ss -i 的 retrans 计数与 netstat -s 的 SACK 统计;持续上涨先查网线、交换机端口、是否与视频监控共网,别先怪 PLC。
8. 滑动窗口:零窗口与糊涂窗口综合症
发送端: 已确认 | 已发未确认 | 允许发送未发 | 不可发送
+------+------------+--------------+----------+
|1~1000| 1001~3000 | 3001~5000 | 5001~ |
+------+------------+--------------+----------+
^ ^ ^
SND.UNA SND.NXT SND.UNA+SND.WND
可用窗口 = SND.UNA + SND.WND - SND.NXT
真实发送上限 = min(cwnd, 对端通告窗口)
实际窗口 = 首部 Window(16b) << Window Scale(0~14) → 最大约 1GB
⚠ 老旧工业网关/防火墙可能错误改写 Window Scale → "小包正常、大块极慢"
零窗口与窗口探测:接收端应用不读 → 缓冲区满 → 通告 win=0;发送端停发并启动持续计时器,周期性发 1 字节窗口探测(退避 1.5s/3s/6s/12s…,上限约 60s,需实测确认),直到收到 win>0 的窗口更新。现场对应"采集周期忽长忽短":ss -i 看 rcv_space 与对端窗口。
糊涂窗口综合症(SWS):接收端刚腾出 1 字节就通告 win=1 → 发送端发 1 字节 → 有效载荷比 1/41 ≈ 2.4%。双向治理:
| 侧 | 规则 | 出处 |
|---|---|---|
| 接收端(Clark 算法) | 可用空间 < min(MSS, 缓冲区一半) 时通告 win=0 | RFC 1122 |
| 发送端 | 只在 ① 可发满 MSS;② 可发 ≥ 曾通告最大窗口的一半;③ 没有已发出未确认的数据 时才发 | RFC 1122(③ 即 Nagle) |
9. 拥塞控制:慢启动、拥塞避免、CUBIC/BBR
① 慢启动:cwnd 初值 10×MSS(RFC 6928,Linux 取值,需实测确认)
每收到一个 ACK,cwnd += 1 MSS → 每 RTT 翻倍
② 拥塞避免:cwnd ≥ ssthresh 后,每 RTT +1 MSS(线性)
③ 拥塞判定:
RTO 超时:ssthresh = max(cwnd/2, 2×MSS),cwnd = 1 MSS → 重回慢启动(惩罚最重)
3 个 dup ACK:→ ④ 快速恢复(惩罚较轻)
④ 快速恢复(Reno):ssthresh = cwnd/2,cwnd = ssthresh + 3×MSS
其后每个额外 dup ACK:cwnd += 1 MSS(临时膨胀)
收到重传数据的 ACK:cwnd = ssthresh → 进入拥塞避免
增长曲线:
cwnd(MSS)
32 | ..: ← 慢启动(指数,每 RTT 翻倍)
16 | :.......................... ← 拥塞避免(线性)
8 | : \
4 | : \ 丢包(RTO)
1 | X \ cwnd=1 → 重回慢启动
+---+---+---+---+---+---+---+---+---+---+---+---+---> RTT
0 1 2 3 4 5 6 7 8 9 10 11 12
吞吐上限与 BDP(跨厂区专线必算):吞吐 ≈ min(cwnd, rwnd) / RTT。例:RTT=20ms、MSS=1460,cwnd=32 MSS → 约 18.7 Mbps,cwnd=1 MSS → 73 KB/s(丢一次包就掉到这水平)。所需 cwnd = BDP/MSS = (带宽×RTT)/MSS:100 Mbps × 20ms = 250 KB = 171 MSS → 接收窗口必须 ≥250KB,否则必须开 Window Scale。
| 算法 | 内核 | 核心思想 | 工业评价 |
|---|---|---|---|
| Reno | 经典 | 丢包即减半,AIMD | 基线,已非主流 |
| CUBIC | Linux 2.6.19+ 默认 | W(t)=C(t-K)³+W_max,C=0.4,增长与 RTT 无关 |
跨厂区高 BDP 链路主力 |
| BBR | Linux 4.9+ | 不以丢包为信号,建模 BDP = BtlBw × RTprop 主动探测 |
车间内 RTT 极小收益有限;无线/跨公网回传可考虑(需实测确认) |
sysctl net.ipv4.tcp_available_congestion_control
sysctl net.ipv4.tcp_congestion_control # 临时切换:sysctl -w ...=bbr
10. Nagle 算法与延迟 ACK:工业采集的 40ms 陷阱
这一节是本篇对采集工程师价值最高的部分。"为什么我的 Modbus 采集延迟稳定是 40ms"的答案就在这。
10.1 Nagle(RFC 896 / RFC 1122)
一句话:若连接上还有已发出未确认的数据,则小数据(<MSS)不允许发送,必须等到攒够 MSS 或收到之前数据的 ACK。
if 有数据要发送:
if 可发数据 >= MSS: 立即发送(满 MSS 不受限)
else:
if 没有"已发出未确认"的数据: 立即发送
else: 缓冲,等 (a) 累积到 MSS 或 (b) 收到 ACK
初衷是治理 Telnet 时代"每敲一个字符发一个 41 字节包"的小包灾难。
10.2 延迟确认(Delayed ACK)
收到数据后不立即回 ACK,等一小段时间:Linux 典型 40ms,Windows/BSD 常见 200ms,RFC 1122 允许最大 500ms(需实测确认)。若期间有数据要回给对端就捎带(piggyback),否则超时发纯 ACK。乱序报文会立即 ACK。
10.3 两者相遇 = 40ms
场景:采集器把请求分成两次 write(先 MBAP 头 7 字节,再 PDU),
PLC 是"收完才回应答",回应前无数据可捎带 ACK
采集器 A PLC B
|-- write#1: MBAP 7B -----------------------> | 收到 → 启动 Delayed ACK(40ms)
| (无未确认数据 → 立即发) |
|-- write#2: PDU |
| Nagle:#1 未确认 → 不能发,缓冲 ... 干等 |
| 40ms 到期 → 纯 ACK
|<------------- 纯 ACK -----------------------|
|-- Nagle 放行,发 #2 ------------------------> | 此时才收全请求 → 回响应
|<------------- 响应 --------------------------|
总耗时 ≈ 40ms + RTT ← 本该只有 RTT
判定自己会不会踩的三个前提:
- 触发条件是"一次请求被分成多次 write 且中间没有读"。一次
write()写完整个请求,Nagle 不会造成延迟。 - 请求大于 MSS 时(如读 4000 字节的 DB 块)内核会切多段,最后一段不足 MSS 可能被卡住(取决于实现,需实测确认)。
- 延迟 ACK 定时器因 OS 而异 → 同一套代码在 Windows 开发机 200ms、Linux 服务器 40ms 是正常的。
10.4 TCP_NODELAY
| 平台 | 设置方式 |
|---|---|
| Java BIO | socket.setTcpNoDelay(true) |
| Java NIO | channel.setOption(StandardSocketOptions.TCP_NODELAY, true) |
| Netty 服务端 / 客户端 | .childOption(ChannelOption.TCP_NODELAY, true) / .option(...) |
该开(工业采集 99% 场景):
✓ Modbus/S7/MC 的请求-响应式交互(小包、强实时)
✓ 一次请求分成多次 write 的写法
✓ 需要严格控制采集周期抖动
影响不大:
✗ 纯大块数据(>MSS 且一次写完),Nagle 本就不生效
经验法则:工业采集一律 setTcpNoDelay(true),不要犹豫。
"一次 write 写完整个请求" + TCP_NODELAY = 双保险。
// 反例:两次 write,即便开了 TCP_NODELAY 也可能被切成两段
out.write(mbapHeader); out.write(pdu); out.flush();
// 正例:拼成一个缓冲区一次写出(注意 write 不保证一次写完,必须循环)
ByteBuffer buf = ByteBuffer.allocate(mbapHeader.length + pdu.length);
buf.put(mbapHeader).put(pdu);
buf.flip();
while (buf.hasRemaining()) {
channel.write(buf);
}
10.5 Nagle vs TCP_CORK
| 对比 | Nagle | TCP_CORK(Linux 特有) |
|---|---|---|
| 释放条件 | 收到 ACK 或 攒够 MSS | 攒够 MSS 或 显式 uncork 或 200ms 到期 |
| 受 ACK 驱动 | 是 | 否 |
| 语义 | "别发小包" | "把接下来若干次 write 拼成一整个包" |
| Java 支持 | setTcpNoDelay |
JDK 标准 API 无(需 JNI/Netty 原生传输,需实测确认) |
Nagle : [w1] --ACK-- [w2] 2 个包,间隔 1 RTT
CORK : [w1 w2 合并] ---------> 1 个包,但要等 uncork/200ms
NODELAY: [w1] [w2] 2 个包,立即发,无等待
11. TCP KeepAlive:为什么它救不了你的断链
| 参数 | Linux 默认 | 含义 |
|---|---|---|
net.ipv4.tcp_keepalive_time |
7200 秒 | 空闲多久后开始探测 |
net.ipv4.tcp_keepalive_intvl |
75 秒 | 探测间隔 |
net.ipv4.tcp_keepalive_probes |
9 次 | 连续无响应判定死亡 |
最坏发现死链耗时 = 7200 + 75×9 = 7875s ≈ 2 小时 11 分钟
对 100ms 采集周期 = 7.8 万个周期数据全丢,完全不可接受
五个硬伤:
- 周期太长,标准 Java API 只能开关、不能改参数。
- 只证明"TCP 层通",不证明"应用层能服务"——PLC 的 TCP 栈由通信处理器负责,CPU 处于 STOP 也能正常回 KeepAlive 探测包。
- 只探测空闲链路——正常数据会重置计时器,采集链路几乎永远不触发。
- 探测包是空的,穿 NAT/防火墙时可能被丢。
- 系统级配置改了会影响同机所有连接。
正确的做法:应用层心跳
- 心跳内容必须是"真实业务请求":Modbus 读一个必定存在的保持寄存器(如 40001)、S7 读一个必定存在的 DB 字节或 SZL、MC 读一个固定 D 寄存器。既验证 TCP 通,也验证 PLC 应用逻辑活着。
- 经验值(需按现场实测调整):心跳周期 = 3×采集周期(或固定 1s/3s/5s);单次超时 = 3×历史 RTT 的 P99(下限 1s、上限 5s);连续失败 3 次判死。例:周期 1s、超时 2s、失败 3 次 → 最坏 6s 内发现。
- 判定故障后:关闭 socket(绝不复用)、标记离线、指数退避重连(1s/2s/4s/8s/16s,上限 60s,加 ±20% 抖动)。⚠ 重连风暴是灾难:200 台 PLC 同时掉线又同时 1s 重连 → 每秒 200 次握手 + S7 协商,会把网关打挂。
- 心跳与业务请求共用连接时必须串行化(调度器设计见 03 篇)。
Java 侧(作兜底,非主力):
import jdk.net.ExtendedSocketOptions;
Socket s = new Socket();
s.setKeepAlive(true);
try { // JDK 11+ 且 OS 支持,Windows/旧内核会抛异常
s.setOption(ExtendedSocketOptions.TCP_KEEPIDLE, 60); // 空闲 60s 开始探测
s.setOption(ExtendedSocketOptions.TCP_KEEPINTERVAL, 10); // 间隔 10s
s.setOption(ExtendedSocketOptions.TCP_KEEPCOUNT, 3); // 3 次失败判死
} catch (UnsupportedOperationException | IOException e) {
// 不支持 → 退回系统默认,靠应用层心跳兜底
}
原则:**KeepAlive 是"清理长期僵死连接"的工具,不是"健康检测"的工具。**健康检测必须由应用层心跳完成,心跳必须是真实业务读。
12. 缓冲区与 send() 的真实语义
应用A write() --①拷贝--> 内核 SNDBUF --②分段+TCP头--> 网卡 ==网络==>
网卡 --③校验重组--> 内核 RCVBUF --④拷贝--> 应用B read()
SNDBUF 里的"未确认数据"必须留着(要重传);RCVBUF 里的"乱序数据"也占空间
12.1 send() 成功 ≠ 对方收到
这是采集代码里最危险的误解:
write()/send() 返回的真实含义:"数据已被完整拷进本端内核发送缓冲区"
它不表示:已出网卡 / 已到对端 / 对端内核已收 / 对端应用已 read()
唯一能确认"对端应用收到了"的方式:对端应用给你一个响应。
Modbus/S7 的请求-响应模式天然提供了应用层的端到端确认。
12.2 写阻塞与缓冲区设置
发送缓冲区满时:
阻塞 socket → write() 阻塞,直到有 ACK 腾出空间
非阻塞 socket → write() 返回 0/部分字节,必须循环写,否则丢数据
Netty → 写任务排队,channel.isWritable() 变 false(高低水位)
| 项 | 说明 |
|---|---|
SO_SNDBUF |
发送缓冲区。Linux 上内核会翻倍使用(部分用于 skb 开销,需实测确认),getsockopt 读回来是 2 倍 |
SO_RCVBUF |
接收缓冲区,同样翻倍,且决定通告窗口上限 |
| 自动调优 | tcp_moderate_rcvbuf=1(默认开)按连接速率动态调整,tcp_rmem/tcp_wmem 三个值 = min/default/max |
| 设置时机 | 必须在 connect() / listen() 之前,否则 Window Scale 已协商完,改了不生效(最常见踩坑) |
| 内存 | 每连接都吃内核内存,几万连接时别盲目开大 |
sysctl net.ipv4.tcp_rmem net.ipv4.tcp_wmem net.ipv4.tcp_moderate_rcvbuf
// 正确顺序:先建未连接 socket → 设缓冲 → 再 connect
Socket s = new Socket(); // 无参构造器
s.setReceiveBufferSize(64 * 1024); // 必须在 connect 之前
s.setSendBufferSize(64 * 1024);
s.connect(new InetSocketAddress("192.168.1.10", 502), 3000);
// 反例:new Socket(host, port) 之后再 setXxxBufferSize → 白改
13. 连接异常:RST、Broken pipe、半开连接
RST 是"立即终止,不商量":收到方立即拆除连接,不走四次挥手,主动发 RST 的一方也不进 TIME_WAIT。
13.1 RST 的产生时机
| 场景 | 触发方 | 说明 |
|---|---|---|
| 连到未监听的端口 | 服务端 | Java 抛 ConnectException: Connection refused |
SO_LINGER=0 关闭 |
主动方 | 有未发送数据也直接 RST |
| 收到数据但连接已关闭 | 任一方 | 对端已 close(),本端还在 write() |
| 应用强制中断(abortive close) | 任一方 | 有未读数据时直接关闭 |
| 序号严重越界 | 接收方 | 先 challenge ACK 确认(RFC 5961),仍不对才 RST |
| 中间设备伪造 | 防火墙/IPS/NAT | 车间防火墙常对"空闲过长"的连接发 RST |
13.2 Java 异常映射
| 异常 | 触发条件 |
|---|---|
ConnectException: Connection refused |
目标端口无监听,收到 RST |
SocketTimeoutException: Connect timed out |
connect 超时 |
SocketTimeoutException: Read timed out |
SO_TIMEOUT 读超时 |
SocketException: Connection reset (by peer) |
已收到对端 RST 后继续 write |
SocketException: Broken pipe |
write 到已被对端关闭的 socket(Linux EPIPE:首次 write 收到 RST,再次 write 触发) |
SocketException: Socket closed |
本端 socket 已关闭还在使用 |
EOFException / read() 返回 -1 |
对端正常关闭(发了 FIN) |
try {
int n = in.read(buf);
if (n < 0) handlePeerClosed(); // 优雅关闭 → 重连即可
} catch (SocketTimeoutException e) {
handleTimeout(); // 计入失败计数
} catch (SocketException e) {
handleBroken(e); // reset/broken pipe → 必须销毁重建
}
13.3 半开连接(Half-Open)
定义:一端认为连接还在(ESTABLISHED),另一端已经没了(崩溃、断电、拔网线、NAT 超时)。因为没有 FIN 也没有 RST,TCP 自身无能为力。
采集器 A(ESTABLISHED) ---请求--> X PLC 突然断电,没人收
A: RTO 退避重传 15 次,十几分钟后内核才放弃
在此之前:A 的 write() 全部"成功"(拷进发送缓冲),read() 一直阻塞到
SO_TIMEOUT —— 这是半开连接最阴险的地方
对端断电后本端如何感知:
| 手段 | 生效时间 | 评价 |
|---|---|---|
| TCP 重传超时(tcp_retries2=15) | 十几分钟 | 不可用 |
| TCP KeepAlive(默认) | 2h11m | 不可用 |
| TCP KeepAlive(per-socket 60s/10s/3) | 约 90s | 兜底可用 |
| 应用层心跳 + 读超时 | 秒级 | 唯一正确解 |
| SO_TIMEOUT(3s) | 3s | 只能发现"这次没回来",需配合失败计数 |
组合拳:SO_TIMEOUT(读超时) + 连续失败计数(3 次) + 应用层心跳(真实读) + 故障后 close + 指数退避重连。
14. Socket 选项速查表
| 选项 | 作用 | Java API | 工业推荐 | 踩坑提示 |
|---|---|---|---|---|
TCP_NODELAY |
关闭 Nagle,小包立即发 | setTcpNoDelay(true) / StandardSocketOptions.TCP_NODELAY / Netty ChannelOption.TCP_NODELAY |
一律 true | 忘记设 → 延迟稳定 +40ms(Linux)或 +200ms(Windows) |
SO_KEEPALIVE |
内核保活探测 | setKeepAlive(true) |
true(仅兜底) | 默认 7200s,不能替代应用层心跳;细粒度参数 JDK 11+ 才支持且 OS 相关 |
SO_TIMEOUT |
读操作阻塞超时 | setSoTimeout(ms) / Netty ReadTimeoutHandler |
1s~5s | ① 只影响 read(),不影响 connect()(用 connect(addr,timeout));② 超时后 socket 仍可用;③ 0 = 无限等待,生产禁用 |
SO_REUSEADDR |
允许绑定 TIME_WAIT 状态的地址 | ServerSocket.setReuseAddress(true) |
服务端 true | ① 必须 bind() 之前设;② 不允许两进程同监听一端口(那是 SO_REUSEPORT) |
SO_LINGER |
控制 close() 行为 |
setSoLinger(on, sec) |
不设置(保持默认) | 见下方专项 |
SO_RCVBUF |
接收缓冲区(决定通告窗口上限) | setReceiveBufferSize(n) |
64KB~256KB | 必须 connect/listen 之前;内核可能翻倍;过大吃内存 |
SO_SNDBUF |
发送缓冲区 | setSendBufferSize(n) |
64KB~256KB | 同上;它不解决"对端不读" |
SO_REUSEPORT(补充) |
多 socket 绑同端口,内核负载均衡 | JDK 标准 API 支持有限(需实测确认) | 一般不用 | 别与 SO_REUSEADDR 混淆 |
TCP_QUICKACK(补充) |
临时关闭延迟 ACK | JDK 标准 API 无 | 一般不用 | 一次性生效,内核会自行恢复 |
14.1 SO_LINGER 专项(重点)
on=false(默认) close() 立即返回,内核尽力发完剩余数据,走正常四次挥手 ✓
on=true, linger>0 close() 阻塞直到数据发完/被确认,最多 linger 秒;
超时后仍走正常流程(不发 RST)。采集线程里别用大值
on=true, linger=0 ⚠ 危险:close() 直接发 RST(abortive close)
· 发送缓冲区未发出的数据 全部丢弃
· 接收缓冲区未读的数据 全部丢弃
· 不进 TIME_WAIT(唯一"好处")
· 对端收到 Connection reset 而不是干净的 EOF
· 污染中间 NAT/防火墙的连接状态
为什么工业采集不该用 SO_LINGER=0:
1) PLC 侧收到 RST 会判定通信异常,某些 S7 CPU 会记录通信错误甚至短暂
拒绝后续连接(需实测确认机型行为)
2) 最后一次写请求可能没发完就被丢 —— 丢写比丢读严重得多(阀门该开没开)
3) 现代内核下 TIME_WAIT 本来就不是问题(60s、内存占用小)
结论:不要用 SO_LINGER=0 来"优化" TIME_WAIT。治本靠长连接 + 连接池。
15. Java 实战:工业级 Socket 初始化
package com.example.iot.net;
import jdk.net.ExtendedSocketOptions;
import java.io.IOException;
import java.net.InetSocketAddress;
import java.net.Socket;
import java.net.SocketAddress;
/** 工业采集场景的 Socket 初始化工具。 */
public final class IndustrialSocketFactory {
public static final int MODBUS_TCP_PORT = 502;
public static final int S7_PORT = 102;
private static final int CONNECT_TIMEOUT_MS = 3_000;
private static final int SO_TIMEOUT_MS = 3_000;
private static final int RCV_BUF_BYTES = 64 * 1024;
private static final int SND_BUF_BYTES = 64 * 1024;
private IndustrialSocketFactory() {
}
/** 创建并配置一个到 PLC 的 Socket。timeoutMs<=0 时用默认 3s。 */
public static Socket connect(String host, int port, int timeoutMs) throws IOException {
Socket socket = new Socket(); // 无参构造器:未连接
configure(socket); // 关键:连之前配置
SocketAddress addr = new InetSocketAddress(host, port);
int t = (timeoutMs > 0) ? timeoutMs : CONNECT_TIMEOUT_MS;
socket.connect(addr, t); // connect 超时由此参数控制
return socket;
}
/** 必须在 connect()/bind() 之前调用 */
public static void configure(Socket socket) throws IOException {
socket.setTcpNoDelay(true); // 1. 关 Nagle,必开
socket.setSoTimeout(SO_TIMEOUT_MS); // 2. 读超时(只管 read)
socket.setReceiveBufferSize(RCV_BUF_BYTES); // 3. 缓冲:连之前设
socket.setSendBufferSize(SND_BUF_BYTES);
socket.setKeepAlive(true); // 4. 保活兜底
trySetPerSocketKeepAlive(socket);
// 5. 不设 SO_LINGER:保持默认优雅关闭
// socket.setSoLinger(true, 0) —— 禁用!会发 RST 并丢弃未发送数据
socket.setPerformancePreferences(1, 0, 2); // 6. 短连接时间 > 低延迟 > 带宽
}
private static void trySetPerSocketKeepAlive(Socket socket) {
try {
socket.setOption(ExtendedSocketOptions.TCP_KEEPIDLE, 60);
socket.setOption(ExtendedSocketOptions.TCP_KEEPINTERVAL, 10);
socket.setOption(ExtendedSocketOptions.TCP_KEEPCOUNT, 3);
} catch (UnsupportedOperationException | IOException e) {
System.err.println("[socket] per-socket keepalive 不可用,依赖应用层心跳");
}
}
public static void main(String[] args) {
try (Socket s = connect("192.168.1.10", MODBUS_TCP_PORT, 3000)) {
System.out.println("remote = " + s.getRemoteSocketAddress());
System.out.println("tcpNoDelay = " + s.getTcpNoDelay());
System.out.println("soTimeout = " + s.getSoTimeout());
System.out.println("rcvBuf = " + s.getReceiveBufferSize());
System.out.println("sndBuf = " + s.getSendBufferSize());
System.out.println("keepAlive = " + s.getKeepAlive());
} catch (IOException e) {
System.err.println("连接失败: " + e);
}
}
}
NIO 版本(注意 NIO 没有 SO_TIMEOUT 等价项,读超时靠 selector.select(timeout) 或 Netty 的 ReadTimeoutHandler):
public static SocketChannel connect(String host, int port) throws IOException {
SocketChannel ch = SocketChannel.open();
ch.configureBlocking(true); // 阻塞模式完成连接
ch.setOption(StandardSocketOptions.TCP_NODELAY, true);
ch.setOption(StandardSocketOptions.SO_KEEPALIVE, true);
ch.setOption(StandardSocketOptions.SO_RCVBUF, 64 * 1024);
ch.setOption(StandardSocketOptions.SO_SNDBUF, 64 * 1024); // 均在 connect 之前
ch.connect(new InetSocketAddress(host, port));
return ch;
}
15.1 SO_TIMEOUT 与 read 超时的关系
setSoTimeout(3000) 的语义:read() 最多阻塞 3000ms
├─ 3000ms 内有任意数据到达 → 立即返回(可能只有 1 字节!这就是"拆包")
├─ 一个字节都没到 → 抛 SocketTimeoutException
└─ 计时是"每次 read 调用"独立计的,不是"整个响应"的总时限
经典 bug:循环 read 凑整帧时,若每帧之间都 <3000ms,循环可能永不结束
→ 正确做法:自己算一个"整帧截止时间"
/** 带整帧截止时间的读取,避免逐次 read 超时被无限重置 */
public static byte[] readFrame(java.io.InputStream in, int expectedLen, long deadlineMs)
throws IOException {
byte[] buf = new byte[expectedLen];
int off = 0;
while (off < expectedLen) {
long remain = deadlineMs - System.currentTimeMillis();
if (remain <= 0) {
throw new java.net.SocketTimeoutException("整帧读取超时: " + off + "/" + expectedLen);
}
int n = in.read(buf, off, expectedLen - off); // 单次受 SO_TIMEOUT 约束
if (n < 0) {
throw new java.io.EOFException("对端关闭: " + off + "/" + expectedLen);
}
off += n;
}
return buf;
}
setSoTimeout 只影响 read();不影响 connect()(用 connect(addr, timeout))、write()(写阻塞无超时选项)、ServerSocket.accept()(需单独设置)。
16. 现场排查:netstat / ss 输出解读
# 推荐 ss(iproute2,连接多时比 netstat 快几个数量级)
ss -antp # 所有 TCP + 进程
ss -antp state established '( dport = :502 or dport = :102 )'
ss -s # 各状态计数总览
ss -i # cwnd/rtt/retrans —— 最有价值
ss -tnp state close-wait | wc -l
netstat -antp # 老系统
netstat -s | grep -iE "retransmit|reset|listen" # 协议级统计
netstat -an | awk '/^tcp/{print $6}' | sort | uniq -c | sort -rn
# Windows:netstat -ano | findstr :502 ;findstr CLOSE_WAIT
# Get-NetTCPConnection -State CloseWait | Select-Object -First 20
ss -i 输出解读:
ESTAB 0 0 192.168.1.100:45123 192.168.1.10:102 users:(("java",pid=1234,fd=88))
cubic wscale:7,7 rto:204 rtt:1.2/0.6 mss:1460 cwnd:10 ssthresh:20
send 97.3Mbps lastsnd:20 lastrcv:20 retrans:0/15 rcv_space:29200
cubic 拥塞算法 | wscale:7,7 双向窗口缩放 → 最大约 8MB | rto:204 当前重传超时
rtt:1.2/0.6 平滑RTT/RTT偏差 | cwnd:10 拥塞窗口 | retrans:0/15 总重传段/重传中
现场口诀:retrans 持续上涨 → 链路丢包,先查网线/交换机端口/是否与视频监控共网
rtt 抖动大 → 有拥塞或与办公网共网 | cwnd 长期很小 → 一直丢包或 WS 被破坏
Send-Q 长期非 0 → 发不出去(对端窗口为 0) | Recv-Q 长期非 0 → 本端没及时读
16.1 排查实例(S7-1200 采集抖动)
现象:60 台 S7-1200,采集周期 500ms,实际平均 540ms、P99 1.2s,偶发 3s+,偶发 Connection reset。
| 步骤 | 命令 | 发现 |
|---|---|---|
| ① | ss -antp state established '( dport = :102 )' | wc -l |
58 条 → 2 台没连上,先查网络/电源 |
| ② | ss -tnp state close-wait | wc -l |
7 个 CLOSE_WAIT → 超时分支 return 了但没关 socket |
| ③ | ss -i '( dport = :102 )' | grep retrans | grep -v "retrans:0/" |
3 条链路有重传(后查明:一根网线被压在桥架下,一台接错 VLAN) |
| ④ | tcpdump -i eth0 -nn 'host 192.168.1.10 and port 102' -w /tmp/s7.pcap → Wireshark Analyze → Expert Info |
有 Retransmission;请求帧被切成两段,第二段比第一段晚约 40ms → 典型 Nagle + Delayed ACK |
| ⑤ | ss -i 看 rcv_space |
29200 偏小 → 读大块前需在 connect 前调大 SO_RCVBUF |
修复清单(按优先级):① 修复超时分支未 close 的 bug;② 所有 socket 加 setTcpNoDelay(true)(消除 40ms);③ 加应用层心跳(读固定 DB 字节,周期 1s、超时 2s、连续 3 次判死);④ 换掉 3 条有重传链路的物理连接;⑤ connect 前设 SO_RCVBUF=64KB。修复后 P99 从 1.2s 降到 560ms。
小结
- 报文头固定 20 字节、最大 60 字节;
Data Offset单位 4 字节、取值 5~15;常用标志是 SYN/ACK/FIN/RST/PSH;SYN 与 FIN 各消耗 1 个序号,ack=N表示"N 之前都收到了"。 - 三次握手耗 1 个 RTT,第三次握手的本质是让发起方拥有否决权、防止历史重复 SYN 建立幽灵连接。这是工业采集必须用长连接的第一个理由。
- 四次挥手中 TIME_WAIT = 2MSL(Linux 60s):接住重传的 FIN 并重发 ACK;让迷途旧报文消散。
CLOSE_WAIT堆积几乎 100% 是本端没调close()的应用 bug。 - 可靠传输 = 序列号 + 累计确认 + 超时重传;RTO 用 Jacobson 公式(α=1/8、β=1/4、
RTO=SRTT+4×RTTVAR);Karn 算法规定重传报文的 RTT 样本不采信,重传时 RTO 指数退避,靠它发现死链要十几分钟。 - 快速重传由 3 个重复 ACK 触发;SACK 补上累计确认的信息缺口,避免白发已收到的段。
- 滑动窗口实际值 =
Window << Window Scale,最大约 1GB;零窗口靠持续计时器探测;糊涂窗口综合症由接收端 Clark 算法与发送端 Nagle 双向治理。 - 拥塞控制:慢启动指数增长 → 拥塞避免线性增长 → RTO 超时惩罚最重(回到 1 MSS)、快速恢复较轻。Linux 默认 CUBIC,BBR 适合高带宽高延迟链路。
- Nagle + Delayed ACK = 40ms(Linux)/ 200ms(Windows),触发条件是"一次请求分多次 write 且中间不读";解法是
setTcpNoDelay(true)+ 一次 write 写完整个帧。 - KeepAlive 默认 7200s + 75s×9 ≈ 2h11m,且只证明 TCP 通、不证明 PLC 应用活着。健康检测必须用应用层心跳,内容是真实业务读。
send()成功只表示数据进了本端内核发送缓冲区;确认对端收到只能靠应用层响应。- RST 常见来源:连未监听端口、
SO_LINGER=0关闭、对端已关后继续写、中间防火墙伪造。SO_LINGER=0会丢弃未发送/未读取数据并强制 RST,工业采集禁用。 - 半开连接没有 FIN 也没有 RST,只能靠 SO_TIMEOUT + 失败计数 + 应用层心跳发现。
- 排查三板斧:
ss -antp看状态、ss -i看retrans/rtt/cwnd/Recv-Q、tcpdump + Wireshark 看 Expert Info。
常见误区
| 误区 | 正解 |
|---|---|
| "TCP 有消息边界,一发一收" | TCP 是字节流,无边界。一次 read() 的字节数与一次 write() 无关(详见 02-粘包与拆包.md) |
| "两次握手够用了" | 第三次握手用于防止历史重复连接请求初始化连接,并确认双向 ISN |
| "TIME_WAIT 越多越有问题,要压掉" | 它是主动关闭方的正常状态。治本用长连接 + 连接池,别用 tcp_tw_recycle(已移除)或 SO_LINGER=0 |
| "CLOSE_WAIT 是内核的事,重启就好" | 是本端没调 close(),重启只掩盖 bug |
| "开了 SO_KEEPALIVE 就不用心跳了" | 默认 7200s 才探测,且只验证 TCP 层。必须做应用层心跳 |
| "心跳发个空包就行" | 空包只证明 TCP 通;PLC 在 STOP 状态通信处理器仍会响应。心跳必须是真实业务读 |
"write() 返回了 PLC 就收到了" |
只表明进了本端发送缓冲区 |
"setSoTimeout 能控制 connect 超时" |
不能。connect 用 socket.connect(addr, timeoutMs);setSoTimeout 只管 read() |
| "调大 SO_SNDBUF 就能提吞吐" | 上限是 min(cwnd, 对端通告窗口)/RTT。对端不读或不缩放窗口,调大无用 |
| "缓冲区设置在 connect 之后也行" | 必须在 connect()/listen() 之前,否则 Window Scale 已协商完 |
| "重传超时能帮我发现 PLC 掉电" | 需十几分钟(tcp_retries2=15 + 指数退避) |
| "关掉 Nagle 一定更好" | 对工业小包是对的;大块数据(>MSS 一次写完)Nagle 本就不生效 |
| "SO_REUSEADDR 能让两个进程同监听一个端口" | 不能,那是 SO_REUSEPORT |
| "Wireshark 显示 checksum 错误就是网络坏了" | 多半是网卡 checksum offload(硬件在发出时计算),正常 |
| "PSH 能强制对方立即收到" | PSH 只是提示尽快上交应用层,Java 无法控制,也不影响可靠性 |
本文是《工业通信与数据采集》栏目第 4/22 篇 · 上一篇:字节序与数据类型编码 | 栏目总览 | 下一篇:粘包与拆包