跳到主要内容

乐观更新失败与数据冲突如何处理

场景

用户在列表中连续点赞、改名或拖拽排序,页面为了即时反馈先更新了 UI,但随后出现请求失败、响应乱序、重复提交或另一设备同时修改,导致界面与服务端数据不一致。应该如何设计?

面试速答版

乐观更新不是简单地“先 setState,失败再改回去”,而是一笔有生命周期的本地事务:

  • 先判断操作是否适合乐观:可逆、成功率高、失败损失低;
  • 为操作生成唯一 mutationId,记录 beforeoptimisticbaseVersion
  • UI 展示乐观状态,但区分 pendingconfirmedfailedconflict
  • 服务端以幂等键去重,并通过版本号或 ETag 做并发控制;
  • 同一实体的操作需要串行、合并或按版本丢弃过期响应;
  • 失败时只有确认当前状态仍由该操作产生,才能安全回滚;
  • 资金、库存扣减、不可逆删除等高风险操作通常不做“假成功”。

一、哪些操作适合乐观更新

操作建议原因
点赞、收藏、开关偏好适合可逆、成功率高
文本编辑、排序条件适合需要版本和冲突处理
新建评论适合可用临时 ID 和发送状态
删除普通草稿条件适合保留撤销窗口和 Tombstone
支付成功、库存扣减不适合假成功失败损失和误导成本高
权限变更、账号注销谨慎安全与不可逆风险高

产品层面应区分“操作已接受”和“服务端已确认”。

二、状态模型

optimistic-model.ts
type MutationStatus = 'pending' | 'confirmed' | 'failed' | 'conflict';

interface OptimisticMutation<T> {
mutationId: string;
entityId: string;
baseVersion: number;
before: T;
optimistic: T;
status: MutationStatus;
createdAt: number;
error?: string;
}

interface VersionedEntity<T> {
id: string;
version: number;
data: T;
pendingMutationIds: string[];
}

保存 before 便于回滚,但连续操作不能简单恢复最初快照,否则会覆盖后来已经成功的更新。更稳妥的做法是保存可逆 Patch,或在权威快照上重新应用仍待处理的操作。

三、推荐的数据流

四、客户端实现关键点

1. 乐观 Patch 与安全回滚

optimistic-store.ts
interface Todo {
id: string;
title: string;
completed: boolean;
version: number;
}

interface ToggleCommand {
mutationId: string;
todoId: string;
baseVersion: number;
nextCompleted: boolean;
}

async function toggleTodo(todo: Todo): Promise<void> {
const command: ToggleCommand = {
mutationId: crypto.randomUUID(),
todoId: todo.id,
baseVersion: todo.version,
nextCompleted: !todo.completed,
};

// 先登记操作,再派生乐观视图,便于崩溃恢复和后续重放
mutationStore.add(command);
todoStore.apply(command);

try {
const confirmed = await api.toggleTodo(command);
mutationStore.confirm(command.mutationId);
// 以服务端版本为基线,再重放同实体仍在 pending 的命令
todoStore.rebase(confirmed, mutationStore.pendingFor(todo.id));
} catch (error) {
if (isVersionConflict(error)) {
mutationStore.markConflict(command.mutationId, error.serverEntity);
todoStore.rebase(error.serverEntity, mutationStore.pendingFor(todo.id));
return;
}

mutationStore.fail(command.mutationId, toMessage(error));
// 不是恢复旧快照,而是从最近权威数据重新计算视图
todoStore.recompute(todo.id, mutationStore.pendingFor(todo.id));
}
}

2. 临时 ID

新建实体时客户端生成稳定临时 ID,并把它作为客户端请求的一部分:

  • UI、评论回复和上传附件先引用临时 ID;
  • 服务端返回正式 ID 后通过映射一次性替换;
  • 请求重试保持同一个 mutationId 和客户端实体 ID;
  • 不要用数组下标作为临时标识。

3. 乱序响应

解决方式取决于操作语义:

  • 搜索、筛选:取消旧请求或只接收最新请求 ID;
  • 同一实体编辑:按版本条件更新,冲突时 Rebase;
  • 可合并计数:发送增量操作而非最终值;
  • 排序:提交稳定 Position/Rank 或完整有版本序列。

五、服务端一致性契约

服务端至少提供两种能力:

幂等

mutationId 在用户或租户作用域内唯一。重复请求返回第一次的处理结果,而不是再次执行副作用。

条件更新

可以使用版本字段:

conditional-update.sql
UPDATE todo
SET completed = :completed,
version = version + 1
WHERE id = :id
AND version = :base_version;

受影响行数为零表示版本已变化,需要返回当前权威数据。HTTP 资源也可以使用 ETag 与 If-Match 表达相同语义。

仅有客户端防抖不等于幂等

防抖只能减少短时间点击,无法处理超时重试、页面刷新、代理重放或多设备重复请求。幂等必须由服务端保证。

六、连续操作的处理策略

策略适用场景示例
串行队列顺序强相关排序、余额相关动作
合并操作中间状态无价值连续修改滑块、输入草稿
最新覆盖只关心最终偏好主题、通知开关
操作累积操作可交换计数增减、集合增删
Rebase编辑必须保留文档、结构化表单

同一实体可以串行,不同实体并行,避免把整个页面锁成单队列。

七、错误交互

  • 可自动恢复:保留 pending,显示轻量同步状态;
  • 需要用户重试:在原位置显示失败和重试,不只弹 Toast;
  • 冲突:展示本地值、服务端值和合并入口;
  • 不可逆失败:恢复权威状态并解释为什么失败;
  • 页面离开:可靠保存待处理操作,或者明确阻止离开。

错误提示不能让用户误以为已经成功。例如评论可以先显示“发送中”,支付必须等待服务端确认。

八、与缓存请求库协作

使用 TanStack Query、SWR 等工具时仍需设计业务语义:

  • onMutate 暂停相关查询并记录上下文;
  • onError 只回滚对应操作;
  • onSettled 失效查询作为最终校验;
  • 多个并发 Mutation 需要独立 ID 和顺序;
  • 页面刷新后要恢复 pending 操作时,需要持久化 Mutation 状态。

工具可以管理生命周期,但不能替你决定冲突如何合并、哪些操作可逆以及服务端怎样幂等。

九、测试与监控

测试矩阵:

  • 请求成功、业务拒绝、超时、5xx409
  • 响应乱序、重复响应和部分成功;
  • 连续点击、页面刷新、多标签页和多设备;
  • 临时 ID 被其他实体引用;
  • 服务端已经成功但客户端超时;
  • 重试期间权限变化或实体被删除。

监控 pending 时长、回滚率、冲突率、重复请求去重率、重试次数和“前端显示成功但服务端未确认”的异常案例。


常见面试问题

Q1: 为什么失败时不能总是恢复 before 快照?

答案:该快照之后可能还有其他已成功或待处理操作,直接恢复会覆盖它们。应从最近的服务端权威版本重新应用仍有效的 pending 操作。

Q2: 超时后应该回滚还是继续显示成功?

答案:超时表示结果未知,不等于失败。低风险操作可保持 pending 并用同一幂等键查询或重试;高风险操作应显示“确认中”,不能宣称成功。

Q3: 409 Conflict 后如何处理?

答案:服务端返回当前版本。客户端按业务选择自动合并、Rebase、让用户手工解决或放弃本地操作,并把冲突作为独立状态而非无限重试。

Q4: 点赞为什么最好发送操作而不是最终总数?

答案:总数会覆盖其他用户的并发更新;发送“当前用户设为已点赞”或可幂等的增量操作,更容易表达业务语义并处理并发。

Q5: 哪些操作绝对不应该乐观显示成功?

答案:支付、资金到账、不可逆权限操作、关键库存确认等失败损失高且会误导用户的操作。可以优化等待体验,但成功状态必须来自权威确认。

Q6: 乐观更新和离线优先是什么关系?

答案:两者都先接受本地操作;离线优先还要求持久化 Outbox、跨会话恢复、后台同步和更完整的冲突协议,范围更大。

相关链接