一、价格没有错,顺序错了

QuoteGapLab 的闪控窗只展示 12 个模拟标的,本来不像会出复杂问题。测试时把应用切到后台 18 秒,再从闪控窗回来,HMO 的价格从 42.18 跳到 42.31,随后又退回 42.24。服务端查到的三条成交都正确,客户端也没有 JSON 解析异常。最后从二进制帧的序列号反推出真相:页面处理完 8420 后,下一帧直接收到了 8424,缺失的 8421—8423 稍后才通过重连补到,于是旧价格覆盖了新价格。

把“最后一条消息赢”当成实时行情规则,在网络稳定时很省事,一遇到后台冻结、弱网或重连就会失真。Demo 页面定名 MarketPulsePage,同步任务为 QSYNC-0709。这轮没有继续优化闪控窗 UI,而是把数据链路改成带序列契约的状态机:正常流式消费;发现缺口后缓存新帧;拉取截至缺口末尾的快照;再按序回放缓存。

二、protobuf 只解决体积,不替你保证有序

服务端用 protobuf 下发 QuoteEnvelope,字段包含 sessionId、seq、symbol、price、serverTime 和 kind。protobufjs 负责把 ArrayBuffer 解成对象,却不会检查序列连续性。客户端必须保存已提交序列 committedSeq,并区分重复帧、下一帧和缺口帧。

项目目录分成 net/QuoteSocket.ets、codec/QuoteCodec.ets、sync/GapRepairCoordinator.ets、pages/MarketPulsePage.ets 和 proto/quote.proto。页面只观察 QuoteSyncState,不直接订阅 socket。状态为 CONNECTING → STREAMING → GAP_DETECTED → SNAPSHOT_LOADING → REPLAYING → STREAMING;离开页面或进入后台则到 SUSPENDED。

下面的解码器解决二进制帧校验。字段缺失、会话不匹配或价格非有限数都会被拒绝,不能让无效对象进入排序缓冲区。

import protobuf from 'protobufjs';

export interface QuoteFrame {
  sessionId: string;
  seq: number;
  symbol: string;
  price: number;
  serverTime: number;
}

export class QuoteCodec {
  constructor(private envelopeType: protobuf.Type) {}

  decode(buffer: ArrayBuffer, expectedSession: string): QuoteFrame {
    const message = this.envelopeType.decode(new Uint8Array(buffer));
    const value = this.envelopeType.toObject(message, { longs: Number });
    if (value.sessionId !== expectedSession) throw new Error('SESSION_MISMATCH');
    if (!Number.isSafeInteger(value.seq) || value.seq <= 0) {
      throw new Error('INVALID_SEQUENCE');
    }
    if (!value.symbol || !Number.isFinite(value.price)) {
      throw new Error('INVALID_QUOTE_PAYLOAD');
    }
    return value as QuoteFrame;
  }
}

longs: Number 只在服务端保证序列不超过安全整数时成立,本项目契约明确限定这一点;若序列可能越界,应改为字符串或 Long 处理。codec 实例与加载后的 protobuf Type 可以复用,但不能把解码结果无限缓存。切换 session 时旧实例可以保留,旧帧仍会被 expectedSession 拒绝。

三、三种序列关系必须走三条路

收到帧后先看 seq。小于或等于 committedSeq 是重复或迟到帧,丢弃并计数;等于 committedSeq + 1 才能直接提交;大于下一期望值则发现缺口。此时不关闭 WebSocket,因为后续帧仍有价值,但它们只能进入有界缓冲,不能刷新页面。

下面的协调器解决顺序判定。当前复现中 committedSeq=8420,收到 8424 后,缺口固定为 8421—8423;8424—8427 进入 pending。

accept(frame: QuoteFrame): void {
  if (frame.sessionId !== this.sessionId) return;
  if (frame.seq <= this.committedSeq) {
    this.duplicateDropped += 1;
    return;
  }
  if (this.state === QuoteSyncState.STREAMING &&
      frame.seq === this.committedSeq + 1) {
    this.commit(frame);
    return;
  }

  this.pending.set(frame.seq, frame);
  if (this.pending.size > 64) {
    this.fail('PENDING_BUFFER_OVERFLOW');
    return;
  }
  if (this.state === QuoteSyncState.STREAMING) {
    this.gapFrom = this.committedSeq + 1;
    this.gapTo = frame.seq - 1;
    this.state = QuoteSyncState.GAP_DETECTED;
    this.repairGap(this.gapFrom, this.gapTo);
  }
}

缓冲上限 64 是故障边界,不是性能参数。缺口修复若长时间未完成,继续收几千帧只会放大内存和回放延迟;超过上限后主动断开并走整量快照。pending 使用序列号作为 key,因此重复帧不会增加容量。页面销毁时必须 clear(),否则下一次打开会把旧 session 的帧带进新连接。

四、快照必须说明“截至哪一帧”

只请求当前行情快照仍然不够。如果快照生成时已经包含 8425,而客户端随后又回放 8424—8427,状态会重复推进。服务端接口因此接受 asOfSeq=8423,返回明确截至 8423 的 12 个标的快照。客户端校验响应的 asOfSeq 与缺口末端一致,再把 committedSeq 推进到 8423,最后从 8424 开始回放缓冲。

下面的代码解决快照和实时流的交接。修复代次 repairGeneration 防止前一次 HTTP 请求晚到覆盖新连接,本轮代次为 6。

async repairGap(from: number, to: number): Promise<void> {
  const run = ++this.repairGeneration;
  this.state = QuoteSyncState.SNAPSHOT_LOADING;
  try {
    const snapshot = await this.api.fetchSnapshot({
      taskId: 'QSYNC-0709', sessionId: this.sessionId, asOfSeq: to
    });
    if (run !== this.repairGeneration) {
      this.staleCallbackDropped += 1;
      return;
    }
    if (snapshot.asOfSeq !== to) throw new Error('SNAPSHOT_SEQ_MISMATCH');
    this.store.replaceAll(snapshot.quotes);
    this.committedSeq = snapshot.asOfSeq;
    this.state = QuoteSyncState.REPLAYING;

    const ordered = [...this.pending.values()].sort((a, b) => a.seq - b.seq);
    for (const frame of ordered) {
      if (frame.seq === this.committedSeq + 1) this.commit(frame);
    }
    this.pending.clear();
    this.state = QuoteSyncState.STREAMING;
  } catch (error) {
    this.pending.clear();
    this.reconnectWithFullSnapshot();
  }
}

回放循环只提交严格连续的帧。若缓冲自身仍有洞,不会跳过继续,而是进入整量快照重连。replaceAll() 与回放应在同一状态提交批次中完成,UI 只看到修复前和修复后两个稳定画面,不展示中间的半套价格。重复调用 repairGap() 会先增加代次,旧请求返回后只计数,不改变状态。

五、Socket 的监听必须成对解绑

webSocket.createWebSocket() 创建连接对象后,页面监听 open、message、close 和 error。问题出在重新进入前台:旧代码每次都重新 on('message'),却从未 off,同一帧会被处理两次。现在 QuoteSocket 只允许一个活动实例,后台时用 1000 正常关闭,原因写成 app_background;前台重建连接并携带 lastSeq=8427 重新订阅。

private socket?: webSocket.WebSocket;
private socketGeneration: number = 5;

connect(lastSeq: number): void {
  const run = ++this.socketGeneration;
  const ws = webSocket.createWebSocket();
  this.socket = ws;
  ws.on('message', (error, data) => {
    if (error || run !== this.socketGeneration || !(data instanceof ArrayBuffer)) {
      return;
    }
    this.coordinator.accept(this.codec.decode(data, `qsync-0709-g${run}`));
  });
  ws.on('close', () => {
    if (run === this.socketGeneration) this.scheduleReconnect(lastSeq);
  });
  ws.connect(`${WS_URL}?lastSeq=${lastSeq}&taskId=QSYNC-0709`);
}

enterBackground(): void {
  ++this.socketGeneration;
  this.socket?.off('message');
  this.socket?.off('close');
  this.socket?.close({ code: 1000, reason: 'app_background' });
  this.socket = undefined;
  this.coordinator.suspend();
}

代次在关闭前递增,使 close 回调不会误触发重连。心跳间隔为 15 秒,异常重连退避 1、2、4、8 秒后封顶;进入后台不走退避,因为那是主动生命周期事件。aboutToDisappear 与后台回调都调用同一个幂等清理函数,第二次执行不会再次关闭空对象。

IDE 图使用固定回归轨迹 2024-07-09 10:24,便于重复制造序列缺口;最终手机页在 07:09 再跑同一帧序列。任务 ID、会话、价格、水位与修复结果保持一致,测试时间不作为行情版本。

六、这次修复要从页面读得出来

固定复现轨迹从 8420 开始,跳到 8424,检测缺口 8421—8423;快照返回 asOfSeq=8423,缓存 8424—8427 共 4 帧,回放后最终序列为 8427,缓冲归零。重复帧丢弃 2 条,过期回调丢弃 1 次,修复耗时 186ms。最终状态 STREAMING,连接代次 6,会话 qsync-0709-g6。

手机页保留行情本身,但诊断卡必须同时展示 lastSeq、gap、snapshot、buffer 和恢复状态。这样价格不再回退时,开发者能确认是序列闭环生效,而不是测试刚好没遇到乱序。HiLog 使用同一组字段:gap=8421..8423、snapshot=8423、replay=8424..8427、state=STREAMING。

七、边界比“自动重连”更值得写清楚

这套方案适合服务端能提供单调序列和指定 asOfSeq 快照的流。若服务端序列按标的独立生成,客户端也必须按 symbol 分桶维护,不能套用一个全局序列。若快照接口无法按序列截断,就要返回自己的版本水位,并让后续订阅从该水位开始。

它也不处理交易撮合,只保证展示层按服务端契约收敛。闪控窗被系统销毁后,持久化的只有最后提交序列和最近稳定快照,不保存待回放缓冲;重新打开时直接用快照恢复,再从 lastSeq 订阅。真正修掉价格回退的不是 protobuf、WebSocket 或重连按钮中的任意一个,而是把“收到、提交、修复、回放”分成可验证的四个阶段,并让每个阶段都有明确水位和释放路径。

Logo

讨论HarmonyOS开发技术,专注于API与组件、DevEco Studio、测试、元服务和应用上架分发等。

更多推荐