简介:围绕正反向隔离装置场景整理的 TCP/UDP 穿透演示包,面向从事网络通信、跨网数据传输与安全隔离研究的开发者和学习者,也适用于课程实验与方案预研,重点解决隔离环境下双向连接建立和 UDP 报文转发的验证问题。压缩包共 11 个文件,约 1.68MB,包含 ini 配置、cpp 源码、代理与测试程序、说明文档及方案幻灯片,覆盖代理端、发送端、接收端和 TCP/UDP 测试环节,便于对照配置与源码理解穿透流程。已有 1253 人学习,适合用于实验复现、连通性验证和方案评估。读者可从中获得演示程序与配置样例,结合方案材料梳理代理转发和 UDP 收发链路,并借助测试程序验证正反向隔离装置下的报文收发、链路连通与异常排查思路,为二次开发、课程设计和实验复现提供参考。
1. 正反向隔离装置上的 TCP/UDP 穿透 demo:为什么直连一定超时
某公司的两台设备之间加了一台正反向隔离装置,业务侧的采集程序去连对端 5000 端口,抓包只看得到本地 SYN 重传,三次之后就超时。这个 TCP/UDP 穿透 demo 要解决的就是这件事:业务代码一行不改,让一条 TCP 连接或一组 UDP 报文能跨过隔离装置走通。这里说的“穿透”是在装置允许的数据交换通道上做协议中转,不是去绕开隔离策略——这个前提想歪了,后面所有设计都会跟着歪。适合正在做隔离环境联调、协议对接和上线前验证的开发与实施人员,也适合手上已经有一台装置、但业务一直跑不通的人。
2. 两条单向通道怎么承载双向 TCP:拆连接、切报文、配对端
2.1 隔离装置不转发 IP 包,业务看到的就是“断网”
正反向隔离装置的工作方式和路由器、防火墙不在一个层面上。防火墙转发 IP 包,只是决定放不放行;隔离装置根本不转发包,它把两侧网络在链路层和网络层上断开,中间只剩一条由装置自己管理的、用于搬运数据体的通道。业务侧的 SYN 发出去,装置不会替你转发到对端,所以现象永远是“连不上”或者“连上了但对端没反应”。
这个差别决定了穿透方案的基本形态:你没法指望改个路由、加条策略就通。必须在装置两侧各放一个程序,让它们各自在本地终结连接,再把应用层数据体通过装置提供的数据交换接口搬运过去。也就是说,一条 TCP 连接在物理上被拆成了两段:业务侧程序到本地中转进程一段,对端中转进程到真实服务一段,中间靠装置的正向、反向两条通道衔接。
我第一次做这类联调的时候,花了半天在查路由表和防火墙规则,最后发现方向从一开始就错了。判断方法很简单:看装置两侧的主机能不能互相 ping 通、能不能看到对方的 MAC。看不到,就说明这是隔离装置而不是防火墙,方案必须走双端中转。
2.2 正向通道送数据、反向通道送回执:把一条 TCP 拆成两半
装置一般提供两条方向相反的单向通道,习惯上叫正向和反向。正向通道负责把一侧的数据送到另一侧,反向通道负责把回执、应答、控制信息送回来。两条通道各自有独立的入口和出口,且通常只允许一个写入端、一个读出端,独占性很强。
TCP 是双向的:客户端发请求,服务端回响应,任何一侧都可能先发数据。UDP 虽然无连接,但请求和应答同样是两个方向。所以一个能跑通的设计必然是四段流:
| 流段 | 起点 | 终点 | 承载内容 |
|---|---|---|---|
| 正向 | 业务侧中转 | 对端中转 | 业务请求报文、连接建立意图 |
| 反向 | 对端中转 | 业务侧中转 | 服务响应、连接关闭意图 |
| 本地 A | 业务程序 | 业务侧中转 | 原始 TCP/UDP 流量 |
| 本地 B | 对端中转 | 真实服务 | 还原后的原始 TCP/UDP 流量 |
中间两条流是单向的,两头两条流是双向的。整个方案的核心工作,就是把双向流翻译成单向流上的消息,再在另一端翻译回来。翻译的载体就是你自定义的帧格式——这一步设计得好不好,直接决定后面调试是半天还是两周。
注意:正向和反向不是“请求走正向、响应走反向”这么简单。TCP 的连接建立、半关闭、异常复位都是独立事件,需要在两条通道上分别表达,否则会出现“数据发过去了但对端不知道要建连接”这种低级卡死。
2.3 报文切片与重组的最小状态机
装置的正向通道本质是一条可靠字节流(有的实现是文件摆渡,形态不同但语义一致:有序、不丢、不重)。字节流没有消息边界,所以必须自己定帧。最小可用的帧只需要四样东西:消息类型、会话标识、负载长度、负载本身。
会话标识是关键。业务侧可能同时有几十上百条连接,它们的数据都挤在同一条正向通道上,对端必须知道这一块字节属于哪条连接。会话标识由业务侧中转分配,全生命周期唯一,对端只做查表,不自己造。
状态机只有四个转移,写清楚就不会乱:
- OPEN:业务侧收到新连接,分配会话号,发一帧 OPEN,对端收到后去连真实服务。
- DATA:任意方向有数据就切片发出,对端按会话号写进对应 socket。
- CLOSE:某一方向数据发完,发一帧 CLOSE,对端只关闭对应方向的写端。
- 超时/异常:设备侧或通道侧断开,直接清掉该会话的所有本地资源,不再补发。
这里最容易漏的是第 3 步的“只关闭一个方向”。TCP 的 FIN 是单向的,A 说“我没数据要发了”,不代表 A 不能再收数据。很多 demo 一收到 FIN 就把整条会话删掉,结果就是大文件传到 99% 断掉——对端还有最后一段没回完。
2.4 TCP 中转、UDP 会话中转、文件摆渡:三种形态怎么选
同样是穿透,落地形态差别很大,选错了不是性能问题而是根本跑不通。
| 形态 | 适用场景 | 优点 | 主要限制 |
|---|---|---|---|
| TCP 中转 | 长连接采集、数据库连接、HTTP 类短连接 | 语义完整,业务无感 | 需要维护会话表,半关闭处理复杂 |
| UDP 会话中转 | 周期上报、视频流元数据、自定义二进制协议 | 开销小,时延低 | 必须自造会话,超时策略要单独调 |
| 文件摆渡 | 大批量、可容忍分钟级延迟的数据 | 实现最简单,天然跨隔离 | 交互式业务完全不能用 |
判断标准其实就一条:业务能不能容忍“一问一答之间隔几秒”。能忍,文件摆渡最省事;不能忍,就必须做 TCP 或 UDP 中转。我一般会先问清楚业务侧的超时设置——如果客户端 3 秒没收到响应就判失败,那文件摆渡这条路线直接排除,别再往下设计了。
3. 手写最小穿透 demo:三个进程、四个端口跑通一条 TCP 会话
3.1 目录结构与帧格式:8 字节头定生死
demo 分三个进程:channel_sim.py模拟隔离装置的两条单向通道,proxy_a.py是业务侧中转,peer_agent.py是对端中转,再加一个被中转的真实服务用于验证。四个端口分别对应正向入口、正向出口、反向入口、反向出口。
先定帧。头部 8 字节,字段含义如下:
# frame.py —— 两侧中转共用的帧格式与拆包器 import struct HDR = struct.Struct("!BBIH") # type(1) flags(1) sid(4) len(2) T_OPEN, T_DATA, T_CLOSE = 1, 2, 3 F_UDP, F_HALF_CLOSE = 0x01, 0x02 MAX_PAYLOAD = 1400 # TCP 单帧负载上限,留余量避免二次分片 def pack(mtype, sid, payload=b"", flags=0): """把一条消息封成一个帧;len 只描述负载,不含 8 字节头。""" return HDR.pack(mtype, flags, sid, len(payload)) + payload class FrameReader: """从字节流里切出完整帧,负责粘包与半包。""" def __init__(self, sock): self.sock, self.buf = sock, b"" def read(self): while True: if len(self.buf) >= HDR.size: mtype, flags, sid, plen = HDR.unpack_from(self.buf, 0) end = HDR.size + plen if len(self.buf) >= end: payload = self.buf[HDR.size:end] self.buf = self.buf[end:] return mtype, flags, sid, payload chunk = self.sock.recv(65536) if not chunk: return None # 通道断开,上层按全量会话失效处理 self.buf += chunkHDR用!大端序,是为了两端即使跑在不同架构的机器上也能对上。len用 2 字节无符号,单帧负载上限 65535,但 TCP 场景我固定切到 1400,原因是帧还要被装置再封装一层摆渡头,负载太大会被装置二次分片,反而增加对端重组压力。FrameReader.read()里那个while True循环是整套代码里最不该省的地方:recv返回多少字节完全由内核决定,一次读回半个头、一个半帧都是常态,必须靠缓冲区自己攒。
3.2 模拟隔离装置的两条单向管道
真装置不好天天拿来调试,先写一个行为等价的模拟器:两条方向相反的单向管道,每条只接受一个写入端和一个读出端。
# channel_sim.py —— 模拟正反向隔离装置的两条单向字节管道 import queue, socket, threading def one_way(in_port, out_port, name): q = queue.Queue() # 装置内部的摆渡缓冲区 def reader(): # 读出端:谁连上来就推给谁 srv = socket.socket() srv.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1) srv.bind(("127.0.0.1", out_port)); srv.listen(1) conn, _ = srv.accept() while True: chunk = q.get() if chunk is None: break conn.sendall(chunk) def writer(): # 写入端:收多少排多少 srv = socket.socket() srv.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1) srv.bind(("127.0.0.1", in_port)); srv.listen(1) conn, _ = srv.accept() while True: data = conn.recv(65536) if not data: break q.put(data) threading.Thread(target=reader, daemon=True).start() threading.Thread(target=writer, daemon=True).start() print(f"[channel] {name}: {in_port} -> {out_port}") if __name__ == "__main__": one_way(17001, 17002, "forward") # 正向:业务侧写,对端读 one_way(17003, 17004, "reverse") # 反向:对端写,业务侧读 threading.Event().wait()这段代码刻意做成“写进去的字节,从另一端原样读出来”,不解析帧内容——真实的隔离装置也不解析你的应用协议,它只负责搬字节。queue.Queue()对应装置内部的摆渡存储,真实装置这里可能是磁盘文件或共享内存,容量有限,写太快会被反压或者直接丢,这一点在第 4 章讲缓冲区时还会回来。listen(1)也是刻意的:单向通道只允许一个写入端,多连一个就会抢数据,这个限制和真装置一致。
跑起来之后,任何往 17001 写的字节都会从 17002 出来,往 17003 写的会从 17004 出来。这条规则记住,后面排查全靠它。
3.3 业务侧中转:本地终结 TCP,按帧写正向通道
业务侧中转要做三件事:监听业务端口、给每条连接分配会话号、把收到的数据切片后写正向通道;同时另起一个线程读反向通道,按会话号把回包写回对应的连接。
# proxy_a.py —— 业务侧:本地终结 TCP,按帧写正向通道,从反向通道收回包 import itertools, json, socket, sys, threading from frame import FrameReader, pack, T_OPEN, T_DATA, T_CLOSE, MAX_PAYLOAD CFG = json.load(open(sys.argv[1] if len(sys.argv) > 1 else "proxy.json")) sessions, sess_lock = {}, threading.Lock() # sid -> 业务侧 client socket send_lock = threading.Lock() # 正向通道只允许一个写者 sid_gen = itertools.count(1) fwd = socket.create_connection((CFG["fwd_host"], CFG["fwd_port"])) # 写正向 rev = socket.create_connection((CFG["rev_host"], CFG["rev_port"])) # 读反向 fwd.setsockopt(socket.IPPROTO_TCP, socket.TCP_NODELAY, 1) def send_frame(mtype, sid, payload=b"", flags=0): with send_lock: # 关键:整帧原子写出,否则帧会交错 fwd.sendall(pack(mtype, sid, payload, flags)) def dispatch_reverse(): """反向通道是全应用共用的一条流,靠 sid 分发回各自的业务连接。""" r = FrameReader(rev) while True: fr = r.read() if fr is None: print("[proxy] reverse channel closed"); return mtype, flags, sid, payload = fr with sess_lock: conn = sessions.get(sid) if conn is None: continue # 迟到或已清理的回包,直接丢 try: if mtype == T_DATA: conn.sendall(payload) elif mtype == T_CLOSE: conn.shutdown(socket.SHUT_WR) # 只关写方向,读方向留给对端收尾 except OSError: pass def handle_client(conn, addr): sid = next(sid_gen) with sess_lock: sessions[sid] = conn send_frame(T_OPEN, sid) conn.settimeout(CFG["idle_timeout"]) try: while True: data = conn.recv(CFG["buf_size"]) if not data: break for i in range(0, len(data), MAX_PAYLOAD): # 切片,保证每帧不超上限 send_frame(T_DATA, sid, data[i:i + MAX_PAYLOAD]) send_frame(T_CLOSE, sid, flags=0x02) except socket.timeout: send_frame(T_CLOSE, sid, flags=0x02) # 空闲超时也走半关闭,不粗暴断链 finally: with sess_lock: sessions.pop(sid, None) conn.close() def main(): threading.Thread(target=dispatch_reverse, daemon=True).start() srv = socket.socket() srv.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1) srv.bind((CFG["listen_host"], CFG["listen_port"])); srv.listen(128) print(f"[proxy] listening on {CFG['listen_port']}") while True: conn, _ = srv.accept() conn.setsockopt(socket.IPPROTO_TCP, socket.TCP_NODELAY, 1) threading.Thread(target=handle_client, args=(conn, _), daemon=True).start() if __name__ == "__main__": main()几个决定成败的细节。send_lock不是可选项:多条业务连接各自在自己的线程里往同一条正向通道sendall,一次sendall内部可能分成多次系统调用,两条线程的字节会真正交织在一起,对端拆出来的就是乱码。加了锁之后整帧原子写出,代价是发送串行化,demo 阶段完全够用,真上量要改成单写线程加发送队列。
conn.shutdown(socket.SHUT_WR)而不是close(),对应 2.3 讲的半关闭语义。用close()会把读方向也关掉,对端后面发来的数据全部作废。
conn.settimeout(idle_timeout)设的是业务侧连接的空闲超时。这个值必须比装置侧的会话老化时间小,原因第 4 章细说——结论是反直觉的:中转程序的超时设得越大,连接反而越容易变成黑洞。
配置文件就四个地址加几个参数:
{ "listen_host": "0.0.0.0", "listen_port": 9001, "fwd_host": "127.0.0.1", "fwd_port": 17001, "rev_host": "127.0.0.1", "rev_port": 17004, "buf_size": 32768, "idle_timeout": 120 }listen_host在真实现场不要图省事写0.0.0.0。机器有两块网卡时,回包的源地址可能从错误的网卡出去,装置侧对不上会话直接丢弃,现象是“偶发连不上”,排查极耗时间。现场我一般绑定到业务网段那一块的具体地址。
3.4 对端中转:从正向通道取帧,回写反向通道
对端要做的事情镜像对称,但有一处关键不同:它必须真的去连业务服务,连接失败要能反馈回去,而不是让业务侧干等到超时。
# peer_agent.py —— 对端:从正向通道取帧,落地成真实连接,回包写反向通道 import json, socket, sys, threading from frame import FrameReader, pack, T_OPEN, T_DATA, T_CLOSE CFG = json.load(open(sys.argv[1] if len(sys.argv) > 1 else "peer.json")) conns, lock, send_lock = {}, threading.Lock(), threading.Lock() rev = None def send_back(mtype, sid, payload=b"", flags=0): with send_lock: rev.sendall(pack(mtype, sid, payload, flags)) def pump_upstream(sid, s): """上游回包 -> 反向通道,一条连接一个读线程。""" try: while True: data = s.recv(CFG["buf_size"]) if not data: break send_back(T_DATA, sid, data) send_back(T_CLOSE, sid) finally: with lock: conns.pop(sid, None) s.close() def main(): global rev src = socket.create_connection((CFG["fwd_out_host"], CFG["fwd_out_port"])) rev = socket.create_connection((CFG["rev_in_host"], CFG["rev_in_port"])) r = FrameReader(src) while True: fr = r.read() if fr is None: break mtype, flags, sid, payload = fr if mtype == T_OPEN: try: s = socket.create_connection((CFG["up_host"], CFG["up_port"]), timeout=CFG["connect_timeout"]) except OSError: send_back(T_CLOSE, sid, flags=0x02) # 连不上也要回一帧,别让业务侧空等 continue with lock: conns[sid] = s threading.Thread(target=pump_upstream, args=(sid, s), daemon=True).start() elif mtype == T_DATA: with lock: s = conns.get(sid) if s: s.sendall(payload) elif mtype == T_CLOSE: with lock: s = conns.get(sid) if s: s.shutdown(socket.SHUT_WR) if __name__ == "__main__": main()except OSError那段回一帧 CLOSE 是整个对端最难想到、也最该写的地方。真实现场最常见的问题是后端服务挂了或者端口改了,业务侧一直等,日志里什么都没有。补了这个分支以后,业务侧的recv会立刻返回 0,报错信息直接从“连接莫名卡住”变成“对端拒绝”,排查时间差一个数量级。
pump_upstream每条连接一个线程,demo 规模下没问题,几百条以上就要换成单线程事件循环,否则线程切换和栈内存会先撑不住。
3.5 UDP 会话表:给无连接协议造一个“连接”
UDP 没有连接,但装置的两条单向通道是有状态的,所以必须自己造一张会话表:以客户端地址为键,分配会话号,空闲超时回收。
# udp 侧的业务侧中转:会话表 + 报文直传 udp_sessions = {} # (ip, port) -> [sid, last_active] def on_udp_packet(data, addr): now, key = time.monotonic(), addr ent = udp_sessions.get(key) if ent is None: if len(udp_sessions) >= CFG["max_udp_sessions"]: evict_oldest() # 满了先淘汰最久未活跃的 sid = next(sid_gen) udp_sessions[key] = [sid, now] send_frame(T_OPEN, sid, flags=0x01) # 标记这是 UDP 会话 else: ent[1] = now sid = ent[0] send_frame(T_DATA, sid, data) # 一条报文一帧,绝不切片 def gc_udp(): while True: time.sleep(5) now = time.monotonic() for key, (sid, ts) in list(udp_sessions.items()): if now - ts > CFG["udp_idle"]: send_frame(T_CLOSE, sid, flags=0x01) udp_sessions.pop(key, None)send_frame(T_DATA, sid, data)这一行绝不能像 TCP 那样套MAX_PAYLOAD切片循环。UDP 的边界就是报文本身,切成两帧之后对端要么收到半个报文解析失败,要么两条报文被合并成一条,业务层看到的是彻底乱掉的数据。帧头的 2 字节长度字段上限 65535,刚好能装下 65507 字节的 UDP 负载,够用;但要确认装置的摆渡单元能接受这么大的单帧,接不了就只能在应用层限制报文长度。
evict_oldest()的淘汰策略也要写死:按last_active最老的先删,并且删之前发一帧 CLOSE,让对端把对应的上游 socket 关掉。不删的后果是会话表无限增长,跑一天之后内存和装置侧的会话资源一起爆。
4. 参数怎么定:超时、缓冲区、帧长、并发上限的取值依据
4.1 会话超时必须小于装置的老化时间
这是整套参数里唯一一个“调大反而更糟”的项。装置内部对每条摆渡会话有老化时间,通常是几十秒到几分钟;业务没数据的时候,装置先把会话回收掉,但两边中转程序并不知道,仍然认为连接活着。此后业务再发数据,帧会带着一个已经不存在的会话号穿过通道,对端查不到、装置直接丢,业务侧永远等不到回包——这就是典型的回包黑洞,抓包能看到数据发出去了,但没有任何响应。
正确取值是装置老化时间的 1/3 到 1/2,留够余量。demo 里装置模拟器不老化,我先给 120 秒;真实部署前必须问清楚装置的会话保持时长,然后按这个比例重算。
| 参数 | 建议起点 | 取值依据 | 调错的典型后果 |
|---|---|---|---|
| idle_timeout | 装置老化时间的 1/3~1/2 | 必须在装置回收前主动收尾 | 调大:回包黑洞;调小:长连接频繁重连 |
| udp_idle | 10~30 秒 | UDP 无 FIN,只能靠空闲判断 | 调大:会话表膨胀,装置资源耗尽 |
| buf_size | 32KB | 与摆渡单元大小同量级 | 调大:单连接内存上升,反压变迟 |
| MAX_PAYLOAD | 1400 字节 | 避免装置二次分片 | 调大:对端重组压力大,时延抖动 |
| connect_timeout | 3 秒 | 上游服务正常建链应在毫秒级 | 调大:后端挂了业务侧长时间无感 |
| max_sessions | 512 | 按装置并发会话上限打七折 | 超限后新连接全被拒,且没有明显报错 |
4.2 帧长与缓冲区:卡在摆渡单元和 MTU 之间
帧长有两个约束在夹击。往小了走,帧头开销占比升高,同样数据量下装置要搬运的帧数翻倍,吞吐上不去;往大了走,超过装置的摆渡单元就会被拆,超过链路 MTU 就会在 IP 层分片,任一片丢了整帧重传,时延抖动会非常明显。
1400 是我反复试出来比较稳的起点:加上 8 字节帧头和装置的封装头,离常见的 1500 还留有余量。如果现场业务以小报文为主(比如每秒几万条几十字节的上报),可以往下压到 512,减少单帧的等待时间。反过来,大文件传输场景可以往上探到 4096 甚至 8192,前提是先确认装置的摆渡单元能一口吃掉。
buf_size说的是recv的缓冲区大小,32KB 是个平衡点:太小会导致系统调用次数暴增,太大则单连接常驻内存上升,几百条连接就很可观了。这个值不需要精调,量级对了就行。
4.3 半关闭时序:FIN 不是一个结束信号
业务侧的 FIN 只说明“我不再发数据了”,读方向依然有效。这条规则在两个地方都要落实:中转程序收到业务侧 FIN 时,发 CLOSE 帧但保持反向通道上的分发关系;对端收到 CLOSE 帧时,对上游shutdown(SHUT_WR)而不是close()。
反过来,对端上游返回 0 时也一样,只发 CLOSE 帧给业务侧,业务侧shutdown(SHUT_WR)。两边都做对了,一条“客户端发完请求就关写端、等服务端把结果全部返回”的经典模式才能跑通。少做一边,就是大文件传一半被切断。
还有一种情况是异常复位。业务侧连接报ECONNRESET时,要发一帧带复位标记的 CLOSE,对端收到直接close()上游 socket,不要走半关闭流程,否则上游会一直挂着等一个永远不会来的数据。
4.4 并发上限、限速与队列水位
demo 阶段不设限也能跑,上生产前三个限制必须补齐。
并发会话上限决定内存和装置资源的边界。到了上限之后的行为要明确:是拒绝新连接(业务侧立刻收到 RST,好排查),还是排队(业务侧超时,难排查)。我选拒绝,且打一条明确日志,因为隔离环境里“静默排队”是最难定位的问题类型。
限速是按会话做还是全局做,取决于装置侧是不是有带宽约束。如果装置本身有吞吐上限,全局限速更有意义,否则单条大流量会话会把其他业务全部挤死。
队列水位是背压的实现方式:正向通道的待发队列涨到高水位就停止从业务 socket 读,降到低水位再恢复。不做这件事的结果是内存一路涨到 OOM,而且是在压力最大的时候崩,事后很难复现。
5. 排查清单:穿透 demo 最常翻车的 5 个地方
5.1 收到乱码或会话串线:粘包没处理
现象:对端解析出来的是半截报文加下一帧的头,或者前几百字节正常、后面全是垃圾;小报文测试一切正常,一上量就出错。
原因:把 TCP 的recv当成了消息边界。recv(65536)返回多少完全由内核决定,可能一次给你三个半帧,也可能半个帧分五次给。demo 里最容易图省事写成data = conn.recv(N); handle(data),本地跑小流量时恰好每次一帧,看起来完全正常。
解决:所有跨通道的读写都必须过FrameReader,用定长头加长度字段自己攒。攒的时候注意缓冲区要能跨多次recv保留,不能每次清空。验证方法很直接:在业务侧一次sendall一个 200KB 的缓冲,看对端能不能原样收到——不处理粘包的实现在这一步必挂。
5.2 偶发数据跑到别的连接上:多线程写同一条管道
现象:A 连接的响应出现在了 B 连接的客户端上,概率很低,几十次出现一次;日志里两边的数据都“发出去了”。
原因:多个线程各自调用sendall往同一条正向通道写,一次sendall在大负载下会被拆成多次系统调用,两条线程的字节真正交织,对端按帧头解析就串了。这类问题在开发机上几乎复现不了,因为负载低、缓冲区一次吃得下。
解决:加发送锁,保证整帧原子写出;或者更彻底一点,改成单写线程加发送队列,所有业务线程只往队列里投帧。第二种方式在会话数上去之后也是必须的,锁竞争会成为瓶颈。
5.3 大文件传到一半断:FIN 被当成关闭全部
现象:小请求响应一切正常,传几十 MB 的文件时,进度到 90% 多就断,业务侧报“连接被重置”或者读到一半recv返回 0。
原因:收到 FIN 后直接close()了整个会话,并且从sessions字典里删掉了。此时对端还在往回送数据,业务侧中转查不到会话号,回包被丢,业务侧以为是连接断了。
解决:FIN 只触发半关闭,会话从字典里删除的时机是“两个方向都结束”或者超时。实现上给会话加一个状态位,收到本端 FIN 置一位,收到对端 CLOSE 帧置另一位,两位都置起来才清理。
5.4 UDP 偶发解析失败:拿 TCP 的切片逻辑套 UDP
现象:UDP 业务偶发丢包,日志里对端报“报文长度不符”或者校验失败;用小报文测毫无问题,报文一超过 1400 字节就开始出。
原因:直接复用了 TCP 那条for i in range(0, len(data), MAX_PAYLOAD)的切片循环。UDP 的边界就是报文本身,切完之后对端把两帧当成两条独立报文往上游发,上游收到的是残缺数据。
解决:UDP 一条报文一帧,不做切片。同时确认帧头长度字段够用(2 字节到 65535),以及装置的摆渡单元能接受这个尺寸。如果装置吃不下,只能在应用层和业务方约定报文最大长度,不能靠切片硬塞。
5.5 闲置一段时间必断:心跳和超时打架
现象:连接建立后一切正常,闲置 30 秒(或某个固定值)后再发数据就没有响应,重连之后又好了;用长连接保活的业务天天报故障。
原因:装置侧的会话老化时间比中转程序的idle_timeout短。装置先回收了会话,中转程序还以为连接活着,后续的帧带着已失效的会话号被静默丢弃。因为正向通道本身没断,两边的日志都干干净净。
解决:把idle_timeout压到装置老化时间的一半以内,并且对长连接业务加应用层心跳——心跳的作用不是保活,是让中转程序有数据可发、装置侧的会话被持续刷新。真上加不上心跳的场景,就得让中转程序自己在空闲时发空 DATA 帧维持会话。
6. 交付前怎么验:沉默期回归、抓包对账和一条压测曲线
demo 能 ping 通不算数,“看起来通了”和“能交付”之间隔着三类验证。
第一类是沉默期回归,专门抓 5.5 那个问题。连接建立、跑通一次请求之后,什么都不发,静置一段时间再
本文还有配套的精品资源,点击获取