《互联网产品的客户端架构·内容、社交与实时互动》5.3 回滚的边界:哪些操作不能乐观

点赞可以乐观,支付与下单不行。本节按回滚成本把写操作分成四档:即时可逆、可补偿、需服务端确认、绝不能乐观,并给出一个可测试的 TypeScript 判定函数与七类操作的决策表;重点解释为什么资金、库存与对外通知不能靠本地回滚,以及重试策略如何与乐观策略解耦。

这一节不写具体交互

前两节分别实现了点赞状态与评论时间线,它们都建立在同一个假设上:操作失败可以回滚,重复请求可以幂等。这一节跳出具体交互,回答一个更靠前的问题:哪些写操作有资格使用乐观更新,哪些必须等服务端确认,哪些连本地「成功」提示都不该出现。

不重复 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,但规则一多,组合就会互相打架。例如支付同时满足「幂等」和「服务端真相」,如果先判断幂等,就可能被误判成可乐观。正确的做法是固定优先级,从上往下短路:

  1. 结算或不可逆:直接 never,不再看其他属性。
  2. 完成状态依赖服务端:confirm-first,本地只展示进行中。
  3. 有外部影响、回滚有成本或请求不幂等:optimistic-with-reconcile,允许先行但必须对账。
  4. 其余: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」模板,回滚成本被低估。
  • 只判断单一属性就下结论,没有固定的规则优先级。
  • 把「能不能先显示」与「能不能自动重试」混成一个布尔值。
  • 关注回滚后假定通知也能撤回,对方其实已经收到。
  • 上传最后一个分片发送完毕就标记发布成功,跳过服务端校验。
  • 下单超时后直接重试而不查询幂等键对应订单,重复创建或错过已成功的订单。

继续阅读

探索更多技术文章

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

全部文章 返回首页

「books」更多文章

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