《互联网产品的客户端架构·内容、社交与实时互动》2.2 去重、插入与位置保持

分页只保证「不重不漏地取」,端上仍会遇到同一张卡片来自刷新、实时推送与翻页三条路径。本节用带假时钟的 TypeScript 窗口实现已见集合、按时间排序的实时插入、锚点 id 位置恢复与 epoch 乱序丢弃,并复盘一次淘汰条目后「越滑越少」的线上问题。

分页正确不等于端上正确

上一节解决了「下一页从哪里开始」:游标让服务端不重不漏地返回一段内容。但客户端拿到的内容不只来自下一页。刷新会带回最新一批,实时通道会推来单条,用户快速滑动时还可能同时发出两个请求。分页协议正确,端上照样会出现重复卡片和跳动。

这一节只讨论响应到达之后如何合并:去重键怎么选、实时内容插到哪里、刷新后如何保住用户正在看的位置、乱序响应如何丢弃。它不重复游标编码(2.1),也不讨论什么时候该触发刷新(2.3);我们假定请求已经发出,只关心回来的数据怎么落到窗口里。

重复的三个来源

来源典型场景表现
翻页重叠重试、边界闭区间、服务端补偿相邻两页出现同一张卡片
拉取与推送并发实时通道推来刚发布的内容,同时刷新也带回它顶部出现两张一样的卡片
刷新与加载更多竞态两个请求几乎同时发出,旧响应后到旧内容被插回顶部,顺序错乱

三者的共同点是:同一份内容以不同路径、不同时间到达。去重不能靠「这次请求有没有见过」,而要靠一个跨路径稳定的身份。

去重键为什么必须是服务端 id

常见但错误的去重键有三种:

  • 数组下标:插入一条后所有下标平移,去重立刻失效。
  • 内容哈希:作者编辑正文后哈希变化,同一条内容会被当成新的。
  • 标题加作者:不同内容可能同名,同一内容也可能改标题。

正确选择是服务端生成的稳定 id。它在信息流、详情、实时事件里必须是同一个值;即使内容被编辑,id 也不变。客户端只负责记住「这个 id 已经出现过」,不负责判断「这条内容是否相同」。

已见集合还有两个工程约束。第一,它必须与条目窗口同生命周期:条目被淘汰时,id 也要从集合里移除,否则滚回顶部重新加载时会被误判为重复。第二,它不能无限增长:集合大小应该被窗口上限约束,而不是随会话时长线性膨胀。

插入位置不是 unshift

实时内容到达时,最直观的写法是 items.unshift(newItem)。如果消息严格按发布时间到达,这没问题;但实时通道会重连、会补发、会乱序。一个延迟五秒才送达的旧内容被 unshift 到顶部后,界面会显示一条「比它新的内容都在它下面」的错序列表。

正确做法是按与服务端一致的排序键插入:createdAt 倒序,同一毫秒按 id 倒序。这样无论消息何时到达,最终位置都由内容本身的键决定,而不是由到达顺序决定。

排序还带来一个可测试性好处:给定同一组条目,无论插入顺序如何,最终的 id 序列必须一致。这条性质比「看起来对」强得多,可以直接写成断言。

位置保持:锚点 id,不是 scrollTop

刷新把新内容插到顶部,列表整体下移。如果客户端只记住 scrollTop 像素值,用户会突然看到另一条内容:图片加载、字体变化、卡片展开都会让像素与内容脱钩。

可靠的做法是记录锚点 id 加锚点内偏移:刷新前记住视口顶部那条内容的 id,以及它距离视口顶部多少像素;刷新后在新数组里找到同一个 id,把滚动位置恢复到「该条目 + 原偏移」。新内容插得再多,只要锚点还在,用户看到的就是同一条内容。

锚点也可能消失:内容被删除、被审核下架、或因窗口上限被淘汰。这时不能假装恢复成功,而要退化到「提示下标附近」并告知调用方 found: false。位置保持的目标是「尽量不跳」,不是「永远不跳」。

乱序响应:用 epoch,不要比时间

两个刷新请求先后发出,先发的可能后到。常见的错误修复是比较响应里的服务端时间戳,但客户端时钟与服务端时钟有偏差,响应时间也不等于请求顺序。

更稳的做法是给每次刷新分配一个客户端 epoch:开始刷新时自增,响应回来时带着发起时的 epoch。窗口只接受等于当前 epoch 的响应,旧 epoch 一律标记为 stale 并丢弃。epoch 是单调的整数,不依赖任何时钟,测试里也不需要等待真实时间。

这里的 stale 与「重复」是两件事:旧响应可能包含新条目,但它属于一个已经被更新请求取代的视图,合并进来只会制造回退。丢弃时仍要报告重复计数,方便日志区分「真的没新内容」与「响应被判定过期」。

一个可运行的窗口实现

下面的 FeedWindow 属于 L2:它没有网络与真实定时器,但通过注入时钟和 epoch 模拟了完整的响应流程。测试用假时钟推进 lastAppliedAt,用两个 epoch 模拟乱序到达。

// file: src/feed/window.ts
// tier: L2
// verify: node --test --test-concurrency=1 test/feed/window.test.ts
import type { ContentItem } from "../core/content-model.ts";

export interface WindowPage {
  items: ContentItem[];
  nextCursor: string | null;
}

export interface ApplyResult {
  added: number;
  duplicates: number;
  stale: boolean;
  anchorIndex: number;
}

export type Clock = () => number;

export class FeedWindow {
  #items: ContentItem[] = [];
  #seen = new Set<string>();
  #anchorId: string | null = null;
  #cursor: string | null = null;
  #epoch = 0;
  #lastAppliedAt = Number.NEGATIVE_INFINITY;
  #limit: number;
  #now: Clock;

  constructor(now: Clock = () => Date.now(), limit = 100) {
    if (!Number.isInteger(limit) || limit < 1) throw new Error("limit 必须是正整数");
    this.#now = now;
    this.#limit = limit;
  }

  get size(): number {
    return this.#items.length;
  }

  get anchorId(): string | null {
    return this.#anchorId;
  }

  get cursor(): string | null {
    return this.#cursor;
  }

  get lastAppliedAt(): number {
    return this.#lastAppliedAt;
  }

  ids(): string[] {
    return this.#items.map((item) => item.id);
  }

  indexOf(id: string): number {
    return this.#items.findIndex((item) => item.id === id);
  }

  markAnchor(id: string): boolean {
    if (!this.#seen.has(id)) return false;
    this.#anchorId = id;
    return true;
  }

  // 每次刷新开始递增 epoch;旧 epoch 的响应即使后到也会被丢弃。
  beginRefresh(): number {
    this.#epoch += 1;
    return this.#epoch;
  }

  applyRefresh(epoch: number, page: WindowPage): ApplyResult {
    if (epoch !== this.#epoch) {
      const duplicates = page.items.filter((item) => this.#seen.has(item.id)).length;
      return this.#result(0, duplicates, true);
    }
    const fresh = this.#takeFresh(page.items);
    this.#items = [...fresh, ...this.#items].sort((a, b) => this.#compare(a, b));
    this.#trimTail();
    this.#cursor = page.nextCursor;
    this.#lastAppliedAt = this.#now();
    return this.#result(fresh.length, page.items.length - fresh.length, false);
  }

  appendOlder(page: WindowPage): ApplyResult {
    const fresh = this.#takeFresh(page.items);
    // 服务端页内已经有序,追加即可;不重排,避免把旧页内容插回顶部。
    this.#items = [...this.#items, ...fresh];
    this.#trimHead();
    this.#cursor = page.nextCursor;
    this.#lastAppliedAt = this.#now();
    return this.#result(fresh.length, page.items.length - fresh.length, false);
  }

  insertLive(items: ContentItem[]): ApplyResult {
    const fresh = this.#takeFresh(items);
    if (fresh.length > 0) {
      this.#items = [...fresh, ...this.#items].sort((a, b) => this.#compare(a, b));
      this.#trimTail();
      this.#lastAppliedAt = this.#now();
    }
    return this.#result(fresh.length, items.length - fresh.length, false);
  }

  #compare(a: ContentItem, b: ContentItem): number {
    if (a.createdAt !== b.createdAt) return b.createdAt - a.createdAt;
    return b.id.localeCompare(a.id);
  }

  #takeFresh(items: ContentItem[]): ContentItem[] {
    const fresh: ContentItem[] = [];
    for (const item of items) {
      if (this.#seen.has(item.id)) continue;
      this.#seen.add(item.id);
      fresh.push(item);
    }
    return fresh;
  }

  // 滑得更深时从头部释放内存:丢掉最早加载的那批,滚回顶部可以重新拉。
  #trimHead(): void {
    while (this.#items.length > this.#limit) {
      const victim = this.#items.findIndex((item) => item.id !== this.#anchorId);
      if (victim === -1) break;
      this.#dropAt(victim);
    }
  }

  // 刷新或实时插入后从尾部释放内存:保留最新内容,丢掉最旧的。
  #trimTail(): void {
    while (this.#items.length > this.#limit) {
      let victim = -1;
      for (let i = this.#items.length - 1; i >= 0; i -= 1) {
        if (this.#items[i].id !== this.#anchorId) {
          victim = i;
          break;
        }
      }
      if (victim === -1) break;
      this.#dropAt(victim);
    }
  }

  // 淘汰条目时必须同时移出去重集合,否则它再次出现时会被误判为重复。
  #dropAt(index: number): void {
    const [removed] = this.#items.splice(index, 1);
    this.#seen.delete(removed.id);
  }

  #result(added: number, duplicates: number, stale: boolean): ApplyResult {
    return { added, duplicates, stale, anchorIndex: this.indexOf(this.#anchorId ?? "") };
  }
}

四个关键决策

淘汰方向随操作而变

appendOlder 从头部淘汰:用户往下滑时,最早加载的那批已经远离视口,释放它们能控制内存;滚回顶部时可以重新拉取。applyRefresh 与 insertLive 从尾部淘汰:新内容刚插到顶部,必须保留最新的一批,丢掉时间上最旧的。如果两个方向共用一个 trim,要么新内容刚插入就被淘汰,要么翻得越深内存越像无底洞。

锚点在淘汰时必须被钉住

如果锚点恰好是最旧的一条,按方向淘汰会把它删掉,下一次恢复位置就找不到目标。#trimHead 与 #trimTail 都会跳过锚点,宁可暂时超出上限一条。这个例外很小,但它把「用户正在看的内容」放在了「严格上限」之上。

淘汰与去重集合必须成对

只从数组删除、不从 #seen 删除,是「越滑越少」的根因:重新加载同一段内容时,所有 id 都被判定为已见,added 恒为 0。反过来,只删 #seen 不删数组,同一条内容会在窗口里出现两次。两者必须在同一个 #dropAt 里完成。

追加旧页不重新排序

服务端返回的页内顺序就是权威顺序。每追加一页就对整个数组排序,除了 O(n log n) 的开销,还可能把因为时间戳精度问题而「看起来更旧」的合法内容重新插到中间,破坏用户已经读过的顺序。排序只用于刷新与实时插入这种「新内容需要找位置」的场景。

测试与真实输出

本机运行结果如下:

$ node --test --test-concurrency=1 test/feed/window.test.ts
✔ 旧 epoch 的刷新响应后到时被丢弃 (1.046375ms)
✔ 刷新把新内容插到顶部并保持锚点 id (0.172458ms)
✔ 实时插入去重,锚点下标随之上移但内容不变 (0.575833ms)
✔ 加载更旧一页跳过已见条目并更新游标 (0.187584ms)
✔ 超过上限从头部淘汰,并允许它重新出现 (0.211792ms)
✔ 锚点是被淘汰的最旧条目时也不被移除 (0.105666ms)
✔ 未加载过的 id 不能设为锚点 (0.071041ms)
✔ 实时插入的同一毫秒条目按 id 倒序稳定排列 (8.29925ms)
ℹ tests 8
ℹ suites 0
ℹ pass 8
ℹ fail 0
ℹ cancelled 0
ℹ skipped 0
ℹ todo 0
ℹ duration_ms 125.138208

八条用例覆盖了乱序、插入、去重、淘汰、锚点与同毫秒排序。它们没有覆盖真实滚动容器,因为「滚动位置是否真的没跳」必须在浏览器里测,假时钟证明不了渲染层的稳定性。

失败复盘:越滑越少

「回声」的关注流曾设置 200 条上限。上线两周后收到一种奇怪反馈:用户滑到很深再回到顶部,下拉刷新后列表只剩很少几条,继续上滑也加载不出内容。

日志显示请求正常返回 20 条,但 added 是 0。原因很直接:淘汰逻辑只做了 items.splice(0, dropped),没有同步清理 #seen。被淘汰的 200 个 id 仍留在集合里,滚回顶部重新请求时全部命中「已见」,于是新数据被当成重复丢弃。用户看到的就是「越滑越少」。

修复只有一行:淘汰时同步 seen.delete(id)。但为了避免同类问题再次出现,测试里加了一条专门的用例:上限设为 3,写入 a、b、c,再写入 d 淘汰头部,然后重新写入 a,断言 added 为 1。这条用例比代码注释更能阻止回归。

第二个事故来自刷新竞态。用户快速下拉两次,第一次请求先发出、后返回,把 20 条旧内容插回顶部,覆盖了第二次请求已经应用的新顺序。修复是引入 epoch:第二次刷新开始时 epoch 自增,第一次的响应回来时发现 epoch 不匹配,直接标记 stale。这也解释了为什么 ApplyResult 里要保留 stale 字段:它不是错误,而是一种需要被观测的正常分支。

与虚拟列表的边界

数据窗口与渲染窗口是两层不同的东西。数据窗口决定「哪些条目在内存里、顺序如何、去重集合含哪些 id」;虚拟列表决定「当前渲染哪几屏 DOM」。虚拟列表把远离视口的卡片卸载,不代表这条内容应该从数据窗口移除,更不代表用户「没看过」。

把两者混在一起,常见结果是:用户快速滑过一段,虚拟列表回收了 DOM,数据层同时把 id 从已见集合删掉,回头再滑上来时出现重复卡片。正确分工是数据层只按自己的上限与锚点淘汰,渲染层只负责 DOM 复用;两者通过 id 关联,不通过下标关联。

小结

分页解决「取哪一段」,窗口解决「取回来怎么放」。去重键用服务端 id,实时插入按内容键排序,刷新后按锚点 id 恢复位置,乱序响应用 epoch 丢弃。淘汰时同步清理去重集合,锚点在淘汰中要被钉住,追加旧页不要重排。

窗口保证了内容在端上稳定存在,但还没有回答「什么时候该去取」。下一节讨论三种刷新触发:下拉、自动轮询、回到前台,各自该在什么条件下发起,以及如何合并。

阅读导航:上一节:2.1 游标分页:为什么页码在内容流里会失效 · 下一节:2.3 刷新策略:下拉、自动、回到前台该不该拉 。

常见错误与边界

  • 用数组下标或内容哈希去重,插入与编辑后立刻失效。
  • 实时内容一律 unshift,延迟到达的旧内容被放到顶部。
  • 淘汰条目却保留 id,滚回顶部重新加载时 added 恒为 0。
  • 只清去重集合不删数组,同一张卡片在窗口里出现两次。
  • 用 scrollTop 像素恢复位置,图片加载或字体变化后跳到别的内容。
  • 锚点被删除时假装恢复成功,用户看到的是另一条内容。
  • 用响应时间戳判断新旧,客户端与服务端时钟偏差导致误判。
  • 把虚拟列表的 DOM 回收当成数据淘汰,回头上滑出现重复卡片。

继续阅读

探索更多技术文章

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

全部文章 返回首页

「books」更多文章

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