news 2026/10/8 7:15:42

HarmonyOS 7 ArkTS:离线消息Schema演进与幂等重放

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
HarmonyOS 7 ArkTS:离线消息Schema演进与幂等重放

ProtoRelay是一个离线笔记同步 Demo。手机断网时把编辑操作编码成 Protobuf 消息,联网后按顺序重放。它稳定跑了几周,直到服务端先上线 v3:消息多了workspaceColor和conflictPolicy两个字段,仍在使用 v2 Schema 的旧客户端接收后,再次转发给另一台新设备,这两个字段竟然消失了。

没有崩溃,没有解析错误,ACK 也正常。真正的问题是旧客户端把“自己不认识的字段”悄悄丢掉,再用 v2 对象重新编码。线上最难排的往往就是这种成功链路里的数据损失。最后我没有让旧版本理解 v3 业务,而是保留原始 payload 与 unknown fields,给每条消息增加幂等账本,并把跨版本重放做成固定回归。

一、兼容不只是“旧版能解码新版消息”

Protobuf 的字段编号允许新旧 Schema 共存。v2 客户端遇到 v3 新字段时,通常仍能读取自己认识的字段,所以第一轮测试很容易得出“兼容”的结论。可我们的链路不是只读:旧设备接收、落盘、恢复网络后还会重新编码并转发。

如果 decode 后只保留已知对象,再 encode,未知字段可能消失。对旧客户端来说这似乎无所谓,对下游 v3 设备却是不可逆的数据降级。workspaceColor丢失会让协作空间恢复默认颜色,conflictPolicy丢失则可能改变冲突合并策略。

本轮回归批次为PR-1002-0514,目标消息msg_20261002_0514_007,发送端 Schema v3,当前客户端 Schema v2。离线队列 37 条,要求重放37 / 37、重复提交 0、未知字段保留 2,最终状态ACKED。

二、先把业务消息和传输信封分开

早期实现直接把NoteMutation编码结果写文件,文件名就是时间戳。重试时只能重新解析业务对象,既没有稳定 messageId,也没有原始字节的校验值。修复后增加OfflineEnvelope:信封由当前客户端完全理解,业务 payload 作为 bytes 原样包裹。

下面的 Schema 解决的是“旧客户端必须转发,却不应改写未知业务字段”。信封字段编号长期稳定;payload内部的NoteMutationV3可以继续演进。

syntax = "proto3"; package relay; message OfflineEnvelope { string messageId = 1; uint32 schemaVersion = 2; int64 createdAtMs = 3; bytes payload = 4; bytes payloadSha256 = 5; uint32 retryCount = 6; } message NoteMutationV3 { string noteId = 1; string operation = 2; string content = 3; string workspaceColor = 8; string conflictPolicy = 9; }

字段 4~7 即使暂时未用,也不会拿来复用旧语义。Protobuf 兼容的底线不是“字段名没变”,而是字段编号和 wire type 的含义不能偷偷改。删除字段后应该保留编号,避免后来的人把同一个 tag 分配给另一种数据。

信封的哈希针对原始 payload,而不是 decode 后的对象。只要旧客户端没有改业务内容,转发前后的 SHA-256 就应该一致。这条断言比比较 JSON 更稳,因为 JSON 化会把默认值、bytes 和 64 位数转换成另一种表现形式。

三、protobufjs 的 unknown fields 要显式纳入策略

当前 protobufjs 支持通过 Reader 控制是否丢弃未知字段。项目没有把全局默认改掉,而是在需要跨版本转发的解码器中设置discardUnknown=false。这样普通只读消息仍采用默认策略,离线中继消息则保留未知 wire 数据。

这段解码器解决的是 v2 客户端读取 v3 payload 时保留 tag 8 和 tag 9。即便 UI 不认识这两个字段,重新编码时也不能把它们吞掉。

import{Reader}from'protobufjs/minimal'import{NoteMutationV2}from'./generated/note_v2'exportinterfaceUnknownAwareMutationextendsNoteMutationV2{$unknowns?:Uint8Array[]}exportfunctiondecodeForRelay(payload:Uint8Array):UnknownAwareMutation{constreader=Reader.create(payload)reader.discardUnknown=falsereturnNoteMutationV2.decode(reader)asUnknownAwareMutation}exportfunctionunknownFieldCount(message:UnknownAwareMutation):number{returnmessage.$unknowns?.length??0}

这里不能先toObject()再持久化。plain object 是业务互操作边界,unknown fields 并不属于 v2 类型定义,转换时很容易消失。队列里始终保存原始 payload;解码对象只用于展示已知字段和做安全校验。

如果未来切换 protobufjs 版本,$unknowns的具体表现需要重新验证,不能把内部属性当永久业务协议。工程上的保险仍是原始 payload 与哈希:即便库的 unknown-field 表示变化,我们也能原样转发,并通过端到端回归发现差异。

四、离线队列保存的是事实,不是临时对象

队列项包含 messageId、Schema 版本、原始信封、payload 哈希、状态、下一次重试时间和最近错误码。状态只能按QUEUED → DECODING_V2 → REPLAYING → ACKED前进;网络异常时从REPLAYING回到QUEUED,但 messageId 不变。

这段仓储入口解决的是应用被杀、重复启动和同一消息多次入队的问题。插入使用 messageId 做唯一键;相同 ID、相同哈希视为重复到达,直接返回现有记录;相同 ID、不同哈希则标记为冲突,绝不能覆盖。

exportasyncfunctionenqueueEnvelope(raw:Uint8Array):Promise<QueueRecord>{constenvelope=OfflineEnvelope.decode(raw)constactualHash=sha256(envelope.payload)if(!bytesEqual(actualHash,envelope.payloadSha256)){thrownewError('PAYLOAD_HASH_MISMATCH')}constexisting=awaitledger.findByMessageId(envelope.messageId)if(existing){if(!bytesEqual(existing.payloadSha256,actualHash)){thrownewError('MESSAGE_ID_COLLISION')}returnexisting}returnledger.insert({messageId:envelope.messageId,schemaVersion:envelope.schemaVersion,rawEnvelope:raw,payloadSha256:actualHash,state:'QUEUED',retryCount:0})}

写入数据库时rawEnvelope使用二进制列,不做 Base64 往返。Base64 可以用于日志摘要或调试导出,但会增加体积,也容易在文本层被换行、裁剪。日志只打印 messageId、schemaVersion、字节数和哈希前 8 位,不输出笔记正文。

页面退出不删除队列,Ability 销毁也不把REPLAYING直接改成ACKED。启动恢复时,超过租约时间的REPLAYING记录回到QUEUED,然后继续重放;这比在生命周期回调里猜网络请求是否成功可靠。

五、幂等不是客户端“记得发过”,而是双方认同同一个 ID

重放器一次领取 10 条记录,为每条记录申请 30 秒租约。服务端以 messageId 去重:第一次写入返回APPLIED,重复到达返回ALREADY_APPLIED,两者都可以转成 ACKED。客户端不能因为 HTTP 200 就删除消息,还要核对响应中的 messageId 与 payload 哈希。

下面的循环解决的是断网重连时重复回调和迟到 ACK。每次执行都有generation;用户切换账号或页面销毁后,旧代响应只能释放租约,不能提交当前账本。

exportclassReplayCoordinator{privategeneration:number=0asyncreplayPending():Promise<void>{construnGeneration=++this.generationconstbatch=awaitledger.leaseQueued(10,30_000)for(constitemofbatch){awaitledger.move(item.messageId,'REPLAYING')try{constack=awaitrelayApi.push(item.rawEnvelope)if(runGeneration!==this.generation)breakif(ack.messageId!==item.messageId||ack.payloadHash!==hex(item.payloadSha256)){thrownewError('ACK_MISMATCH')}awaitledger.move(item.messageId,'ACKED')}catch(error){awaitledger.requeueWithBackoff(item.messageId,String(error))}}}cancel():void{this.generation++}}

循环没有Promise.all()。离线编辑有顺序语义,同一 noteId 的 PATCH 必须按序提交;不同 noteId 可以在分组后并行,但要保证每组单通道。盲目并发会把“传输成功”变成“业务顺序错乱”。

退避使用 2、4、8、16、32 秒并加少量抖动,上限 5 分钟。永久错误如SCHEMA_UNSUPPORTED不再自动重试,而是进入NEEDS_UPGRADE,避免后台无限耗电。

六、跨版本回归必须走一遍 v3→v2→v3

单元测试若只做 v2 自己的 encode/decode,看不见未知字段。回归夹具先用 v3 Schema 生成消息,确认 tag 8 和 tag 9 存在;再交给 v2 解码器读取并持久化;最后将队列中的原始 payload 交给 v3 解码,检查两个字段仍为#4A6CF7和MERGE_BY_CLOCK。

第一次测试里,我们故意走旧路径:v2 decode 后调用toObject(),再用fromObject()与encode()生成 payload,结果 unknown fields 从 2 变成 0,报告状态DATA_LOSS_BLOCKED。改为原始 payload 中继后,哈希前后相同,字段保留数恢复为 2。

回归还模拟了三种时序:请求已到服务端但 ACK 丢失、应用在 REPLAYING 时被杀、同一消息在网络恢复回调中被触发两次。最终 37 条全部 ACK,服务端实际应用 37 次,重复写入 0。

七、最终页面把兼容链路完整摊开

ReplayLedgerPage不做聊天界面,而是把最难核对的证据放在同一屏:批次PR-1002-0514、目标消息msg_20261002_0514_007、发送 Schemav3、当前解码v2、保留未知字段2、队列37、已重放37 / 37、重复写入0。

页面底部状态为ACKED,路径显示QUEUED → DECODING_V2 → REPLAYING → ACKED。payload 哈希前缀8f3a21c7在入队、发送和 ACK 三处一致。状态栏时间固定为 05:14,HiLog 也使用同一批次和 messageId。

八、哪些 Schema 变化仍然不该自动兼容

新增可选字段通常容易兼容,修改既有字段编号、改变 wire type、把同一编号换成另一种业务含义则很危险。即使某个库“能解码”,业务语义也可能已经错了。此类变化应提升主版本并明确拒绝旧客户端。

unknown fields 保留也不是万能策略。消息中若包含旧端不应继续传播的敏感字段,服务端需要通过版本策略或字段级加密控制,而不是指望旧端理解安全含义。中继层只保证字节不被意外改写,不替代权限判断。

最后,ACKED 记录不能无限增长。项目保留 7 天幂等账本,清理时只删 ACKED 且已超过服务端去重窗口的记录;QUEUED、REPLAYING、NEEDS_UPGRADE 一律不能按时间粗暴清除。

这次故障让我重新理解了“向前兼容”:不是旧版不崩就够了,而是旧版经过一次落盘、恢复、转发后,新版数据仍然完整。原始 payload、未知字段策略、稳定 messageId 和双方幂等账本缺一不可。把这条链路做成回归后,Schema 升级才不再是一场靠运气的线上实验。

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/10/8 7:15:16

跟着韩顺平老师学Linux的第六天:学习网络配置与hosts映射

今日学习了网络配置的相关知识&#xff0c;首先就是ipconfig 的指令在Windows的命令提示符中去查询VMnet8的IPv4的地址&#xff0c;同时在Linux上使用ip a的指令去查询enss33&#xff08;网卡&#xff09;的inet地址。发现两个地址的前三位是一样的&#xff0c;根据韩老师的讲解…

作者头像 李华
网站建设 2026/10/8 7:15:10

高湿环境下住宅装修的防潮技术框架

高湿气候下的住宅装修&#xff0c;防潮问题的技术根源集中在三个环节&#xff1a;基层含水率失控、防潮层构造不连续、防水闭水试验缺位。围绕 “回南天装修注意什么” 的讨论&#xff0c;若不落到这三项可测量指标&#xff0c;往往流于经验之谈。本文结合星艺装饰在海口的工程…

作者头像 李华
网站建设 2026/10/8 7:15:10

写论文如何又快又好?资深导师力荐这几个AI论文软件

写论文又快又好&#xff0c;关键在于用对 AI 工具、走对流程——资深教授普遍推荐&#xff1a;千笔AI&#xff08;中文全流程首选&#xff09; 豆包学术版&#xff08;轻量高效&#xff09; DeepSeek 学术版&#xff08;理工 / 长文本&#xff09; Grammarly Academic&#xff…

作者头像 李华
网站建设 2026/10/8 7:14:50

学工管理系统如何破解高校信息孤岛难题

✅作者简介&#xff1a;合肥自友科技 &#x1f4cc;核心产品&#xff1a;智慧校园平台(包括教工管理、学工管理、教务管理、考务管理、后勤管理、德育管理、资产管理、公寓管理、实习管理、就业管理、离校管理、科研平台、档案管理、学生平台等26个子平台) 。公司所有人员均有多…

作者头像 李华
网站建设 2026/10/8 7:14:50

Java与Kotlin项目选型的进阶玩法:优劣势对比与开

作为一名Java开发者&#xff0c;最近在项目选型中遇到了Java和Kotlin的抉择问题。其实&#xff0c;这两种语言各有优劣&#xff0c;关键要看项目需求和个人偏好。今天想和大家分享一下我的思考过程。 首先&#xff0c;Java的优势还是很明显的。它的社区支持非常强大&#xff0c…

作者头像 李华
网站建设 2026/10/8 7:14:50

week7-练习

编程练习题&#xff1a;Matplotlib scatter text annotate 综合绘图题目使用 matplotlib 完成下面绘图任务&#xff1a;创建画布与子图&#xff0c;figsize(8,6)准备数据&#xff1a;x [1, 3, 5, 7, 9] y [2, 6, 3, 8, 4]绘制散点图 scatter&#xff0c;点大小 s80&#xf…

作者头像 李华