15 保存节流与刷盘机制
1 TACS 增量保存
你已经在第 11 篇中见过 TACS 的卸载流水线。增量保存发生在每个 tick 中,不是只有 6000 tick 一次的自动保存:
MinecraftServer.tick()
→ tickWorlds()
→ serverWorld.tick()
→ getChunkManager().tick()
→ threadedAnvilChunkStorage.tick()
→ unloadChunks()
在 ThreadedAnvilChunkStorage.unloadChunks() 末尾(ThreadedAnvilChunkStorage.java:522-529):
每 tick 最多保存 20 个区块。k 只在 save(ChunkHolder) 返回 true 时增加,也就是说只有真的写了区块、冷却没有拦截、不在卸载任务中,计数器才往前走。
这个循环和 6000 tick 的自动保存相互独立,是服务端 tick 的常态流程之一。
2 秒保存冷却
TACS 用一个 chunkToNextSaveTimeMs(Long2LongOpenHashMap)维护每个区块的下次可保存时间戳。当 save(ChunkHolder) 发现 "现在还不到下次保存时刻",直接返回 false,不触发序列化也不增加保存计数。
在 ThreadedAnvilChunkStorage.save(ChunkHolder) 中(ThreadedAnvilChunkStorage.java:790-813):
如果当前时间 n 小于上次记录的 m,直接跳过。只有真的保存成功(save(chunk) 返回 true),才把下次可保存时间设置为 n + 10000L,也就是 10 秒后。
这个冷却只作用在 save(ChunkHolder) 的入口,不作用在 save(Chunk) 或强制 flush 路径。
3 直接区块保存
save(Chunk) 是最终做实际序列化的地方(ThreadedAnvilChunkStorage.java:816-846):
它先检查 needsSaving(),如果区块没脏标记就跳过。然后把脏标记清掉(setNeedsSaving(false)),调用 ChunkSerializer.serialize() 把区块转成 NBT,最后通过 setNbt() 交给存储层。
这个调用是主线程上的同步操作。区块对象在这时被读取,之后 NBT 进入写盘队列。
4 StorageIoWorker 写盘队列
setNbt() 最终调用 VersionedChunkStorage.setNbt(),它会把 NBT 交给 StorageIoWorker:
这个方法把 NBT 放进 results(一个 ChunkPos → Result 的 map)。Result 内部有一个 CompletableFuture<Void>,在实际写盘完成后才会 complete(null)。
真正的写盘在后台异步执行。writeRemainingResults() 链式调度 writeResult(),每次从 results 取一个条目,调用 storage.write(pos, nbt),然后 complete 对应的 future(StorageIoWorker.java:201-223)。
普通 tick 中,这些 future 可能还没 complete;强制 flush 时,completeAll(true) 会等所有 future 并调用 storage.sync()。
5 正常 Flush
当 ServerWorld.save() 收到 flush=true 时,调用链是这样的(ThreadedAnvilChunkStorage.java:440-471):
注意这里调用的是 save(Chunk),绕过了 save(ChunkHolder) 的冷却检查。这就是为什么 flush 路径不受 10 秒冷却限制。
然后 completeAll() 会调用 StorageIoWorker.completeAll(true)(StorageIoWorker.java:154-168):
sync=true 时会等所有 future 完成,然后调用 RegionBasedStorage.sync(),它遍历所有打开的 .mca 区域文件,对每个调用 RegionFile.sync()(RegionBasedStorage.java:91-94):
而 RegionFile.sync() 直接调用 FileChannel.force(true)(RegionFile.java:251-253):
force(true) 把缓冲区写盘并刷新文件元数据。这是正常 flush 流程中唯一真正的 "刷盘" 保证。
6 自动保存 vs. 正常关服
服务端每 6000 tick(5 分钟)调用一次 saveAll(true, false, false)(调用点 MinecraftServer.java:873-876,saveAll 定义 566-602):
注意 flush=false。这意味着普通自动保存只是把 NBT 交给 StorageIoWorker,不等 future、不刷盘。在 1.20.1 中,自动保存不保证 .mca 文件已经落到磁盘上。
正常关服(/stop 命令或 Ctrl+C)会进入 MinecraftServer.shutdown()(MinecraftServer.java:609-665):
关键点:
- 它先排空延迟区块任务(
shouldDelayShutdown()循环)。 - 然后调用
save(false, true, false),这里flush=true,会绕过冷却、等所有 future、刷盘。 - 之后才关闭世界。
这就是为什么正常关服不会出现 Savestate:所有脏区块都被最后一次 flush 写完并刷盘,没有遗漏的旧快照。
7 Watchdog 强制退出
DedicatedServerWatchdog 在单独线程中监控主线程 tick 时间。当发现单个 tick 超过 maxTickTime(默认 60 秒),它会:
- 打印错误日志:
FATAL_MARKER,"Considering it to be crashed, server will forcibly shutdown." - 收集所有线程的堆栈并写崩溃报告。
- 调用
shutdown()方法,而这个方法做了两件事(DedicatedServerWatchdog.java:90-103):
它先调度一个 10 秒后的 Runtime.halt(1),然后立刻调用 System.exit(1)。System.exit(1) 会启动 JVM 关闭流程和注册的关闭钩子,但它不会展开任意卡住的线程或执行那些线程的 finally 块。由于服务器线程已经卡在主循环中,runServer() 的 finally 块(调用 shutdown() 和最后的 save(false, true, false))根本不会从该线程执行。10 秒后 halt(1) 直接杀进程,不等任何清理。
结果:最后一次 save(false, true, false) 来不及执行,之前被冷却跳过的区块就永久留在旧快照状态。
8 与现象章节的关系
本章只保留保存节流、异步写盘、flush 与强退路径的机制说明。基于这些机制产生的 Savestate、OOM 区块保存抑制和状态分叉现象,见 Savestate 与区块保存抑制。
9 小结
- 增量保存:每 tick 保存最多 20 个区块,独立于自动保存。
- 10 秒冷却:
save(ChunkHolder)拒绝过于频繁的保存,保护被跳过的区块处于旧状态。 - 异步写盘:
StorageIoWorker把 NBT 排队,flush 时才等 future 和刷盘。 - 自动保存不 flush:6000 tick 的自动保存
flush=false,不保证磁盘写入。 - 正常关服 flush:
shutdown()中的save(false, true, false)绕过冷却、等所有 future、刷盘。 - Watchdog 强退:10 秒后
halt(1)杀进程,正常 flush 来不及执行。
