运维脚本上线前,先把这五处协议坑堵死
一、字段长度含不含自身
[wanglaowu]把长度字段单独拎出来说,是因为它错起来最安静。协议文档写“长度域 2 字节”,你默认它只管 payload,抓包一看,整包 18 字节、payload 12 字节、长度域填 16,这 16 既不含长度域自己,也把校验位算了进去。你按 payload 去 recv(16),多收了 4 个字节的尾部校验,后面每一条消息全部错位 4 字节,直到某个字段恰好越过边界才崩。
去年有个内部采集 agent,版本 1.4.2,跑在 CentOS 7.9 上,对着自研推送网关,就因为这一处错位,单机每分钟失败 3 到 5 次,重连日志里反复出现
socket.error: [Errno 104] Connection reset by peer
当时以为是网关限流,把并发从 8 降到 2,失败率没变,才回头数字节。
定长度的办法只有一条:抓一条真实帧,手工逐字段数一遍,把“长度域含不含自身、含不含校验、含不含 magic”写成注释钉在解析器顶部。下面这段就是按“长度含自身、不含尾部 CRC”写的,先读 2 字节长度头再读满:
def recv_frame(sock):
head = recv_exact(sock, 2)
total = int.from_bytes(head, "big")
assert total >= 2, total
body = recv_exact(sock, total - 2)
return head + body
解析时拿 total 减去头部固定开销,剩下的才是 payload 边界,任何一次 assert 失败都说明文档和实际帧不一致,先停下来对数,别急着加重连。改完这一处,那台机器失败率降到每小时 0 次,同一批脚本里后面三处坑才有得谈。
二、粘包与半包读满逻辑
[wanglaowu]上文那个 recv_exact 只解决了"差多少读多少",粘包和半包还没碰。真正的坑在于 TCP 是字节流,一次 recv 可能返回半个帧,也可能一次返回两个半帧,脚本里直接 recv(4096) 然后 json.loads,十次里八次报 json.decoder.JSONDecodeError: Extra data: line 1 column 128 (char 127),剩下两次报 Expecting value: line 1 column 1 (char 0),因为缓冲里只剩半截。堵法是把 socket 套一层缓冲区,读满长度头再读满 payload,一次消费一个完整帧,多余的字节留在缓冲里给下一次。Python 3.8 到 3.11 上这套逻辑一致,区别只在 socket.recv 超时抛 socket.timeout 还是 TimeoutError,统一 catch 两者即可。
class FrameReader:
def init(self, sock):
self.sock = sock
self.buf = bytearray()
def _fill(self, n):
while len(self.buf) < n:
chunk = self.sock.recv(max(n - len(self.buf), 4096))
if not chunk:
raise ConnectionError("peer closed, need %d have %d" % (n, len(self.buf)))
self.buf.extend(chunk)
def read_frame(self):
self._fill(2)
total = int.from_bytes(self.buf[:2], "big")
assert 2 <= total <= 65535, total
self._fill(total)
frame = bytes(self.buf[:total])
del self.buf[:total]
return frame
注意 recv 那次 max 里给 4096 兜底,否则 n 很小的时候一次只读一两个字节,连接数上到 200 时吞吐直接掉到 80 帧每秒,实测把兜底加上后回到 1100 帧每秒。每次 read_frame 之后 assert len(frame) == total,短了就是 _fill 出了问题,长了说明 del 切错了位置。压测拿 5000 个随机长度 20 到 300 字节的帧喂进去,解析结果与发送侧逐帧比对,零差异;再人为把 recv 改成每次只返回 7 字节模拟最烂分片,跑一遍仍然零差异,这一处才算真堵住。
三、字节序与编码对齐
[wanglaowu]字节序和编码这两件事,平时不显眼,一出就是整包错位。协议头里每个多字节字段都得单独标清楚是大端还是小端,别指望“大家都用网络序”这种默契。上个月现场就撞了一次:设备侧把长度字段按小端发,服务端按大端解,`0x00 0x64` 被读成 25600,回显直接是 `struct.error: unpack requires a buffer of 25600 bytes`,抓包一看 payload 才 100 字节。定位办法很土但管用,先 dump 原始字节再解:
raw = frame[:8]
print(raw.hex(' '))
total = int.from_bytes(frame[4:8], 'big') # 设备侧实为 little
把 `big` 换成 `little` 立刻对上。我的做法是每个字段在解析器里显式传字节序,写成 `int.from_bytes(buf[4:8], 'little')`,不靠默认值。编码对齐同理,设备回传的字符串是 GBK 还是 UTF-8 得先验,别直接 `.decode()` 撞 `UnicodeDecodeError: 'utf-8' codec can't decode byte 0xd6 in position 0`。稳妥点先 `decode('gbk', errors='strict')`,失败再 fallback,并且把原始 bytes 一并落盘。Python 3.11.4 下实测,混用两种编码的 500 条样本里,靠先 dump 后解码的方式全部还原,零乱码。字节序和编码定死写进协议文档,联调时双方各跑一遍对拍,这一处才算收口。
四、Token 过期重连分支
[wanglaowu]Token 过期这事儿最阴的不是掉线,是它不掉线——连接还活着,你发过去的包设备照收,回过来一个 business code 4013,脚本以为这帧是数据,啪一下解析成垃圾,日志里连个异常都没有。我的做法是在状态机里给 token 单独留一个 invalid 分支,收到 4013 不重试原请求,先置 `state=TOKEN_EXPIRED`,掐掉旧连接重走一遍鉴权握手,握手成功再把原请求按 msg_type 重放一次,只放一次,第二次还 4013 就落盘退出,别在死循环里把设备打死。刷新 token 的时机也要提前,别等 4013,本地按握手回包里的 `expires_in` 减 120 秒做倒计时,到点主动刷。Python 3.11.4 上实测过一版,`requests` 连接池复用了过期的 keep-alive,刷新后第一帧直接 `ConnectionResetError: [Errno 104] Connection reset by peer`,改成刷新时 `session.close()` 重建 session 才干净。鉴权握手超时给 5 秒、重连退避 1s→2s→4s→8s 封顶 32 秒、总共 5 次,参数全进 `argparse`。重放的那一帧要打标记 `is_replay=True` 写进日志,不然事后查不出哪条是补发的。断线重连和 token 刷新别混成一个重试计数,混在一起你永远不知道是网断了还是凭证废了,这两条路分开记,日志里一眼能看出是哪一种。
- 点赞
- 收藏
- 关注作者
评论(0)