一个手机偶现问题的排查:从"设备没反应"挖到 WebSocket + Promise 缓存悬挂
记录一次手机端偶现问题的排查:本地复现不了,靠"源码 + 服务端日志"两条线对上,定位到根因并修复。
一、先说业务:掌静脉采集在干什么
掌静脉采集是门禁人员管理里的一个生物特征录入功能。前端和设备之间走的是 WebSocket 长连接------前端通过 WS 向设备发请求,设备也通过 WS 把采集到的数据持续推回来。
采集流程三步:
- 前端发一个
attach请求(WS 上加密后是system.attachSec),向设备申请一个推送通道 ,设备回一个SID(会话 id); attach成功后,前端再发一个captureCmd(采集命令),设备这才开始采集,并通过刚才那个推送通道把数据推回来;- 前端收到数据,采集完成。
关键点: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;
}
两个关键事实:
- 首次
attach:缓存空 → 新建 Promise → 响应回来 →resolve→.then执行 → 发captureCmd。正常。 - 后续
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 |
日志告诉我们两件事:
- 断连前一切正常 :14:20:04~14:21:45,每轮
attach后都配了detachSec,attach-detach 成对,订阅节点建了就释放、不残留。 - 断连后只 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 → 先清 → 再新建 → 立即恢复 |
六、结果
改完后让测试复测,之前那个"失败几次后设备无反应、点不动"的场景不再复现------采集失败后再点重新录入,设备能正常重新进入录入界面、继续采集。问题解决。
七、总结:偶现问题怎么排
这次排查我总结了几条经验:
- 从现象倒推,别一上来就猜环境 。"设备没反应" →
captureCmd没发 →attach没成功 → Promise 没 resolve。一步步缩范围。 - 翻源码 + 看日志,两条线对上才下结论。源码告诉我"复用分支只发不 resolve",日志告诉我"断连后只 attach 不 detach",对上了才有底气。
- 区分"偶发事件"和"没有恢复机制" 。WS 响应丢失是偶发、合理的;但 SDK 没超时兜底、
onclose不清缓存,才是它变成永久卡死的原因。bug 往往不在偶发事件本身,而在"系统没法从偶发事件恢复"。 - 修"恢复路径"比死磕根因更高效。直接在业务层保证"attach 前先清缓存",不管是不是响应丢失触发的,悬挂的 Promise 都能被清掉、恢复采集。这种兜底修复对多种可能的根因都有效。
- 复用项目里已有的模式 。
publicAttach那套"attach 前先 detach"项目里早就在用,照搬过来风险小、评审好过。
偶现问题排查难就难在"没现场",但只要能把"可能的原因"收敛到一条自洽链路、并针对它修恢复路径,就能把问题解掉。