《互联网产品的客户端架构·交易、增长与平台化》12.2 上报队列、离线缓存与去重

上报队列不是「失败重发」四个字。本节给出一个内存加注入存储的端上队列:批量取件、指数退避、容量上限与三种丢弃策略、崩溃后重排未确认事件,以及按 eventId 的幂等去重;测试用假时钟与内存存储推进重试,不碰真实网络,也不依赖任何第三方库。

一次「补发把订单事件补丢了」的复盘

「回声」的端上曾经用一个简单的失败重发:请求失败就把事件塞回数组,下次启动时全部再发一遍。平时没问题,弱网大促时出了两类事故。

第一类是把队列撑爆。首页曝光事件量最大,断网十分钟就积压几千条,恢复后一次性并发上报,把端上内存和网络都打满,真正重要的支付事件排在最老的位置,反而被服务端限流拒掉。

第二类是重复。重发时没有保留原 eventId,每次重新生成,服务端无法判断「这是重试」还是「用户又买了一次」,订单事件被重复计数。

复盘结论很直接:队列不是数组加定时器,它要同时处理批量、重试、上限、丢弃、持久化和去重六件事。 这六件事互相牵制,单独优化任何一件都会把问题推到另一件上。

队列要回答的六个问题

问题可选答案默认取舍
一次发多少单条、固定批量、按字节预算固定批量,按时间窗触发
失败怎么重试立即重试、固定间隔、指数退避指数退避,设最大次数
队列多长无上限、按条数、按字节按条数,防止内存无界
满了丢谁丢最旧、丢最低优先级、拒新丢最低优先级,保护订单事件
进程退出怎么办不持久化、持久化待发、持久化含在途持久化待发与在途
重复怎么识别服务端按内容去重、端上按 eventId端上先去重,服务端再兜底

这张表没有唯一正确答案,但每一项都必须显式写出来。最常见的故障不是选错策略,而是根本没意识到这里有一个策略。

优先级不是所有事件都一样

交易事件可以粗分三档:

  • critical:下单、支付、退款。丢了就无法对账,宁可挤掉别的。
  • normal:加购、点击。影响分析,但不影响资金。
  • low:曝光、页面浏览。量大,可采样、可丢弃。

队列满时按优先级淘汰,比「先进先出丢最旧」更符合交易场景。因为最旧的那条可能恰好是支付事件,而最新的一条只是首页曝光。反过来说,如果新来的事件优先级不高于将被淘汰的事件,就不应该为了它挤掉旧数据——这只会让队列在原地抖动。

一个内存加注入存储的队列

下面的模块属于 L2:队列状态在内存里,存储与时钟都从构造函数注入。测试用假时钟推进退避,用内存存储模拟崩溃恢复,不碰真实网络。

// file: src/analytics/report-queue.ts
// tier: L2
// verify: node --test test/analytics/report-queue.test.ts

export type QueuePriority = "low" | "normal" | "critical";
export type DropPolicy = "drop_lowest_priority" | "drop_oldest" | "reject_new";

export interface ReportEvent {
  eventId: string;
  name: string;
  payload: string;
  priority?: QueuePriority;
}
export interface QueuedEvent {
  eventId: string;
  name: string;
  payload: string;
  priority: QueuePriority;
  enqueuedAt: number;
  attempts: number;
  nextAttemptAt: number;
  inflightAt: number | null;
}
export interface PersistedQueue {
  version: 1;
  events: QueuedEvent[];
  inflight: QueuedEvent[];
  seen: string[];
  deadLetters: QueuedEvent[];
}
export interface QueueStorage {
  load(): PersistedQueue | null;
  save(state: PersistedQueue): void;
}
export interface QueueOptions {
  now: () => number;
  storage: QueueStorage;
  capacity?: number;
  batchSize?: number;
  maxAttempts?: number;
  backoffMs?: number;
  dropPolicy?: DropPolicy;
}
export interface EnqueueResult {
  accepted: boolean;
  reason: "ok" | "duplicate" | "queue_full";
  droppedId: string | null;
}
export interface NackResult {
  requeued: number;
  dead: number;
}

const PRIORITY_RANK: Record<QueuePriority, number> = { low: 1, normal: 2, critical: 3 };

function copyEvent(event: QueuedEvent): QueuedEvent {
  return { ...event };
}
function assertPositiveInt(value: number, name: string): void {
  if (!Number.isInteger(value) || value < 1) throw new Error(`${name} 必须是正整数`);
}

export class ReportQueue {
  pending: QueuedEvent[];
  inflight: QueuedEvent[];
  seen: Map<string, number>;
  deadLetters: QueuedEvent[];
  now: () => number;
  storage: QueueStorage;
  capacity: number;
  batchSize: number;
  maxAttempts: number;
  backoffMs: number;
  dropPolicy: DropPolicy;
  dropped: number;

  constructor(options: QueueOptions) {
    this.pending = [];
    this.inflight = [];
    this.seen = new Map();
    this.deadLetters = [];
    this.now = options.now;
    this.storage = options.storage;
    this.capacity = options.capacity ?? 100;
    this.batchSize = options.batchSize ?? 20;
    this.maxAttempts = options.maxAttempts ?? 5;
    this.backoffMs = options.backoffMs ?? 1_000;
    this.dropPolicy = options.dropPolicy ?? "drop_lowest_priority";
    this.dropped = 0;
    assertPositiveInt(this.capacity, "capacity");
    assertPositiveInt(this.batchSize, "batchSize");
    assertPositiveInt(this.maxAttempts, "maxAttempts");
    if (!Number.isInteger(this.backoffMs) || this.backoffMs < 0) {
      throw new Error("backoffMs 必须是非负整数");
    }
  }

  private persist(): void {
    this.storage.save({
      version: 1,
      events: this.pending.map(copyEvent),
      inflight: this.inflight.map(copyEvent),
      seen: [...this.seen.keys()],
      deadLetters: this.deadLetters.map(copyEvent),
    });
  }

  private lowestPriorityIndex(): number {
    let best = 0;
    for (let i = 1; i < this.pending.length; i += 1) {
      if (PRIORITY_RANK[this.pending[i].priority] < PRIORITY_RANK[this.pending[best].priority]) {
        best = i;
      }
    }
    return best;
  }

  enqueue(event: ReportEvent): EnqueueResult {
    if (typeof event.eventId !== "string" || event.eventId.trim() === "") {
      throw new Error("事件 id 不能为空");
    }
    if (typeof event.name !== "string" || event.name.trim() === "") {
      throw new Error("事件名不能为空");
    }
    if (typeof event.payload !== "string" || event.payload === "") {
      throw new Error("事件载荷不能为空");
    }
    const at = this.now();
    if (this.seen.has(event.eventId)) {
      return { accepted: false, reason: "duplicate", droppedId: null };
    }
    const queued: QueuedEvent = {
      eventId: event.eventId,
      name: event.name,
      payload: event.payload,
      priority: event.priority ?? "normal",
      enqueuedAt: at,
      attempts: 0,
      nextAttemptAt: at,
      inflightAt: null,
    };
    if (this.pending.length >= this.capacity) {
      if (this.dropPolicy === "reject_new") {
        return { accepted: false, reason: "queue_full", droppedId: null };
      }
      const victimIndex = this.dropPolicy === "drop_oldest" ? 0 : this.lowestPriorityIndex();
      const victim = this.pending[victimIndex];
      if (
        this.dropPolicy === "drop_lowest_priority" &&
        PRIORITY_RANK[queued.priority] <= PRIORITY_RANK[victim.priority]
      ) {
        return { accepted: false, reason: "queue_full", droppedId: null };
      }
      this.pending.splice(victimIndex, 1);
      this.dropped += 1;
      this.pending.push(queued);
      this.seen.set(queued.eventId, at);
      this.persist();
      return { accepted: true, reason: "ok", droppedId: victim.eventId };
    }
    this.pending.push(queued);
    this.seen.set(queued.eventId, at);
    this.persist();
    return { accepted: true, reason: "ok", droppedId: null };
  }

  drainBatch(at: number = this.now()): QueuedEvent[] {
    const ready: QueuedEvent[] = [];
    const rest: QueuedEvent[] = [];
    for (const event of this.pending) {
      if (ready.length < this.batchSize && event.nextAttemptAt <= at) {
        event.inflightAt = at;
        ready.push(event);
      } else {
        rest.push(event);
      }
    }
    if (ready.length === 0) return [];
    this.pending = rest;
    for (const event of ready) this.inflight.push(event);
    this.persist();
    return ready;
  }

  ack(ids: string[]): number {
    const wanted = new Set(ids);
    const before = this.inflight.length;
    this.inflight = this.inflight.filter((event) => !wanted.has(event.eventId));
    const removed = before - this.inflight.length;
    if (removed > 0) this.persist();
    return removed;
  }

  nack(ids: string[], retryable = true): NackResult {
    const wanted = new Set(ids);
    const at = this.now();
    const moving = this.inflight.filter((event) => wanted.has(event.eventId));
    this.inflight = this.inflight.filter((event) => !wanted.has(event.eventId));
    let requeued = 0;
    let dead = 0;
    for (const event of moving) {
      event.attempts += 1;
      event.inflightAt = null;
      if (!retryable || event.attempts >= this.maxAttempts) {
        this.deadLetters.push(event);
        dead += 1;
      } else {
        event.nextAttemptAt = at + this.backoffMs * 2 ** (event.attempts - 1);
        this.pending.push(event);
        requeued += 1;
      }
    }
    if (moving.length > 0) this.persist();
    return { requeued, dead };
  }

  // 进程崩溃时 in-flight 可能已发出但未确认,恢复时一律重排,由 eventId 在服务端幂等。
  restore(): number {
    const state = this.storage.load();
    if (state === null) return 0;
    if (state.version !== 1) throw new Error("队列存储版本不支持");
    const at = this.now();
    this.pending = state.events.map(copyEvent);
    this.inflight = [];
    this.deadLetters = state.deadLetters.map(copyEvent);
    this.seen = new Map(state.seen.map((id) => [id, at]));
    for (const event of state.inflight) {
      const copy = copyEvent(event);
      copy.inflightAt = null;
      copy.nextAttemptAt = at;
      this.pending.push(copy);
    }
    this.persist();
    return this.pending.length;
  }

  stats(): { pending: number; inflight: number; seen: number; deadLetters: number; dropped: number } {
    return {
      pending: this.pending.length,
      inflight: this.inflight.length,
      seen: this.seen.size,
      deadLetters: this.deadLetters.length,
      dropped: this.dropped,
    };
  }
  pendingIds(): string[] {
    return this.pending.map((event) => event.eventId);
  }
  inflightIds(): string[] {
    return this.inflight.map((event) => event.eventId);
  }
  deadIds(): string[] {
    return this.deadLetters.map((event) => event.eventId);
  }
  nextReadyAt(): number | null {
    if (this.pending.length === 0) return null;
    let next = this.pending[0].nextAttemptAt;
    for (const event of this.pending) {
      if (event.nextAttemptAt < next) next = event.nextAttemptAt;
    }
    return next;
  }
}

seen 与 pending 是两套状态。 seen 记录「这个 eventId 已经进过队列」,确认送达后也不删除;pending 只记录「还没发出去」。如果只靠 pending 去重,事件一旦发出,同一个 id 就能再次入队。

drainBatch 只移入 inflight,不删除。 只有 ack 才代表服务端接受。发送失败调用 nack,事件带着递增的 attempts 回到 pending,退避时间按 2 ** (attempts - 1) 增长。

崩溃恢复一律重排。 进程退出时 inflight 里的事件可能已经到达服务端,也可能没有。端上无法区分,所以恢复时全部放回 pending,依靠 eventId 在服务端做幂等。这是「至少一次」投递,不是「恰好一次」。

丢弃不写 seen。 被挤掉的事件没有送达,如果把它标记为已见,后续重试会被误判为重复。丢弃就是丢弃,让上游的采样或补偿逻辑去处理。

测试与真实输出

本机运行结果如下:

$ node --test --test-concurrency=1 test/analytics/report-queue.test.ts
✔ 入队后可按批量大小取出 (1.2535ms)
✔ 重复 eventId 被拒绝且不影响原事件 (0.102ms)
✔ 确认送达后重复 eventId 仍被拒绝 (0.119583ms)
✔ 队列满时优先丢弃低优先级事件 (0.132292ms)
✔ 新事件优先级不高于受害者时拒绝入队 (0.094833ms)
✔ drop_oldest 策略总是丢弃最旧事件 (0.073708ms)
✔ reject_new 策略在队列满时拒绝新事件 (0.076792ms)
✔ 未到重试时间的事件不会被取出 (0.175792ms)
✔ 重试退避按次数翻倍 (0.115291ms)
✔ 达到最大尝试次数后进入死信 (0.135ms)
✔ 不可重试错误直接进入死信 (0.08ms)
✔ 待发事件与去重集合可以持久化恢复 (0.125541ms)
✔ 恢复时未确认的 in-flight 会重新入队 (0.072833ms)
✔ 每次状态变更都会写入存储 (0.052416ms)
✔ 非法事件与非法容量被拒绝 (0.262833ms)
ℹ tests 15
ℹ suites 0
ℹ pass 15
ℹ fail 0
ℹ cancelled 0
ℹ skipped 0
ℹ todo 0
ℹ duration_ms 152.886459

十五条用例覆盖:批量取件、重复拒绝、确认后仍去重、低优先级淘汰、同级拒绝、两种丢弃策略、退避等待、指数增长、最大次数、不可重试、持久化恢复、在途重排、写盘次数、非法输入。

一次「重复上报把 GMV 翻倍」的复盘

某交易客户端在支付回跳页上报 trade.order_pay。上报失败时,队列按指数退避重试三次。问题出在 eventId 的生成位置:它在每次发送前生成,而不是在事件产生时生成。

于是一笔支付在弱网下被上报了三次,服务端看到三个不同的 id,按三条支付记录入账。大盘 GMV 在一天内明显偏高,排查时却发现订单表没有对应增量。

修复只有一行:eventId 在事件创建时生成并随队列项一起持久化,重试复用同一个值。服务端同时补了一层按 eventId 的幂等表,即使端上逻辑回退,也不会重复入账。

离线缓存与队列的边界

一个可执行的划分是:

  • 队列只保存待上报的事件,有明确的上限与淘汰策略,消费后即删除。
  • 缓存保存的是可重放的业务数据,比如商品详情、配置、草稿,有 TTL 和版本号。
  • 两者都不要无限增长。端上磁盘配额有限,超过阈值时应先降级低优先级事件,而不是让整个存储写失败。

小结

上报队列要同时回答六个问题:批量、重试、上限、丢弃、持久化、去重。交易场景的默认取舍是固定批量、指数退避、按条数限容、按优先级淘汰、持久化待发与在途、按 eventId 去重。

ReportQueue 把 seen 与 pending 分开,ack 之后才删除,崩溃恢复一律重排,把「至少一次」投递的责任交给服务端幂等兜底。

阅读导航:上一节:12.1 埋点模型与事件契约 · 下一节:12.3 漏斗与归因的端上边界 。

常见错误与边界

  • 重试时重新生成 eventId,服务端把一次支付记成多笔。
  • 队列无上限,弱网积压后一次性并发上报,把重要事件挤到限流队列末尾。
  • 用「先进先出丢最旧」淘汰,最先丢掉的恰好是支付事件。
  • 发送成功就删除事件,服务端实际未接受,数据静默丢失。
  • ack 之前就写 seen,发送失败后无法重试。
  • 进程退出不持久化在途事件,重启后这批数据永久消失。
  • 把业务缓存与上报队列塞进同一个存储,TTL 与淘汰策略互相打架。
  • 事件体原样落盘,手机号、地址等敏感字段进入本地明文存储。

继续阅读

探索更多技术文章

浏览归档,发现更多关于系统设计、工具链和工程实践的内容。

全部文章 返回首页

「books」更多文章

  1. 《互联网产品的 AI 架构·平台化与治理》附录 C 平台化评审清单
  2. 《互联网产品的 AI 架构·平台化与治理》附录 B 能力目录与配额字段速查
  3. 《互联网产品的 AI 架构·平台化与治理》附录 A 治理角色与职责速查