WebSocket 断连之后:一次手机采集偶现卡死的排查与修复

一个手机偶现问题的排查:从"设备没反应"挖到 WebSocket + Promise 缓存悬挂

记录一次手机端偶现问题的排查:本地复现不了,靠"源码 + 服务端日志"两条线对上,定位到根因并修复。

一、先说业务:掌静脉采集在干什么

掌静脉采集是门禁人员管理里的一个生物特征录入功能。前端和设备之间走的是 WebSocket 长连接------前端通过 WS 向设备发请求,设备也通过 WS 把采集到的数据持续推回来。

采集流程三步:

  1. 前端发一个 attach 请求(WS 上加密后是 system.attachSec),向设备申请一个推送通道 ,设备回一个 SID(会话 id);
  2. attach 成功后,前端再发一个 captureCmd(采集命令),设备这才开始采集,并通过刚才那个推送通道把数据推回来;
  3. 前端收到数据,采集完成。

关键点:captureCmd 是写在 attach 成功的 .then 里的 ------attach 成功(拿到 SID)才会发 captureCmd,设备才会动。

js 复制代码
// 业务层 HandMap.attachHand(简化)
attachNotify('...attachPersonCollection', { DeviceID })   // 走 WebSocket 发 attach
    .progress(data => { /* 收到设备推送的采集数据 */ })
    .then(res => {
        // attach 成功后才发采集命令
        HandMap.SID = res.params.SID;
        RPC.AccessPersonCollection.captureCmd({ ... OperType: 1 });  // 让设备开始采
    })

记住这一点,后面排查全靠它。

二、问题现象

测试反馈:

手机 web 上点"重新录入",设备进入 了掌静脉录入界面,但录了好几次都失败 ;后面再次点击重新录入,设备无反应、不进入录入界面。

先把两件事拆开:

  • "录了好几次都失败" :采集识别本身的问题(手没放好、识别率等),和这次 bug 无关,是另一回事。
  • "后面再点设备无反应、不进录入界面":这才是 bug------设备根本没收到"开始采集"的命令。

"设备没反应" = captureCmd 没发出去。而 captureCmd 在 attach 的 .then 里 → 说明 attach 没成功 (.then 没执行)。

所以问题聚焦到:为什么 attach 没成功?

三、我的排查思路

偶现问题最烦的是本地复现不了。我的做法是:从现象倒推 + 翻源码 + 看服务端日志,三条线对上才下结论。

第 1 步:从现象定位到 attach

"设备没反应" → captureCmd 没发 → captureCmd 在 attach 的 .then → attach 没成功 → .then 没执行。

那 .then 为什么不执行?要么 attach 的 Promise 没 resolve,要么报错没走到。继续往下挖。

第 2 步:翻 SDK 源码,看 attach 的 Promise 是怎么管的

attach 走的是 SDK(@sdd-web/rpc)的 wsAttach。我扒了一下它的实现,发现它对同一个订阅 key 会缓存 Promise:

js 复制代码
// wsAttach(简化)
wsAttach(method, notifyKey, data) {
    let promise = this.topics[notifyKey];   // 先查缓存
    if (promise) {
        // 命中缓存 → 复用旧 Promise,但请求照发!
        this._send(method, data, res => { callback(res); });
        // 注意:没有 resolve(promise),旧 Promise 不 settle
    } else {
        // 首次 → 新建 Promise,响应回来才 resolve
        promise = new PromiseEx(resolve => {
            this._send(method, data, res => { resolve(res); });
        });
        this.topics[notifyKey] = promise;
    }
    return promise;
}

两个关键事实:

  1. 首次 attach :缓存空 → 新建 Promise → 响应回来 → resolve → .then 执行 → 发 captureCmd。正常。
  2. 后续 attach(缓存还在) :走复用分支 → 请求照发,但旧 Promise 不 resolve → .then 不执行 → captureCmd 不发。卡死。

这里有个反直觉的点:"复用"不是"不发请求",而是"照发请求但不 resolve"。日志里会看到请求一直在发,容易误判"在工作",其实业务回调根本没触发。

SDK 这么设计是有道理的:attach 是订阅 (长生命周期,设备通过 WS 持续推数据),不是一次性请求。缓存 Promise 是为了"WS 重连后业务无感续用、.progress 监听不断"。设计没错,但它没考虑"旧 Promise 已经卡死"的情况。

第 3 步:看服务端日志,找断连点

webapp 日志完整时间线如下(session c01c2356...,来自 192.168.3.3):

时间 报文 SID 服务端动作
14:20:04 detachSec id=8 262168 delete type:262168
14:20:09 attachSec id=9 262172 insert sub(新建)
14:20:25 detachSec id=10 262172 delete type:262172
14:20:47 attachSec id=11 262174 insert sub(新建)
14:21:07 detachSec id=12 262174 delete type:262174
14:21:13 attachSec id=13 262176 insert sub(新建)
14:21:45 detachSec id=14 262176 delete type:262176
14:21:46 WS reset --- Connection reset by peer,error 104,id=15 报文丢失
14:21:49 新 WS 连接 --- 重连
14:22:22 attachSec id=16 262178 insert sub(新建)
14:22:26 attachSec id=17 262178 复用 262178
14:22:31 attachSec id=18 262178 复用 262178
14:22:43 attachSec id=19 262178 复用 262178
14:22:51 attachSec id=20 262178 复用 262178
14:23:02 attachSec id=21 262178 复用 262178

日志告诉我们两件事:

  1. 断连前一切正常 :14:20:04~14:21:45,每轮 attach 后都配了 detachSec,attach-detach 成对,订阅节点建了就释放、不残留。
  2. 断连后只 attach、零 detach :14:22:22 起 #16-21 全是 attachSec,一次 detachSec 都没有,262178 节点建了就一直没释放。

第 4 步:为什么断连前正常?因为业务层会调 detach 清缓存

这里要解释一下"断连前为什么没卡":业务层在采集成功(.then)或失败(.catch)时,都会调用 detachHand:

js 复制代码
// AddHand 组件(简化)
HandMap.attachHand(...)
    .then(data => { ...; HandMap.detachHand(handDevice); })   // 成功后 detach
    .catch(e => { HandMap.detachHand(handDevice); })           // 失败也 detach

detachHand 里会调 detachNotify → SDK 的 wsDetach,而 wsDetach 里有同步清缓存的一行:

js 复制代码
// wsDetach(简化)
wsDetach(method, notifyKey, data) {
    this.send(method, data);              // 发 detachSec
    delete this.topics[notifyKey];        // ← 同步清缓存!不依赖响应、不依赖 SID
    ...
}

delete this.topics[notifyKey] 是同步执行的,所以每轮采集结束调一次 detachHand,缓存就被清掉,下次 attach 走新建分支,一切正常。

但断连后就不一样了 :WS 断了,attach 的响应丢了,Promise 永远 pending,.then/.catch 都不触发 → detachHand 根本没被调到 → 缓存清不掉 → 下次 attach 走复用分支 → 卡死。

第 5 步:拼出完整链路

把源码和日志对上,根因清楚了:

断连前一切正常。断连重连后,前端发了 attach(#16),服务端也回了响应(新建 262178),但这个响应在回前端的路上丢了------手机端 WS 又断了一次(息屏 / 后台 / 网络切换,移动端这种事很常见)。

响应丢了,attach 的 Promise 就永远卡在 pending 。而 SDK 没有超时兜底 去 reject 它、ws.onclose 也不清缓存 ,所以这个悬挂的 Promise 就一直留在 topics 里。之后用户再点"重新录入" → wsAttach 发现缓存还在 → 走复用分支 → 照发请求但不 resolve → .then 不执行 → captureCmd 不发 → 设备不动。用户反复点,死循环。

四、根因:为什么响应丢失会变成永久卡死

把链路完整画出来:

复制代码
1. 采集中 WS 断连 → attach 的 SID 响应在路上丢失
2. wsAttach 缓存的 Promise 永久悬挂(既不 resolve 也不 reject)
3. SDK 的 ws.onclose 只重连,不清 topics(不补 detachSec、不清缓存)
4. 业务层 .then / .catch 都不触发 → 不调 detachHand、不报错、不发 captureCmd
5. 用户重新点击 → wsAttach 发现 topics 还在 → 走复用分支 → 又只发不 resolve
6. 服务端收到重复 attachSec → 订阅节点去重 → 返回旧 SID、不重新执行业务
7. 前端永远收不到结果 → 用户反复点 → 死循环

核心是第 2 步的"永久悬挂" :Promise 的状态只能被 resolve/reject 改变,响应丢了 resolve 这条回调链就断了,而 SDK 既没超时兜底、onclose 也不清理,于是 Promise 永远停在 pending,.then/.catch/.finally 全不触发。

成熟网络库(axios、fetch + AbortController)都有超时机制------X 秒没响应就 reject,调用方走 .catch 能感知失败、重试、清理。SDK 的 wsAttach 恰恰缺了这个兜底,所以"响应丢失"在它这里没有"失败"这个出口,只能变永久卡死。

响应丢失本身是偶发的、合理的 ;问题是没有从响应丢失恢复的机制,才让它变成永久卡死。

五、修复:在 attach 前先清掉缓存

SDK 改不了(也不归我改),但我可以在业务层兜底:每次 attach 前,先强制清掉 SDK 的缓存 ,让这次 attach 必走"新建分支"、能 resolve、能发 captureCmd。

这个思路不是我发明的,项目里已有的 publicAttach(事件订阅)就是这么干的------attach 前先检查有没有旧的、有就先 detach 释放。我照搬这个模式到掌静脉采集的 HandMap。

js 复制代码
// HandMap(改后)
export const HandMap = {
    SID: '',
    UUID: null,
    attached: false,   // 标记"已 attach 未 detach"

    attachHand: (deviceID, isLeft) => {
        // ★ attach 前若上次没正常 detach(如响应丢失、Promise 悬挂),
        //   先强制清掉 SDK 缓存,使本次 attach 走新建分支能正常 resolve
        if (HandMap.attached) {
            if (HandMap.UUID) {
                // 上次发过采集命令的话,先取消,重置设备采集状态
                RPC.AccessPersonCollection.captureCmd({
                    Info: { AllowTime: 30, Type: 8, UUID: HandMap.UUID, OperType: 0 },
                    DeviceID: deviceID
                });
            }
            // 清 SDK 的 topics 缓存(wsDetach 里 delete topics 是同步的,必清)
            detachNotify(
                'AccessPersonCollection.detachPersonCollection',
                'client.notifyPersonCollection',
                { SID: HandMap.SID || '' },   // SID 空也发,只为触发清缓存
                {}
            );
            HandMap.SID = '';
            HandMap.UUID = null;
        }
        HandMap.attached = true;
        return new Promise((resolve, reject) => {
            attachNotify('AccessPersonCollection.attachPersonCollection', ...)
                .progress(...)
                .then(res => { /* 设 SID、发 captureCmd */ })
                .catch(err => { reject(err); });
        });
    },

    detachHand: handDevice => {
        if (!HandMap.attached) return;
        if (HandMap.UUID) {
            RPC.AccessPersonCollection.captureCmd({
                Info: { AllowTime: 30, Type: 8, UUID: HandMap.UUID, OperType: 0 },
                DeviceID: handDevice
            }).then(() => { HandMap.UUID = null; });
        }
        // SID 为空也发:wsDetach 内部 delete topics 是同步的,必清 SDK 缓存
        detachNotify(
            'AccessPersonCollection.detachPersonCollection',
            'client.notifyPersonCollection',
            { SID: HandMap.SID || '' },
            {}
        )
            .then(() => { HandMap.SID = ''; HandMap.UUID = null; HandMap.attached = false; })
            .catch(err => { HandMap.attached = false; console(err); });
    }
};

为什么这么改能行 ,关键还是 wsDetach 里那行同步清缓存:

js 复制代码
// wsDetach(简化)
wsDetach(method, notifyKey, data) {
    this.send(method, data);              // 发 detachSec
    delete this.topics[notifyKey];        // ← 同步清缓存!不依赖响应、不依赖 SID
    ...
}

delete this.topics[notifyKey] 同步执行,不管 detachSec 发没发成功、SID 有没有值。所以只要在 attach 前调一次 detachNotify,SDK 缓存必被清,下次 attach 必走新建分支、必能 resolve、必能发 captureCmd。

恢复链 :用户点重新录入 → attachHand 开头检测到 attached(上次没正常收尾)→ 先取消上次采集 + detachNotify 清缓存 → attach 走新建 → resolve → .then → 发 captureCmd → 设备进入录入界面。点一下就恢复,不用等超时。

三条路径都覆盖:

路径 结果
正常采集成功 业务层调 detachHand 复位,下次 attach 不重复 detach
采集失败(.catch 触发) detachHand 清缓存,下次走新建
响应丢失/悬挂 .then/.catch 都不触发,但下次 attachHand 开头检测到 attached → 先清 → 再新建 → 立即恢复

六、结果

改完后让测试复测,之前那个"失败几次后设备无反应、点不动"的场景不再复现------采集失败后再点重新录入,设备能正常重新进入录入界面、继续采集。问题解决。

七、总结:偶现问题怎么排

这次排查我总结了几条经验:

  1. 从现象倒推,别一上来就猜环境 。"设备没反应" → captureCmd 没发 → attach 没成功 → Promise 没 resolve。一步步缩范围。
  2. 翻源码 + 看日志,两条线对上才下结论。源码告诉我"复用分支只发不 resolve",日志告诉我"断连后只 attach 不 detach",对上了才有底气。
  3. 区分"偶发事件"和"没有恢复机制" 。WS 响应丢失是偶发、合理的;但 SDK 没超时兜底、onclose 不清缓存,才是它变成永久卡死的原因。bug 往往不在偶发事件本身,而在"系统没法从偶发事件恢复"。
  4. 修"恢复路径"比死磕根因更高效。直接在业务层保证"attach 前先清缓存",不管是不是响应丢失触发的,悬挂的 Promise 都能被清掉、恢复采集。这种兜底修复对多种可能的根因都有效。
  5. 复用项目里已有的模式 。publicAttach 那套"attach 前先 detach"项目里早就在用,照搬过来风险小、评审好过。

偶现问题排查难就难在"没现场",但只要能把"可能的原因"收敛到一条自洽链路、并针对它修恢复路径,就能把问题解掉。

相关推荐
Novproxy_Z2 小时前
机房 IP 和住宅 IP 怎么区分?IP ASN、归属信息检测脚本
linux·网络·tcp/ip
黑码哥2 小时前
OpenClaw 网络数据采集实战入门
网络
码少女2 小时前
Linux——网络层-IP协议以及路由选择
linux·网络·网络协议·tcp/ip
发光小北2 小时前
EtherCAT 转 ModbusTCP 网关现场应用中会遇到什么问题?
网络
Ivanqhz2 小时前
MoE 原理、TC/EC 路由、LSH-MoE 与负载均衡浅析
运维·网络·人工智能·深度学习·算法·机器学习·负载均衡
悟道书生2 小时前
若依 RBAC 权限控制学习笔记
java·网络·笔记·sql·学习·vue
电梯界知识分子3 小时前
景德镇老房重新设计阶段规划楼梯与电梯,1.7m×1.6m空间装400kg观光电梯,棕色框架茶色玻璃适配中式装修
大数据·服务器·前端·网络·家用电梯
砚底藏山河3 小时前
python量化:实时行情WebSocket断线重连状态机
java·python·websocket·金融
Yiran_G3 小时前
双频 Wi-Fi 6 + 蓝牙 5.4:无声讯通 WS8712 模组的开发与集成要点
网络·嵌入式硬件·物联网·智能家居