Skip to content

自动保存显示成功了,最后一次修改却没存上 ​

一份在内存里编辑、由后台定时保存的数据:用户改完,界面显示「已保存」,重启之后内容回到了上一版。日志里没有错误。另一次,存下来的数据里两个本该加起来等于 100 的字段,加起来是 130,而内存里它们从来没有不等于 100 过。

内存里的状态加一个后台保存线程,是很常见的结构:编辑器的自动保存、配置的本地缓存落盘、聚合了一段时间再写库的计数器、路由表的持久化。它通常用一个脏标记(dirty flag)来避免无谓的保存:改了就置位,保存时检查。问题出在三个地方:标记什么时候清、保存的是什么时候的内容、多次保存谁先谁后。

本文在 JDK 25.0.4 上用可注入失败和阻塞点的内存存储,把这几种时序逐个排出来。实验见文末。

一、先清标记再保存:失败的修改没有人再提起 ​

java
if (!dirty) return;
dirty = false;                 // 先清
store.write(render(state));    // 再写:这里失败了

实测修改为 v1 后第一次保存失败,后台任务又跑了两轮:dirty=false,存储里还是 v0,存储总共只被调用了 1 次。失败被记进了日志,但标记已经清掉,后面每一轮都认为「没有东西要保存」。直到用户下一次修改,这次的内容才会顺带存上;如果没有下一次修改,它就丢了。

二、成功后再清:保存期间的修改被一起清掉 ​

把清标记挪到保存成功之后,失败的情况修好了,又出现另一个窗口:

脏标记内存内容 v1内容 v2dirtytruefalse:v2 再也不会被保存保存线程写入 v1 ……成功,清标记版本号内存版本 1版本 2已保存版本01:小于内存版本2保存线程写入版本 1 ……写入版本 2保存期间:又改了一次第一次保存完成保存失败时同理:脏标记如果在保存前就清掉,失败的修改没有人再提起;版本号只在成功后前进
图 1 · 第 1 版正在写入存储时,用户又改出了第 2 版。脏标记只有「脏」和「不脏」两个值,分不清「这次保存的是哪一版」:保存完成后把标记清掉,第 2 版也跟着被当成已保存。换成版本号,保存完成时记下的是「已保存到第 1 版」,内存里是第 2 版,下一轮自然会继续保存(横轴为示意)

实测 v1 正在写入时用户修改为 v2,保存完成后 dirty=false,存储里是 v1,之后两轮没有触发保存。标记是一个布尔值,它表达不了「已保存的是 v1,内存里是 v2」。保存越慢(网络存储、大文件),这个窗口越宽。

给整个「读取、保存、清标记」加一把锁可以关掉这个窗口,代价是保存期间所有修改都被阻塞。慢存储上这等于把后台保存变回了同步保存。

三、保存的内容要是某一时刻的整体 ​

保存线程直接读可变对象的字段,还有第三个问题。两个预算字段之和应当恒为 100:

java
int online = budget.online;        // 读到 60
                                   // 此时另一个线程:online = 30; offline = 70;
int offline = budget.offline;      // 读到 70
store.write(online, offline);      // 存下 60 和 70

实测存储里是 online=60 offline=70,和为 130;内存里的两个值无论修改前后都是 100。存下来的是一个从未存在过的状态。字段越多、对象嵌套越深,保存线程读完一遍需要的时间越长,读到「一半新一半旧」的机会越大。这类数据在重启加载之后才暴露,离出错的时刻已经很远。

四、版本化的不可变快照 ​

三个问题可以用同一个办法解决:把状态做成不可变对象,带一个版本号,每次修改整体替换。

java
record Snapshot(long version, int online, int offline, String note) {}

final AtomicReference<Snapshot> current = new AtomicReference<>(initial);
long persistedVersion;             // 只由保存线程读写

void update(UnaryOperator<Snapshot> change) {
    current.updateAndGet(s -> change.apply(s).withVersion(s.version() + 1));
}

void saveRound() {
    Snapshot s = current.get();                    // 一次读取,拿到自洽的整体
    if (s.version() <= persistedVersion) return;   // 没有新版本
    if (store.writeIfNewer(s.version(), s)) {      // 失败会抛异常,下面一行不执行
        persistedVersion = s.version();
    }
}

上面省略了 withVersion 的定义和异常处理;修改函数不能有副作用,updateAndGet 在竞争时会重试它,原因见 volatile、CAS 与 LongAdder。

对照前三节:

时序结果
第一次保存失败已保存版本仍为 0;下一轮重试,变为 1
第 2 版保存期间修改为第 3 版保存完成时已保存版本 2、内存版本 3;下一轮保存第 3 版
保存线程读取期间发生修改读到的是修改前或修改后的完整对象,不会各取一半

「要不要保存」从一个布尔值变成了两个版本号的比较,失败和并发修改都不需要特殊处理:只要已保存的版本落后,下一轮就会继续。修改不会被保存阻塞,保存线程拿到快照之后,内存里怎么改都不影响它手里的那份。

这样做的前提是快照真的不可变。record 的字段如果是可变集合,要在构造时复制成不可变集合(List.copyOf),否则保存线程仍然会读到变化中的内容。数据很大时每次修改都复制整体的成本要考虑,可以只把经常一起变化的部分做成一个快照。

五、旧的写入后到 ​

保存如果可能并发(定时保存和关闭时的保存同时触发、多个实例写同一份数据、一次超时的写入其实还在路上),还要防止旧版本覆盖新版本:

存储第 5 版先写入,第 4 版的写入后到
不比较版本,后写覆盖最终是第 4 版
只接受更新的版本第 4 版的写入被拒绝,最终是第 5 版

版本比较要由存储在写入的同一个原子操作里完成。在数据库里是带条件的更新,UPDATE … SET content = ?, version = ? WHERE id = ? AND version < ?,看影响行数;对象存储和键值存储一般有各自的条件写。在应用里「先读版本、比较、再写」不行,两步之间别的写入可以插进来。超时的写入可能其实已经成功,重试时同样靠这个条件保证不会倒退,见一次 HTTP 调用超时了,对方到底执行了没有。

六、还剩下的问题 ​

  • 进程崩溃:最后一次保存之后的修改只在内存里。能接受丢多少,决定了保存间隔;不能接受丢失的数据不应该用后台保存,而应该在修改时同步写入或先写日志。
  • 关闭:停止接收修改之后再做最后一次保存,并确认它成功。关闭流程的顺序见队列设了上限,内存还是涨上去了。
  • 一直保存失败:已保存版本长期落后于内存版本,是一个应该报警的指标。暴露两个版本号的差值和最后一次成功保存的时间。
  • 界面上的「已保存」:应该由「已保存版本追上了用户看到的版本」得出,而不是由「保存任务执行过了」得出。

配套实验

参考资料

文章以 CC BY-NC-SA 4.0 授权 · 代码片段以 MIT 授权