BetterAutoSave v0.19.0 — Forge 1.20.1 / NeoForge 1.21.1 异步存档优化
v0.19.0 — 玩家存档与 level.dat 的数据安全加固,以及自动保存的主线程尖峰优化
0.19.0 补上了三处原版遗留的静默数据丢失路径,为 level.dat 建立起完整的读写校验闭环,并新增两项默认关闭的性能优化。数据安全类修复默认开启,性能类优化默认关闭
(0.18.0 未公开发布,其内容已并入本版)
一、三条会静默丢数据的路径
这三处都是原版行为,触发时不报错、不中断、玩家自己也未必立刻察觉
玩家存档读取失败等同于新玩家。 playerdata/<uuid>.dat 读取抛异常时,原版记一行日志然后按新玩家处理——背包、位置、经验全部归零,而 <uuid>.dat_old 就躺在同一个目录里从未被查看。写盘中途断电、磁盘坏块、外部工具截断文件都会走到这里
playerData.loadFallback(默认开启)改为:先把损坏的主文件隔离为 <uuid>_corrupted_<时间戳>.dat 保留证据,再尝试 .dat_old,读成功则正常走一遍数据修复器并返回。两份都读不出来时才回落到原版行为。主文件缺失(真正的新玩家)这条路径行为完全不变
成就与统计文件是截断写。 advancements/<uuid>.json 和 stats/<uuid>.json 原版直接覆盖写入实时文件,没有临时文件、没有备份。写到一半掉电,留下的是一个长度截断的 JSON——下次读取解析失败,该玩家所有成就与统计清零
playerData.atomicSidecarWrite(默认开启)改为临时文件 + 原子改名,并在改名前把上一份留作 .bak。文件系统不支持原子改名时自动回退
消除截断窗口靠的是「临时文件 + 原子替换」本身。额外的 fsync 另有一个独立开关 playerData.sidecarFsync,默认关闭:PlayerList.save 跑在服务器主线程上,每名在线玩家每次自动保存都要写这两个文件,开着它就是把两次同步刷盘按人数放大钉在同一个 tick 上(60 人即 120 次)。原版在这条路径上一次 fsync 都没有,连 playerdata/<uuid>.dat 都没有。而 ext4 在默认的 data=ordered 下,本来就会在提交「覆盖已有文件的改名」之前把新写的数据刷下去。只有在主机没有带电池的写缓存、且确实要防非正常断电时才建议打开,并配合下面的分批错峰使用
崩溃关服会跳过存盘收尾。 BetterAutoSave 的四个关服守卫此前都挂在 ServerStoppingEvent 上。服务器异常退出时该事件从不发出,另有一种情况是其它 mod 在该事件里抛异常打断了整条事件链——两种情况下守卫全部失效,队列中在途的存盘任务被直接丢弃
本版把关服标记提前到 stopServer 入口,绕开事件机制。同时把降级善后的契约上移成 SaveTask 接口的方法:此前那段按类型分派的代码在遇到未登记的任务类型时会静默丢弃它,并且外层仍然计入"已妥善处理 N 个"
二、level.dat 的读写校验闭环
level.dat 存着世界种子、出生点、游戏规则、维度配置和 Forge 的注册表 ID 表。它损坏的后果不是丢一块地皮,是整个世界打不开,或者更糟——用一份被判定为"可读"的空白元数据启动,世界种子变成 0
原版只有一层保护:写盘时把上一份轮转为 level.dat_old。但读取侧只在文件存在但读不出来时才会去看它,判据是"能不能解析成 NBT"
本版补上另外三层,都默认开启:
levelData.verifyOnStartup— 启动时按四级判据检查(文件缺失 / 无法解压或解析 / 结构不完整 / 正常),前三种从level.dat_old自动修复。"结构不完整"指能解析成 NBT 但缺少Data、DataVersion或LevelName,这正是原版判据放行、而后果最严重的那一类levelData.startupBackup— 通过校验后立刻在<世界>/betterautosave/leveldat/下留一份原始字节副本,保留三代。原版永远不会读取这个目录,所以它不参与自动修复;检测到主副本和level.dat_old同时损坏时,日志会直接打出可复制的还原命令,由管理员决定levelData.postWriteVerify— 写盘后在工作线程上把文件读回来校验一遍(默认CHECKSUM,完整流式解压以触发 gzip 的 CRC 与长度校验;FULL额外做结构判据;OFF关闭)。这条只读、不修复,作用是让"写坏了"在下一次自动保存把好的那份轮转掉之前就被发现
三、自动保存的主线程尖峰(issue #25)
用 spark 观察一台 mod 较多的服务器,MSPT 曲线上通常能看到每 5 分钟一根整齐的尖峰,即使服上一个人都没有。它不来自区块存盘,而来自原版每次自动保存都会无条件重写一遍 level.dat
这里要先纠正一个容易误判的归类:出问题的是世界根目录下的 level.dat(世界元数据),不是 world/data/ 下的 *.dat(SavedData)。BetterAutoSave 此前的异步存盘只覆盖后者,level.dat 这条路径一直在覆盖范围之外
而 mod 装多以后,level.dat 里绝大部分内容是 Forge 的注册表 ID 对照表。真实生产服实测(Forge 1.20.1,137 个 mod):
| 项 | 实测值 |
|---|---|
level.dat 解压后大小 |
1,234,370 字节 |
其中注册表 ID 表 fml/Registries |
1,215,091 字节(98.44%,17 个注册表 / 26,648 条 ID) |
世界业务数据 /Data |
12,018 字节(0.97%) |
| 主线程重建该表的开销 | 每次自动保存约 25ms |
把相隔 5 分钟的两代 level.dat 解压后逐字节比对,1,234,370 字节中只有 5 个字节不同,且全部落在 /Data 区间——注册表那 1,222,341 字节完全相同。也就是说,每 5 分钟花 25ms 重算一遍的内容,算出来跟上次一模一样
新增 levelData.cacheRegistrySnapshot,开启后缓存该表并在后续保存中复用。缓存由三层相互独立的机制失效,任一触发即重建:
- Forge 的
IdMappingEvent,覆盖官方全部三条 ID 变更路径 - 每次写盘前对每个持久化注册表采一次指纹(条目数与冻结状态),兜住
ForgeRegistry.unfreeze()这条公开且不发任何事件的路径 levelData.registryCacheRevalidateCycles周期性强制重算一次并与缓存逐字段对拍,不一致则记 ERROR 并采用实时结果
这项优化只减少主线程重建工作,不改变写盘时机、不引入后台线程、不触碰 level.dat 的落盘协议
需要留意的是,缓存命中时会跳过 ForgeHooks.writeAdditionalLevelSaveData 整个方法,其它 mod 注入到该方法里的逻辑也会一并跳过。目前没有已知的这类 mod(该方法标注为内部 API 且只有一处调用),且缓存内容本身就取自那些注入全部执行过的一次结果;若某个 mod 往里写入随时间变化的数据,周期性对拍会将其报为 MISMATCH
生产环境实测:连续运行 15 小时 18 分,93 次周期性强制重算全部一致、零 MISMATCH,四次 save-all 取回的 level.dat 注册表区间逐字节相同,writeAdditionalLevelSaveData 的主线程耗时由 76ms 降至 16ms
四、玩家存盘的主线程开销
在 4 名玩家的生产服上抓取,每次自动保存里 PlayerList.saveAll 约占 80ms,约合每人 6.7ms,其中成就文件占 55%、玩家存档占 30%、统计文件占 15%。这一项随人数线性增长,60 人时会外推到每次自动保存约 400ms
本版提供两个默认关闭的开关:
playerData.advancementsSkipMode — 原版每次自动保存都会无条件重写每个在线玩家的成就文件,无论进度是否变化。开启后按脏标志跳过。三档:OFF(原版行为)、AUDIT(照常写,但与上次内容摘要对拍,只在判断本会出错时记录,用于确认脏标志在你的 mod 组合下没有漏判)、ON(真正跳过)
这里有一处刻意的实现选择:没有复用原版的 progressChanged 集合。它被 flushDirty 每 tick 清空,自动保存时几乎恒为空,拿它当写盘脏标志会把确实变了的保存也跳掉。本版用独立标志,只在授予或撤销进度真正成功时置位
跳过写入唯一真正失去的东西,是原版"每次无条件重写"顺带具备的、对外部改动(备份还原、管理员手改文件)的自愈性。playerData.advancementsForceFullWriteCycles(默认 12)通过定期强制全写把它还回来,同时也兜住绕过标准接口改进度的第三方 mod
playerData.staggerMaxPerTick — 把一次自动保存的玩家写盘分摊到随后的若干 tick,默认 0 即原版行为(全部在同一 tick 内写完)。只在自动保存窗口内生效,/save-all、关服和玩家退出走的仍然是即时写入
建议的开启路径与注册表缓存一致:先 AUDIT 跑几天确认日志中没有出现不一致,再改 ON
五、新增配置项
| 配置项 | 默认 | 作用 |
|---|---|---|
playerData.loadFallback |
true |
玩家存档读取失败时隔离并回退到 .dat_old |
playerData.atomicSidecarWrite |
true |
成就与统计文件改为原子写并保留一份备份 |
playerData.sidecarFsync |
false |
上一项额外做一次同步刷盘(代价在主线程,按人数放大) |
levelData.verifyOnStartup |
true |
启动时校验 level.dat 并从 level.dat_old 修复 |
levelData.startupBackup |
true |
启动时留存三代 level.dat 副本 |
levelData.postWriteVerify |
CHECKSUM |
写盘后在工作线程回读校验 |
levelData.cacheRegistrySnapshot |
false |
缓存注册表 ID 表,消除自动保存尖峰 |
levelData.registryCacheRevalidateCycles |
12 |
周期性强制重算并与缓存对拍 |
playerData.advancementsSkipMode |
OFF |
成就文件按脏标志跳过写入 |
playerData.advancementsForceFullWriteCycles |
12 |
连续跳过若干次后强制一次全写 |
playerData.staggerMaxPerTick |
0 |
玩家存盘分摊到多个 tick |
六、双端差异
本版新增内容中,levelData 与 playerData 两组配置目前仅 Forge 版提供
注册表缓存在 NeoForge 上不存在对应问题:上游已经把注册表 ID 表整个从 level.dat 中移除,没有可缓存的对象,也就没有这根尖峰。其余各项是 Forge 版先行,NeoForge 版的对称移植将在后续版本跟进。README 中的双端功能对照表已补齐,标注了每一项当前的覆盖范围
七、验证
- 单元测试 436 项全部通过(公共模块 69 + Forge 232 + NeoForge 135),其中本版新增 46 项
- 对每一处新增逻辑做了变异检查:移除备份复位逻辑、移除备份轮转、移除脏标志判断、移除
DataVersion判据、移除回读重试、移除关服窗口复位、移除主开关判据、移除统计写失败的回退后,相应用例均如期失败 - 发布前另做了一轮针对性的对抗式代码审查,确认并修掉 6 个问题(2 个会在真实负载下造成主线程回归或玩家存档回退,4 个较轻)。上文中「
fsync独立成开关」「关服路径强制复位自动保存窗口」「回读校验带重试」「新增开关一律受主开关约束」都是这一轮的结果 - 注册表缓存在生产服连续运行 15 小时 18 分,详见上文第三节
八、升级
- 替换 jar 即可,存档格式不变
- 数据安全类修复默认开启,性能类优化默认关闭;配置文件中的新键会在首次启动时按默认值补齐,已有设置不受影响
level.dat与玩家存档的落盘格式与原版一致,可随时回退到更早版本