给设备影子加 TTL、给消息加序号:鸿蒙任务接续的两个细节鸿蒙HarmonyOSArkTS
系列第 4 篇。鸿蒙多设备任务接续的服务跑通之后,代码里还留着两个迟早会出事的地方:离线设备的影子越积越多,重连之后消息会重复、会乱序。这篇记录怎么用 TTL 和序号把这两个洞补上,附验证脚本和结果。
环境与版本
| 项目 | 版本 / 规格 |
|---|---|
| 服务端 | 3 台华为云 ECS,8 vCPU / 16 GiB,Ubuntu 24.04.4 |
| Node.js | 20.20.2 |
| Redis | 7.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 实测。
更多推荐


所有评论(0)