没有服务端合并、没有账号,存档还能多设备收敛——G-Counter 加 CAS
键道 KeyDo 复盘:匿名、无登录、多设备各自离线练习,怎么保证存档最终一致又不丢更新。答案是把责任切开——Worker 只做带版本号的原子存取(CAS),前端做一个满足交换律、结合律、幂等律的确定性合并。
键道 KeyDo 是匿名的:没有账号,一个口令代表一份存档,多台设备可以各自离线练习。这就带来一个分布式难题:在不做服务端语义合并、也不引入登录的前提下,怎么保证同一份存档跨设备最终一致,而且并发写入不丢更新(lost update)?
这次整改的答案是把责任切成两半,边界很清楚:Worker 只保证「带版本号的原子存取」,前端负责「确定性合并」。 Worker 不懂 SQL 之外的语义、不重写前端的合并规则;前端不碰版本控制的原子性。两边各守一摊。
前端这半:一个必须满足三律的合并
存档不是「后写覆盖」——那样多设备并发必然有人的练习被整包盖掉。它是一个收敛式模型:每个字段用一个可交换、可结合、可幂等的 join 合并。对任意两份合法存档 a、b,merge(a, b) 必须同时满足:
- 交换律:
merge(a,b) == merge(b,a)——谁先到无所谓; - 结合律:三份以上任意结合顺序结果一致;
- 幂等律:
merge(a,a) == a——同一份重复合并不变。
满足这三律,多设备以任意顺序、任意次数互相合并,都收敛到同一个结果,不需要任何中心裁决。这本质上是 CRDT 的思路。具体到各字段:
练习时长是一个带代际的 G-Counter。 这是最关键的一块:
timeCounter = { epoch, legacy, devices: { "a1b2c3d4": ms, "e5f6...": ms } }
每台设备只写自己那个 8 位 hex 槽、单调递增。合并规则是逐槽取 max——因为每台设备的槽只增不减,取 max 天然满足三律。总时长 = legacy + Σ devices。epoch(代际)是给「破坏性修正」留的口子:代际不同时,高代际整份胜出、低代际的数值绝不并入。(比如某次要清算一批异常刷出来的时长,就整体提一个代际,旧数值自动作废,而不是去改一个布尔标记——布尔标记会被后续或别的身份复用,代际不会。)
其余字段各用各的 join: 解锁进度、revision 取 max(单调不减);周时长先比周序、取较新周,同周才合并 counter,于是跨周的离线数据不会污染当前周;历史记录按 UUID 去重求并集、再确定性排序截最新 50 条;最佳成绩用一个全序比较(主指标降序 → 准确率降序 → 时间升序 → id 升序 → 记录 JSON 升序),保证同分也能确定性决胜。
后端这半:两条互斥 SQL 做 CAS
版本号不是 HTTP 的 ETag,是存在行里的一个计数器 saves.version。读返回 {data, version},没有行就是 {data: null, version: 0};写请求带 {expectedVersion, data}。
关键是没用通用的 UPSERT,而是两条互斥的 SQL——防止「缺行 + 非零版本」意外插入一条:
-- 首次写入:只有 expectedVersion === 0 才走这条
INSERT INTO saves (user_id, data, version, updated_at)
VALUES (?1, ?2, 1, ?3)
ON CONFLICT(user_id) DO NOTHING
RETURNING version, updated_at;
-- 更新已有行:只有 expectedVersion > 0 才走这条
UPDATE saves SET data = ?2, version = version + 1, updated_at = ?3
WHERE user_id = ?1 AND version = ?4 -- ?4 = expectedVersion
RETURNING version, updated_at;
两条都靠 RETURNING 有没有返回行来判成败:INSERT ... ON CONFLICT DO NOTHING 撞行了就不返回,UPDATE ... WHERE version = expectedVersion 版本对不上也不返回。没有返回行 = 冲突,抛 409。这就是对一行的 compare-and-swap,服务端一行合并逻辑都不用写。
拼起来:CAS 失败就拉取-合并-重试
两半合起来,客户端一轮同步是这样:GET 拿远端 {data, version} → 校验 → 用满足三律的合并把本地和远端 join 一份 → 带着刚拉到的 remote.version 提交。如果收到 409(提交期间别的设备先写了),就退避一下([100, 300]ms 加抖动)从头再来,最多 3 轮。
这里三律不是理论洁癖,是它撑住了整个重试的正确性:
- 重试安全靠幂等——一次重试重新合并再提交,不会把时长重复累加;
- 甚至**「服务端已提交、但响应在网络里丢了」也能收敛**:客户端超时后重新 GET、重新合并、重新提交,因为合并幂等,重放不会翻倍。这正是收敛模型相对「增量上报」的核心优势——增量上报一旦响应丢失,你不知道该不该重发,重发就可能多算。
几个魔鬼细节
浮点数不满足结合律,counter 算术得当心。 timeCounter 的总和原来按 Object.values(devices) 的插入顺序累加——但浮点加法不满足结合律,同一份 devices 因插入顺序不同,累加出的 total 会不一样,破坏了「校验和合并结果一致」的前提。改成先按 key 排序再求和。还有更刁的:legacy 补足到某个下限时,legacy + (下限 - 总量) 可能因舍入下溢一个 ULP,补完还是差一点。新实现改成对 IEEE-754 位模式二分查找能让总量达到下限的最小可表示 legacy——因为非负浮点数的位模式顺序和数值顺序一致,可以二分。
旧存档迁移要靠稳定摘要 ID 去重。 v1/v2 的历史记录没有 id。迁移时用一个自实现的纯 JS SHA-256 对「模式 + 记录规范字段」求哈希、塞成一个 UUID——这样同一条旧记录在多台设备各自迁移后得到相同 id,历史并集才能正确去重;按毫秒时间戳去重会漏。
写入路径强制一批不变量。 canonical v3 校验器只接受完整规范存档,并强制:计数器总量不得低于「用户确实练过这么久」的单调证据下限、周序不得倒退、设备 id 必须 ^[a-f0-9]{8}$、序列化后不超 100 KiB、拒绝 __proto__ 这类危险键——任一违反直接 422。合并可以宽容,落库必须严格。
复盘
- 切开责任:存储层做原子性,应用层做合并。Worker 只保证「带版本号原子存取」,前端做确定性合并——服务端不懂业务语义、也就不会成为合并逻辑的另一处真相源。CAS 用两条互斥 SQL +
RETURNING判空,一行合并代码都不用写; - 合并满足交换/结合/幂等,收敛就免费了。多设备任意顺序任意次合并都收敛到同一结果,不需要中心裁决;G-Counter(每设备一槽、取 max)是把「累加量」做成幂等合并的标准手法;
- 幂等是重试正确性的地基。有了它,CAS 失败重试、甚至「响应丢失后重发」都安全——这是收敛模型比增量上报强的地方:增量上报丢了响应就不敢重发;
- 浮点数不满足结合律,凡是累加要确定化。求和先排序、增长要防精度卡死、补足要防 ULP 下溢——分布式一致性里,连加法的顺序都是不变量的一部分;
- 代际胜过布尔标记。要做破坏性修正就提代际、让旧数值整体作废,而不是加一个「已修复」布尔——布尔会被复用,代际是单调的、能保证低代际数据永不复活。
留言