BetterAutoSave v0.20.0 — Forge 1.20.1 / NeoForge 1.21.1 异步存档优化
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.syncLoadDetection与diagnostics.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 的「明确不做及其理由」