移动端离线模式 API 规格:同步与冲突解决

移动端离线模式 API 规格:同步与冲突解决
Spec Coding 编辑部 · Spec-First 工程实践内容

我评审过的大多数移动端 API,设计前提都是工程师桌旁那台信号满格的手机。可我们真正要发布给用户的,根本不是那台设备。我写离线可用的 API 规格时,会直接假设客户端要在地下室里待满九个小时——用户在这段时间里点的每一下操作,都必须活得下来、能同步、必要时还能在信号恢复后被服务端礼貌地拒绝。

发布于 2026-03-01 · ✓ 已更新 2026-04-29 · 阅读约 6 分钟 · 作者:Spec Coding 编辑部 · 审校:编辑与事实核查政策

内容整理说明

复查日期:2026-04-29。本文作为定向参考保留,主要指导已并入 API 契约 Hub。它不再出现在 sitemap、RSS 和站内搜索推荐中。

本地优先是规格决策,不是实现细节

在任何人打开 endpoint 文件之前,我都会强迫规格先回答一个问题:两次同步之间,真相的源头是手机还是服务端?对于现场巡检清单、笔记应用、检查工具、配送扫码这类场景,赢家是手机。每一次 mutation 都先写入本地存储,立刻给 UI 返回成功,再由后台同步 worker 接手。服务端是合并伙伴,不是守门员。

这条一旦白纸黑字写下来,很多愚蠢的争论就消失了。不会再有人提议在保存按钮上加转圈,不会再有人建议断网时把功能灰掉。API 不再是一份 RESTful 愿望清单,而是一份用来调解两个发散存储的契约。

客户端的 outbox 模式必须写进规格

我的默认做法是在客户端建一张 outbox 表,四个字段:客户端生成的 ID(ULID)、mutation 类型、payload JSON、尝试次数。规格必须把这件事点出来,因为后续三件事都挂在上面:幂等性、重试策略、删除的 tombstone。

每一次 mutation 都会把自己的 ULID 作为 Idempotency-Key 请求头发出去。服务端至少持久化这个 key 30 天。如果客户端重试了——信号中途断了、操作系统杀掉后台任务、用户强制退出——服务端要返回原始结果。我要求 30 天,是因为我亲眼见过一个现场服务 App 在技术员开回信号覆盖区之后,把两周前的 mutation 重放了一遍。

删除必须用 tombstone。客户端本地删掉一条记录时,要用同一个 ID 加一个 deleted_at 时间戳插入一条 tombstone。同步 worker 把它推上去,服务端接收,客户端要等到服务端确认之后才真正清理。跳过这一步,增量同步的下一次拉取就会让这些删除悄悄"诈尸"。

用 cursor 做增量同步,别做全量刷新

拉取服务端变更这件事,我会强硬地顶回去"不就重新拉一次列表吗"的提议。一个带着 400 张工单的现场技术员,根本扛不起每次开 App 都用蜂窝流量拉一次完整 payload。规格要定义一个 cursor——由服务端下发的不透明 token——以及一个 GET /sync?cursor=X 接口,返回该 cursor 之后的所有变更,再附上一个新的 cursor。

cursor 故意是不透明的,客户端不允许去解析它。我在一个项目上换过三次编码方式,客户端都不用更新,就因为契约里写着"不透明字符串,原样回传"。如果服务端返回 410 Gone,客户端就执行一次全量刷新,并存下新的 cursor。

服务端拒绝一条已排队的 mutation 时会发生什么

这一节是大多数规格会跳过的部分,也是最关键的部分。客户端在离线时排队了一条 mutation,同步跑起来,服务端说不行。我会在规格里强制区分三类拒绝,每一类对应不同的客户端行为:

last-writer-wins 确实偷懒,但对 80% 的字段来说够用了。剩下的 20% 值得各自定一条规则,逐字段写下来。

永远不要信任客户端时间戳用于排序

一个技术员的手机时间可能停留在上周二,我真见过。规格里禁止把客户端提供的时间戳用作主排序键。客户端可以发 client_generated_at 给 UI 显示,但排序必须用服务端接收时间,或者由服务端在入口处分配的 hybrid logical clock。当两条 mutation 撞上,都自称更新,由服务端根据规格里写明的命名规则来裁决,而不是由最后写合并函数的那个人来拍板。

大附件走独立协议

一次现场巡检拍的照片一张 4 MB,技术员在停车场里拍了 18 张。如果附件挤在主 mutation 的 payload 里,那就什么都同步不出去。规格要把它们分开:mutation 用客户端 ID 引用附件,附件通过分片可恢复的 PUT 传到预签名 URL,同步 worker 要等到所有被引用的附件都返回 200 之后,才把 mutation 标记为就绪。分片 1 MB,带 content-range 头,resume 时返回下一个期望的字节,上传走系统级后台 API(iOS 用 URLSession,Android 用 WorkManager)。除非用户把记录标为紧急,否则不要在蜂窝网络下上传附件。

为那些永远不会升级的客户端做 schema 演进

现场设备里有一部分非小比例跑的是 14 个月前的构建。规格要定两条死规矩:服务端必须至少在 12 个月内接受任何文档化过的历史请求形态;服务端响应里客户端不认识的字段,必须在回传时原样保留。后面这条让我们可以加字段而不会破坏老客户端——老客户端回头再把自己的编辑同步回来时,这些字段还在。弃用要带 Sunset 头和最低版本门槛;低于门槛,sync 直接返回 426 Upgrade Required

验收标准

- Given a client is offline and the user creates three notes
  When the device regains connectivity on a metered network
  Then the outbox drains in ULID order with exponential backoff and each mutation carries its idempotency key

- Given a queued mutation is rejected with HTTP 409 and a server-state payload
  When the conflict touches a field marked "escalate" in the spec
  Then the client surfaces a resolution UI and does not auto-merge

- Given the server rotates its cursor encoding
  When a client presents an old cursor and receives 410 Gone
  Then the client performs a full refresh, stores the new cursor, and does not drop local unsynced mutations

服务端真正需要的可观测性

离线可用的 App 出问题时是静默的。没有合适的信号,最早的异常信号往往是一周之后的客服工单。我会在规格里要求三项指标,由客户端在每次 sync 时上报:

当某个 endpoint 的冲突率一夜之间从 0.2% 跳到 4%,那要么是 schema bug,要么是合并规则的 bug,我要的是上线当天就看到——而不是投诉飞来的那一周才发现。

离线模式要写清本地队列状态

移动端离线不是“失败后重试”这么简单。客户端需要知道请求在本地队列里是什么状态、什么时候展示 pending、什么时候允许撤销、冲突回来后如何恢复。

Offline write states:
- queued: no network, stored in local outbox
- sending: request in flight with client_operation_id
- accepted: server stored operation, waiting for sync confirmation
- rejected_retryable: server timeout or 503, keep in outbox
- rejected_final: validation or permission error, show user action
- conflicted: server version changed, open conflict resolution UI

边界:不要让所有写操作都离线。付款、权限变更、删除账号这类操作通常需要在线确认,最多允许保存草稿。

服务端也要认识客户端操作 ID

离线写入最好带 client_operation_id。服务端用它做去重、冲突提示和审计,客户端用它把本地 outbox 状态和远端结果对上。没有这个字段,重试后 UI 很难知道哪条本地操作已经被接受。

关键词:offline-first mobile API · outbox 模式 · idempotency key · delta sync cursor · 冲突解决 · tombstone 删除 · schema 演进

编辑说明与免责声明

本文用于软件工程教学与实践参考,不构成法律、税务或投资建议。示例场景用于解释规格方法,不对应真实客户数据。