Skip to content

Releases: ShinoyukiMiyako/Shinoyuki-BetterAutoSave

BetterAutoSave v0.20.1 — Forge 1.20.1 / NeoForge 1.21.1 异步存档优化

Choose a tag to compare

@github-actions github-actions released this 22 Aug 03:35
3e92575

v0.20.1 — 修复与 Lithium 系优化 mod 同装时启动崩服

0.20.0 与 Harium、Radium、Radium Re-Reforged、Canary 一类 Lithium 移植同装时会导致服务器启动崩溃。装了这些 mod 的用户请直接升级到 0.20.1,或临时回退到 0.19.0。只有 0.20.0 受影响

一、症状

启动时 mixin 应用失败,整个配置被拒绝:

Mixin apply failed shinoyuki_betterautosave.mixins.json:ServerChunkCacheSyncLoadMixin
  -> net.minecraft.server.level.ServerChunkCache
InvalidInjectionException: @At("INVOKE") on ...betterautosave$measureSyncChunkLoad with priority 1000
  cannot inject into ServerChunkCache::m_7587_ merged by
  me.jellysquid.mods.lithium.mixin.world.chunk_access.ServerChunkManagerMixin with priority 1000

失败的是 0.20.0 新增的同步加载检测器,但代价是整份 shinoyuki_betterautosave.mixins.json 一起被拒绝——异步存盘、异步加载全部随之失效,服务器起不来

二、原因

0.20.0 的同步加载检测器用 @WrapOperation 包住 ServerChunkCache.getChunk 里那次等待区块就绪的调用,并声明了 require = 0,本意是「注入点不存在时静默跳过,不让一个纯观测功能把服务器搞崩」

这个声明不足以覆盖真实的失败模式。Lithium 系用 @Overwrite 整体接管了 getChunk,而 Mixin 在 Injector.findTargetNodes 里有一条更早的判定:注入器的优先级不高于已经 merge 该方法的 mixin 时,直接抛 InvalidInjectionException。两边优先级都是默认的 1000,不满足条件;这条判定发生在 require 的命中数检查之前,所以 require = 0 根本没有被问到

0.20.0 发行说明里「这种情况下同步加载检测静默不生效,而不是让服务器启动失败」这句话,方向是对的,实现没有兑现。这一版补上

三、修复

同步加载检测器的注入改为在类加载期先过一道门控,命中任一条就完全不注入这个 mixin:

  1. 磁盘上 common.tomldiagnostics.syncLoadDetectionfalse(缺文件、缺段或缺键按默认值 true 处理)
  2. 在场 mod 中含已知会 @Overwrite getChunk 的:hariumradiumcanarylithium

门控关闭时打一行 WARN 指明是哪个 mod 触发的:

[BetterAutoSave] sync chunk load detector disabled: mod 'harium' overwrites
ServerChunkCache.getChunk, which Mixin will not let us wrap. Async saving and loading are unaffected

这一行是必要的:否则「装了 0.20 却看不到任何同步加载报告」将无从解释。存盘、异步加载以及其余全部功能不受该门控影响

没有采用提高该 mixin 优先级的做法。那样 BAS 会先应用,对方的 @Overwrite 随后把整个方法体连同本注入一起覆盖掉,结果是崩溃风险转移给对方而本功能照样失效

四、名单之外的同类 mod

第 2 条是白名单,只覆盖已知的四个。若今后出现同样 @Overwrite getChunk 而不在名单上的 mod,把 diagnostics.syncLoadDetection 设为 false 并重启即可(走第 1 条判定),不必等待版本更新

判定逻辑对两个加载器完全一致;NeoForge 侧此前没有 mixin 配置插件,这一版补齐

五、验证

  • 529 项单元测试,其中门控判定规则 11 项,覆盖白名单匹配、大小写、误报反向断言、TOML 段落识别与默认值语义
  • 测试服 1.20.1 双向实测:BAS 0.20.0 + Harium 2.0 可稳定复现启动崩溃;换 0.20.1 后服务器正常启动,日志中 WARN 指明 harium;移除 Harium 后服务器正常启动且不打该 WARN,检测器照常注入
  • 上述崩溃异常原文已随修复一并记入源码注释,避免今后再次用 require = 0 去应对同类问题

六、升级

替换 jar 即可,存档格式与配置键不变。0.19.0 及更早版本不含该 mixin,不受此问题影响

BetterAutoSave v0.20.0 — Forge 1.20.1 / NeoForge 1.21.1 异步存档优化

Choose a tag to compare

@github-actions github-actions released this 21 Aug 03:17
7063ee5

v0.20.0 — 把主线程同步区块加载与 tick 间停顿变成可观测的

0.20.0 新增两项诊断能力,只观测、不干预:主线程同步区块加载检测,以及 tick 之间长停顿的监控。两项都默认开启,两个加载器功能一致

一、这两类卡顿以前查不到

一台 74 人在线的生产服出现整服冻结。用 spark 抓下来之后,主线程上 BetterAutoSave 自己的存盘路径(NbtIo 写入)只占 0.03%,存盘不是原因。真正的两个来源是:

  • 一次由第三方调用发起的主线程同步区块加载,单次阻塞 5.2 秒
  • 两段发生在 tick 与 tick 之间的停顿,17.1 秒和 14.8 秒

第二类尤其麻烦。MSPT 只统计一个游戏刻内部的耗时,两刻之间的等待不计入,所以这 17 秒在 TPS 曲线和常规监控面板上完全看不见——面板一切正常,玩家却在断线。把这两处从 spark 的 inclusive 调用树里翻出来花了数小时人工排查。0.20.0 要做的就是让服务器自己把它们报出来

二、主线程同步区块加载

区块不在内存里时,如果有代码在主线程上直接把它要过来,主线程就得原地等到硬盘读完、必要时连地形生成一起等完,这期间整个服务器停住。原版在正常运行中很少走到这条路径,实际触发它的通常是在事件回调、定时任务或命令里直接按坐标取区块的第三方逻辑

BAS 在原版 ServerChunkCache.getChunk 里那次「等待区块就绪」的调用上做计时,超过 diagnostics.syncLoadThresholdMs(默认 50 毫秒,即一个游戏刻)时记一次,内容包括阻塞时长、区块坐标、维度,以及调用栈中第一个非原版类:

[BetterAutoSave] main-thread sync chunk load: 5188ms at (120,-340) in minecraft:overworld, called from com.example.protection.RegionScanner

同一个来源只在首次出现时打印一行,之后只累加进统计表,避免跑图时刷屏

这个调用点位于原版四槽区块缓存未命中之后的分支上:请求的区块已在缓存里时根本执行不到这里。未命中时也只多两次 System.nanoTime()(数十纳秒),而调用栈只在确实超过阈值之后才采集——正常运行中一次都不采

三、tick 间停顿

一个游戏刻结束到下一刻开始之间,服务器一边等下一刻到来,一边处理任务队列。这段时间不计入 MSPT。队列里的东西(其它 mod 提交的任务、区块系统回调、命令执行)如果卡住,MSPT 可以一直保持健康,而玩家已经卡了十几秒

默认档在 tick 开始与结束各取一次时间戳,间隔超过 diagnostics.tickGapThresholdMs(默认 1000 毫秒)即记一次:

[BetterAutoSave] inter-tick gap: 17100ms after tick 148213 (this time is not counted in MSPT)

默认档只报「有多长、发生在哪个 tick 之后」,不报是谁。要落到具体任务,打开 diagnostics.tickGapDeepAttribution(默认关闭):它给任务队列里的每个任务单独计时,把耗时超过阈值十分之一(默认即 100 毫秒)的任务按实际 Runnable 类型归档。这一档每个任务多两次纳秒取时,而服务器每刻要跑几百个任务,所以只建议在已经收到 tick gap 报告、需要缩小范围时临时打开,查完关掉

四、关于归因的措辞

检测报告的是「阻塞发生在哪条调用链上」,不是「谁有 bug」。同步取区块在很多场景下是完全合理的写法,只是代价随服务器规模、视距和硬盘速度放大;一次 5 秒的等待里,磁盘、地形生成以及同一台机器上的其它负载都可能是主因。检测到的阻塞多数来自第三方 mod 的调用模式,不代表该 mod 存在缺陷

请把归因结果当作定位的起点,而不是结论。命令输出里固定带着同一句话:

note: stalls listed above are attributed to the call site, not to a defect in the owning mod.

五、三条查看路径

/betterautosave diagnose [数量](默认 10,可填 1-50)打印当前累计与 Top N 归因,/betterautosave diagnose reset 清空两张统计表并重置日志去重记录。完整输出样例见 docs/CONFIGURATION.md 第八节

reset 刻意不清累计计数(bas_sync_load_stalls_total 一类):Prometheus 的 counter 必须单调递增,清零会破坏 rate()。命令回执里会说明这一点

周期性诊断摘要(diagnostics.diagnosticLogging,默认开启)末尾新增两行:

[BetterAutoSave]   |- syncLoad: stalls=37 totalBlocked=48210ms tracked=2 top=examplemod=31400ms x12, com.example.map.RegionCache=14600ms x19
[BetterAutoSave]   `- tickGap: exceeded=2 max=17100ms last=14800ms after tick 148213 deepTasks=0

Prometheus 导出器新增四个指标:

指标 类型 含义
bas_sync_load_stalls_total counter 超阈值的主线程同步区块加载次数
bas_sync_load_stall_seconds_total counter 上述阻塞的累计秒数
bas_tick_gap_exceeded_total counter 超阈值的 tick 间停顿次数
bas_tick_gap_max_seconds gauge 本次启动以来最长的一次 tick 间停顿

六、新增配置项

配置项 默认 作用
diagnostics.syncLoadDetection true 主线程同步区块加载检测总开关
diagnostics.syncLoadThresholdMs 50 单次阻塞达到该毫秒数才记录(1 - 60000)
diagnostics.syncLoadTrackLimit 64 同时追踪的(归因主体,调用栈)组合上限,超出按 LRU 逐出。重启生效
diagnostics.syncLoadStackDepth 24 每次记录保留的非原版栈帧数(4 - 128)
diagnostics.tickGapDetection true tick 间停顿检测总开关
diagnostics.tickGapThresholdMs 1000 间隔达到该毫秒数才记录(50 - 600000)
diagnostics.tickGapDeepAttribution false 深度档:逐任务计时以定位造成停顿的任务
diagnostics.tickGapDeepTrackLimit 64 深度档表的 LRU 上限,深度档关闭时无效。重启生效

除两个 TrackLimit 外全部可热重载,改完即刻生效

七、已知的限制

  • 如果同时装了重写区块获取路径的 mod(C2ME 一类),BAS 的探针可能因为目标调用不复存在而装不上去。这种情况下同步加载检测静默不生效,而不是让服务器启动失败——纯观测功能不值得换来一次启动崩溃。注入点是否还在由构建期的门禁测试保证,运行期不再硬校验
  • 归因取的是调用栈中第一个非原版类的全限定名;能在加载器的 mod 文件扫描表里匹配到时换成 modid,匹配不到就保留类名。调用链经过事件总线时,这个类名可能是事件订阅者而不是最初的发起方,深一层的线索在命令输出的栈帧行里
  • 深度档「阈值的十分之一」这个派生系数没有实测依据,是「构成一次 1 秒停顿的单个任务通常在 100 毫秒量级」的工程估计。若真机上发现漏掉的中等任务太多,会在后续版本把它提为独立配置项
  • 两个 TrackLimit 在服务器启动时冻结,改完需要重启

八、行为变更

周期性诊断摘要现在在管线降级会话中也继续输出,此前它随降级一起停摆。同步加载与 tick 间停顿由外部调用模式引起,与 BAS 自己是否降级无关,而降级会话往往正是最需要观测的时候。副作用是存盘指标摘要在降级会话里也会继续打印

九、升级

  • 替换 jar 即可,存档格式与既有配置键不变
  • 顺序要对:先换 jar 重启,让新版本把 8 个新键写进 common.toml,再去改它们。反过来先改配置再换 jar,运行中的旧 jar 会把它不认识的键当作非法项删掉
  • 不想要这两项检测时,diagnostics.syncLoadDetectiondiagnostics.tickGapDetection 设为 false 即刻关闭

十、验证

  • 统计表的滑动窗口、p99、LRU 逐出、并发写入,栈过滤规则,以及新增四个指标的累加与「取最大值」语义,均有单元测试覆盖,并逐条做了变异检查
  • 两个平台各有一份构建期 ASM 门禁,断言探针包住的确实是那次等待调用,且计时闭合与采栈都发生在它返回之后——防止后续改动把「常态不采栈」这条性质改掉
  • 一处如实的覆盖缺口:把类名换成 modid 的那张反查表依赖加载器运行期的 mod 文件扫描结果,没有单元测试,匹配失败时退回全限定类名

十一、为什么这一版做的是诊断

BAS 经过评估,在目前的极致兼容性前提下已经没有更多的异步区块优化空间可做;尚有空间的地方,动了都会破坏兼容性,导致数据安全问题和各种 mod 间的兼容问题

同一次 74 人压测采样 550 秒:主线程上 NbtIo 写入占 0.03%,ChunkSerializer 序列化占 0.67%,BAS 自身全部开销合计 1.42%。剩下的确实还有——copySections 对空 section 也无条件做两份 PalettedContainer.copy 约 0.1 个百分点,POI 回放批量化约 0.1 至 0.2 个百分点——但都已在噪声量级,不改变兼容性前提能拿到的收益不足半个百分点

也不能说区块加载不再是瓶颈,事实相反:瓶颈在区块系统中 BAS 够不着的那半边,同一份采样里 DistanceManager 的距离场传播占掉了区块系统主线程预算的 82%,真正推进加载的任务只占 18%

再往前一步的代价是具体的:ForgeCaps 搬到 worker 线程与 issue #8 那次数据丢失同一家族,挂区块 capability 的 mod 会静默丢数据;ChunkDataEvent.Load 搬到 worker 线程会让所有监听方在非主线程上被回调,不抛异常,只是慢慢腐化;POI / SectionStorage 搬到 worker 线程会让村民 AI 数据静默腐化,因为 SectionStorage 不是线程安全的;接管 DistanceManager 面对的是一个线程不安全的状态机,且与 C2ME 直接冲突;强制双端安装或强制全量异步则要放弃单端安装、opt-in、随时可回退这三条

性能这条路已经走到了兼容性允许的尽头,所以这一版换了方向:不再挖那零点几个百分点,而是让服务器自己说清楚卡顿来自哪里。完整论证见 docs/ROADMAP.md 的「明确不做及其理由」

BetterAutoSave v0.19.0 — Forge 1.20.1 / NeoForge 1.21.1 异步存档优化

Choose a tag to compare

@github-actions github-actions released this 03 Aug 14:46
2511de4

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>.jsonstats/<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 但缺少 DataDataVersionLevelName,这正是原版判据放行、而后果最严重的那一类
  • 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

六、双端差异

本版新增内容中,levelDataplayerData 两组配置目前仅 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 与玩家存档的落盘格式与原版一致,可随时回退到更早版本

BetterAutoSave v0.17.0 — Forge 1.20.1 / NeoForge 1.21.1 异步存档优化

Choose a tag to compare

@github-actions github-actions released this 31 Jul 11:55
f77ffad

v0.17.0 — 主线程存盘取材成本减半

0.17.0 是一个性能版本。区块存盘在主线程上的取材成本降低约一半,来源是消除一处此前每次快照都会执行、但结果从不被使用的全区块遍历。本版同时修补一处第三方生物群系容器可能被直接引用进异步快照的数据安全隐患

消除快照期的无效方块计数(issue #24

主线程 capture 此前把每个 section 的两个调色板容器包进一个 LevelChunkSection。该构造器会无条件调用 recalcBlockCounts(),对每个调色板多于一项的 section 遍历全部 4096 格并逐格喂进一个哈希表,一个区块 24 个 section 约合 9.8 万次哈希写入

而它算出的三个方块计数(非空方块数、随机刻方块数、随机刻流体数)只服务于活跃区块的 tick 调度与网络同步,原版序列化 section 时一个都不读——计数全错的副本,落盘字节与正确副本逐字节相同。也就是说这次遍历的结果在存盘链路上从未被使用

本版改为只携带 worker 编码真正需要的两个容器,落盘 NBT 字节不变,不涉及任何 mixin 或原版行为改动

真实生产服前后对比(Forge 1.20.1,137 个 mod,AMD Ryzen 9 9950X3D2,ZGC,60 秒采样,eventCompatMode = PARTIAL):

主线程帧 改前 改后
快照取材入口 captureWithGeneration 6692ms(占忙碌时间 19.1%) 2948ms(8.2%)
其中 section 取材 copySections 4096ms 1024ms
方块计数重算 recalcBlockCounts 3152ms 0(调用已不存在)
BetterAutoSave 主线程总占用 占忙碌时间 25.8% 15.2%

两次采样的存盘区块量以不受本次改动影响的帧交叉核对,改后一轮反而高出约 15%,故上表降幅未被工作量差异夸大

附带收益:每个 section 一个临时哈希表的分配消失后,ZGC 重定位读屏障(forwarding_find)同步下降 32%。调色板容器本身的深拷贝因此也快了 20%,尽管这部分代码未改

需要说明的是,这不会提升一台本就健康的服务器的 TPS——两次采样 TPS 均为 20。它换来的是主线程余量:同样的存盘吞吐占用更少主线程时间,在高负载或大存档下更不容易挤占刻预算

FULL 兼容档不再重复取材

eventCompatMode = FULL 下,快照取材(section 拷贝、光照层克隆、高度图克隆、方块实体 NBT、结构数据拷贝)此前在模式分支之前无条件执行,但该档实际使用的是原版 ChunkSerializer.write 产出的完整 NBT,而 write 内部会自行采集同一批数据。这批先采后废的工作现已跳过

此前为规避 issue #8 而改用 FULL 档的用户,本版起不再承担双倍的主线程取材开销

数据安全

活跃 section 的生物群系容器若不是原版的 PalettedContainer 实现(原版不会出现,仅在第三方 mod 提供只读容器实现时触发),此前会被直接引用进异步快照。只读接口没有拷贝方法,无法脱钩,意味着 worker 编码期间主线程仍可改动同一对象。本版改为在主线程当场编码为 NBT 带走,与活跃容器彻底分离

升级

  • 替换 jar 即可,无配置变更,存档格式不变
  • 落盘 NBT 与此前逐字节一致,可随时回退到 0.16.3

BetterAutoSave v0.16.3 — Forge 1.20.1 / NeoForge 1.21.1 异步存档优化

Choose a tag to compare

@github-actions github-actions released this 08 Jul 05:10
150f4d3

v0.16.3 — 修复 Modrinth 上 NeoForge 用户下到 Forge jar + 自 0.16.2 起的修复合集

0.16.3 主要修复一个分发问题:Modrinth 上 NeoForge 1.21.1 用户按默认下载拿到的是 Forge 1.20.1 的 jar,装上后 FML 报「需要 forge / 1.20.1」,在用户侧表现为「不兼容 neoforge」。mod 本身从未有此问题——0.16.2 的 NeoForge jar 经真机在 NeoForge 21.1.233 与 21.1.235 上均正常加载与运行。本版同时打包自 0.16.2 以来累积的一批数据安全与配置修复

分发修复(本版主因)

此前每次发版把 Forge 与 NeoForge 两端 jar 塞进同一个 Modrinth 版本,并给该版本打上两端加载器(forge、neoforge)与两个 MC 版本(1.20.1、1.21.1)的并集标签。Modrinth 的标签作用于整个版本,默认下载的是版本里的首个文件(Forge 的自包含 -all.jar)——于是这一个版本会同时出现在「NeoForge 1.21.1」「Forge 1.20.1」乃至并不存在的「Forge 1.21.1」「NeoForge 1.20.1」等所有组合下,NeoForge 用户过滤后点默认下载拿到的恒是 Forge jar

本版把发布管线改为按加载器各发一个独立 Modrinth 版本(+forge / +neoforge),每版只挂对应那一个 jar、即自身默认下载,各自只出现在正确的加载器与 MC 版本下。GitHub Release 侧一直是两个 jar 分别具名,不受影响

数据安全与正确性

  • 多维度同名 SavedData 的在途去重键改用文件路径,修复不同维度同名数据被误判为同一份的隐患
  • 在线单 chunk 回退的 install 环节补齐真正的事务回滚;回退尾段的异步光照/重发链补 exceptionallyAsync,异常不再被静默吞掉
  • 修复存盘降级翻转的残窗内、已过闸门的 task 被静默丢失
  • 异步加载(opt-in,仅 Forge)的 replay 失败与 worker 解析失败分开承接,并订正误导日志
  • 异步加载 mixin 改为按 load.enabled 门控应用,功能关闭时对字节码零介入
  • SavedData 大文件守卫判据改按未压缩内存足迹,更贴合真实尖峰

配置变更

  • 移除无效的 entityChunksPerTickBase 旋钮(此前不起作用)
  • deadlineGuardSeconds 下界抬到 5,杜绝设为 0 时关掉存盘截止兜底

性能与兼容

  • NeoForge 端 SavedData 移植 serialize-once,消除主线程上 tag.copy 深拷贝的存盘尖峰
  • 启动时探测到 fastasyncworldsave 等同样接管 ChunkMap.save 的异步存盘 mod 会打 WARN,提示二者二选一

升级

  • 替换 jar 即可
  • NeoForge 1.21.1 用户:Modrinth 现按加载器正确分发,直接下 NeoForge 版即可(旧的错标合并版本已下架)
  • config 里若手动设过已移除的 entityChunksPerTickBase,或把 deadlineGuardSeconds 设在 5 以下:启动时会被自动订正

BetterAutoSave v0.16.2 — Forge 1.20.1 / NeoForge 1.21.1 异步存档优化

Choose a tag to compare

@github-actions github-actions released this 02 Jul 00:01
8d547b1

v0.16.2 — mixinextras 版本范围热修(多模组启动崩溃)

0.16.2 是一个打包热修:放开内嵌 MixinExtras 依赖的版本上界,修复在装有较新模组的整合包中服务器启动即崩的问题。仅影响 Forge 1.20.1 端;不改任何运行期行为

修了什么

自 0.13 起,Forge 端把内嵌的 mixinextras-forge 在 jarJar 里声明为 [0.3.5,0.5)——上界封死在 0.5 以下。当整合包里存在要求 mixinextras >=0.5 的模组(如 C2ME、letmefix、hariplayer、dcfixes 等)时,两个版本区间交集为空,FML 的 JarSelector 选不出一个能同时满足所有模组的 mixinextras 版本,直接在 mod 加载阶段抛「conflicting versions of io.github.llamalad7:mixinextras-forge」并崩服,服务器无法启动。这条上界原本是为了避免版本冲突,但生态普遍升到 0.5.x 后,它反而成了冲突的唯一来源(#22

本版把「内嵌版本」与「声明范围」解耦:内嵌固定 0.5.4,对外声明兼容范围放开为 [0.3.5,)。放开上界后,FML 会选出所有模组共同满足的最高版本;仍保留 0.3.5 下界,不会反向强制只装 0.4.x 的整合包升级。NeoForge 端由平台自带 MixinExtras、不经 jarJar 内嵌,不受影响

验证

  • 实构建产出的自包含 jar 内 META-INF/jarjar/metadata.jsonrange=[0.3.5,)artifactVersion=0.5.4,内嵌 mixinextras-forge-0.5.4.jar
  • 对照崩溃日志区间求交(BAS [0.3.5,) 与其余模组 >=0.5.0~>=0.5.4)后收敛到 0.5.4,冲突消除

升级

  • 替换 jar 即可,无配置变更
  • 与需要 mixinextras 0.5.x 的模组(C2ME 等)同装、且此前卡在启动崩溃或退回旧版规避的服务器:建议升级

BetterAutoSave v0.16.1 — Forge 1.20.1 / NeoForge 1.21.1 异步存档优化

Choose a tag to compare

@github-actions github-actions released this 30 Jun 23:37
caa5887

v0.16.1 — entity 终态接力修复 + NeoForge 1.21.1 随版发布

0.16.1 是一个数据安全热修:补齐 entity 存盘在 IO 重试耗尽这一终态路径上唯一漏接力的分支,杜绝实体卸载期的最新增量在持续写盘失败时被静默丢弃。同时 NeoForge 1.21.1 经双端真机回归后随本版正式发布,与 Forge 1.20.1 并行维护

修了什么

entity 存盘有四条「在飞碰撞后把最新实体列表接力落盘」的路径,此前只有 IO 重试耗尽(FAILED_TERMINAL)这一条在取出待接力快照后直接丢弃,而非像其余三条那样重投。触发场景:某坐标实体在卸载瞬间被改写、其首代写盘连续失败到耗尽重试预算、且更新代已登记为待接力——此时实体已被原版驱逐出内存、entity 路径又无坐标恢复队列兜底,这份最新增量(命名生物、盔甲架、展示框等)会永久静默丢失

本版让该终态分支与区块侧 handleTerminalFailure 完全对称:待接力快照存在时重投一次,沿用已耗尽的重试预算保证一步级联收敛、不在持续 IO 故障下无限接力;无快照可投时才记 ERROR 收尾。Forge 与 NeoForge 两端逐字节同构修复

NeoForge 1.21.1 随版发布

NeoForge 1.21.1 此前因未做真机回归而未随版发布。本版前对两端做了真机端到端验证(Forge 1.20.1 测试服与 NeoForge 1.21.1 服务端:加载、存盘、entity 计数、关服 drain 均清白),故 0.16.1 起 NeoForge 1.21.1 与 Forge 1.20.1 一同发布。NeoForge 端不含异步区块加载(该功能仅 Forge)

验证

  • entity 终态接力落盘 + 接力再败一步收敛,新增两项回归单测,两端测试套件全绿
  • 双端真机端到端:worker 全部干净 drain(97ms / 90ms),entity 路径零失败、无 gauge 泄漏

升级

  • 替换 jar 即可,无配置变更
  • 跑高卸载量(大型农场、刷怪塔、频繁传送)且实体密集的服务器:建议升级

BetterAutoSave v0.16.0 — Forge 1.20.1 异步存档优化

Choose a tag to compare

@github-actions github-actions released this 29 Jun 21:57
6bba72a

v0.16.0 — 异步区块加载(正式版)

0.16.0 把 v0.15 引入的异步区块加载打磨到正式版:加载侧 POI 读盘后台化、capability 子系统的线程安全对称补齐、关服路径彻底加固。异步加载仍默认关闭、需手动开启(load.enabled),面向追求加载性能、愿意 opt-in 的服主。仅 Forge 1.20.1 构建

这一版带来什么

异步加载(PARTIAL 模式)把区块反序列化从主线程搬到后台 load worker。0.16 周期围绕这条路径做了三件事:

  • Tier A 异步 POI 预读:load worker 在反序列化那一刻顺手把该列 POI region 字节读到后台,主线程回放前填好缓存,消除原本主线程在 tryRead().join() 上的同步读盘等待。生产服真机(137 mod,8 load worker,高速飞行)主线程 POI getOrLoad 从 15.0% 降到 0.1%,加载侧主线程开销净降约 13.7%
  • capability 子系统线程安全对称补齐:区块加载时 LevelChunk 构造会派发全局 AttachCapabilitiesEventreadCapsFromNBT 会触发第三方 capability 的反序列化——这两者与已经回主线程的 ChunkDataEvent.Load 同性质(第三方代码普遍假设主线程)。本版把它们一并挪回主线程回放,修复了为区块挂持久 capability 的 mod 在异步加载下的潜在线程安全隐患。回放忠实执行、capability 数据照常读回,无数据丢失
  • 关服路径加固:load / save worker 改为 daemon 线程并加 JVM 关闭兜底 hook,防止别的 mod 在关服事件链上抛异常打断 BAS 收尾时拖死整个服务器;load 在途任务也纳入关服 drain 屏障,与存盘侧对称

验证

  • 发版前对 capability off-thread 路径做了多角度对抗审查 + 155 项单元测试(含 capability defer 的逐字节回放断言)
  • 测试服 reobf jar 真机端到端:异步 load worker 加载 spawn 区块经 capability defer + 主线程回放,零异常、零数据偏差

安全与兼容

  • 无损:capability 的 gather 与反序列化按原顺序回主线程忠实回放,与 vanilla 同步加载逐字等价
  • 失败兜底:worker 解析失败自动退回主线程 vanilla 读,只读磁盘字节、零数据丢失
  • 未开异步加载(默认)的用户:本版加载路径无任何行为变化

配置([load] 段)

  • load.enabled(默认 false):异步区块加载总开关,opt-in
  • load.asyncPoiPrefetch(默认 true):异步加载开启且 PARTIAL 模式时,在 load worker 上预读 POI region;POI 存储相关 mod 异常时设 false 退回主线程同步读

升级

  • 替换 jar 即可。未开启异步加载(load.enabled=false,默认):行为无变化
  • 为区块挂持久 capability 的整合包:本版修复了这类 capability 在异步加载下的线程安全隐患,建议升级

BetterAutoSave v0.16.0-beta.1 — Forge 1.20.1 异步存档优化

Choose a tag to compare

@github-actions github-actions released this 27 Jun 17:01
f594493

v0.16.0-beta.1 — 异步 POI 预读(Tier A,加载侧性能)

本版在 v0.15 异步加载的基础上,把区块加载时的 POI region 读盘从主线程挪到后台 load worker。异步加载开启后,这段 POI 读盘等待是主线程上最大的剩余开销;本版将其基本清零。功能随异步加载自动生效,仅 Forge 1.20.1 构建。异步加载本身仍默认关闭、需手动开启

这是什么

  • 异步加载(PARTIAL 模式)把区块反序列化搬到后台,但 PoiManager.checkConsistencyWithBlocks(POI 一致性校验)仍回主线程回放,而它触发的 POI region 读盘让主线程在 tryRead().join() 上同步阻塞等磁盘
  • spark 实测:高速移动 / 大视距下,这段 POI 读盘等待是异步加载开启后主线程的头号剩余开销
  • 本版让 load worker 在反序列化那一刻顺手把该列 POI region 字节读出后台,主线程回放前用预读字节填好 POI 缓存,随后的一致性校验直接命中缓存、不再读盘

实测效果

  • 生产服真机(137 mod 重整合包,8 load worker,高速飞行):主线程 POI getOrLoad15.0% 降到 0.1%,那段同步读盘等待清零,主线程区块加载开销净降约 13.7%
  • TPS 满,零解析回退,POI 数据零偏差

配置([load] 段)

  • load.asyncPoiPrefetch(默认 true,可热重载):是否在 load worker 上预读 POI region。仅当异步加载开启(load.enabled=true)且模式为 PARTIAL 时生效。设 false 回到主线程同步读 POI(vanilla 行为),作 POI 存储相关 mod 异常时的兜底

安全与兼容

  • 线程安全:worker 只触底层 IOWorker(线程安全),绝不碰非并发的 POI 缓存;POI 缓存的解析与写入全部留在主线程回放,与所有其它 POI 访问同线程序
  • 不丢数据:预读到的是该列已存档的 POI 字节,主线程解析填缓存与 vanilla readColumn 逐字等价;若该列在回放前已被加载,跳过预读字节、以活数据为准
  • 失败兜底:POI 预读失败不影响区块加载,自动退回主线程按 vanilla 读 POI
  • 在线区块回退路径不预读(回退的是已加载区块,POI 多在内存,预读会被护栏跳过而浪费)

为什么仍标 beta

  • Tier A 是异步加载的加载侧增强,异步加载本身仍在公测、默认关闭;本版随同标记预发布
  • 已真机验证主线程开销清零、零丢数据信号,但多人长时间并发的稳态仍在持续观察

升级

  • 替换 jar 即可。未开启异步加载(load.enabled=false,默认)的用户:本版无任何行为变化
  • 已开启异步加载的用户:Tier A 默认自动生效(asyncPoiPrefetch=true),无需额外配置;要关闭设 asyncPoiPrefetch=false

BetterAutoSave v0.15.0-beta.1 — Forge 1.20.1 异步存档优化

Choose a tag to compare

@github-actions github-actions released this 27 Jun 09:28
a0848ed

v0.15.0-beta.1 — 异步区块加载(可用版,默认关闭)

0.15.0-beta.1 是异步区块加载首个能在真实生产服上运行的预发布。上一个公开预发布 v0.13.0-beta.1 的 jar 漏打了 MixinExtras 依赖,真实服务器一旦开启异步加载会因缺类直接崩溃;本版修复打包、发布自包含 jar,并把异步加载实现重写为非阻塞。功能仍默认关闭、需手动开启,仅 Forge 1.20.1 构建。开启前请先备份整个世界文件夹

关键修复:MixinExtras 打包

  • 异步加载的指令注入基于 MixinExtras 的 @WrapOperation。v0.13.0-beta.1 发布的 jar 未把该依赖打入,真实服务器加载异步加载 mixin 时抛 ClassNotFoundException 崩溃(开发环境自带该依赖,故此前只在 dev 验证、未暴露问题)
  • 本版发布自包含 jar(内嵌 MixinExtras),安装即用、无需手动补依赖。这是异步加载首个可在生产环境验证的版本

这是什么

  • 原版加载区块时,把硬盘字节解析成游戏对象(ChunkSerializer.read)压在主线程上;视距大、玩家快速移动 / 传送时这步成为主线程负担
  • 开启后纯反序列化走后台 load worker,主线程只保留必须当场做的部分:POI 一致性、光照分段、ChunkDataEvent.Load 回放
  • 收益落点是「视距 10-12 + 多人」的生产服:腾出的主线程时间转成 TPS 余量、加载更顺、容纳更多玩家
  • 不是飞行测速的银弹:纯单人视距拉满的极限飞行撞的是原版单线程区块流水线天花板,异步解析帮不上那一段——这部分无法在不破坏 mod 兼容的前提下并行

不优化什么

  • 优化的是已存在区块的存读盘序列化边界(写盘、从盘读回),不碰 worldgen。凭空生成新地形不受影响:全新区块根本不经过 ChunkSerializer.read,异步加载只对"以前生成并存过盘"的区块生效

v0.13.0-beta.1 以来的改动

  • 非阻塞 future 链:异步加载内部从「@WrapOperation + join 阻塞等待」重写为非阻塞 future 链,主线程不再为等待后台解析而停顿
  • 在飞限流(LoadInFlightLimiter):限制同时在途的解析任务,避免一批区块同时解析完、回放全砸进同一游戏刻造成瞬时卡;maxInFlight 默认 32 → 128,且支持配置热调
  • 细粒度 Codec 锁:解析路径的并发保护收窄到 Codec 派发缓存一点,降低 worker 间争用
  • 已并入 v0.12.1 的 SavedData 深拷贝尖峰修复(issue #12):超大 .dat 存盘不再在主线程整份深拷贝
  • 新增在线区块回退的底层协调(SaveCoordination):供配套 BetterBackup 在不停服情况下原地回退单个或一片区块;BAS 单独安装时此为休眠的底层能力,需配合 BetterBackup 才有对应指令

配置([load] 段,默认关闭)

  • load.enabled(默认 false):异步加载总开关,独立于存档侧的 general.enabled
  • load.loadEventCompatMode(默认 PARTIAL):PARTIAL = 解析走后台、POI/光照/事件回主线程;FULL = 整段解析留主线程(等于本功能关闭、零行为偏差,作 mod 不兼容时的兜底)
  • load.maxInFlight(默认 128,可热调):同时提交给后台的解析任务上限,防止一批区块同时解析完、回放全砸进同一游戏刻造成瞬时卡,按服逐步上调到突发重现为止
  • load.loadMaxRetries(默认 1):后台解析抛错时的重试次数,用尽退回原版主线程读取
  • workers.loadWorkerThreads(默认 2):异步加载后台线程数,解析基本单线程瓶颈,2 个够用;高核数机器配合高视距 / 高速移动可适当上调

安全与兼容

  • 数据安全:后台解析失败用同一份字节在主线程重读兜底,任何情况不丢数据
  • 一键兜底:loadEventCompatMode=FULLload.enabled=false 即刻回到原版加载行为,配置热重载无需重启
  • mixin 兼容:所有区块读取注入点用 @WrapOperation 实现,与 architectury 等改同一指令的 mod 共存,不抢占
  • 与其它「自己也异步加载区块」的 mod(C2ME-Forge)二选一;其余 mod 正交不冲突

为什么仍标 beta

  • 已在 137 个 mod 的重整合包(Create / 沉浸载具等密集方块实体)真机验证:自包含 jar、异步加载开启,450 m/s 高速飞行下 TPS 锁 19.99,重区块反序列化确认离开主线程,零 worker 异常、零降级、零丢数据信号
  • 但密集区反复存读、多人长时间并发尚未穷尽验证,故默认关闭、标记预发布——请先备份、小范围灰度,遇问题提 issue

升级

  • 替换 jar 即可,本版发布的 jar 已内嵌 MixinExtras,安装即用;请勿继续使用旧 v0.13.0-beta.1 的 jar(缺依赖会崩)
  • 异步加载默认关闭、不动既有存档行为;要试新功能再把 load.enabled 设为 true,并务必先备份存档