Skip to content

ADR 0004:离线复习 —— 只追加的日志与确定性重放

日期:2026-09-17 · 状态:已采纳

背景

「一张卡一个归属端」(不变量 3)让手机必须联网复习,而且归了桌面的卡片手机永远碰不到。真正的 移动端离线复习需要客户端自己调度、事后把记录合并回来——而两端各自调度同一张卡时,纸面状态 (stability / difficulty / due)会分叉。

分叉不能靠「合并状态」解决:状态是推导出来的,不是事实。真正的事实是这次复习发生了

决策

  1. ReviewLog 是只追加的事实,卡片状态是它的投影。 每条日志带一个客户端生成的稳定 client_id(唯一索引)与 device_id;重复上传按 client_id 幂等跳过,同一条永远不会被算两次。
  2. 合并 = 追加 + 重放,不比较状态。 上传后,对每张被触及的卡按 reviewed_at 时间序重放它 全部 mode == "scheduled" 的日志,得到的 FSRS 状态写回卡片;不取任何一端的 stability / difficulty。时间序相同时按自增 id 兜底,保证确定性。
  3. 客户端用 ts-fsrs 本地调度,参数必须与后端一致desired_retention 与个性化 fsrs_parameters 都从 /api/settings 取)。两端口径不一致时,离线期间显示的到期时间会与 合并后的实际安排不同——不算数据损坏,但是说谎。
  4. 上传失败留在本地队列localStorage),联网后批量重传;被拒绝的条目(例如卡片已不存在) 返回给客户端且不落库,客户端保留它们,不静默丢弃。

理由

  • 为什么不是「最后写入获胜」(LWW):它会丢掉另一端的整条复习记录。记录是你花了时间做的 事实,丢掉它等于让这段时间白费;而且 LWW 合并后的 stability 无法由任何单一历史复现,之后 再也不能用日志审计调度。
  • 为什么不是 CRDT:CRDT 解决的是「多个副本对同一份可变状态并发写、需要自动收敛」。而这里 的可变状态(卡片字段)根本不需要同步——它可以从日志重算。用 CRDT 是给一个本可以只读的投影 支付并发合并的复杂度,收益为零。合并后的值必须可由历史复现,CRDT 的合并结果通常不可。
  • 为什么重放是对的:日志天然有序(时间 + id)、重放是纯函数(相同日志 + 相同参数 → 相同 状态)、幂等(追加被跳过就不改变输入)。冲突消解变成排序问题,没有冲突消解代码。
  • client_id 由客户端生成:离线时拿不到服务端 id;UUID4 让重传与去重不依赖网络往返。

代价与边界

  • 重放用「当前」参数,不是历史参数。 如果用户中途换过 fsrs_parameters(跑过优化器),重放 结果与当时逐次调度的结果可能不同。这是有意的取舍:重放回答的是「以今天的参数看,这段历史 对应什么安排」,而不是复刻旧安排。优化器拿到的输入因此也是「当前参数下的历史」。
  • 时区与时钟偏移。 日志按客户端记录的 reviewed_at 排序;跨设备时钟不同步会改变顺序。 单用户本地优先场景下接受这一假设,不引入向量时钟。
  • 从不与任何服务器见面的复习会丢。 设备损坏且记录只在它本地时,那段历史不存在——这是离线 优先的固有限制,不是本方案的缺陷。
  • 不变量 3 的政策意义不变,约束方式变了。 归属端仍然决定「哪台设备看到这张卡」,但不再是 「只能由它调度」;两端都复习时,合并按时间序接受两者。同一张卡仍不会被同一个设备重复安排。
  • 不覆盖 quiz 日志。 mode == "session_quiz" 不参与重放也不改变状态(不变量 1),它们只被 追加记录。
  • 遗留日志 client_id 为 NULL。 迁移不回填;NULL 不参与唯一索引去重,重放照常包含它们。

代码 AGPL-3.0-or-later · 词典数据 © EDRDG,CC BY-SA 4.0