自动保存显示成功了,最后一次修改却没存上
一份在内存里编辑、由后台定时保存的数据:用户改完,界面显示「已保存」,重启之后内容回到了上一版。日志里没有错误。另一次,存下来的数据里两个本该加起来等于 100 的字段,加起来是 130,而内存里它们从来没有不等于 100 过。
内存里的状态加一个后台保存线程,是很常见的结构:编辑器的自动保存、配置的本地缓存落盘、聚合了一段时间再写库的计数器、路由表的持久化。它通常用一个脏标记(dirty flag)来避免无谓的保存:改了就置位,保存时检查。问题出在三个地方:标记什么时候清、保存的是什么时候的内容、多次保存谁先谁后。
本文在 JDK 25.0.4 上用可注入失败和阻塞点的内存存储,把这几种时序逐个排出来。实验见文末。
一、先清标记再保存:失败的修改没有人再提起
if (!dirty) return;
dirty = false; // 先清
store.write(render(state)); // 再写:这里失败了实测修改为 v1 后第一次保存失败,后台任务又跑了两轮:dirty=false,存储里还是 v0,存储总共只被调用了 1 次。失败被记进了日志,但标记已经清掉,后面每一轮都认为「没有东西要保存」。直到用户下一次修改,这次的内容才会顺带存上;如果没有下一次修改,它就丢了。
二、成功后再清:保存期间的修改被一起清掉
把清标记挪到保存成功之后,失败的情况修好了,又出现另一个窗口:
实测 v1 正在写入时用户修改为 v2,保存完成后 dirty=false,存储里是 v1,之后两轮没有触发保存。标记是一个布尔值,它表达不了「已保存的是 v1,内存里是 v2」。保存越慢(网络存储、大文件),这个窗口越宽。
给整个「读取、保存、清标记」加一把锁可以关掉这个窗口,代价是保存期间所有修改都被阻塞。慢存储上这等于把后台保存变回了同步保存。
三、保存的内容要是某一时刻的整体
保存线程直接读可变对象的字段,还有第三个问题。两个预算字段之和应当恒为 100:
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。存下来的是一个从未存在过的状态。字段越多、对象嵌套越深,保存线程读完一遍需要的时间越长,读到「一半新一半旧」的机会越大。这类数据在重启加载之后才暴露,离出错的时刻已经很远。
四、版本化的不可变快照
三个问题可以用同一个办法解决:把状态做成不可变对象,带一个版本号,每次修改整体替换。
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 调用超时了,对方到底执行了没有。
六、还剩下的问题
- 进程崩溃:最后一次保存之后的修改只在内存里。能接受丢多少,决定了保存间隔;不能接受丢失的数据不应该用后台保存,而应该在修改时同步写入或先写日志。
- 关闭:停止接收修改之后再做最后一次保存,并确认它成功。关闭流程的顺序见队列设了上限,内存还是涨上去了。
- 一直保存失败:已保存版本长期落后于内存版本,是一个应该报警的指标。暴露两个版本号的差值和最后一次成功保存的时间。
- 界面上的「已保存」:应该由「已保存版本追上了用户看到的版本」得出,而不是由「保存任务执行过了」得出。
配套实验
- codesphere-labs/java/versioned-snapshot-persistence:JDK 25.0.4 上先清脏标记、成功后清脏标记、逐字段读取可变对象、版本化不可变快照、乱序到达的旧写入五种时序(验证记录)
参考资料