这一节不写具体交互
前两节分别实现了点赞状态与评论时间线,它们都建立在同一个假设上:操作失败可以回滚,重复请求可以幂等。这一节跳出具体交互,回答一个更靠前的问题:哪些写操作有资格使用乐观更新,哪些必须等服务端确认,哪些连本地「成功」提示都不该出现。
不重复 5.1 的序号与意图层,也不重复 5.2 的占位与草稿。这里不讨论服务端如何实现幂等索引、如何扣减库存,只讨论客户端在发起请求前后应该表现到什么程度。判定函数是纯函数,输入是操作画像,输出是策略、重试方式与本地表现。
回滚成本由五个维度决定
「这个操作能不能乐观」不是靠直觉判断,而是由五个可以写下来的属性决定:
| 维度 | 问题 | 高成本信号 |
|---|---|---|
| 可逆性 | 撤销后能否回到原状态 | 已扣款、已发货、已通知他人 |
| 外部影响 | 是否触达当前用户之外的人 | 关注通知、评论可见、私信 |
| 结算属性 | 是否涉及资金、库存、额度 | 支付、下单、提现、优惠券 |
| 幂等性 | 重复请求是否产生重复副作用 | 没有幂等键的创建接口 |
| 服务端真相 | 完成状态是否只能由服务端判定 | 上传完成、审核通过、订单状态 |
五个维度不是并列打分,而是有优先级的。一个操作只要涉及结算或不可逆,其他维度再理想也不能乐观;一个操作如果完成状态只能由服务端确认,本地最多展示「进行中」;只有全部维度都温和,才可以像点赞一样先改 UI。
四档策略
把连续的回滚成本压成四档,便于产品、客户端与服务端用同一套语言讨论:
| 策略 | 含义 | 本地表现 | 典型操作 |
|---|---|---|---|
optimistic | 立即展示最终态,失败静默回滚 | 计数与选中态 | 点赞、收藏 |
optimistic-with-reconcile | 立即展示,但必须对账与去抖 | 计数、占位气泡 | 关注、评论 |
confirm-first | 只展示进行中,完成态等服务端 | 进度条、预览 | 上传并发布 |
never | 不展示任何成功暗示 | 禁用按钮、等待回执 | 支付、下单 |
四档的分界不是「重要不重要」,而是「回滚时用户会损失什么」。点赞回滚损失的是一次点击;关注回滚可能损失对方已经看到的通知;评论回滚损失的是别人已经读到的内容;支付回滚损失的是钱。损失越难追回,本地就越应该少说话。
七类操作的决策表
把常见写操作放进同一张表,策略差异会非常直观:
| 操作 | 可逆性 | 外部影响 | 结算 | 策略 | 重试 | 本地表现 |
|---|---|---|---|---|---|---|
| 点赞 | 即时 | 无 | 无 | optimistic | 自动 | 计数 |
| 收藏 | 即时 | 无 | 无 | optimistic | 自动 | 计数 |
| 关注 | 即时 | 有通知 | 无 | optimistic-with-reconcile | 自动 | 按钮态 |
| 评论 | 可补偿 | 对外可见 | 无 | optimistic-with-reconcile | 自动 | 占位 |
| 上传并发布 | 可补偿 | 无 | 无 | confirm-first | 自动 | 进度 |
| 支付 | 不可逆 | 无 | 有 | never | 人工 | 无 |
| 下单 | 可补偿 | 有 | 有 | never | 人工 | 无 |
注意「下单」的可逆性写的是「可补偿」而不是「不可逆」。取消订单在业务上存在,但取消要经过库存回补、支付退款、优惠券返还,成本远高于一次点赞。判定时看的是回滚成本,不是理论上能不能撤销。
判定顺序比规则数量重要
七类操作可以有七条 if,但规则一多,组合就会互相打架。例如支付同时满足「幂等」和「服务端真相」,如果先判断幂等,就可能被误判成可乐观。正确的做法是固定优先级,从上往下短路:
- 结算或不可逆:直接
never,不再看其他属性。 - 完成状态依赖服务端:
confirm-first,本地只展示进行中。 - 有外部影响、回滚有成本或请求不幂等:
optimistic-with-reconcile,允许先行但必须对账。 - 其余:
optimistic,可以像点赞一样处理。
顺序本身就是安全边界。把「不可逆」放在第一行,意味着以后新增操作时,只要标记了不可逆,就不会因为其他字段看起来友好而被放进乐观档。
重试策略要和乐观策略分开判断。never 不等于「不能重试」:支付请求带幂等键可以安全重试,只是界面不能提前显示成功。反过来,一个可以乐观看待的操作,如果请求不幂等,也不能自动重试。把「能不能先显示」和「能不能自动重发」混成一个布尔值,是这类判定函数最常见的建模错误。
一个可运行的判定函数
下面的 rollback.ts 属于 L1:全部是纯函数与静态画像,不依赖时钟、网络或浏览器。decide 按优先级短路,decisionTable 把七类操作展开成可直接渲染或上报的行。
// file: src/interaction/rollback.ts
// tier: L1
// verify: node --test test/interaction/rollback.test.ts
export type OperationKind =
| "like"
| "favorite"
| "follow"
| "comment"
| "upload"
| "payment"
| "order";
export type Reversibility = "instant" | "compensable" | "irreversible";
export type OptimisticPolicy = "optimistic" | "optimistic-with-reconcile" | "confirm-first" | "never";
export type RetryMode = "automatic" | "manual";
export interface OperationProfile {
kind: OperationKind;
label: string;
reversibility: Reversibility;
externalEffect: boolean;
settlement: boolean;
idempotent: boolean;
serverTruthRequired: boolean;
localPreview: boolean;
}
export interface Decision {
kind: OperationKind;
policy: OptimisticPolicy;
retry: RetryMode;
reasons: string[];
rollback: string;
}
const PROFILES: Record<OperationKind, OperationProfile> = {
like: {
kind: "like",
label: "点赞",
reversibility: "instant",
externalEffect: false,
settlement: false,
idempotent: true,
serverTruthRequired: false,
localPreview: false,
},
favorite: {
kind: "favorite",
label: "收藏",
reversibility: "instant",
externalEffect: false,
settlement: false,
idempotent: true,
serverTruthRequired: false,
localPreview: false,
},
follow: {
kind: "follow",
label: "关注",
reversibility: "instant",
externalEffect: true,
settlement: false,
idempotent: true,
serverTruthRequired: false,
localPreview: false,
},
comment: {
kind: "comment",
label: "评论",
reversibility: "compensable",
externalEffect: true,
settlement: false,
idempotent: true,
serverTruthRequired: false,
localPreview: true,
},
upload: {
kind: "upload",
label: "上传并发布",
reversibility: "compensable",
externalEffect: false,
settlement: false,
idempotent: true,
serverTruthRequired: true,
localPreview: true,
},
payment: {
kind: "payment",
label: "支付",
reversibility: "irreversible",
externalEffect: false,
settlement: true,
idempotent: true,
serverTruthRequired: true,
localPreview: false,
},
order: {
kind: "order",
label: "下单",
reversibility: "compensable",
externalEffect: true,
settlement: true,
idempotent: true,
serverTruthRequired: true,
localPreview: false,
},
};
const ROLLBACK: Record<OperationKind, string> = {
like: "恢复计数与选中态,不需要向用户解释",
favorite: "恢复收藏态,列表与详情共用同一份状态",
follow: "恢复关注态;对方可能已收到通知,服务端需要去抖",
comment: "移除本地占位并保留原文,允许重试或改为草稿",
upload: "撤销本地预览,保留可续传任务,不宣称发布成功",
payment: "不允许本地回滚,转人工对账与退款流程",
order: "不允许本地回滚,取消订单由服务端确认库存与支付状态",
};
export function profileOf(kind: OperationKind): OperationProfile {
return { ...PROFILES[kind] };
}
// 规则有明确优先级:不可逆与结算优先于幂等,服务端真相优先于外部影响。
export function decide(profile: OperationProfile): Decision {
const reasons: string[] = [];
const retry = retryMode(profile);
if (profile.settlement || profile.reversibility === "irreversible") {
if (profile.settlement) reasons.push("涉及资金或库存结算,服务端是唯一事实来源");
if (profile.reversibility === "irreversible") reasons.push("操作不可逆,本地没有补偿手段");
return {
kind: profile.kind,
policy: "never",
retry,
reasons,
rollback: ROLLBACK[profile.kind],
};
}
if (profile.serverTruthRequired) {
reasons.push("完成状态只能由服务端确认,本地只能展示进行中");
return {
kind: profile.kind,
policy: "confirm-first",
retry,
reasons,
rollback: ROLLBACK[profile.kind],
};
}
if (profile.externalEffect || profile.reversibility === "compensable" || !profile.idempotent) {
if (profile.externalEffect) reasons.push("操作会触达其他人,撤回无法收回已发出的通知");
if (profile.reversibility === "compensable") reasons.push("回滚有成本,需要服务端对账");
if (!profile.idempotent) reasons.push("请求不幂等,自动重试可能产生重复副作用");
return {
kind: profile.kind,
policy: "optimistic-with-reconcile",
retry,
reasons,
rollback: ROLLBACK[profile.kind],
};
}
reasons.push("即时可逆、无外部影响且请求幂等");
return {
kind: profile.kind,
policy: "optimistic",
retry,
reasons,
rollback: ROLLBACK[profile.kind],
};
}
export function retryMode(profile: OperationProfile): RetryMode {
if (profile.settlement || !profile.idempotent) return "manual";
return "automatic";
}
export function canAutoRetry(profile: OperationProfile): boolean {
return retryMode(profile) === "automatic";
}
// 本地最多能表现到什么程度:计数、占位气泡、进度条,或者什么都不做。
export function localRepresentation(
profile: OperationProfile,
): "count" | "state" | "placeholder" | "progress" | "none" {
if (profile.settlement || profile.reversibility === "irreversible") return "none";
if (profile.kind === "upload") return "progress";
if (profile.kind === "comment") return "placeholder";
if (profile.kind === "follow") return "state";
if (profile.kind === "like" || profile.kind === "favorite") return "count";
return "none";
}
export function decisionTable(): Decision[] {
return Object.values(PROFILES).map((profile) => decide(profile));
}
为什么支付与下单不能乐观
支付的本质是把本地状态与一笔真实的资金变动绑定。客户端没有任何办法知道银行、支付渠道或账务系统最终是否成功;它收到的「请求已受理」不等于「扣款成功」。如果界面先显示「支付成功」,而服务端最后返回失败,用户已经离开了页面,甚至已经基于这个提示完成了下一步操作。回滚一个错误的成功提示,比一开始就显示「处理中」昂贵得多。
下单还多两层复杂性。第一层是库存:客户端看到的「有货」可能在一秒前已经失效,乐观地显示「下单成功」会直接把超卖暴露给用户。第二层是价格与优惠:服务端在创建订单时会重新计算,最终金额可能和本地展示不同。一个「成功」的本地订单,可能既没有锁定库存,也没有锁定价格。
幂等键能解决重复下单,但不能解决乐观展示。带同一个幂等键重试,服务端只会创建一单;可如果客户端在第一次请求还没回执时就显示成功,用户仍然会以为已经买到。正确顺序是:按钮进入「提交中」并禁用,收到服务端订单号与状态后再展示成功;超时后不要盲目重试,而是用订单查询接口确认这次提交是否已经落库。
「查询后重试」比「直接重试」多一次请求,但它把不确定性交给了服务端事实。支付与下单的客户端代码里,最值得写的不是动画,而是这条对账路径。
关注与评论:可乐观,但必须对账
关注可以立即改变按钮状态,因为撤销关注在客户端只是一次反向请求。但关注会触发对方的通知,通知一旦发出就收不回来。界面回滚只能让当前用户看到「未关注」,对方手机上的「某某关注了你」仍然存在。因此关注属于「乐观加对账」:本地先行,服务端失败时回滚,同时服务端要对通知做去抖,避免用户快速点两次产生两条通知。
评论的占位可以立即出现,因为用户需要看到自己刚写的内容。但评论一旦被其他人看到,删除只是把可见性改掉,别人可能已经截图或回复。客户端能做的回滚是移除本地占位、保留原文、允许重试;服务端能做的是软删除与审核。两边都不应该承诺「删除后等于没发生过」。
这两类操作的共同点是:本地回滚是界面动作,不是业务事实的撤销。 把它们放进 optimistic-with-reconcile,而不是 optimistic,就是为了在策略层面提醒调用方:失败后还要做一次对账,不能只把图标改回去。
上传:进度可以本地,完成不能
上传是另一类容易混淆的操作。字节进度完全可以本地计算:已发送字节除以总字节,不需要服务端参与。分片上传的续传状态也可以本地保存。但「上传完成」与「发布成功」必须由服务端确认:服务端要校验文件、生成资源 id、可能还要转码或审核。
所以上传的策略是 confirm-first,本地表现是 progress。用户可以立刻看到进度条,但不能看到「已发布」。如果客户端把「最后一个分片发送完毕」当成「发布成功」,就会出现内容其实还在审核、URL 还不可访问,但作者已经在自己的主页看到它的错位。
上传的幂等键通常用内容哈希或上传会话 id。它保证重复上传不会产生多份资源,但不改变「完成状态以服务端为准」这条规则。重试可以自动,展示完成不可以提前。
测试与真实输出
本机运行结果如下:
$ node --test test/interaction/rollback.test.ts
✔ 点赞与收藏即时可逆、无外部影响,可以完全乐观 (0.606917ms)
✔ 关注虽然即时可逆,但会触达对方,降级为乐观加对账 (0.111542ms)
✔ 评论回滚有成本且对外可见,只允许占位式乐观 (0.073041ms)
✔ 上传完成必须等服务端确认,本地只展示进度与预览 (0.073084ms)
✔ 支付与下单永不乐观,重试也交给人工确认 (0.135875ms)
✔ 不可逆规则优先于幂等与其他标记 (0.080667ms)
✔ 非幂等写操作只能对账式乐观,重试必须人工触发 (0.074708ms)
✔ 决策表覆盖全部操作,策略与回滚说明都不为空 (0.895666ms)
ℹ tests 8
ℹ suites 0
ℹ pass 8
ℹ fail 0
ℹ cancelled 0
ℹ skipped 0
ℹ todo 0
ℹ duration_ms 197.629
八条用例覆盖四档策略、规则优先级、非幂等重试与决策表完整性。它们验证的是判定逻辑,不验证真实支付渠道或库存系统;后者需要接口级与业务级测试,不能用一个纯函数替代。
失败复盘:把「下单成功」画在了本地
「回声」的周边商城曾经这样写下单:用户点「立即购买」后立即把按钮改成「购买成功」,同时异步发请求。理由是想让流程「跟点赞一样顺滑」。上线当天遇到两个问题:弱网下用户看到成功提示后反复点击,创建了三笔订单;另一次是库存其实已经售罄,服务端返回失败,但用户已经关掉页面,第二天来问为什么没发货。
第一版修复只加了幂等键,重复订单消失了,但「售罄却显示成功」仍然存在,因为界面还是在请求前就展示了完成态。第二版把策略改成 confirm-first:按钮进入「提交中」并禁用,只有收到服务端订单号才显示成功;超时后不直接重试,而是先查询该幂等键对应的订单是否存在。这个改动让下单的成功提示晚了半秒,但再没有出现「本地成功、服务端没单」的情况。
复盘时我们意识到,问题不在某个请求写错了,而在策略选错了。点赞的回滚成本是一次点击,下单的回滚成本是库存、资金与客服;把两者放进同一个「先改 UI」的模板,代码越简洁,事故越大。判定函数的价值就是把这个选择从个人直觉变成一条可审查的规则。
小结
乐观更新不是一种通用交互,而是一种有门槛的工程策略。判定看五个维度:可逆性、外部影响、结算、幂等与服务端真相;策略分四档:乐观、乐观加对账、服务端确认、绝不乐观;规则按「结算与不可逆优先」的顺序短路。点赞收藏可以完全乐观,关注评论需要对账,上传只提前展示进度,支付下单必须等服务端事实。
到这里,互动写操作的生命周期已经完整:先判断能不能乐观,再决定本地如何先行、失败如何回滚。下一章离开用户主动发起的写操作,进入服务端主动推送的实时世界,先回答轮询、SSE 与 WebSocket 该如何选择。
阅读导航:上一节:5.2 评论的顺序、草稿与失败重发 · 下一节:6.1 轮询、SSE 与 WebSocket 的判定表 。
常见错误与边界
- 把「请求已受理」当成「操作已成功」,在回执前展示完成态。
- 认为有幂等键就可以乐观展示,忽略资金与库存的最终状态。
- 把支付、下单与点赞放进同一个「先改 UI」模板,回滚成本被低估。
- 只判断单一属性就下结论,没有固定的规则优先级。
- 把「能不能先显示」与「能不能自动重试」混成一个布尔值。
- 关注回滚后假定通知也能撤回,对方其实已经收到。
- 上传最后一个分片发送完毕就标记发布成功,跳过服务端校验。
- 下单超时后直接重试而不查询幂等键对应订单,重复创建或错过已成功的订单。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。