乐观更新失败与数据冲突如何处理
场景
用户在列表中连续点赞、改名或拖拽排序,页面为了即时反馈先更新了 UI,但随后出现请求失败、响应乱序、重复提交或另一设备同时修改,导致界面与服务端数据不一致。应该如何设计?
乐观更新不是简单地“先 setState,失败再改回去”,而是一笔有生命周期的本地事务:
- 先判断操作是否适合乐观:可逆、成功率高、失败损失低;
- 为操作生成唯一
mutationId,记录before、optimistic、baseVersion; - UI 展示乐观状态,但区分
pending、confirmed、failed、conflict; - 服务端以幂等键去重,并通过版本号或 ETag 做并发控制;
- 同一实体的操作需要串行、合并或按版本丢弃过期响应;
- 失败时只有确认当前状态仍由该操作产生,才能安全回滚;
- 资金、库存扣减、不可逆删除等高风险操作通常不做“假成功”。
一、哪些操作适合乐观更新
| 操作 | 建议 | 原因 |
|---|---|---|
| 点赞、收藏、开关偏好 | 适合 | 可逆、成功率高 |
| 文本编辑、排序 | 条件适合 | 需要版本和冲突处理 |
| 新建评论 | 适合 | 可用临时 ID 和发送状态 |
| 删除普通草稿 | 条件适合 | 保留撤销窗口和 Tombstone |
| 支付成功、库存扣减 | 不适合假成功 | 失败损失和误导成本高 |
| 权限变更、账号注销 | 谨慎 | 安全与不可逆风险高 |
产品层面应区分“操作已接受”和“服务端已确认”。
二、状态模型
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 与安全回滚
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 在用户或租户作用域内唯一。重复请求返回第一次的处理结果,而不是再次执行副作用。
条件更新
可以使用版本字段:
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 状态。
工具可以管理生命周期,但不能替你决定冲突如何合并、哪些操作可逆以及服务端怎样幂等。
九、测试与监控
测试矩阵:
- 请求成功、业务拒绝、超时、
5xx、409; - 响应乱序、重复响应和部分成功;
- 连续点击、页面刷新、多标签页和多设备;
- 临时 ID 被其他实体引用;
- 服务端已经成功但客户端超时;
- 重试期间权限变化或实体被删除。
监控 pending 时长、回滚率、冲突率、重复请求去重率、重试次数和“前端显示成功但服务端未确认”的异常案例。
常见面试问题
Q1: 为什么失败时不能总是恢复 before 快照?
答案:该快照之后可能还有其他已成功或待处理操作,直接恢复会覆盖它们。应从最近的服务端权威版本重新应用仍有效的 pending 操作。
Q2: 超时后应该回滚还是继续显示成功?
答案:超时表示结果未知,不等于失败。低风险操作可保持 pending 并用同一幂等键查询或重试;高风险操作应显示“确认中”,不能宣称成功。
Q3: 409 Conflict 后如何处理?
答案:服务端返回当前版本。客户端按业务选择自动合并、Rebase、让用户手工解决或放弃本地操作,并把冲突作为独立状态而非无限重试。
Q4: 点赞为什么最好发送操作而不是最终总数?
答案:总数会覆盖其他用户的并发更新;发送“当前用户设为已点赞”或可幂等的增量操作,更容易表达业务语义并处理并发。
Q5: 哪些操作绝对不应该乐观显示成功?
答案:支付、资金到账、不可逆权限操作、关键库存确认等失败损失高且会误导用户的操作。可以优化等待体验,但成功状态必须来自权威确认。
Q6: 乐观更新和离线优先是什么关系?
答案:两者都先接受本地操作;离线优先还要求持久化 Outbox、跨会话恢复、后台同步和更完整的冲突协议,范围更大。