系列第 4 篇。鸿蒙多设备任务接续的服务跑通之后,代码里还留着两个迟早会出事的地方:离线设备的影子越积越多,重连之后消息会重复、会乱序。这篇记录怎么用 TTL 和序号把这两个洞补上,附验证脚本和结果。

环境与版本

项目版本 / 规格
服务端3 台华为云 ECS,8 vCPU / 16 GiB,Ubuntu 24.04.4
Node.js20.20.2
Redis7.0.15(主从 + 三哨兵)
客户端鸿蒙云手机 ALN-AL00 / HarmonyOS 5.0.1 / API 17
ArkTS 网络库@ohos.net.webSocket(系统内置)
验证方式云手机 hdc 抓屏 + Redis 命令核对

一、两个问题长什么样

问题一:影子只增不减。 设备接入时写一条 shadow:{deviceId},断开时我特意不删——因为重连后还要靠它恢复状态。但代价是那些再也不回来的设备(用户卸载、换机、模拟器销毁)会永久占着 Redis 内存。压测跑过几轮之后,里面已经躺了几万条没人会再读的影子。

问题二:重连之后消息对不上。 弱网环境下,客户端超时重发是常规操作,服务端会收到同一笔任务态两次;跨节点转发经过 Redis Pub/Sub,到达顺序也不保证。鸿蒙端如果只按"收到就渲染",就会出现光标来回跳、草稿被旧版本覆盖。

两个都不是架构问题,但都是上线后一定会被投诉的问题。

二、设备影子 TTL:在线续期,离线自然消失

思路:给影子设一个过期时间,只要设备还在线就不断续期,真离线了就让它自己过期删除。这样不需要在 close 事件里做任何清理逻辑——而 close 恰恰是最不可靠的事件(进程被杀、网络中断都不会触发)。

写入和续期:

const SHADOW_TTL = 300;   // 5 分钟

async function touchShadow(device) {
  await pub.hset('shadow:' + device, 'node', NODE_ID, 'state', 'online', 'ts', Date.now());
  await pub.expire('shadow:' + device, SHADOW_TTL);
}

// 连接建立时写一次
wss.on('connection', (ws) => { touchShadow(ws.device); });

// 每 60 秒给本节点所有在线设备续期
setInterval(() => {
  for (const d of clients.keys()) touchShadow(d).catch(() => {});
}, 60000);

关键配比是 TTL 必须明显大于心跳间隔:300 秒 TTL 对 60 秒心跳,意味着允许连续漏掉 4 次续期才过期。如果心跳 60 秒、TTL 只有 10 秒,一次事件循环卡顿就会把在线设备的影子删掉。

验证(用 TTL=10 秒的独立实例,不影响主服务):

连接后 shadow:vC 的 TTL      = 9
断开 3 秒后 TTL              = 6
断开 15 秒后 exists          = 0

离线设备的影子按预期自动消失,全程没有 del,也没有依赖 close 回调。

注意这里有个取舍:影子过期 ≠ 设备状态丢失。影子里存的是"最后一笔任务态",真正需要长期保存的草稿内容应该落在业务数据库里。影子是缓存,不是账本。

三、消息序号:先解决重复,再解决乱序

去重:一条消息只处理一次

发送端为每台设备维护自增序号,服务端用 SET NX 做原子去重:

if (typeof m.seq === 'number') {
  const seen = await pub.set(`seen:${m.from}:${m.seq}`, '1', 'EX', 120, 'NX');
  if (!seen) { stats.dup++; return; }   // 已处理过,直接丢弃
}

SET NX 保证并发下只有一个请求能设置成功,不需要先 GET 再 SET(那样有竞态)。120 秒过期是为了防止去重键本身变成新的内存负担——重传窗口一般不超过这个时间。

乱序:不硬排,而是标记缺口

收到跳号消息时有两种做法:缓存等前序到齐(引入延迟和状态),或者直接下发并告诉客户端"你漏了几条"。任务接续的场景里,最新状态比完整序列更重要,所以选后者:

if (m.type !== 'ack' && typeof m.seq === 'number') {
  const last = lastSeq.get(m.target) || 0;
  if (m.seq > last + 1 && last > 0) {
    stats.gap++;
    m.gap = m.seq - last - 1;      // 告诉客户端缺了几条
  }
  if (m.seq > last) lastSeq.set(m.target, m.seq);
}

客户端看到 gap 字段,就去拉一次 /shadow/{deviceId} 拿全量状态,而不是干等那条可能永远到不了的消息。这跟鸿蒙端重连后先拉影子是同一个思路:用全量快照修复增量通道的不可靠。

四、验证结果

两个设备 A/B,A 依次发 seq=1、2、2、4(第二条重发一次,跳过 3):

B 实际收到: [{"seq":1},{"seq":2},{"seq":4,"gap":1}]
  • 重复的 seq=2 只投递了一次,服务端 dup 计数 +1;
  • seq=4 正常下发,但带上了 gap:1,客户端据此知道漏了第 3 条,去拉影子补齐;
  • 服务端 gap 计数 +1。

顺手还发现了一个验证方法本身的坑:第一次跑完脚本后立刻重跑,B 一条消息都收不到——因为 120 秒内的 seen:{from}:{seq} 还在,所有消息被判重。去重逻辑生效的最直接证据,就是同样序号的脚本第二遍跑不通。 所以验证脚本里设备名要加随机后缀:

const S = 'x' + (Date.now() % 100000);
const A = await open('vA' + S, 8080);
const B = await open('vB' + S, 8080);

云手机端实测:缺口检测真的触发了

服务端脚本之外,我在鸿蒙云手机上验证了客户端的表现。杀掉后端节点、再手动重连,云手机日志如下:

云手机重连后检测缺口并拉取影子

12:18:45 已连接 hm-phone-01
12:18:48 收到 seq=305 来自=hm-tablet-01 [缺口1条->拉影子]
12:18:49 影子恢复: {"task":"tablet-task-305","payload":"平板侧第305次编辑","ts":"1794673129006","node":"node2","state":"online"}

这里同样暴露了上一节说的问题:序号从 288 跳到 305,实际缺 16 条,但服务端报的是 gap:1——因为 lastSeq 存在进程内,重连到 node2 后被重置了。

但兜底机制救了统计错误:客户端只判断 gap > 0,只要大于 0 就拉影子。拉回来的 node 字段已经是 node2,状态是最新的。

这印证了本节的核心结论:缺口计数可以不准,但「有缺口」这个判断必须可靠,恢复动作必须全量。

五、小结

两个改动加起来不到 30 行,但补上了任务接续最容易被忽略的两块:

改动解决什么代价
影子 TTL + 心跳续期离线设备永久占内存每 60s 一轮续期写入
SET NX 序号去重弱网重传导致重复渲染每条消息一次 Redis 写
跳号标记 gap乱序导致旧状态覆盖新状态客户端多一次影子拉取

有几个边界目前还没覆盖:lastSeq 存在 Node 进程内存里,节点重启后缺口检测会重新计数;去重键 120 秒过期后,极端延迟的重传仍可能漏进来。真到生产环境,这两个状态也应该挪进 Redis。这是下一步的事。


环境:4 × 华为云 x3e.8u.16g(8C16G,Ubuntu 24.04.4),Node 20.20.2,Redis 7.0.15(主从 + 三哨兵),Nginx 1.24.0,2026-11 实测。

Logo

作为“人工智能6S店”的官方数字引擎,为AI开发者与企业提供一个覆盖软硬件全栈、一站式门户。

更多推荐