ADR 0004:离线复习 —— 只追加的日志与确定性重放
日期:2026-09-17 · 状态:已采纳
背景
「一张卡一个归属端」(不变量 3)让手机必须联网复习,而且归了桌面的卡片手机永远碰不到。真正的 移动端离线复习需要客户端自己调度、事后把记录合并回来——而两端各自调度同一张卡时,纸面状态 (stability / difficulty / due)会分叉。
分叉不能靠「合并状态」解决:状态是推导出来的,不是事实。真正的事实是这次复习发生了。
决策
ReviewLog是只追加的事实,卡片状态是它的投影。 每条日志带一个客户端生成的稳定client_id(唯一索引)与device_id;重复上传按client_id幂等跳过,同一条永远不会被算两次。- 合并 = 追加 + 重放,不比较状态。 上传后,对每张被触及的卡按
reviewed_at时间序重放它 全部mode == "scheduled"的日志,得到的 FSRS 状态写回卡片;不取任何一端的stability/difficulty。时间序相同时按自增id兜底,保证确定性。 - 客户端用 ts-fsrs 本地调度,参数必须与后端一致(
desired_retention与个性化fsrs_parameters都从/api/settings取)。两端口径不一致时,离线期间显示的到期时间会与 合并后的实际安排不同——不算数据损坏,但是说谎。 - 上传失败留在本地队列(
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 不参与唯一索引去重,重放照常包含它们。
