11 保存与卸载流水线
第 7 篇已经讲过:ChunkHolder 的三条 Future 被 complete 为 UNLOADED,只是关闭对应的运行视图。玩家不能再通过这些 Future 拿到可 tick 的 WorldChunk,但数据本身还可能留在内存里,等待保存、摘除实体、释放光照状态和移出表结构。
这一篇补上后半段:一个区块真的离开内存之前,ThreadedAnvilChunkStorage(以下简称 TACS)怎样把它放进三级卸载结构,怎样判断是否需要写盘,怎样把内存中的 Chunk 转成 NBT,最后怎样交给 .mca 文件系统。
1 回顾:"降温"≠"卸载"
降温是运行态变化,卸载是资源回收流程。前者发生在 ChunkHolder.tick() 中:accessibleFuture、tickingFuture、entityTickingFuture 根据加载等级升降而 complete。后者发生在 TACS 的卸载保存流水线中:区块先被标记,再从当前表移入待卸载表,最后由异步任务真正保存并清出内存。
因此,看到 UNLOADED 不要立刻理解为 “数据已经消失”。它更像一扇门被关上:外部访问停止了,但屋里的东西还要按顺序清点、打包、搬走。
2 三级卸载结构
TACS 维护了三层与卸载相关的数据结构:
第一级是 unloadedChunks,也就是已卸载区块集。它只存 long 坐标,不存 ChunkHolder。当 setLevel() 发现新区块等级已经不再 accessible 时,会立刻把坐标加入这里:
这一层的意义是 “需要进入卸载流程的坐标标记”。它非常轻,只负责记录事实:这个位置已经退到 TACS 不应该继续对外维持 ChunkHolder 的范围。
第二级是 chunksToUnload,也就是待卸载区块映射。当 unloadChunks() 处理 unloadedChunks 时,它会把 ChunkHolder 从 currentChunkHolders 移出,再放进 chunksToUnload:
这一层的重点是 “暂存完整的 ChunkHolder”。区块刚离开当前表时,保存 Future 可能还没稳定,异步生成或转换任务也可能刚完成。直接丢掉 holder 会让这些尾部任务失去落点。更重要的是,如果同一坐标很快又被票系统拉回 accessible,setLevel() 会先从 chunksToUnload 取回旧 holder:
这就是复用:玩家在边界来回走动时,区块不必每次都 new 一个新的 ChunkHolder,也不必在卸载后又立刻重新装配同一套中间状态。
第三级是 unloadTaskQueue,也就是卸载任务队列。tryUnloadChunk() 不会直接在当前调用栈里保存和清理,而是把 holder.getSavingFuture() 的回调交给这个队列:
之后 unloadChunks() 每 tick 从队列里取出一批 Runnable 执行。真正的尾部工作都在这里:WorldChunk.setLoadedToWorld(false)、save(chunk)、world.unloadEntities(worldChunk)、lightingProvider.updateChunkStatus()、chunkToNextSaveTimeMs.remove() 等。
unloadChunks() 本身也有节流:普通 tick 中最多优先处理一批 unloadedChunks,当积压超过阈值时才加速清空;队尾保存则每 tick 最多额外尝试约 20 个 holder。卸载因此是一条持续推进的流水线,而不是一次性扫空所有待卸载区块。
为什么要分三级,而不是 setLevel() 里直接保存并卸载?因为卸载不是一个原子动作。它要等待 savingFuture 稳定,要避免遍历表时修改表,要给短时间复活的区块留出复用窗口,还要把磁盘 IO 从主线程路径上拆出去。unloadedChunks 负责低成本标记,chunksToUnload 负责持有可复用的对象,unloadTaskQueue 负责把真正昂贵的尾部动作排队执行。
3 保存条件与节流
区块是否需要保存,首先由 Chunk.needsSaving 决定。这个布尔标记在很多写路径中被设为 true:方块或光照数据变化会调用 setNeedsSaving(true),结构引用变化会把 needsSaving 置为 true,ChunkSerializer.deserialize() 读到旧 NBT 中的 shouldSave 也会恢复这个标记。ChunkHolder.markForLightUpdate() 里甚至能直接看到它:光照更新先找到对应 Chunk,再调用 chunk.setNeedsSaving(true)。
但 TACS 不会因为 needsSaving == true 就每 tick 写盘。save(ChunkHolder) 先要求 holder 曾经可访问:
这里的 accessible 是第 7 篇讲过的粘性标记:它表示这个 holder 曾经达到过 FULL 运行视图。一个从未真正可访问过的中间生成对象,不应该被当成完整可保存区块处理;而曾经可访问过的区块,即使后来降温,也仍然可能有玩家操作、随机刻、光照或方块实体带来的脏数据,需要进入保存判断。
第二道门是 chunkToNextSaveTimeMs,也就是区块下次保存时间映射:
这说明普通保存有节流:同一个区块成功保存后,下一次至少要等约 10 秒。区块数据可能每 tick 都在变,但磁盘不应该每 tick 都被同一块区域反复写入。TACS 的调用链是 save(ChunkHolder) → save(Chunk):前者负责 holder 层面的可访问性、Future 稳定性和时间节流;后者负责真正判断 chunk.needsSaving(),并在需要时序列化和提交写入。
自动保存、/save-all 和关服保存的区别主要在 flush。服务器每 6000 tick 自动调用一次 MinecraftServer.saveAll(true, false, false),也就是保存但不强制立刻 flush 到存储设备;/save-all flush 和关服路径会走 flush = true,要求 TACS 等待保存 Future、处理卸载队列,并调用 completeAll() 等待 StorageIoWorker 把挂起写入真正同步完成。关服前还会循环检查 shouldDelayShutdown(),直到 chunksToUnload、unloadedChunks、unloadTaskQueue 等尾部任务都不再阻塞关闭。
4 ChunkSerializer 的职责
ChunkSerializer 是区块序列化器。它的职责是把内存中的 Chunk 转成 Anvil 格式使用的 NbtCompound,以及从 NbtCompound 还原出 ProtoChunk / WorldChunk 的中间包装。它不直接打开文件,也不管理 .mca 扇区;它只回答一个问题:这个区块用 NBT 应该长什么样。
保存时,TACS.save(Chunk) 会调用:
serialize() 写入的内容包括:区块坐标和数据版本、Status、LastUpdate、InhabitedTime、升级数据 UpgradeData、每个 section 的方块状态和生物群系容器、方块光与天空光、方块实体、计划刻队列、高度图、结构起点与结构引用、后处理列表,以及 proto chunk 特有的实体列表和雕刻掩码。
注意边界:ChunkSerializer 不负责 “写入磁盘”。它构建完 NbtCompound 后就结束了。后续的排队、合并、压缩、写 .mca,都属于 VersionedChunkStorage、StorageIoWorker、RegionBasedStorage 和 RegionFile 的职责。
5 StorageIoWorker 与 .mca
TACS 继承自 VersionedChunkStorage。当它调用 setNbt(chunkPos, nbt) 时,实际进入的是 StorageIoWorker.setResult()。StorageIoWorker 把 NBT 暂存在 results 映射里,然后通过 TaskExecutor 把真实写入安排到 IO worker:前台任务负责接收读写请求,后台任务逐个 writeResult()。这就是为什么普通保存不会把主线程卡在文件写入上。
写入底层由 RegionBasedStorage 找到对应区域文件:r.<regionX>.<regionZ>.mca。每个 .mca 由 RegionFile 管理,文件头保存 1024 个区块槽位的扇区偏移和时间戳;SectorMap 用一个 BitSet 记录哪些 4096 字节扇区已经被占用。写入时,RegionFile.writeChunk() 会为压缩后的 chunk 数据分配新扇区、更新头部、释放旧扇区;过大的 chunk 会写到外部 .mcc 文件,并在 .mca 中留下指向它的头部记录。
POI 和实体数据不和方块区块混在同一个目录。区块方块数据写到 region/,POI 由 PointOfInterestStorage 使用 poi/,实体由 EntityChunkDataAccess 使用 entities/。这样做可以让村民工作站、传送门兴趣点、实体列表等生命周期不同的数据独立保存:例如 ServerWorld.save() 在保存区块后还会调用 entityManager.save() 或 entityManager.flush(),实体系统有自己的 StorageIoWorker 和实体 NBT 格式。
6 小结
- 降温只是关闭运行视图,卸载才是保存、清理和释放内存的流水线。
unloadedChunks记录坐标标记,chunksToUnload暂存可复用的ChunkHolder,unloadTaskQueue执行真正的卸载尾部任务。needsSaving决定区块是否有脏数据,accessible决定 holder 是否曾经值得保存,chunkToNextSaveTimeMs防止同一区块过于频繁写盘。ChunkSerializer只做 NBT 格式转换;磁盘写入由StorageIoWorker和RegionFile完成。- 自动保存通常不 flush;
/save-all flush和关服保存会等待 IO worker 把挂起写入同步完成。
7 源码走读
7.1 TACS.save(boolean flush):保存入口与卸载收尾
ServerChunkManager.save(boolean flush) 会先 tick() 一次,让加载等级和 holder 快照尽量更新,然后调用 threadedAnvilChunkStorage.save(flush)。
非 flush 保存非常直接:
这里遍历的是 chunkHolders 快照,而不是直接遍历 currentChunkHolders。严格说,save(boolean flush) 的保存遍历入口是 chunkHolders;它是 currentChunkHolders.clone() 得到的 volatile 快照,适合在保存、tick、dump 等路径中稳定遍历。currentChunkHolders 本体会在后面的 unloadChunks() 中被修改:待卸载坐标对应的 holder 从这里移出,再进入 chunksToUnload。每个 holder 会进入 save(ChunkHolder),再由它判断是否 accessible、是否已经过了 chunkToNextSaveTimeMs、当前 savingFuture 是否已经能拿到 WrapperProtoChunk 或 WorldChunk。
flush 分支更重:
这里有两个关键点。
第一,flush 会等待 holder 的 savingFuture 稳定。循环里的 future != holder.getSavingFuture() 是为了处理这种情况:等待过程中又有新的生成、转换或运行视图 Future 被合并进 savingFuture。只有拿到稳定版本后,TACS 才 join() 并尝试保存。
第二,flush 不只是保存当前 holder,还会强制跑一轮 unloadChunks(() -> true)。这时流程会从 unloadedChunks 取坐标,把 holder 从 currentChunkHolders 移到 chunksToUnload,然后 tryUnloadChunk() 等待保存 Future 完成。真正 “走 chunksToUnload” 发生在回调里:
也就是说,save(boolean flush) 本身主要遍历 chunkHolders 快照;卸载路径则通过 unloadChunks() 把对象转入 chunksToUnload,再由 unloadTaskQueue 中的回调执行最终保存和清理。
7.2 TACS.save(Chunk):从脏标记到 NBT 提交
save(ChunkHolder) 只负责筛选;真正构建 NBT 的是 save(Chunk):
第一行先保存 POI:pointOfInterestStorage.saveChunk(chunk.getPos())。然后才检查 chunk.needsSaving()。这意味着 POI 有自己的脏数据和保存路径,不完全依赖区块本体的 needsSaving。
接着 TACS 会把 needsSaving 先清成 false,再尝试序列化。如果序列化或提交写入抛异常,方法返回 false 并记录错误;如果成功,就调用 setNbt() 把 NBT 交给 VersionedChunkStorage。这里的 setNbt() 不是同步文件写入,而是把结果交给 StorageIoWorker.setResult() 排队。
proto chunk 还有额外保护:如果磁盘上已经是 LEVELCHUNK,TACS 不会用一个未完成的 proto chunk 覆盖它;如果 EMPTY 状态且没有任何结构起点子节点,也没必要写一个空壳文件。这样可以避免生成中间态把更完整的数据倒退覆盖。
7.3 ChunkSerializer.serialize():关键字段如何写进 NBT
ChunkSerializer.serialize(ServerWorld world, Chunk chunk) 从基础元数据开始:
随后处理升级与混合数据:blending_data、below_zero_retrogen、UpgradeData。这些字段让旧世界升级、新旧地形混合和低版本区块修补能跨保存保留下来。
section 序列化是主体:
这里的 block_states 和 biomes 都是 paletted container 编码:相同方块状态或生物群系会通过调色板压缩成更小的索引数组,而不是逐格写字符串。光照数据则来自 LightingProvider,只有非空且已初始化的 ChunkNibbleArray 才会进入 NBT。
方块实体单独写成列表:
然后是 proto chunk 特有数据:未并入世界实体系统的 entities、CarvingMasks。对于已经是 WorldChunk 的区块,实体保存主要由 ServerEntityManager 和 EntityChunkDataAccess 负责,不再把普通实体列表作为区块 NBT 的主体内容。
最后写计划刻、高度图和结构:
serializeTicks() 会把方块计划刻写入 block_ticks,把流体计划刻写入 fluid_ticks,并用世界时间计算相对触发时间。writeStructures() 则分别写结构起点 starts 和结构引用 References。到这里,ChunkSerializer 的工作完成:它返回一个完整 NBT;至于这个 NBT 什么时候压缩、写到哪个 .mca 扇区、是否强制 sync(),都交给后面的存储层。
8 参考
net.minecraft.server.world.ThreadedAnvilChunkStoragenet.minecraft.server.world.ChunkHoldernet.minecraft.world.ChunkSerializernet.minecraft.world.storage.VersionedChunkStoragenet.minecraft.world.storage.StorageIoWorkernet.minecraft.world.storage.RegionBasedStoragenet.minecraft.world.storage.RegionFilenet.minecraft.world.storage.SectorMap
