☰
openGym多设备同步原理:服务器版本号与409冲突合并机制详解
2026/10/8 15:45:54 网站建设 项目流程

openGym多设备同步原理:服务器版本号与409冲突合并机制详解

【免费下载链接】openGymSelf-hosted gym & body-weight tracker — plan routines, log workouts (supersets, warm-ups, cardio), see which muscles are trained, fatigued or detrained, import from FitNotes/Strong/Hevy, passkey login. Your data, your server.项目地址: https://gitcode.com/GitHub_Trending/op/openGym

openGym是一款自托管的健身与体重记录应用,支持制定训练计划、记录含超级组与热身的工作量、查看肌肉负荷状态,并支持多设备数据同步。当你用手机在健身房记录一组深蹲、用电脑修改训练计划时,数据如何不打架、不丢失?答案就在 openGym 的服务器版本号(_rev)乐观锁与409 冲突合并机制里。本文用最小篇幅讲透这套多设备同步方案的完整原理。

为什么多设备同步容易"丢数据"?

想象一个经典场景:

  1. 你的手机和电脑都读取了同一份档案(版本号 rev 7)
  2. 手机先保存成功,服务器版本号变为rev 8
  3. 电脑随后把自己的旧副本推上去——如果服务器直接覆盖,手机刚才记录的那次训练就悄无声息地消失了

这正是 openGym 在引入版本号之前真实发生过的 Bug:一个整天开着的电脑标签页,会把手机在期间记录的训练直接冲掉(见 sync-merge.js 文件开头的注释)。

openGym 的解法可以概括为三步:服务器发号、条件写入、冲突合并。

第一步:服务器版本号_rev—— 每个档案的"修订号"

每个用户在服务器上只有一份完整档案(JSON 文档),里面存着训练记录、计划、体重等所有数据。服务器为这份文档维护一个单调递增的计数器_rev:

  • 每次成功写入,版本号 +1,并且版本号完全由服务器掌管——客户端上报的值会被忽略(body.state._rev = curRev + 1)
  • 拉取时GET /api/data返回{ state, rev },把数据和版本号一起交给设备

核心代码位于 server.js:

// `rev` 是服务器对这份档案写入次数的计数(同时存在文档内部的 `_rev` 里) // 客户端将其作为 `baseRev` 回传,基于"从未见过的文档"的写入会被拒绝 json(res, 200, { state, state?._rev || 0 });

廉价的版本轮询:/api/data/rev

既然版本号这么重要,设备如何低成本地知道"服务器变了没"?openGym 提供了一个只返回一个数字的端点(server.js):

'GET /api/data/rev': async (req, res) => { json(res, 200, { rev: readStateCached(user.id)?._rev || 0 }); }

设备在页面打开期间每 30 秒轮询一次,并在每次回到前台时立即检查。只有当数字变化时才拉取完整文档——解析一份数 MB 的 JSON 只为了读一个数字会浪费 30+ 毫秒,所以这里还叠加了一层 stat 缓存,让轮询几乎零成本。

第二步:条件写入与 409 冲突响应

推送到服务器的请求长这样:

{ "state": { ...你的完整档案... }, "baseRev": 7 }

baseRev表示"我这份数据是基于版本号 7 改出来的"。服务器执行的是比较并写入(compare-and-write)(server.js):

// 条件写入:baseRev 不等于当前版本号,说明这个客户端读的是旧文档 // ——另一台设备在期间已经写入过。当前文档随 409 一起返回, // 客户端可以合并后重试,不需要第二次请求 const curRev = cur?._rev || 0; if (body.baseRev != null && body.baseRev !== curRev) { return json(res, 409, { error: 'conflict', rev: curRev, state: cur }); }

三个设计要点:

  • 409 Conflict 不是"失败",而是服务器的一次"善意提醒":拒绝覆盖,同时把当前最新文档整个塞回响应体,省掉客户端再发一次 GET 的往返
  • 无baseRev的写入视为有意的全量替换(如备份导入),保持向后兼容
  • 比较与写入之间没有任何await,是同步原子操作,不会出现竞态

前端在 api.js 中特意保留了 409 的响应体,注释写明:"The body rides along on the error: a 409 from /api/data carries the server's document."(响应体随错误一起抛出:来自 /api/data 的 409 携带着服务器的文档)。

第三步:字段级合并 ——mergeStates的精细规则

收到 409 后,前端不会简单地"谁新谁赢",而是调用 sync-merge.js 中的mergeStates(a, b)做逐字段的智能合并。核心思想:

两边的条目都保留(并集),只有没有天然"并集"语义的字段,才由更新的那份副本决定。

各字段的合并规则速览:

字段类型合并规则
标量与设置项取_ts较新的副本
训练记录workouts按 id 并集;同一 id 保留各自_ts更晚编辑的版本,最后按日期与开始时间排序
训练照片/视频按 hash 并集——一边加的照片不会因另一边编辑了同一训练而丢失
训练计划routines按 id 并集;同一 id 保留最后编辑的版本(由 stampRoutines 在每次修改时打时间戳)
体重记录按天并集;同一天保留t更晚编辑的条目
收藏favEx有序集合并集,新副本在前
各动作最大重量exWeights普通动作取更大值,助力器械取更小值(助力器械是"越轻越强",issue #232 的教训)
单位(kg/lb)合并前先把两边换算到同一单位——最先执行的一条规则
重置resetAt/resetIds只能向前推进;"清空一切"会记录被清空的条目名单,未见过重置的设备会精确剔除这些条目,其余数据全部保留

两个值得注意的细节:

  • 单位先行:每份档案里的重量都以其unit为准,两份不同单位的副本绝不会直接拿数字比较,会先把其中一方换算过来。曾经有个 Bug:在一台设备上切成 lb 的副本,遇到另一台设备的 kg 副本后会"变回 kg,但底下还是 lb 的数字"
  • 合并副本的媒体列表:mergeWorkoutMedia让保留版本的媒体清单吸纳另一副本中多出的 hash,所以一边拍的照片不会因另一边改了备注而消失

自动重试:设备侧的完整闭环

前端 useStore.js 中,doPush捕获 409 后自动走"合并 → 重推"循环,最多重试 2 次:

if (e.status === 409 && e.data && attempt < 2) { // 另一台设备在本设备上次读取后写入过。服务器已把当前文档发回来了; // 合并后针对该版本号再推一次 mergeInto(get().S, e.data.state, e.data.rev || 0) return doPush(attempt + 1) }

mergeInto会记录服务器的版本号(writeSync(rev, ts)),让紧随其后的重推是精确针对刚合并过的那份文档的条件写入。如果连续两次仍被拒绝(说明冲突窗口内又有新写入),数据不会丢:副本被标记为"欠推"(owed),等下次回到前台拉取时从那条路径继续处理。

完整循环回到开头的时序图:手机推 rev 7 → 服务器升 rev 8;笔记本推 rev 7 →409 + 当前文档;笔记本本地合并进 rev 8 → 推 rev 8 → 成功得 rev 9;手机轮询发现 rev 9 → 拉取最新。全程没有任何一次数据丢失。

已知边界:删除的"短暂复活"

sync-merge.js 文件头坦诚地记录了一个已知限制:由于没有"各端删除了什么"的墓碑(tombstone)记录,在冲突窗口内(通常只有几秒)一端删除的条目,可能会从另一端"复活"回来。

openGym 认为这个取舍是对的——一条复活的条目可以一键再删,一条丢失的数据则永远没了。而且窗口极短:store 在恢复前台时就会拉取,且每次推送都是条件写入。

小结:三行话记住 openGym 多设备同步

  1. 服务器发号:_rev单调递增、仅服务器可写,/api/data/rev让轮询便宜到可以每 30 秒一次
  2. 条件写入:baseRev不匹配就返回 409,并把最新文档随响应带回
  3. 精细合并:mergeStates按字段做并集/最新编辑获胜,单位换算与重置名单是两条前置铁律

这套"乐观锁 + 409 携带文档 + 字段级合并"的组合,让 openGym 在没有中心化合并服务、没有 WebSocket的极简自托管架构下,依然做到了多设备同步的数据零丢失——这也是 SELF_HOSTING.md 中单机部署就能放心多端使用的原因。

延伸阅读

  • 同步图源码(Mermaid):sync.mmd
  • 合并规则单测:sync-merge.test.js
  • 服务器端版本机制测试:server-data-rev.test.js、server-data-rev-endpoint.test.js

【免费下载链接】openGymSelf-hosted gym & body-weight tracker — plan routines, log workouts (supersets, warm-ups, cardio), see which muscles are trained, fatigued or detrained, import from FitNotes/Strong/Hevy, passkey login. Your data, your server.项目地址: https://gitcode.com/GitHub_Trending/op/openGym

创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询