老刘
发布于 2026-10-26 / 0 阅读

从"后写覆盖"到三层裁决——两台设备同时改了进度,听谁的

从"后写覆盖"到三层裁决——两台设备同时改了进度,听谁的

旦阅诞生记 · 11。同步协议有了身份(BookKey)和坐标(绝对下标),还差最激烈的一环:冲突。手机上读了一晚上,白天在电脑上又翻了几章,晚上两台设备的进度一起涌向服务端——听谁的?这一篇复盘 v1.0.5 的那次重构:裁决规则从一行 if 变成了三层,而且每一层都不是白给的。

最初的方案:后写覆盖

第一版同步的裁决规则朴素到只有一句话:谁的时间戳新,谁赢。

if (dto.ClientUpdatedAt >= rec.ClientUpdatedAt) {
    rec.Progress = dto.Progress;    // 位置、章节、已读完……全部覆盖
    ...
}

这是分布式系统里的 LWW(Last-Write-Wins),加上"个人书库、两三台设备"的约束,它甚至基本够用——大多数时候,"最近在读的那台设备"确实代表用户当前的意志。作为 v1 的裁决,它的简单是优点,不是错误。

问题出在阅读这个场景的两个特殊性上,LWW 恰好各踩一个。

LWW 在阅读场景的两个误判

误判一:回看也是一次"新"写入。

时间戳只回答"什么时候写的",回答不了"写到哪了"。用户在服务端进度 80% 的时候,拿起手机往回翻了两页查一个伏笔——这两页翻动产生了更新的 clientUpdatedAt,一次同步就把书架进度打回第 3 章。用户什么都没做错,进度自己倒退了。书架上的百分比、"已读完"的标记,全都跟着遭殃。

误判二:离线设备永远翻不了身。

手机在飞机上读了两小时,攒下一大段宝贵的进度;落地同步时,只要电脑在你读的过程中有过任何一次更新(哪怕只是打开阅读页刷了下 UpdatedAt),手机的时间戳就输了——而且按 LWW 的规则,输一次就一直输:它下次同步时进度反而"更新"了,但服务端那台电脑的时间戳总是比你大几分钟。两台设备从此隔空对峙,谁也不认谁的。

还有两个副作用:服务端为了防时钟漂移把超前的客户端时间夹到自己的 now,结果一台时钟快 10 分钟的设备每次上报都等于"此刻",在 LWW 里立于不败之地;Finished 被无条件覆盖,另一台设备随手翻一页就能把"已读完"抹掉。

四个症状指向同一个病根:我们只用一个数字(时间)去裁决一个本质上是二维的问题——"读到哪"其实是"最远读到哪"和"这次停在哪"两个量。

拆开:水位、位置、时间

重构把"读到哪"拆成三个维度,各自有各自的裁决规则(对应服务端 MergeInto,两个写入入口共用):

维度 含义 裁决规则
水位 maxProgress / maxCharOffset 读过的最远处 取两端更远,只增不减
位置 progress / charOffset 这次停在哪 前进无条件;小幅回退按时间;大幅回退需显式跳转
时间 clientUpdatedAt 最后一次真实阅读 降级为辅助,不再单独裁决

核心代码值得整段读——这是整个同步协议里逻辑最密的一块:

private static bool MergeInto(
    BookProgress rec, ProgressDto dto, long clientAt,
    string deviceId, string deviceName, long now)
{
    // …… 输入归一化、水位兜底(老记录无水位字段时用当前位置顶上)……

    var positionAdvance = Ahead(inProgress, inOffset, rec.Progress, rec.CharOffset);
    var backward = rec.Progress - inProgress;   // > 0 表示这次上报往回退了

    // ① 位置:前进无条件采纳(不看时间戳);
    //    小幅回退按时间;大幅回退只有显式跳转才采纳。
    bool acceptPosition;
    if (positionAdvance || dto.Seek)
        acceptPosition = true;
    else if (backward <= SmallBacktrack)          // 2%
        acceptPosition = clientAt >= rec.ClientUpdatedAt;
    else
        acceptPosition = false;

    if (acceptPosition) { /* 写入位置、章节、LastSeek、设备标识 */ }

    // ② 水位:只进不退。回看不会让它倒退,落后设备的补传也照样能推进。
    if (waterAdvance)
    {
        rec.MaxProgress = inMaxProgress;
        rec.MaxCharOffset = inMaxOffset;
        rec.MaxAt = clientAt;
        changed = true;
    }

    // ③ 已读完:终态。自然阅读只能置上,取消需要显式 Seek。
    if (dto.Seek) { rec.Finished = dto.Finished; }
    else if (dto.Finished && !rec.Finished) { rec.Finished = true; }

    // ④ 元数据随时补,但不参与冲突判定
    ApplyMeta(rec, dto);
    ...
}

三层各自的道理:

**水位只增不减。**它是"这本书读到过最远哪"的公证,取两端更远者——回看两页不会让它倒退,离线设备的补传哪怕时间戳输了也能推进它。书架的百分比、"已读完"从此以水位为准,LWW 的两个误判在这里同时失效。

**位置分三种情况。**前进无条件采纳——这是关键的一步放权:前进不需要时间戳背书,因为位置前进的本质是"用户读到了新的地方",不存在需要保护的旧状态;2% 以内的小幅回退(排版差异、重扫索引造成的微抖动)按时间先后裁决,这是 LWW 剩下的合理领地;超过 2% 的大幅回退,只有客户端显式标了 Seek 才采纳——点目录、拖进度条、搜索定位算,逐页翻动不算。

**时间降级为辅助。**它不再单独决定胜负,只在"小幅回退"这个窄缝里说话;服务端也不再粗暴地夹到 now,Clamp 上限放宽到 now + 2 分钟,时钟略快的设备不会凭空获得永远的优势。

Seek 是协议的一部分,不是 UI 细节

这套规则能转起来,靠一个容易低估的设计:客户端必须诚实地区分"翻页"和"跳转"。

桌面端用一个显式的挂起标记走完了完整生命周期:

// BookSession.cs —— 点目录/拖进度条等显式跳转时置位
if (seek && placeChanged) Book.SeekPending = true;

// ProgressMapper.cs —— 上报时把标记带给服务端
Seek = book.SeekPending,

// SyncService.cs —— 拉取完成后清零,一次性语义
b.SeekPending = false;

用户在手机上点了目录第 3 章,这是意志,服务端必须采纳并记下 LastSeek 回传给其他设备——电脑下次拉取时知道"这次回退是对方点目录来的",于是放心跟随,而不是把用户的显式跳转当成进度倒退拒绝掉。反过来,逐页翻动产生的"回看"不带 Seek,只影响本机,下次同步也不会把正在回看的读者拽回最远处。

一次翻页和一次跳转在 UI 上只差一个手势,在协议里差一个布尔字段。冲突裁决的精度上限,取决于客户端对用户意图的表达精度——这是那次重构里最有普适性的一课。

两个工程细节

**老数据升级。**水位是新字段,EnsureCreated 不会给已有 SQLite 库加列。服务端启动时跑一个幂等的 EnsureUpToDate:补列,并把老记录的当前位置回填成水位——否则升级后的第一次合并会把水位当 0,全书架进度集体倒退,一次升级变成一场事故。老版本客户端也兼容:不上报水位时服务端用当前位置兜底,只是享受不到"回看不倒退"。

比较要留死区。"谁更靠前"的判定 Ahead 不是裸比较:

private const double EpsProgress = 0.0005;    // 进度容差 0.05%
private const long OffsetDeadZone = 32;       // 字符死区 32

private static bool Ahead(double aProgress, long aOffset, double bProgress, long bOffset)
{
    if (aProgress > bProgress + EpsProgress) return true;
    if (aProgress < bProgress - EpsProgress) return false;
    return aOffset > bOffset + OffsetDeadZone;
}

进度比例相同(同一刻度)才比绝对下标,下标还要留 32 字符的死区——抵消两端排版像素差带来的微小平移。没有死区,两台设备的进度会围绕同一个位置无限互相覆盖,Revision 疯涨而用户毫无感知。

验证:规则复杂了,测试就得更硬

LWW 一行 if 不需要专门测试,三层规则必须。这版配了三层验证:

  • 桌面端自检新增 TestWatermarkRules:水位不倒退、回看不被拽走、另一端显式跳转要跟随、上报时位置与水位分离;
  • 服务端新增 watermark <地址> 联调命令,对真实 HTTP 服务跑 8 个场景 10 项断言;
  • 加上既有的界面冒烟,9606 项全通过后才发的 v1.0.5。

收尾

把这篇压缩成三句话:

  1. 一个时间戳裁决不了两个维度的问题。"最远读到哪"和"这次停在哪"是两个量,混在一个字段里,任何单一规则都会在另一半场景上误判。
  2. **"前进"和"回退"的信任级别天然不同。**前进不需要理由;小幅回退看时间;大幅回退必须有明确的用户意志背书。把信任分级,比找一条万能规则可靠得多。
  3. **协议的精度上限是客户端意图表达的精度。**想让服务端聪明,先让客户端诚实。

下一篇讲一个看起来不像同步问题的同步问题:手机报 10 小时、电脑报 5 小时的阅读时长,为什么直接相加总是错的。


评论