Skip to content

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
· 2 commits to main since this release
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 的「明确不做及其理由」