我自己写了个网页阅读器,手机和平板上都装着,读到哪儿两边同步。
它的数据库里,冲突框一共被处理过 170 次。其中 106 次,冲突发生前的上一次写入,来自同一台设备。
也就是说,62% 的「冲突」,是一台设备在跟它自己吵架。
这个阅读器从头到尾只有我一个人在用,两台设备。我原本以为这种规模的同步,出错也就是偶尔的边角。后来拿形式化验证工具跑了一遍,8 条我写下的规则,全部能找到反例,最快的不到一秒。
想直接拿两个公式的看第 2 章和第 5 章;想知道怎么看一份「验证通过」的报告,看第 6 章,我修完之后自己就有一条是空通过。
1. 这个阅读器是怎么同步的
规则不复杂,我先照代码念一遍:
- 服务端每本书存一行进度,带一个版本号。客户端写的时候报上「我手里的版本是几」,对上了才写,版本 +1;对不上就回 409,把服务端现在的值给你,前端弹冲突框
- 翻页后等 1.2 秒再推,翻得快就合并成一次。这个等待叫 debounce
- 切到后台或者锁屏,用浏览器的
sendBeacon把当前位置甩出去。beacon 的特点是发出去就不管了,响应读不到 - 断网时进度先存进本地的离线队列,一本书一条,有网再推
- 另外每次写成功,顺手双写一份到家里 NAS 上的 KOReader 同步服务(KOSync),这样墨水屏阅读器也能跟上。每次打开书时反过来检查一下 KOSync 有没有更新的进度,有就拉回来
每一条单看都挺合理。问题都出在它们凑到一起的时候。
2. 跟自己吵架的那 106 次
先说那 62% 是怎么来的。
翻一页,debounce 开始倒数 1.2 秒。如果 1.2 秒之内锁了屏,beacon 先把位置写上去了,服务端版本从 v1 变成 v2。可 beacon 读不到响应,手机手里的版本号还停在 v1。
解锁之后,那个还挂着的 debounce 照常发出去,报的版本是 v1。服务端一看,现在是 v2,409。冲突框弹出来,框里「云端」那一栏写的设备名,就是这台手机自己。
我用真代码在浏览器里复现了一遍,下面是输出摘录:
1) phone 读到 40% → { f: 0.4011, v: 1 }
2) 锁屏 beacon 后 → { f: 0.4521, v: 2 }
3) 回到前台: 冲突框出现= true 框里"云端"标签= 云端(iOS Safari)
翻完一页马上锁屏,这个动作我每天都在做。
这里能拿走的第一个数,任何带版本号同步的东西都能算:
自冲突比例 = 冲突前一次写入与冲突方是同一设备的次数 ÷ 冲突总次数
我的:106 ÷ 170 ≈ 62%
这个比例高,说明你的冲突检测在抓自己。正常的多设备冲突应该发生在设备切换的时候,同一台设备连续两次写入之间本来不该冲突。
有一点得先说清楚:上面那个锁屏机制能解释这个形状,但 106 条我没有逐条归因。里面可能混着别的路径,比如下面第 4 章那条冲突后自动拿版本号的 bug。
3. 把规则写成模型,TLC 跑了不到一秒
TLA+ 是一种把系统写成「状态 + 动作」的语言,TLC 是配套的检查器:它会把所有动作在所有顺序下的组合挨个走一遍,看有没有哪条路走到了你说不允许的状态。说白了就是暴力穷举时序。
我把两台设备、服务端、离线队列、KOSync、在途的网络请求全写了进去,每个动作旁边标上对应的代码行号。规模压得很小:两台设备,进度只有三档,每台最多读两次、开关书一次、断网两次。
然后写了 8 条「不许发生」,比如:
- 一台设备没见过更远的进度,却把服务端写回了更早的位置
- 离线队列里较新的条目被旧的覆盖或误删
- 关书时最后的位置既没上服务端也没进离线队列
- 跟自己上一次的写入冲突
每条单独跑。结果 8 条全部违反。我又把其中一条按来源拆成三条子规则,两条也违反,合计 10 条违规:
| 规则 | 走过的状态数 | 反例步数 | 耗时 |
|---|---|---|---|
| 跟自己冲突 | 567 | 4 | 不到 1 s |
| 关书丢位置 | 494 | 4 | 不到 1 s |
| 写回更早的位置 | 25,466 | 7 | 不到 1 s |
| 离线队列被覆盖 | 367,580 | 8 | 1 s |
| KOSync 把进度拉回旧值 | 2,024,203 | 9 | 3 s |
最短的反例只有 4 步。拿「跟自己冲突」那条说,大致就是翻页、锁屏、解锁、推送。我自己每天都在走这 4 步,只是从来没意识到它是个反例。
装起来有两个小坑。TLC 要 Java 11 以上,我机器上只有 8,装了个便携版 JDK 21。另外它的状态目录很能吃盘,跑大一点的规则一轮就写到 7G 左右,跑完自己删,但峰值能把一块快满的盘顶穿。我那天根分区见底,它就是帮凶之一。
4. 10 条反例,7 个是真的
TLC 给的是模型的反例。模型是我写的,我写错了它照样报。所以每一条都得拿真代码再走一遍。
我搭了一个小台子:服务端那段同步代码按标记原样切出来,逻辑一行不手抄;KOSync 换成本地假的一个,线格式一样;前端直接用仓库源码起;Playwright 开两个互相隔离的浏览器上下文当两台设备,真的去翻页、断网、切后台。下面的输出都是摘录,设备名我简化过。
10 条违规按根因去重,剩 9 个。最后 7 个复现了,2 个没复现。
最狠的两个:
冲突之后,下一次写入直接覆盖别人。 收到 409 时,前端顺手把自己的版本号改成了服务端的。冲突框弹着,你还没选,又翻了一页,这次版本号对上了,写成功。平板上读到的 60%,被手机的 30.6% 盖掉:
5) phone 在 30% 处翻一页 → 冲突框= true row { f: 0.6008, v: 4, dev: 'tablet' }
6) phone 再翻一页(没碰冲突框)→ { f: 0.3059, v: 5, dev: 'phone' }
冲突框是个弹窗,可键盘、手柄翻页根本不理它。
离线读的那一截,重新开书时被旧值盖掉。 地铁里离线从 30% 读到 50%,出站有网,打开书。代码先拉服务端(30%),跳过去,然后才推离线队列(50%)。服务端是 50% 了,屏幕还停在 30%。这时候翻一页,30.4% 写上去,50% 没了:
4) 重新打开书: 屏幕显示 30.1 % ; row { f: 0.5008, v: 2 }
5) 在屏幕位置翻一页: 屏幕 30.4 % ; row { f: 0.3036, v: 3 }
没复现的那 2 条,是模型比代码宽。模型里我允许离线也能开书,TLC 顺着这条路找出了「离线开书从头开始」,另一条的最短路径也踩在离线开书上。真代码离线时连书架都加载不出来,直接显示「书不存在」,这条路根本走不到。这个 bug 不存在,可它顺带告诉我:离线阅读只在「开着书时掉线」这一种情况下成立。
9 个里 7 真 2 假。我一开始挺想把 10 条全报成 bug 的,数字好看。
5. 跨时钟比较:我以为怕的是时钟漂移
KOSync 那条,我原来以为毛病在时钟上。
回灌的判据是这样的:KOSync 上那条记录的时间戳,比服务端这行的更新时间晚 1.5 秒以上,并且不是同一台设备写的,就当成「别处有更新」拉回来。KOSync 的时间戳是 NAS 的钟,服务端是另一台机器的钟,两个钟在比。
我第一反应是测时钟偏差,结果两边差在 ±20 毫秒以内,1.5 秒的余量绰绰有余。
可它照样出事。复现用的是这个顺序:
1) phone PUT 30% → 服务端 v1,双写 KOSync(这次在 NAS 上卡了 4 秒)
2) tablet PUT 60% → 服务端 v2,双写 KOSync 先落盘
3) phone 那次旧写入后到 → KOSync 变成 30%,时间戳反而是最新的
4) 任一设备打开书 → {"fraction":0.3,"version":3,"deviceId":"phone"}
旧写入后落盘,带的就是新时间戳。这里一个钟都没漂,只是两个请求的到达顺序反了。
这个窗口有多大,按代码里两个常数就能算。设手机那次双写在路上走了 d 秒,平板在手机之后 g 秒写入。回灌会发生,要求旧写入落盘的时刻比平板那次写入晚 1.5 秒以上:
d > g + 1.5
d 最多到双写的超时,5 秒
⇒ 两台设备先后写入的间隔 g < 5 − 1.5 = 3.5 秒,就有可能被拉回旧值
推广一下:
回退窗口 = 写入链路允许的最大延迟 − 时间戳比较的安全余量
余量比延迟上限小,这个窗口就一定存在,跟钟准不准无关。3.5 秒是按常数推出来的上限。NAS 平时多久能响应完,我没测,所以触发概率我说不出来,只能说它不是零。
修法也没去调余量:服务端自己推到 KOSync 的值,回来时一律不回灌,只回灌真正的墨水屏阅读器写的;双写按书排队,只推最新一次。
6. 怎么看一份「0 违规」
这一章是给读别人验证报告的时候用的。我自己修完以后,就差点被自己的「通过」骗了。
修复做完,TLC 再跑修好的模型,7 条规则各跑 300 秒,每条走了四千多万个不同状态,没有反例。真代码那边,一个检查器 28 项全过。
听起来稳了。三个地方要拆开看:
跑完没有。 300 秒是我设的上限,到点就停,状态空间没穷尽。修复前有一条规则我跑了 15 分钟、生成了 2.7 亿个状态没找到反例,也是我手动停的。TLC 没跑完时报 0 违规,意思就是「这么多状态里没撞见」,跟证明差得远。
那条路径在模型里走得到吗。 修完以后,「KOSync 把进度拉回旧值」这条在模型里是通过的。我回头查可达性才发现,修复加了「自己推过去的值不回灌」,而模型里的两台设备全是我自己的阅读器,没有一台墨水屏,回灌那段逻辑在模型里一次都没被执行过。这条规则是空通过:它检查的那条路根本没人走。真正的墨水屏回灌,最后是靠一个真代码用例兜住的。
检查器能不能分出好坏。 跑这 28 项的同时,我把同一个检查器喂给修复前的代码,报 10 项 FAIL。这一步不做,28/28 什么都说明不了,一个永远报绿的检查器也能 28/28。
所以看到「形式化验证通过」,先问三句:跑完了还是到点停的;它检查的那条路径,在模型里有没有被走到过;同一套判据喂给已知有 bug 的版本,会不会报红。三句里任何一句答不上来,这个「通过」先打个问号。
7. 这次没做到的
- 模型规模很小:2 台设备、3 档进度、每台最多读 2 次。更长的序列里有没有别的东西,不知道
- 时钟偏差只建模了 0。偏差超过 1.5 秒时,KOSync 还有一条「推送丢失 + 漂移」的路,没探索也没复现。实测偏差 ±20 毫秒,暂时不急
- 锁屏是拿 Playwright 的
visibilitychange模拟的,断网是setOffline。真 iOS Safari 冻结后台页面的行为只会更糟,不会更好 - KOSync 那条用的是我注入的 4 秒延迟。NAS 的真实延迟分布没测,所以第 5 章只给得出窗口上限,给不出概率
- 106 次自冲突没有逐条归因到具体机制
- 同一台设备开多个标签页、阅读器的独占会话和冲突交织的全部时序,都没建模
8. 下次再写同步逻辑,按这个顺序过一遍
- 先翻一下冲突记录,算自冲突比例。高于一半,先别查多设备,先查自己的 debounce、beacon 和版本号什么时候前进
- 任何「发出去不看响应」的写入,都要问:它成功之后,本地的版本号谁来更新
- 收到冲突时,别顺手采用服务端版本号。冲突没解决之前,后续写入要停
- 开书、恢复网络这类时刻,先推本地积压,再拉远端
- 有跨机器时间戳比较的地方,把「最大延迟 − 安全余量」算出来。大于零,就别靠时间戳判谁新
- 把规则写成 TLA+ 跑一遍,最短的反例往往只有四五步,几秒钟就出来。每条反例都拿真代码复现一遍再算 bug
- 修完后的「0 违规」,补三件事:跑完没有、路径可达吗、检查器喂旧代码会不会红
- TLC 的状态目录放到空间大的盘上