这一节只处理一个布尔值
点赞、收藏在界面上只是图标亮起、数字加一,但它是客户端架构里最小的分布式一致性问题:用户看到的状态由本地意图和服务端事实共同决定,两者到达的时间还不一样。本节只讨论「当前用户与一条内容之间的二元关系」——本地如何先行、服务端如何裁决、列表与详情两份视图如何合并、连点与取消如何不把计数弄坏。
不讨论评论的占位、草稿与重发,那是 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。一次点赞被算了两遍。
规则要写成不依赖调用顺序的硬约束:
- 绝对快照只覆盖确认层,永远不做加法。它可能比本地旧,也可能已经包含本地这次写,覆盖之后再由意图层补差。
- 增量事件只加到确认层,并且必须能区分来源。自己的写产生的回显事件直接丢弃,否则会和确认响应重复。
- 确认响应只采纳序号最大的那一个。旧响应即使后到,也不能改确认层。
这三条合起来,任意到达顺序都能收敛到同一个展示值。
列表与详情不是两份状态
信息流里点完赞,进入详情发现图标没亮;或者在详情里取消,返回列表还显示已赞。根因通常不是请求失败,而是两个页面各自维护了一份 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 接口,超时重试把点赞翻回未点赞。
- 列表与详情各存一份状态,页面切换后图标与计数互相覆盖。
- 绝对快照一到就清空本地意图,用户看到图标莫名熄灭。
- 用响应到达顺序或服务端时间戳判断新旧,时钟偏差导致误判。
- 自己的实时回显也计入增量,与确认响应形成双重计数。
- 展示计数不设下限,取消未确认的赞时出现负数。
- 失败后只在本地回滚却不重新拉取绝对快照,后续状态永久偏移。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。