背景脱敏:一个手游项目,客户端与服务端在两个独立 git 仓库,各自有各自的 CI。聊天服务走一条独立的 WebSocket 长连接,线格式原本是 JSON。一次改造要把上行切成 protobuf 二进制。本文记录这次改造怎么把线上聊天整条链路打断,以及为什么只需要看一行日志就能判定原因。
现象
2026-08-14 19:07,服务端聊天服务的日志开始每 100ms 刷一条:
Read Message error: invalid character '\b'
同一用户反复重连,每次连上就断。玩家看到的是「聊天连不上,请重试或退出登录」,不是「请更新版本」。
第一反应是查网络、查连接稳定性。但这条日志本身就足够定性,不需要查网络。
一行日志的判定
\b 是退格符,字节值 0x08。protobuf 的每个字段以一个 tag 字节开头,tag = (field_number << 3) | wire_type。field 1、varint 类型(wire_type = 0)的 tag 恰好是 (1 << 3) | 0 = 0x08。
也就是说:几乎所有 protobuf 消息的第一个字节都是 0x08,只要它的第一个字段是编号为 1 的整数。而 JSON 解析器读到第一个字节不是 {、[、引号或空白,就报 invalid character。
所以 invalid character '\b' 的意思是:发送方在发 protobuf 二进制,接收方在按 JSON 解。这不是网络问题,是两端线格式对不上,而且能进一步推出是哪一侧超前了:客户端已经切新格式,服务端还在跑旧解析。
排查
顺着这个判定去看服务端主干,ws_conn.go 里还是 c.ReadJSON(&p)。服务端「双读」的改动确实写了,但全部躺在 6 个未推送的 worktree 分支里,谁都没合。
时间线拼起来是这样的:
| 时间 | 事件 |
|---|---|
| 09:48 | 客户端 CI 从服务端仓同步 proto 产物,删掉了 Wire.cs(服务端仓里没有对应源文件),客户端主干编译红 |
| 10:03 | 再次同步,再次删除 |
| 17:54 | 手工提交「暂时恢复被 CI 过早删除的 Wire.cs」 |
| 17:58 | 二进制上行的改动合进客户端主干,客户端开始发二进制帧 |
| 18:14 | 「再次恢复被 CI 删除的 Wire.cs」,第二轮循环 |
| 19:07 | 线上日志刷 invalid character '\b',聊天不可用 |
两个信号都出现过,都被按掉了。
第一个信号是 CI 反复删 Wire.cs。客户端的 proto 产物目录由 CI 以「删光重建」的方式维护,源里没有的 proto 就必删。它被读成「CI 过早删除」,用手工恢复消掉;正确的读法是「客户端引用了服务端仓上根本不存在的包」,即依赖倒挂。判据只需一条命令:
git -C <服务端仓> log --diff-filter=A -- "*wire.proto"
# 输出为空 ⇒ 服务端仓从来没有过这个 proto ⇒ 客户端那份是手工造的
第二个信号是 6 个分支互相有编译级依赖(一个常量定义在 A 分支的包里,B 分支已经把那个包拆走),合并顺序从未被任何人验证过。分支没合,等于服务端能力不存在,客户端却按「服务端已支持」写了代码。
修复
修复本身不复杂,关键在顺序。
- 建一个集成分支把 5 个服务端分支合到一起,解掉 3 处冲突,跑通
go build与本地 CI。 - 服务端
ReadJSON改成ReadMessage()加双读:BinaryMessage走proto.Unmarshal,TextMessage仍走 JSON。下行暂时不动,仍是 JSON。 - 消息列表解码改成按首字节探测:
{走 JSON,否则走 protobuf;解析失败必须记账,不再_ =静默丢。 - 握手补客户端版本闸门,缺参一律放行,兼容存量客户端。
wire.proto正式合入服务端仓并接入 CI 同步链路,让客户端产物由 CI 合法生成,手工恢复循环终结。
部署后同一用户的 Read Message error 从每 100ms 一次降到 0 次,端到端冒烟覆盖世界频道收发、单聊未读与已读清理全部通过。
客户端侧顺手修了一个衍生问题:版本闸门的「已拦截」标记是内存静态位,重启即清零,原实现被拒后无条件重启。一旦最低版本配得高于渠道上可下载的最新包,就是「连聊天 → 被拒 → 重启」的死循环。改成持久化标记只重启一次,心跳通了再清除。
唯一的安全顺序
线格式变更(JSON 与二进制互切、字段类型变更、帧结构变更)只有一个不会断连的顺序:
服务端「同时接受新旧两种格式」先合并、先部署
→ 客户端切新格式发版
→ 观测上行新格式占比接近 100%
→ 服务端下行再切、删旧读
反过来任何一步,中间窗口都等于「新客户端 + 旧服务端」,连上即断。而且服务端没有版本闸门时,故障表现是「反复掉线」而不是「请更新」,会把排查引向网络。
这条原则可以抽象成一句话:接收方先兼容,发送方后切换。 用它去看另外两个案例,结论完全一致。
服务端主动推送加新命令字。 推送没有版本协商,老客户端收到未知命令字走 default 静默丢弃。所以顺序是:协议先做纯增量合并 → CI 同步到客户端 → 客户端补上处理分支 → 服务端最后才切换到新命令字发送。这里客户端是接收方,所以客户端先行。同一原则,方向相反。
心跳迁移遗漏。 更早的一次事故:服务端把心跳解析全面切到 protobuf,客户端的心跳发送还在底层核心包里用两个 uint32 裸写 8 字节。服务端解析失败不回应答,客户端 10 秒超时断连,登录后 100% 复现。它被遗漏是因为心跳用固定消息号、不走常规路由,迁移时按模块扫描处理器列表自然跳过了它。从顺序的角度看,这是接收方删旧读删早了,发送方还没切完。
层叠分支
本次的 6 个分支各自为战,是顺序失控的直接土壤。互相有依赖的分支,必须先建集成分支验证合并顺序,跑通编译与本地 CI 之后再逐个合入。一周后另一次同类改造刻意用了正确顺序:服务端合并且部署到目标环境之后才放行客户端提交,派给客户端子任务的指令里把「禁止 push」写成硬门,而不是指望执行者自己判断时机。
教训
invalid character '\b'一眼判定。0x08是 protobuf field 1 varint 的 tag 字节,JSON 解析器报它就是两端线格式错位,先查发布顺序,不查网络。- 线格式变更只有一个安全顺序:接收方先兼容双格式并部署,发送方后切换。推送加新命令字是同一原则的镜像。
- 编译红要分清「产物缺失」还是「依赖倒挂」。CI 反复删同一个生成文件,是在告诉你源头不存在;手工恢复只是把告警按掉。一条
git log --diff-filter=A就能定性。 - 层叠分支先建集成分支。分支没合等于能力不存在,另一端不能按「已支持」写代码。
- 没有版本闸门,协议不匹配的症状会伪装成网络问题。握手带版本、缺参放行,是让故障表现指向正确方向的最低成本手段。