《互联网产品的客户端架构·内容、社交与实时互动》5.1 点赞与收藏:本地先行、服务端为准

点赞与收藏看似只是改一个布尔值,真正难的是本地先行之后如何让服务端裁决:绝对计数与增量事件冲突、列表与详情各存一份状态、连点与取消产生乱序响应。本节给出一个带序号与意图层的 TypeScript 状态存储,用假时钟复现乱序响应,并完整复盘一次计数越点越少的线上问题。

这一节只处理一个布尔值

点赞、收藏在界面上只是图标亮起、数字加一,但它是客户端架构里最小的分布式一致性问题:用户看到的状态由本地意图和服务端事实共同决定,两者到达的时间还不一样。本节只讨论「当前用户与一条内容之间的二元关系」——本地如何先行、服务端如何裁决、列表与详情两份视图如何合并、连点与取消如何不把计数弄坏。

不讨论评论的占位、草稿与重发,那是 5.2;也不讨论哪些写操作根本不该乐观,那是 5.3。支付、下单、上传完成这些边界,本节只在对照时提一句。服务端计数如何分片、如何防刷,属于服务端主题,这里假定接口最终会返回一个可信的绝对计数。

乐观更新的三个前提

「先改 UI,失败再回滚」听起来是一个交互技巧,实际上是一组工程契约。缺任何一条,乐观更新都会从体验优化变成数据事故。

前提含义不满足时的后果
操作可逆且成本低撤销一次点赞不产生外部副作用回滚要通知别人、退款或补偿
请求幂等重复发送同一意图,服务端结果相同重试把一次点赞变成两次
失败可对账失败后能拉到一份绝对快照本地只能猜,越猜越偏

点赞和收藏三条都满足:没有外部通知,接口可以设计成「把状态设为 true/false」,失败后重新拉一次详情就能对齐。关注差一条:回滚本身免费,但对方可能已经收到通知。评论差两条:占位可以删,但别人可能已经看见;重发必须靠客户端幂等键去重。这三档差异,正是 5.3 要展开的分类。

本地先行的不是事实,是意图

最常见的实现错误,是把本地状态直接当成新的事实:点一下就把 liked 设为 true、把 likeCount 加一,然后把服务端响应当成一次「迟到的事实覆盖」。这样写,单次请求没问题,一旦连点、重试或实时回显混进来,本地事实与服务端事实就会互相打架。

更稳的模型把状态拆成两层:

  • 确认层:服务端最后一次告诉我们的绝对状态,包含 active 与 count。
  • 意图层:用户已经表达、但还没有被服务端确认的目标状态,只有 true、false 或「没有意图」。

界面永远渲染「确认层 + 意图层」的组合值,而不是直接修改确认层。用户点一下,只改意图;服务端确认回来,才改确认层。两层分开之后,「列表和详情谁覆盖谁」「响应乱序怎么办」「实时增量加到哪里」都有了统一答案。

绝对计数与增量更新的冲突

同一条内容的计数会从三条路径变化:

路径携带的信息正确用法
详情或列表刷新绝对计数 + 当前用户状态覆盖确认层基数
实时事件某个用户带来的增量加到确认层基数,不加到展示值
自己的写请求确认绝对计数 + 最终状态按请求序号决定是否覆盖

三条路径混用的典型事故是重复计数。假设确认层是 10,用户点赞后展示 11;此时实时通道推来一条「有人点赞」的增量 +1。如果客户端把 +1 加到展示值 11 上,会得到 12;确认响应回来时,又把本地意图当作尚未兑现的 +1 加到绝对计数 11 上,展示仍是 12。一次点赞被算了两遍。

规则要写成不依赖调用顺序的硬约束:

  1. 绝对快照只覆盖确认层,永远不做加法。它可能比本地旧,也可能已经包含本地这次写,覆盖之后再由意图层补差。
  2. 增量事件只加到确认层,并且必须能区分来源。自己的写产生的回显事件直接丢弃,否则会和确认响应重复。
  3. 确认响应只采纳序号最大的那一个。旧响应即使后到,也不能改确认层。

这三条合起来,任意到达顺序都能收敛到同一个展示值。

列表与详情不是两份状态

信息流里点完赞,进入详情发现图标没亮;或者在详情里取消,返回列表还显示已赞。根因通常不是请求失败,而是两个页面各自维护了一份 liked 与 likeCount。

正确做法是让状态按内容 id 存放在一个共享存储里,列表、详情、搜索卡片都只是这份状态的渲染器。页面切换不迁移状态,只切换观察者。这样还有一个附带好处:详情页刷新回来的快照,可以安全地合并进同一份状态,而不需要知道用户是从哪个入口进来的。

合并时仍要遵守上一节的规则。详情快照可能是在用户点赞之前生成的:它带回 active: false, count: 99,而本地意图是 true。此时确认层更新为 99,意图层仍是 true,展示为 100。等服务端确认 active: true, count: 100,意图被兑现并清空,展示仍是 100。整个过程没有一刻出现 101。

连点与取消:发送最终状态

连点是乐观更新里最容易写错的部分。常见接口是 POST /like/toggle,客户端每点一次发一次。这个设计在正常网络下能用,但在超时重试时会把「点赞」执行两次:第一次其实成功了,只是响应丢了;重试又 toggle 回未点赞。用户看到图标闪一下又灭掉,计数也跟着错。

更稳的协议是让客户端发送目标状态,而不是动作:

PUT /contents/{id}/reaction
{ "kind": "like", "active": true }

服务端把「设为 true」做成幂等操作。重复发送、超时重试、实时补发都得到同一结果。客户端每点一次仍然可以发一次请求,但请求体是绝对意图,不是「翻转」。代价是连点会多发几次请求,收益是任何一次重复都不会改变最终状态。

请求乱序仍然存在:第一次点赞的响应可能比第二次取消的响应晚到。解决办法不是比较服务端时间戳,而是给每次请求一个客户端单调序号。存储只采纳比已采纳序号更大的响应,旧响应标记为 stale 后丢弃。序号不依赖时钟,测试里也不需要真的等待。

一个可运行的点赞状态存储

下面的 ReactionStore 属于 L2:它没有网络,但把确认层、意图层、请求序号、假时钟和快照合并都放进了同一个进程内模型。列表与详情通过同一个内容 id 读取它。

// file: src/interaction/optimistic.ts
// tier: L2
// verify: node --test --test-concurrency=1 test/interaction/optimistic.test.ts
export type ReactionKind = "like" | "favorite";
export type Clock = () => number;
export type DeltaSource = "self" | "other";

export interface ReactionSnapshot {
  active: boolean;
  count: number;
}

export interface ReactionView {
  like: ReactionSnapshot;
  favorite: ReactionSnapshot;
}

export interface ReactionRequest {
  seq: number;
  id: string;
  kind: ReactionKind;
  active: boolean;
  sentAt: number;
}

export interface SettleResult {
  applied: boolean;
  stale: boolean;
  pending: boolean;
  view: ReactionSnapshot;
}

export interface FailResult {
  applied: boolean;
  stale: boolean;
  needsRefresh: boolean;
  view: ReactionSnapshot;
}

export interface SnapshotResult {
  pending: number;
}

interface KindState {
  confirmed: ReactionSnapshot;
  // null 表示用户没有未确认的意图;true/false 表示本地希望服务端最终处于的状态。
  desired: boolean | null;
  // 已经采纳过的最大响应序号,用来丢弃乱序后到的旧响应。
  latestSeq: number;
}

const KINDS: readonly ReactionKind[] = ["like", "favorite"];

function emptyState(): KindState {
  return {
    confirmed: { active: false, count: 0 },
    desired: null,
    latestSeq: 0,
  };
}

function assertSnapshot(snapshot: ReactionSnapshot): void {
  if (typeof snapshot.active !== "boolean") throw new TypeError("active 必须是布尔值");
  if (!Number.isInteger(snapshot.count) || snapshot.count < 0) {
    throw new RangeError("count 必须是非负整数");
  }
}

// 展示值 = 服务端绝对基数 + 尚未确认的本地意图带来的偏移。
function render(state: KindState): ReactionSnapshot {
  const active = state.desired ?? state.confirmed.active;
  const shift = (active ? 1 : 0) - (state.confirmed.active ? 1 : 0);
  return { active, count: Math.max(0, state.confirmed.count + shift) };
}

export class ReactionStore {
  #items = new Map<string, Record<ReactionKind, KindState>>();
  #seq = 0;
  #now: Clock;

  constructor(options: { now?: Clock } = {}) {
    this.#now = options.now ?? (() => Date.now());
  }

  // 列表、详情或刷新响应都可以播种;已经存在的条目只覆盖服务端确认部分。
  seed(id: string, view: Partial<ReactionView> = {}): void {
    if (id.trim() === "") throw new Error("id 不能为空");
    const pair = this.#items.get(id) ?? { like: emptyState(), favorite: emptyState() };
    for (const kind of KINDS) {
      const snapshot = view[kind];
      if (!snapshot) continue;
      assertSnapshot(snapshot);
      pair[kind].confirmed = { active: snapshot.active, count: snapshot.count };
    }
    this.#items.set(id, pair);
  }

  view(id: string): ReactionView {
    const pair = this.#pair(id);
    return {
      like: render(pair.like),
      favorite: render(pair.favorite),
    };
  }

  pending(id: string, kind: ReactionKind): boolean {
    return this.#state(id, kind).desired !== null;
  }

  // 每次点击都发送「最终状态」而不是 toggle 动作,重复投递因此天然幂等。
  toggle(id: string, kind: ReactionKind): ReactionRequest {
    const state = this.#state(id, kind);
    const current = render(state);
    state.desired = !current.active;
    this.#seq += 1;
    return {
      seq: this.#seq,
      id,
      kind,
      active: state.desired,
      sentAt: this.#now(),
    };
  }

  settle(request: ReactionRequest, server: ReactionSnapshot): SettleResult {
    assertSnapshot(server);
    const state = this.#state(request.id, request.kind);
    if (request.seq <= state.latestSeq) {
      return {
        applied: false,
        stale: true,
        pending: this.#hasPending(state),
        view: render(state),
      };
    }
    state.confirmed = { active: server.active, count: server.count };
    state.latestSeq = request.seq;
    // 服务端确认态与用户意图一致时,意图已经兑现,不必再保留。
    if (state.desired === null || state.desired === server.active) {
      state.desired = null;
    }
    return {
      applied: true,
      stale: false,
      pending: this.#hasPending(state),
      view: render(state),
    };
  }

  fail(request: ReactionRequest): FailResult {
    const state = this.#state(request.id, request.kind);
    if (request.seq <= state.latestSeq) {
      return {
        applied: false,
        stale: true,
        needsRefresh: false,
        view: render(state),
      };
    }
    // 失败时先退回最后一次服务端确认态,再要求调用方拉一次绝对快照。
    state.latestSeq = request.seq;
    state.desired = null;
    return {
      applied: true,
      stale: false,
      needsRefresh: true,
      view: render(state),
    };
  }

  // 任何来源的绝对快照都覆盖确认基数,但不覆盖尚未兑现的本地意图。
  applySnapshot(id: string, view: Partial<ReactionView>): SnapshotResult {
    const pair = this.#pair(id);
    let pending = 0;
    for (const kind of KINDS) {
      const snapshot = view[kind];
      if (!snapshot) continue;
      assertSnapshot(snapshot);
      const state = pair[kind];
      state.confirmed = { active: snapshot.active, count: snapshot.count };
      if (state.desired === snapshot.active) state.desired = null;
      if (state.desired !== null) pending += 1;
    }
    return { pending };
  }

  // 实时增量只改确认基数;自己的回显必须忽略,否则会和确认响应重复计数。
  applyDelta(id: string, kind: ReactionKind, delta: number, source: DeltaSource): number {
    if (!Number.isInteger(delta) || delta === 0) {
      throw new RangeError("delta 必须是非零整数");
    }
    const state = this.#state(id, kind);
    if (source === "self") return render(state).count;
    state.confirmed.count = Math.max(0, state.confirmed.count + delta);
    return render(state).count;
  }

  #hasPending(state: KindState): boolean {
    return state.desired !== null && state.desired !== state.confirmed.active;
  }

  #pair(id: string): Record<ReactionKind, KindState> {
    const pair = this.#items.get(id);
    if (!pair) throw new Error(`未知内容 ${id},先 seed 再读取`);
    return pair;
  }

  #state(id: string, kind: ReactionKind): KindState {
    return this.#pair(id)[kind];
  }
}

四个关键决策

展示值是确认基数加意图偏移

render 不保存第三份状态,而是每次用 confirmed.count 加上「意图是否与确认态不同」带来的 ±1。这样确认层永远只存服务端给过的数字,意图层只存布尔值,不会出现「本地把计数改成 11,服务端说 10,最后到底信谁」的模糊地带。计数下限钳到 0,避免取消一个从未被服务端确认的赞时出现负数。

序号只增,旧响应不改状态

每个请求带一个全局递增的 seq,每个内容与操作类型记住已采纳的最大序号。响应回来时先比较序号,小于等于已采纳值的直接标记 stale。这条规则同时覆盖了重复投递:同一个响应到达两次,第二次的序号不大于已采纳值,天然幂等。

快照覆盖基数,但不覆盖意图

applySnapshot 无条件用绝对快照替换确认层,却只在「意图已经等于快照状态」时清空意图。这看起来会让旧快照短暂压过用户意图,但展示值是基数加偏移,用户看到的仍是自己点的状态。等真正的确认响应到达,意图才被兑现。反过来,如果快照一来就清空意图,用户会看到图标莫名其妙地灭掉。

自己的回显必须忽略

实时通道常常把自己的写也广播回来。applyDelta 接收来源标记,self 直接返回当前计数,不修改确认层。理由很简单:自己的写已经有一条带序号的确认响应,回显再算一次就是双重计数。区分来源比在服务端过滤更可靠,因为多端登录时「自己」的定义会变化,客户端至少要知道这条事件是不是本端发出的。

测试与真实输出

本机运行结果如下:

$ node --test --test-concurrency=1 test/interaction/optimistic.test.ts
✔ 点赞先本地生效,服务端确认前计数先加一 (0.996708ms)
✔ 连点两次回到原状态,两次请求各自携带绝对状态 (0.677166ms)
✔ 服务端响应乱序时旧序号被丢弃,界面不回退 (0.121083ms)
✔ 列表与详情共用同一状态,后到的旧快照不会覆盖未确认意图 (0.140542ms)
✔ 绝对计数覆盖增量,他人事件累加而自己的回显被忽略 (0.12375ms)
✔ 同一响应重复投递只采纳一次 (0.077042ms)
✔ 失败回滚到已确认状态并要求重新拉取绝对快照 (0.098042ms)
✔ 未知内容与非法计数在边界处直接拒绝 (0.291625ms)
ℹ tests 8
ℹ suites 0
ℹ pass 8
ℹ fail 0
ℹ cancelled 0
ℹ skipped 0
ℹ todo 0
ℹ duration_ms 156.65225

八条用例覆盖了本地先行、连点、乱序、跨面合并、绝对与增量、重复投递、失败回滚和输入边界。它们没有覆盖真实网络,因为「服务端到底按什么顺序处理请求」必须在接口层测;这里证明的是客户端在任意响应顺序下都能收敛。

失败复盘:点赞数越点越少

「回声」第一版的点赞接口是 POST /like/toggle,客户端收到成功响应后执行 count += response.active ? 1 : -1。上线后出现一种反馈:用户点赞再取消,计数偶尔比原来少一。

日志把路径还原得很清楚。用户点赞,服务端计数 10 → 11,响应因为弱网超时;客户端重试,服务端此时已经把状态设为点赞,toggle 又把它翻回未点赞,计数 11 → 10;第一个请求的响应这时才到,客户端按 active: true 加了 1,显示 11;重试的响应随后到达,按 active: false 又减 1,显示 10。如果取消操作也发生重试,减法会执行两次,用户就看到 9。

这里有三个独立错误叠在一起:接口不是幂等的、客户端把响应当增量、响应没有序号。修复分三步:接口改成发送目标状态;客户端只采纳最新序号的绝对计数;实时回显按来源过滤。上线后同样的弱网重试不再改变最终计数,因为重复的「设为 true」在服务端是同一个结果,在客户端也只采纳一次。

第二个教训来自列表与详情的双份状态。修复前,详情页每次进入都会用接口返回值覆盖本地,用户在列表点的赞在详情里消失;返回列表时列表又用自己的旧值覆盖回来。把状态收敛到按内容 id 的单一存储后,这个问题不是被修好,而是没有地方再发生。

小结

点赞与收藏的乐观更新可以归纳成四句话:本地改意图,不改事实;展示值等于确认基数加意图偏移;请求发送目标状态而不是翻转动作;响应按客户端序号裁决,旧响应与自己的回显都丢弃。做到这四点,连点、乱序、重试和跨页面共享都会落到同一套规则里。

点赞只涉及一个布尔值和一个小计数,评论则要多处理正文、时间顺序、草稿和失败重发。下一节把同一套「本地先行、服务端为准」的思路扩展到一条评论的完整生命周期。

阅读导航:上一节:4.3 图片格式选择与端上降级 · 下一节:5.2 评论的顺序、草稿与失败重发 。

常见错误与边界

  • 把服务端响应当成增量累加,重试或回显到达时重复计数。
  • 使用 toggle 接口,超时重试把点赞翻回未点赞。
  • 列表与详情各存一份状态,页面切换后图标与计数互相覆盖。
  • 绝对快照一到就清空本地意图,用户看到图标莫名熄灭。
  • 用响应到达顺序或服务端时间戳判断新旧,时钟偏差导致误判。
  • 自己的实时回显也计入增量,与确认响应形成双重计数。
  • 展示计数不设下限,取消未确认的赞时出现负数。
  • 失败后只在本地回滚却不重新拉取绝对快照,后续状态永久偏移。

继续阅读

探索更多技术文章

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

全部文章 返回首页

「books」更多文章

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