02 1.14+ Chunk Savestate 机制分析
此文档由HackerRouter撰写,机制由 BFladderbean 发现。
参考视频:
1 TL;DR
TACS(ThreadedAnvilChunkStorage)在普通 tick 的增量保存中保存了区块 A(旧状态)并设置 10 秒冷却 →
区块 A 和 B 的状态被改变 →
后续保存尝试中区块 A 因冷却被跳过、区块 B 正常保存 →
主线程卡死后 Watchdog 强退 →
重启后区块 A 是旧快照、区块 B 是新快照,物品同时存在于两处。
2 TACS
TACS(ThreadedAnvilChunkStorage),在 Mojang 映射中对应 ChunkMap 类,是服务端区块持久化的核心组件。
它负责决定哪些区块需要保存、何时保存、以及保存的节流控制。
TACS 持有所有已加载区块的 ChunkHolder(区块持有者,每个已加载区块在 TACS 中的包装对象)引用,
这些引用存储在 visibleChunkMap(一个 Long2ObjectLinkedOpenHashMap<ChunkHolder>,以区块坐标 long 编码为 key)中。
TACS 在每 tick 和自动保存中驱动区块的序列化与写盘流程。
下文涉及的所有保存逻辑(增量保存、自动保存、冷却节流)都在 TACS 内部完成。
3 区块保存的实际触发
区块保存不只发生在 6000 tick 一次的自动保存中。
每个 tick,服务器都在做增量保存。
调用链如下:
MinecraftServer.tickServer()
→ tickChildren()
→ serverLevel.tick()
→ chunkSource.tick() // ServerChunkCache.tick()
→ chunkMap.tick() // ChunkMap.tick()
→ processUnloads()
→ 遍历 visibleChunkMap,每 tick 最多保存 20 个区块
在 ChunkMap.processUnloads() 末尾(1.19.2 ChunkMap.java:502-509):
每 tick 最多尝试保存 20 个区块。这个循环独立于自动保存,一直在后台运行。
4 10 秒保存冷却
10 秒保存冷却是 TACS 对单个区块保存频率的节流机制。
其目的是避免频繁变化的区块(如有持续红石活动的区块)在每个 tick 都被序列化和写盘,从而减少 I / O 开销。
具体实现在 ChunkMap.saveChunkIfNeeded()(1.19.2 ChunkMap.java:762-786):
chunkSaveCooldowns 是一个 Long2LongOpenHashMap,key 为区块位置的 long 编码,value 为下次允许保存的时间戳(ms)。
一个区块保存成功后,其冷却截止时间被设置为当前时间 + 10000ms。
在冷却期内,即使区块已经因为方块变化再次变脏(needsSaving == true,即区块的 dirty 标记被置位),saveChunkIfNeeded() 也会直接返回 false 跳过保存。
dirty 标记只是保存的必要条件,不是充分条件。冷却未过期时,dirty 也照样跳过。
5 序列化在主线程同步完成
ChunkMap.save()(1.19.2 ChunkMap.java:788-818)的关键顺序:
ChunkSerializer.write() 在主线程内联执行,遍历 chunk.getBlockEntityPositions(),
对每个位置调用 chunk.getPackedBlockEntityNbt()。
序列化器看到的是调用那一瞬间 live chunk(内存中正在被游戏逻辑使用的活跃区块对象)的逻辑状态。
I / O 线程读取活跃区块内存的情况不存在。
6 异步写盘 —— 三层链路
序列化后的 NBT 经过三层才到达磁盘:
第一层:主线程 → IOWorker.pendingWrites(JVM 堆内存)
ChunkStorage.write() → IOWorker.store(),将 NBT 存入 pendingWrites 这个 LinkedHashMap。
第二层:IOWorker 后台线程 → RegionFile(OS 文件缓存)
IOWorker.storePendingChunk() 每次取一个 entry,调用 RegionFile.write()。
RegionFile.write() 调用 FileChannel.write() 将数据写入文件,但不调用 force()/fsync()(操作系统级的强制刷盘系统调用)。
数据进入 OS 的 page cache(操作系统在内存中缓存文件写入的区域,写入后数据暂存于此而非直接写入物理磁盘),尚未保证到达物理磁盘。
第三层:OS page cache → 物理磁盘
没有 fsync,刷盘取决于 OS 的 dirty page writeback 策略。
进程终止时,未刷盘的数据可能丢失。
IOWorker 是逐区块、逐 entry 写盘的,不是整个世界一次性原子提交。
7 自动保存不绕过冷却
每 6000 tick 触发的自动保存(MinecraftServer.tickServer() 1.19.2:828):
这里的 flush 参数决定保存行为的彻底程度:
flush=true:强制序列化所有 dirty 区块(绕过 10 秒冷却),并等待IOWorker将所有排队数据写完磁盘后才返回。用于/save-all flush或关服时。flush=false:走普通的saveChunkIfNeeded()路径,受冷却限制,且提交给IOWorker后立即返回,不等待实际落盘完成。
自动保存使用的是 flush=false。
saveEverything → saveAllChunks → serverLevel.save() → serverChunkCache.save(false) → ChunkMap.saveAllChunks(false)。
saveAllChunks(false) 的非 flush 分支(1.19.2 ChunkMap.java:452-454):
自动保存调用的是同一个 saveChunkIfNeeded()。
如果某区块在之前的增量保存中刚被保存过,冷却期内自动保存也会跳过它。
8 区块变脏的时机
WorldChunk.setBlockState() 在主线程执行时(1.19.2):
- 修改 section 内部方块状态
- 对旧状态调用
onStateReplaced - 必要时
removeBlockEntity - 最后设置
needsSaving = true
区块一旦发生方块变化就重新变脏。
但 dirty 状态和之前已排入 IOWorker 的旧 NBT 没有回写关系 —— 旧 NBT 已经是独立的数据对象。
9 Watchdog 强制退出
DedicatedServerWatchdog 在独立线程中监控主线程。
检测到超时后:
- 写 crash report
- 调用
System.exit(1) - 安排 10 秒后
Runtime.halt(1)兜底
System.exit(1) 触发 JVM shutdown 流程。
在完全退出前,IOWorker 后台线程仍可能继续运行一小段时间,把 pendingWrites 中已排队的区块继续写出。
直到 JVM 正常退出或被 halt(1) 硬杀。
10 流程梳理
以发射器发射潜影盒到相邻区块,即 Comedy 视频中的演示为例:
T = 0:增量保存保存了区块 A
普通 tick 中,增量保存循环遍历到区块 A(含发射器 + 潜影盒),条件满足:
- 冷却已过期
needsSaving == true
主线程同步执行 ChunkSerializer.write(),生成 A_old_nbt(发射器含潜影盒)。
提交给 IOWorker。
设置 chunkSaveCooldowns[A] = now + 10000ms。
此后区块 A 进入 10 秒冷却期。
T = 1:区块 A 和 B 被改变
发射器被红石激活,4 tick 后 ServerTickList.tick() 执行该计划刻,发射器触发。
潜影盒从区块 A(发射器)移动到区块 B(着陆位置)。
区块 A 变脏(发射器变空),区块 B 变脏(出现潜影盒方块)。
T = 2:自动保存 / 增量保存再次运行
自动保存触发,或增量保存循环遍历到这两个区块:
- 区块 A:
saveChunkIfNeeded()→ 冷却未过(仍在 10 秒内)→ 跳过 - 区块 B:无冷却或冷却已过 → 保存成功 → 生成
B_new_nbt(含潜影盒)
此时持久化状态已经改变:
- 区块 A:磁盘上是
A_old_nbt(发射器含潜影盒) - 区块 B:磁盘上是
B_new_nbt(着陆位置有潜影盒)
T = 3:主线程被红石卡死 + Watchdog 强退
主线程冻结后不再进行新的序列化。
IOWorker 后台线程继续将 pendingWrites 中的剩余 NBT 写出。
Watchdog 检测超时,System.exit(1)。
注意:状态分叉在 T = 2 阶段就已经形成 —— 区块 A 因冷却被跳过,区块 B 已被保存为新快照。
T = 3 的 Watchdog 强退只是阻止了后续可能修正这个分叉的保存机会(例如冷却过期后的下一次增量保存)。
此外,由于 RegionFile.write() 不调用 fsync(见第 5 节),JVM 退出时 OS page cache 中尚未刷到物理磁盘的数据也可能丢失,但这只是分叉的附加因素,不是核心原因。
重启后
区块 A 从磁盘加载:发射器含潜影盒(旧快照)。
区块 B 从磁盘加载:着陆位置有潜影盒(新快照)。
潜影盒被复制。


