移动端离线模式 API 规格:同步与冲突解决
我评审过的大多数移动端 API,设计前提都是工程师桌旁那台信号满格的手机。可我们真正要发布给用户的,根本不是那台设备。我写离线可用的 API 规格时,会直接假设客户端要在地下室里待满九个小时——用户在这段时间里点的每一下操作,都必须活得下来、能同步、必要时还能在信号恢复后被服务端礼貌地拒绝。
内容整理说明
复查日期: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,同步跑起来,服务端说不行。我会在规格里强制区分三类拒绝,每一类对应不同的客户端行为:
- 瞬时错误(5xx、429、网络错误)。留在 outbox 里,带 jitter 的指数退避,最多拖到 12 小时。不要暴露给用户。
- 校验错误(带已知错误码的 4xx)。把这条 mutation 挪到
rejected状态,把服务端的提示信息展示给用户,并提供"编辑或丢弃"选项。不要悄悄丢掉——用户能察觉自己的工作凭空蒸发。 - 冲突(409 并附带当前服务端状态)。套用服务端的合并策略。我的默认策略是标量字段走 last-writer-wins,以服务端时间戳为准,但任何具有破坏性的字段——状态流转、金额、库存——都要升级给用户或人工操作员处理。
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 时上报:
- 每台设备的 outbox 深度(待处理条数、最老一条的年龄)。
- 同步成功率,按 App 版本和 OS 版本分桶。
- 每个 endpoint 的冲突率,并带上拒绝类别。
当某个 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 很难知道哪条本地操作已经被接受。
继续阅读
编辑说明与免责声明
本文用于软件工程教学与实践参考,不构成法律、税务或投资建议。示例场景用于解释规格方法,不对应真实客户数据。
- 作者信息:Spec Coding 编辑部
- 编辑政策:编辑与事实核查政策
- 联系方式:联系页面