ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

语音助手场景下WebSocket协议契约化改造实践

语音助手场景下WebSocket协议契约化改造实践 做语音助手后端的这大半年我被 WebSocket 协议折腾得不轻。功能跑通的时候一切都很美好音频流上传、识别结果回传、TTS 音频下发一条长连接全搞定。但等产品进入快速迭代期问题就全冒出来了字段随手加、格式不统一、老客户端兼容新服务端全靠运气。最惨的一次是我把audio_format从pcm改成opus前端三端没一起发版线上语音对讲直接哑火了一个小时。这篇文章就是把那次教训整理成的一套方法核心就一句话语音助手场景下WebSocket 协议必须做契约化改造。我会从为什么改造、改什么、怎么改、怎么灰度上线到常见问题排查完整过一遍适合正在做或者准备做语音助手、实时音频传输、长连接服务的后端和客户端同学参考。1. 语音助手场景下WebSocket 协议是怎么一步步失控的1.1 为什么语音助手离不开 WebSocket 长连接语音助手的交互链路跟普通 HTTP 接口完全不一样。拿我们产品举例用户按住说话客户端采集音频16kHz 采样率、单声道、PCM 裸流一边采集一边往服务端推服务端这边的语音识别模块做流式识别出了中间结果就要往回推让 App 实时显示字幕等到整句说完识别结果要交给对话管理模块拿到语义回复后再交给 TTS 合成音频最后合成好的语音还要推回客户端播放。这条链路有非常强的实时性和双向性要求。HTTP 轮询做不到音频是持续流式产生的等一整段上传完再请求识别延迟高到用户根本没法用而且服务端识别到一半的中间结果也需要主动推给客户端这也不是普通 HTTP 响应能解决的。WebSocket 恰好合适——一次 HTTP Upgrade 握手建立长连接之后就是全双工通道服务端和客户端随时都能发消息延迟低、开销小。语音助手的实时性要求比普通聊天场景更高。我这边实测过常规指标从用户说完话到最后一句 TTS 开始播放整体需要在 800ms 左右完成否则用户就觉得卡。这要求音频分包不能太大我们用的是 40ms 一包每包 1280 字节 PCM 数据并且每包之间的间隔要稳定不能靠攒数据来减少请求次数。有人会问为什么不用 MQTT 或者直接基于 TCP 自研协议MQTT 在 IoT 场景确实很强但它在 Web 端和 App 端的原生支持不够需要额外引库而且 MQTT 的发布订阅模型对于一对一的长连接语音流来说语义上有点绕。自研 TCP 协议更麻烦要处理粘包拆包、心跳保活、重连逻辑、前端浏览器根本不支持裸 TCP还得做一套 WebSocket 到 TCP 的桥接。所以到最后WebSocket 几乎是唯一一个浏览器原生支持 App 端支持良好 全双工 低延迟的选项。1.2 协议失控的几个经典症状WebSocket 长连接比短连接 HTTP 更容易失控关键在于连接有状态。我一个一个说看看你们是不是也遇到过。第一字段随手加。后端接口在迭代过程中经常觉得这里加个字段不就完了。问题在于新字段对老客户端是透明的但如果新字段改变了语义老客户端就会误解。我上面提到的audio_format就是典型后端悄悄把默认值从pcm改成opus想着新老客户端都能解析 opus自以为是结果老客户端的解码器根本不认 opus直接崩溃。第二连接没有版本概念。客户端拿着一个{type: start, params: {...}}就开始上传音频服务端升级后改成{type: audio_start, ...}老客户端发送的消息根本不匹配。最要命的是这种错误不是立刻报出来的而是表现为连接建立成功但没有识别结果返回排查的时候特别容易绕弯子。第三信令和媒体流混用。语音助手的长连接上有两类数据控制信令开始、停止、识别中间结果、错误码和音频数据PCM/Opus 二进制流。我们早期一股脑全用 JSON 文本帧传音频一包音频转成 Base64 塞进 JSON解析性能差不说Base64 膨胀 33% 的带宽成本在弱网下非常明显。后来改成二进制帧传音频但因为没有统一帧格式客户端经常分不清哪一帧是音频、哪一帧是信令。第四心跳机制缺失。WebSocket 有 TCP 层面的 Keep-Alive但默认两小时才探测一次中间 Nginx 代理早就把空闲连接断掉了。客户端还傻等服务端推送表现就是语音对讲按下去了好久没有响应用户直接退出重进。这些问题叠在一起最终逼迫我得做一个决定把 WebSocket 协议当成一份正式的契约来管理而不是靠脑子记、靠嘴巴传。2. 契约化改造到底改什么2.1 契约化的定义不是规范文档而是可执行约束聊契约化改造之前先厘清一个概念。很多人以为出了协议文档就算契约化了这不够文档存在于 Confluence 里没人看就相当于不存在。真正的契约化是把协议约束变成代码层面可校验、可感知、可回退的机制。我打个比方。早期我们几个人的项目好比合租屋里靠口头约定谁带垃圾、谁交水电费全靠自觉。后来人多了口头约定就全乱套了每个人都按自己的理解行事自然就吵架。契约化改造相当于把合租屋变成长租公寓签合同写清租期、租金、违约责任换租客了按合同办。协议契约化就是这份合同它明确写了版本号是多少、消息有哪些字段、字段的取值范围、双方遇到不认识的字段怎么办。落到代码层面契约化至少要解决三个问题。版本可协商客户端和服务端在连接建立后第一时间互相告知各自支持的协议版本如果不匹配能明确告诉对方我支持多少到多少你升级到哪个版本再来。消息可校验每一条消息的字段结构、类型、枚举值都在入口处做校验非法消息直接拒绝、记日志而不是等到业务逻辑深处才出诡异 bug。变更可回退协议升级不是上线的瞬间就切过去而是通过版本号让新老协议在服务端同时存活出错随时回退。2.2 协议契约的三层设计传输层、信令层、业务层改造的时候我没有一股脑把所有人都塞进一套大而全的框架里而是把 WebSocket 通信拆成了三层每一层管好自己的事。这个分层思路是整场改造里我觉得最值得沉淀的东西。传输层是标准 WebSocket 语义帧类型文本帧/二进制帧、ping/pong 控制帧、关闭帧状态码1000 正常关闭、1006 异常断开等。这一层基本不用我们操心交给浏览器和 WebSocket 库但要明确一条约定音频数据必须用二进制帧信令必须用文本帧 JSON不许交叉混用。信令层是通用能力层所有业务共用一套机制包含三类消息。连接鉴权客户端连接建立后的第一条消息必须是hello携带 token 和客户端支持的协议版本服务端校验通过后返回hello_ack否则返回error。心跳保活客户端每 30 秒发一次ping业务层心跳不是 WebSocket 协议层的 ping服务端回pong超过 90 秒未收到心跳则主动断开。版本协商hello里带着客户端的协议版本号服务端根据自己支持的版本范围决定继续、降级还是拒绝。业务层就是语音助手自己的业务状态机audio_start开始采集、audio_chunk上传音频二进制帧、audio_end结束采集、asr_partial识别中间结果、asr_final最终识别结果、tts_audio合成音频下发、error错误报告。业务层的消息类型可以持续扩展只要遵循只加不改不删的演进原则就不会破坏契约。分层的核心价值在于信令层的版本协商和心跳机制一旦稳定就不需要再动业务层再怎么加新功能都不会影响连接层的稳定性。业务层升级只影响消息类型和字段不威胁连接本身。2.3 用 JSON Schema 和规范枚举把契约固化下来光口头说大家按这个格式传没用我在项目里引入了两层固化手段。第一层是 JSON Schema 做消息校验服务端在消息入口统一做校验第二层是规范化的枚举定义每个消息类型的字段列表、字段类型、取值范围都列清楚代码评审的时候对照这个列表查。消息信令帧我用了一段统一的 JSON 外壳{ v: 1, type: hello, seq: 1680000001, client_id: android_12_a1b2c3, data: { token: xxx, supported_versions: [1, 2] } }v是客户端协议版本type是消息类型seq是单调递增的序列号用来做会话内消息排序和丢包检测data是具体业务负载。这个外壳在 JSON Schema 里是一个强约束{ type: object, required: [v, type, seq], properties: { v: { type: integer, minimum: 1 }, type: { type: string, enum: [hello, hello_ack, ping, pong, audio_start, audio_chunk, audio_end, asr_partial, asr_final, tts_audio, error] }, seq: { type: integer, minimum: 0 }, data: { type: object } } }服务端在升级入口统一跑一遍这个 Schema不通过的连接直接拒绝并回错误码。这样做的直接好处是协议变更不再靠大家开会商量然后写文档而是改 Schema、改枚举、改代码三处同步评审的时候有据可查。你可能要问为什么不用 Protobuf我也考虑过。但当时客户端有 iOS原生、Android原生、WebH5三端JSON 的调试成本最低浏览器开发者工具直接能看App 也能打日志看原始报文不用额外引入 proto 编译和序列化库。音频二进制帧不需要 Schema直接走独立的二进制帧类型避免 Base64 编码。如果你客户端只有自己一家且都是原生用 Protobuf 完全没问题但别在 Web 场景下跟 JSON 混用那会让调试麻烦不少。3. 实操落地把 WebSocket 协议管理起来3.1 服务端版本协商与消息网关的代码实现服务端我们用的是 GoWebSocket 库选的gorilla/websocketHTTP 框架用的 Gin。改造的第一步是在升级到 WebSocket 之后、正式进入业务逻辑之前强制插入一个版本协商环节。我直接贴关键代码。// 连接升级后的第一个入口所有新连接必须先过这一关 func handleWebSocket(c *gin.Context) { conn, err : upgrader.Upgrade(c.Writer, c.Request, nil) if err ! nil { return } defer conn.Close() // 1. 先做版本协商协商不通过直接关闭 if !negotiateVersion(conn) { return } // 2. 协商通过后进入消息循环 reqCtx, cancel : context.WithCancel(context.Background()) defer cancel() go startHeartbeat(conn, reqCtx) messageLoop(conn, reqCtx) } func negotiateVersion(conn *websocket.Conn) bool { // 设置一个较短的首帧超时防止恶意连接占着不发送 hello conn.SetReadDeadline(time.Now().Add(10 * time.Second)) _, raw, err : conn.ReadMessage() if err ! nil { return false } var req HelloRequest if err : json.Unmarshal(raw, req); err ! nil { writeError(conn, ERR_BAD_JSON, invalid hello message) return false } if req.Type ! hello { writeError(conn, ERR_FIRST_MSG_NOT_HELLO, first message must be hello) return false } // 清掉超时时间进入正常读写 conn.SetReadDeadline(time.Time{}) v : req.Version if v minVersion { writeError(conn, ERR_VERSION_TOO_OLD, fmt.Sprintf(client version %d too old, min version is %d, v, minVersion)) return false } if v currentVersion { writeError(conn, ERR_VERSION_TOO_NEW, fmt.Sprintf(client version %d not supported yet, current is %d, v, currentVersion)) return false } ack : HelloAck{ Version: currentVersion, MinVersion: minVersion, Supported: supportedVersions, } _ conn.WriteJSON(ack) return true }这段代码有几个细节值得展开。ReadDeadline必须设置否则客户端连接上来不发任何消息协程会一直被卡住连接数一多直接打满 Goroutine。首帧超时我设的是 10 秒正常客户端 1 秒内就会发hello10 秒对于弱网也足够宽裕了。版本协商通过之后服务端立刻返回hello_ack把服务端当前版本、最低支持版本、支持的版本列表全告诉客户端。这样客户端就能判断自己是否需要升级或者当前版本在服务端灰度范围内。协商完成之后服务端要保存这个连接对应的版本号后续消息处理按版本走不同的逻辑分支。3.2 音频二进制帧与信令帧如何共存语音助手长连接上最核心的技术细节就是同一连接上文本帧信令和二进制帧音频共存。WebSocket 本身支持文本帧和二进制帧两种类型gorilla/websocket读消息时messageType参数区分是文本还是二进制。我们需要在messageLoop里做类型分发。func messageLoop(conn *websocket.Conn, ctx context.Context) { for { msgType, raw, err : conn.ReadMessage() if err ! nil { if !websocket.IsUnexpectedCloseError(err, websocket.CloseNormalClosure, websocket.CloseGoingAway) { log.Printf(unexpected close: %v, err) } return } switch msgType { case websocket.TextMessage: handleSignaling(conn, raw) case websocket.BinaryMessage: handleAudioChunk(conn, raw) } } }文本帧全部走信令处理二进制帧全部视为音频数据。这里有一个很容易踩的坑不要用ReadMessage返回的raw字节切片去缓存或异步处理这个切片在下次ReadMessage调用之后会被复用。要异步处理音频数据必须先把rawcopy 一份到自己的缓冲池。二进制帧我定义的格式是前 4 字节是音频序号uint32大端序后面跟一帧 PCM 数据。为什么不直接把音频序号放进 JSON 信令帧因为二进制帧的解析性能比 JSON 高很多而且音频流是高频数据每秒 25 包每包都塞进 JSON 既浪费带宽又把 CPU 烧在 JSON 编解码上得不偿失。业务层用三个信令控制音频流边界audio_start表示开始采集里面带采样率、位深、声道数、编码格式audio_chunk是持续上传的二进制帧本体audio_end表示采集结束。服务端只有在收到audio_end后才认为这段语音完整否则如果连接断开就按异常语音处理丢弃半段。服务端下发的tts_audio因为要携带合成音频也是二进制帧但通过文本信令tts_start预告格式和长度随后跟的是二进制音频数据。这套信令预告 二进制载荷的模式在语音场景下比全塞 JSON要清晰得多。3.3 心跳、超时与连接健康管理WebSocket 长连接有个永恒的话题连接到底还活着吗TCP 层的 Keep-Alive 不够及时Nginx 代理默认空闲 60 秒就可能断开连接而客户端毫不知情。所以我在信令层自己做了心跳规则非常简单客户端每 30 秒发送ping信令服务端收到ping后立即回pong服务端如果 90 秒没收到客户端的ping主动关闭连接并记录日志客户端如果 90 秒没收到pong主动断开并重连这里的时间参数不是拍脑袋定的。30 秒心跳间隔小于 Nginx 常见的proxy_read_timeout60 秒确保连接不会因为空闲被代理切断90 秒超时给网络抖动留了 3 次心跳周期的余量不至于一次丢包就断线。如果你的链路还有更长的代理层记得把心跳间隔设成链路中最小超时时间的三分之一以下。服务端实现心跳我用了独立的 Goroutine 配合定时器func startHeartbeat(conn *websocket.Conn, ctx context.Context) { ticker : time.NewTicker(30 * time.Second) defer ticker.Stop() for { select { case -ticker.C: // 主动探测超过 90 秒无 pong 就关闭 if err : conn.WriteControl(websocket.PingMessage, nil, time.Now().Add(5*time.Second)); err ! nil { conn.Close() return } case -ctx.Done(): return } } }实际上gorilla/websocket自带的WriteControl就可以发协议层 ping我这里直接复用了 WebSocket 协议本身的 ping/pong没有用业务层 JSON 心跳。两者区别在于协议层 ping/pong 更轻但业务层 ping 可以携带更多状态信息。语音助手场景协议层心跳足够如果你需要统计往返时延用业务层 ping 带时间戳更好。连接健康管理还包含写超时。服务端给客户端推 TTS 音频时如果客户端不消费比如用户退到后台网络被系统挂起写操作会一直阻塞。我给所有写操作统一加了 10 秒超时conn.SetWriteDeadline(time.Now().Add(10 * time.Second)) err : conn.WriteMessage(websocket.BinaryMessage, audioData) if err ! nil { log.Printf(write tts audio failed: %v, err) conn.Close() }写超时是很多人忽略的细节。长连接场景下读超时大家都会设但写超时经常漏掉。漏掉的后果就是某个客户端断网了但服务端不知道写操作挂在 TCP 缓冲区上不返回协程越攒越多最终服务端内存被吃满。加写超时就是给所有写操作上一道保险。4. 版本兼容与灰度发布改造的核心收益4.1 双版本并存的兼容策略契约化改造之后版本升级就不需要明天全量切换了。我们的做法是服务端同时保留当前版本和上一版本两套解析与处理逻辑具体通过版本号路由决策。举例说明。客户端 v1 发的是{type: start, audio_format: pcm}客户端 v2 发的是{type: audio_start, audio: {format: opus, sample_rate: 16000}}。服务端在hello协商的时候拿到了客户端的版本号后续消息处理就按这个版本分发到对应的处理器。v1 的处理器解析旧格式v2 的处理器解析新格式两套逻辑在同一个进程里共存。协议演进要遵守三条铁律我写在团队文档最显眼的位置评审时必须逐条对照。只加不改新增字段必须有默认值老客户端没传这个字段也能正常工作老字段的语义永远不变即使你觉得这个字段名起得不合理也绝对不能改要改就新增一个字段。枚举只增不删type和各类枚举值只能新增不能修改已有枚举的含义更不能删除。废弃字段要保留解析被废弃的字段不要直接从代码里删除要保留解析逻辑并打日志观察一段时间确认没有流量后再下线。这三条铁律在代码里怎么落实服务端的消息结构体不要直接复用老字段而是为每个版本定义独立的结构体并分别写解析器。伪代码如下func handleMessage(conn *Conn, raw []byte) { switch conn.Version { case 1: handleV1Message(conn, raw) case 2: handleV2Message(conn, raw) } }每个版本的解析器在自己的目录里互不影响。当 v2 稳定运行一个月后v1 的流量降到接近零再走一次下线流程把 v1 代码删掉。这个流程比升级就是改字段稳妥得多回滚也方便——出问题就把路由切回旧版本处理器不需要重新发布二进制。4.2 灰度发布与回滚节奏契约化的一个隐藏收益是让协议升级可以像功能上线一样灰度。我们通过一个简单的开关控制服务端接受哪些客户端版本新版本逻辑更新后先只在测试环境开放给内部客户端确认无误后在生产环境灰度 5% 的流量通过客户端版本号或者用户 ID 哈希观察监控指标稳定后再逐步放开到 30%、50%、100%。灰度期间重点盯三个指标。连接成功率这个是最直接的一根红线低于 99% 立刻报警并回滚。错误码分布如果ERR_VERSION_TOO_OLD、ERR_BAD_JSON这类错误码突然增多说明客户端有异常的兼容性问题。核心业务指标语音会话的平均首帧耗时、TTS 下发成功率、音频中断率这些指标比技术指标更能反映真实用户体验。我们踩过一次灰度的坑。新版本上线后所有技术指标都正常连接成功率 99.9%错误码没有异常但业务方反馈语音识别准确率下降。排查下来发现新版本处理音频分包的 buffer 大小从 2048 字节调成了 4096 字节某些小的音频包在边界处理时被丢弃了导致识别准确率下降。这类问题技术指标很难发现所以要留业务指标一起看。灰度发布不是和技术指标对一下就行得让业务指标拍板。回滚机制设计得更简单每次发布新版本逻辑时服务端都保留上一版本的处理器和路由规则。一旦灰度期发现问题运维只需把版本路由开关切回上一版本不需要重新编译、不需要停机30 秒内完成回滚。这个路由开关本质上就是契约化版本号的直接应用。5. 常见问题与排查实录5.1 连接反复断开关闭码 1006这是 WebSocket 场景下最经典的问题没有之一。1006 表示连接在没有收到关闭帧的情况下被异常断开几乎所有的中间层都可能成为元凶。我遇到的情况是这样的客户端每隔几分钟就会出现一次连接断开关闭码是 1006重连成功之后症状消失过一段时间又断。排查下来发现是 Nginx 代理层的proxy_read_timeout默认配置是 60 秒客户端和服务端之间只要 60 秒没有任何数据交互Nginx 就主动断开连接。而我们的心跳是 30 秒一次理论上不应该触发超时但实际排查发现 H5 端的 WebSocket 库在页面隐藏或手机息屏的时候定时器被浏览器冻结了心跳发不出去自然就断线了。解决方案有两层。第一层是 Nginx 配置把proxy_read_timeout和proxy_send_timeout都调到 75 秒略大于心跳间隔的 2 倍确保只要有心跳就不会被断。第二层是客户端在收到 1006 后不要立刻重连等 1 秒、2 秒、4 秒的退避节奏避免所有客户端同时重连把服务端打挂。1006 本身就是网络层问题重连是最快的恢复手段。5.2 H5 能连、打包成 App 连不上这个问题在热词列表里频繁出现说明很多人踩过。现象比较诡异同一个 WebSocket 地址用浏览器打开 H5 页面能正常连接但打包成 App 后就报连接失败。我遇过三种具体原因。第一种是 Android 网络安全配置。Android 9API 28开始默认禁止明文流量如果你连的是ws://非 TLS必须显式开启明文流量许可。在res/xml/network_security_config.xml里配一下就好network-security-config base-config cleartextTrafficPermittedtrue / /network-security-config第二种是证书校验问题。如果 App 连的是wss://但使用自签名证书或私有证书App 端的 WebSocket 库会在 TLS 握手阶段拒绝连接而浏览器可能因为用户手动信任了证书而正常连接。解决方法是把证书正确配置到 App 的信任链里不要用跳过证书校验这种方案——除非你只做内测包。第三种是服务器端的跨域和 Origin 校验。浏览器 WebSocket 会带Origin请求头服务端可以对Origin做白名单校验但 App 端可能不带Origin或者带的值和 H5 不同如果服务端校验写得太死App 就被误杀了。我的做法是生产环境校验Origin白名单但白名单里包含 App 端固定的Origin值比如com.yourapp.app同时在日志里记录所有被拒绝的连接请求方便排查。5.3 stream disconnected before completion / io.ErrUnexpectedEOF这个报错我在热词里看到了英文原文是stream disconnected before completion: failed to send websocket request: io典型的流式请求中断问题。在语音助手里出现的场景是客户端上传音频过程中连接被切断服务端读到一个残留的二进制帧或者 EOF 错误。从原理上看io.ErrUnexpectedEOF表示读取时数据意外中断。服务端在处理时有一个关键点不要把这种 EOF 当成致命错误而要作为一种正常状态来处理——代表用户可能取消了本次语音、网络断开了、或者客户端进程被杀。正确的做法是_, raw, err : conn.ReadMessage() if err ! nil { if websocket.IsCloseError(err, websocket.CloseNormalClosure, websocket.CloseGoingAway, websocket.CloseNoStatusReceived) { // 正常关闭回滚当前会话 session.Cancel() return } // 异常断开同样要清理会话状态 session.Cancel() log.Printf(connection error: %v, err) return }这里最容易被忽略的坑是连接断开时当前正在处理的音频会话如果不做Cancel清理服务端会一直等待audio_end信号直到超时。我们线上遇到过这种情况用户语音对讲时切到后台被系统杀了进程服务端对应会话的 goroutine 迟迟不退出要等 60 秒超时才清理。后来所有会话都绑定了连接生命周期连接一断会话立即取消相关资源立刻释放。客户端侧也要处理这个报错。如果客户端在发送音频流期间收到这个错误说明服务端已经断开客户端应该停止发送并进入重连流程。重连后如果用户还没松开说话键可以让用户重新说一遍或者提示网络连接中断请重试。5.4 WebSocket 鉴权为什么不能用 Header很多新手第一个问题就是我在 WebSocket 握手时加个 Authorization Header 不就行了。但浏览器 WebSocket API 根本不允许自定义 Headerws.onopen之前你能控制的只有 URL。所以业界常见做法是 token 放在 URL query 里比如ws://api.example.com/ws?tokenxxx。但这个方案有隐患URL 会出现在 Nginx access log、浏览器历史、代理日志里token 等于明文泄露。我采用的方案是URL token 只做第一道临时校验真正的鉴权在第一条hello消息里完成。具体流程是客户端先用 URL 里的临时 token 建立 TCP 连接这个 token 有效期 60 秒专门用于握手然后立刻发送hello消息里面带正式的业务 token。服务端收到hello后先校验正式 token校验通过后这个连接才真正进入业务逻辑校验失败就回error消息并关闭连接。这样设计的另一个好处是版本协商和鉴权可以在同一条hello消息里完成客户端只需一次网络往返就完成鉴权 版本确认 连接就绪三个动作体验最好。5.5 开发环境 vite 代理连不上 WebSocket这个问题只出现在 Web 端开发场景但很让人抓狂。前端本地起 vite dev server后端接口通过代理转发HTTP 请求都正常唯独 WebSocket 连不上。原因基本可以断定是 vite 代理配置漏了ws: true。以我项目里的配置为例// vite.config.ts export default defineConfig({ server: { proxy: { /ws: { target: ws://localhost:8080, changeOrigin: true, ws: true } } } })不要漏掉ws: true也不要漏掉changeOrigin: true——后者会把请求头里的Host改写成目标地址否则部分后端框架会拒绝来自非预期 Host 的升级请求。如果配了ws: true还是连不上下一步看浏览器 Network 面板的 WS 请求状态码。如果是 400把 Nginx 或后端的升级日志打出来看具体报错如果是 404检查代理路径/ws跟后端注册的 WebSocket 路径是否一致如果是 502说明后端没起或者代理目标写错了。这类问题按 400/404/502 分类排查基本都很明确。6. 几点经验补充整个契约化改造做完到现在我的体会是协议契约化的价值不在于设计了多精妙的协议格式而在于它建立了一套变更需要评审、升级需要灰度、异常需要感知的机制。协议本来就是服务端和客户端共同遵守的约定既然要共同遵守就必须有明确的版本、明确的字段、明确的校验和明确的退出流程。最后补充一个小技巧也是我认为整个改造中性价比最高的一件事在服务端给每一条协议消息加一个字段级别的访问日志。不是打印消息体而是打印哪个版本、哪个消息类型、哪个字段被访问了。这个日志不需要全量保留只需要以极低的采样率记录比如千分之一。当你想下线某个废弃字段或者怀疑某个字段在新版本里已经没有业务使用时翻一下这个日志远比问产品经理、翻各种文档来得靠谱。契约化的最后一公里靠的就是这种可观测。
返回列表