离线应用同步如何保证持久化与幂等
教程以健身记录为例,讨论本地保存可恢复、重试不重复生效,以及同步时保留未解决的本地修改。方案采用 SQLite 和稳定标识符,并明确示例为未经编译测试的 TypeScript 风格伪代码。
完整中文译文
![]()
用户完成一组训练,输入 8 次重复,然后点击保存。就在这时,健身房的网络断了。
应用应该确认数据已保存在本地,让用户继续训练,而不是让用户等着 API 响应。
这是离线训练记录的起点。更棘手的问题还在后面:服务端接受了请求,响应却迟迟没有到达;另一台设备修改了训练记录;或者应用在同步过程中被终止。
本指南围绕三个要求展开:
- 已确认的本地保存,在应用重启后依然有效。
- 重试同一个操作,不会重复产生效果。
- 同步过程会保留尚未解决的本地更改。
示例假设本地数据库使用 SQLite。应用逻辑通过 TypeScript 风格的伪代码说明,其中的辅助接口并非现有软件包提供的 API。这些代码片段未经编译或测试。
1. 设计离线优先的训练数据模型
将训练内容与同步元数据分开。
为训练、动作和组次使用稳定的标识符。在首次本地保存之前生成这些标识符,同步后也不要更改。
{
"id": "workout-21",
"userId": "account-7",
"startedAt": "2026-10-21T07:00:00Z",
"completedAt": null,
"exercises": [
{
"id": "exercise-4",
"exerciseKey": "squat",
"sets": [
{
"id": "set-9",
"repetitions": 8,
"weightKg": 40
}
]
}
],
"createdAt": "2026-10-21T07:00:00Z",
"updatedAt": "2026-10-21T07:05:00Z",
"serverRevision": 3,
"localSequence": 5,
"syncStatus": "pending"
}
明确单位可以避免歧义。相比没有单位说明的 weight,weightKg 能传达更多信息。
区分训练实际发生的时间,以及记录被编辑或同步的时间。今天上传昨天的训练,不应该改变这次训练的日期。
serverRevision 表示已经确认的服务端状态。localSequence 用于排列同一台设备上的更改顺序,不能作为跨设备比较的版本号。
将已确认的服务端快照与本地工作状态分开维护。工作状态包含待处理的更改,快照则为数据协调提供基准。
校验记录结构和测量值。为数据库 schema 和队列操作格式设置版本,确保升级时能够保留尚未同步的工作。
关于活动记录的更全面规划,可参阅《健身追踪应用开发》。
2. 同步之前,先将训练数据保存到本地
界面应该读取已经提交的本地数据。网络活动则通过独立的同步流程更新本地存储。
对于包含训练、动作、组次等关联数据的应用,SQLite 通常是合适的选择。其他持久化框架也可以使用,前提是其事务、迁移和查询行为能够满足需求。
关键边界是本地事务:用户的更改,以及用于同步这项更改的指令,必须一起保存。
// Illustrative TypeScript-style pseudocode.
// Database methods and helpers are application-defined.
async function recordSet(input: RecordSetInput) {
const account = requireActiveAccount();
const values = validateSet(input);
const setId = newStableId();
const operationId = newStableId();
await db.transaction(async (tx) => {
const workout = await tx.requireOwnedWorkout(
account.id,
input.workoutId
);
const sequence = workout.localSequence + 1;
await tx.insertSet({
id: setId,
workoutId: workout.id,
...values
});
await tx.updateWorkout(workout.id, {
localSequence: sequence,
syncStatus: "pending",
updatedAt: now()
});
await tx.insertOutbox({
operationId,
userId: account.id,
workoutId: workout.id,
sequence,
kind: "addSet",
payload: { setId, ...values },
state: "pending"
});
});
// The interface observes committed database state.
requestBestEffortSync(account.id);
}
如果数据库写入失败,就回滚事务,并告知用户保存未完成。
不要在发起网络请求时一直保持事务开启。SQLite 同一时间只允许一个写入者,因此写事务应尽量短。
训练过程中,每次有实际意义的操作都要及时持久化。如果等到最后点击“完成”按钮时,才保存所有已完成的组次,之前的训练数据就一直存在丢失风险。
框架选择也会影响持久化集成和生命周期处理。在比较 React Native 与 Flutter 开发健身应用时,应该结合应用的实际需求,一并考虑这些因素。
3. 构建可靠的训练同步队列
持久化 outbox 用来记录仍需服务端确认的操作。
{
"operationId": "operation-12",
"userId": "account-7",
"workoutId": "workout-21",
"sequence": 6,
"kind": "addSet",
"payload": {
"setId": "set-10",
"repetitions": 6,
"weightKg": 45
},
"state": "pending",
"attempts": 0,
"nextAttemptAt": null,
"leaseExpiresAt": null
}
使用 pending、syncing、retryable 和 acknowledged 状态。同时,要能区分冲突、身份认证问题,以及永久性的校验失败。
起步阶段,让每条训练记录同一时间只处理一个进行中的操作。这样既能简化顺序管理,又允许互不相关的训练记录独立同步。
发送之前,先持久化一份不可变请求,其中包含操作 ID、payload 和预期的服务端版本。重试时复用完全相同的请求。
通过带过期时间的租约领取队列项。worker 被终止后,不应让操作永久停留在 syncing 状态。
处理结果不确定的请求
超时并不能证明服务端拒绝了请求。服务端可能已经提交,只是响应在传输途中丢失了。
在后端实现以账号为作用域的幂等机制:
- 识别操作 ID。
- 校验重复请求的内容是否一致。
- 只执行一次数据变更。
- 将操作结果与数据变更原子性地存储。
- 对已经接受的重复请求,返回之前记录的结果。
使用带随机抖动、且设有上限的指数退避:
// Illustrative pseudocode.
const ceilingMs = Math.min(
maximumDelayMs,
initialDelayMs * 2 ** attempts
);
const delayMs = randomBetween(0, ceilingMs);
遵循服务端适用的重试指示。在刷新身份认证或请求用户介入期间,保留被阻塞的操作。
网络连接事件可以作为尝试同步的契机,但不能证明后端可达。后台执行同样受平台控制,因此应用回到前台时,也应该尝试恢复同步。
4. 处理跨设备数据冲突
假设手机和平板都加载了版本为 3 的训练记录。
手机将某一组改成 10 次重复。处于离线状态的平板则将同一组改成 6 次。
两次编辑都是有效的用户操作。单凭上传顺序,无法确定应该保留哪个值。
Last-write-wins 会按照预先定义的顺序选择一次更新。
这种策略可以用于允许覆盖的偏好设置,但也可能丢弃有意义的训练测量数据。设备时钟可能存在偏差,服务端接收请求的顺序也不一定反映用户实际操作的顺序。
基于版本的并发控制
每次数据变更都携带预期的服务端版本。只有它与当前版本匹配时,才接受更新。
// Illustrative server pseudocode.
// All operations below occur within one database transaction.
await serializeOperationKey(account.id, request.operationId);
const receipt = await findAcceptedOperation(
account.id,
request.operationId
);
if (receipt) {
requireIdenticalRequest(receipt, request);
return receipt.result;
}
const workout = await lockAuthorizedWorkout(
account.id,
request.workoutId
);
if (workout.revision !== request.expectedRevision) {
return conflict(workout);
}
const updated = applyValidatedMutation(workout, request);
updated.revision = workout.revision + 1;
await saveWorkoutAndIdempotencyResult(updated, request);
return accepted(updated);
数据库必须强制保证操作唯一,并对并发到来的重复操作进行串行处理。对于之前已接受的重复请求,应该先返回对应回执,而不是因为它携带的预期版本已经过时就拒绝它。
HTTP API 可以使用 entity tags 和 If-Match 实现条件更新。
合并相互独立的更改
比较已确认的基准状态、本地操作意图,以及服务端当前版本。
编辑备注和新增一组训练,可能可以安全合并。但如果两次更改都修改了同一个重复次数,就需要单独制定处理策略。
请求被明确拒绝后,基于新版本重新调整的请求应使用新的操作 ID。对于结果仍不确定的请求,绝不能在保留原操作 ID 的同时修改请求内容。
保留存在歧义的更改
如果自动合并会丢弃有意义的信息,就展示两个版本,让用户决定。
在冲突解决结果被持久化保存之前,保留尚未解决的值。删除与编辑之间的冲突,也需要明确的处理策略。
5. 防止重复记录和更新丢失
假设 workout-21 已经创建成功,但响应丢失了。
重试时使用相同的训练标识符和操作标识符。服务端返回已接受的结果,而不是再创建一条训练记录。
这可以防止传输过程造成的重复。对于两条独立记录、但看起来相似的训练,则需要单独的数据协调流程。
保留后续本地编辑
对 sequence 6 的确认,不能把 sequence 7 也标记为已同步。
在事务中处理确认结果:
- 更新已确认的服务端快照。
- 将匹配的 outbox 操作标记为完成。
- 重放后续待处理的更改。
- 推导界面显示的同步状态。
绝不要用较旧的服务端响应,直接替换整个本地工作记录。
同步删除和下载的数据
使用包含标识信息和版本的删除墓碑。否则,旧的离线记录可能会重新创建已经删除的内容。
根据支持的离线时长制定保留策略。超过这个时长后重新上线的设备,需要进行数据协调,同时保留待处理的工作。
在同一个本地事务中,应用下载的服务端更改,并推进对应的变更游标。先更新已确认的基准状态,再重放待处理操作,并将冲突呈现出来。
先创建作为父记录的训练,再创建依赖它的记录。除非服务端保证整个批次的原子性,否则应逐项处理批量请求的结果。
绝不要仅仅因为上传请求已经发出,就删除本地训练记录。
如果活动数据也来自外部设备,要区分同步重试产生的重复与源数据本身的重复。相关规划事项可参阅《可穿戴设备训练数据处理》。
6. 设计清晰的离线和同步状态提示
根据持久化状态推导用户可见的状态。
应用重启,不应该让待同步训练的状态变成 Synced。
防止用户意外重复点击,但仍要保留后端幂等机制,作为最后一道保障。
保存在本地与备份到远端,是两种不同的结果。向用户说明这种区别,但不要每次离线操作都弹出错误对话框,打断用户。
7. 保护训练数据和用户隐私
在本地记录、队列和服务端授权中,严格执行账号隔离。
不要将请求提交的 userId 当作所有权证明。访问权限应由经过身份认证的用户身份决定。
使用适当的凭据存储方式和 HTTPS/TLS。根据威胁模型评估本地加密需求,评估范围应包括密钥、备份和导出文件。
避免在应用日志中记录敏感训练内容和 token。为记录、删除墓碑及同步回执制定保留期限。
使用《健身应用数据收集检查清单》,逐项审视所收集的字段是否必要。
退出登录时,停止与该账号绑定的 worker,并使其会话上下文失效。应用延迟到达的响应之前,再次核对账号身份。
绝不能使用另一个账号的凭据,上传当前账号队列中的记录。
隐私义务取决于应用、数据、用户和业务关系。《健身应用 HIPAA 与 GDPR 指南》提供了更多相关背景;仅靠离线架构,并不能证明应用符合合规要求。
8. 测试离线记录和同步
使用单元测试验证合并规则、重试决策和状态推导。
使用集成测试验证数据库事务、版本检查、授权和幂等性。
使用端到端测试验证应用重启和网络变化后的恢复能力。
有一个特别有用的测试:在服务端提交之后、客户端收到响应之前,中断响应传输。
Given one pending addSet operation
When the server commits it but the response is dropped
And the client restarts and retries the identical request
Then exactly one set exists on the server
And the client records its acknowledgment
And later local edits remain pending
这是建议采用的测试场景,并非已经执行的测试结果。
将这些故障边界纳入健身应用的集成测试计划。
9. 实用实现检查清单
- [ ] 发起网络请求前,先持久化到本地。
- [ ] 为记录和操作生成稳定的 ID。
- [ ] 将数据更改与 outbox 条目一起提交。
- [ ] 恢复被中断的队列 worker。
- [ ] 在服务端实现原子性的幂等机制。
- [ ] 检查预期版本。
- [ ] 定义合并和删除策略。
- [ ] 处理确认时保留后续编辑。
- [ ] 安全地应用下载的更改。
- [ ] 如实显示同步状态。
- [ ] 隔离账号并保护凭据。
- [ ] 自动化测试重启、重试和冲突场景。
对于 MVP,应该优先做可靠的一条完整训练流程,再添加更复杂的同步行为。健身应用的 MVP 范围也应体现这一点。
让离线训练记录可靠运行
本地持久化保存训练数据。幂等性让结果不确定时的重试也能安全进行。版本检查与冲突策略保护并发更改。
先实现一条完整的离线训练流程。验证它能经受应用重启、响应丢失、重试和同步,同时保留尚未解决的用户操作。
Android Developers — 构建离线优先应用
Apple Developer — 为应用选择后台执行策略
SQLite — 事务
Amazon Builders’ Library — 通过幂等 API 实现安全重试
RFC 9110 — HTTP 语义
如需采取进一步措施,可以考虑屏蔽此人和/或举报滥用行为。