01 TCP 协议深入

本篇定位:把 Modbus TCP / S7comm / MC 三条链路底下那层"看不见的契约"讲透——从 20 字节报文头的每一个比特位,到握手挥手状态机、可靠传输、滑动窗口、拥塞控制,再到工业采集真正会咬人的 Nagle + 延迟 ACK 40ms 问题、KeepAlive 为什么救不了断链、缓冲区与 send() 语义、RST 与半开连接。读完你应该能对着 ss -i 的输出说出链路出了什么问题。报文边界与粘包/拆包的处理不在这里展开(详见 02-粘包与拆包.md),本篇只从传输层解释"为什么会粘"。

本篇目录


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

为什么不是两次

理由一(工程):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

一句话:第三次握手是让发起方拥有最后一次否决权,防止历史重复请求初始化错误连接。


攻击者发大量 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

判定自己会不会踩的三个前提:

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 万个周期数据全丢,完全不可接受

五个硬伤:

  1. 周期太长,标准 Java API 只能开关、不能改参数。
  2. 只证明"TCP 层通",不证明"应用层能服务"——PLC 的 TCP 栈由通信处理器负责,CPU 处于 STOP 也能正常回 KeepAlive 探测包。
  3. 只探测空闲链路——正常数据会重置计时器,采集链路几乎永远不触发。
  4. 探测包是空的,穿 NAT/防火墙时可能被丢。
  5. 系统级配置改了会影响同机所有连接。

正确的做法:应用层心跳

  1. 心跳内容必须是"真实业务请求":Modbus 读一个必定存在的保持寄存器(如 40001)、S7 读一个必定存在的 DB 字节或 SZL、MC 读一个固定 D 寄存器。既验证 TCP 通,也验证 PLC 应用逻辑活着。
  2. 经验值(需按现场实测调整):心跳周期 = 3×采集周期(或固定 1s/3s/5s);单次超时 = 3×历史 RTT 的 P99(下限 1s、上限 5s);连续失败 3 次判死。例:周期 1s、超时 2s、失败 3 次 → 最坏 6s 内发现。
  3. 判定故障后:关闭 socket(绝不复用)、标记离线、指数退避重连(1s/2s/4s/8s/16s,上限 60s,加 ±20% 抖动)。⚠ 重连风暴是灾难:200 台 PLC 同时掉线又同时 1s 重连 → 每秒 200 次握手 + S7 协商,会把网关打挂。
  4. 心跳与业务请求共用连接时必须串行化(调度器设计见 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。


小结

常见误区

误区 正解
"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 篇 · 上一篇:字节序与数据类型编码 | 栏目总览 | 下一篇:粘包与拆包