18 Savestate 与区块保存抑制
此现象由 BFladderbean 发现。原理文档由 HackerRouter 撰写并首次发表于《Chunk Savestate 原理分析》(BV1uV4y1a7Xx),本文在其基础上扩充 OOM 路径和源码行锚,并纳入 ChunkMechanics 主线。
1 核心因果链
Chunk Savestate 是这样出现的:
- TACS(
ThreadedAnvilChunkStorage)在普通 tick 的增量保存中保存了区块 A(旧状态)并设置 10 秒冷却。 - 区块 A 和区块 B 的状态被改变(例如玩家在 A 和 B 之间移动物品)。
- 后续保存尝试中,区块 A 因冷却被跳过、区块 B 正常保存。
- 主线程卡死,Watchdog 强退(或 OOM 杀进程)。
- 服务器重启后,区块 A 读出旧快照、区块 B 读出新快照,物品同时存在于两处。
这不是随机的保存竞争,而是节流冷却和强制退出共同作用的必然结果:冷却保护了过时快照,强退阻止了后续修正。
2 机制前置
本章只讨论现象链路。保存冷却、StorageIoWorker、flush、自动保存与 Watchdog 强退路径的源码细节,见 保存节流与刷盘机制。
3 保存失败的路径与后果
加载失败会产生降级区块或崩溃,但保存失败的后果更隐蔽 —— 区块在内存中正常运行,玩家看不出异常,但数据无法写入磁盘,重启后会回滚到上次成功保存的状态。
3.1 磁盘空间不足
触发条件:StorageIoWorker 在写入 region 文件时,操作系统返回 "磁盘已满" 错误(通常是 IOException: No space left on device)。
发生路径:
ServerWorld.save() / 自动保存 / 关服
↓
ThreadedAnvilChunkStorage.save(boolean flush)
↓
tryUnloadChunk() → save(chunk)
↓
setNbt(chunkPos, nbt)
↓
StorageIoWorker.setResult(chunkPos, nbt)
↓
worker 线程执行 writeChunk()
↓
RegionFile.getChunkOutputStream() 申请扇区
↓
磁盘空间不足 → IOException
↓
Future.completeExceptionally(exception)
后果:
- 区块数据丢失:内存中的修改无法写入
.mca文件,重启后回滚到上次成功保存的快照 - 实体数据也可能丢失:
ServerEntityManager的trySave()也走StorageIoWorker,磁盘满时实体保存也会失败 - POI 数据丢失:
PointOfInterestStorage独立保存到poi目录,磁盘满时也无法写入 - 日志会记录异常:
StorageIoWorker在writeChunk()的exceptionally中记录错误 - 服务器不会崩溃:保存失败被当作 "非致命错误",服务器继续运行,玩家可能完全不知道数据没保存
3.2 磁盘写入权限被拒绝
触发条件:操作系统拒绝写入 region 文件(通常是 IOException: Permission denied)。
常见原因:
- 世界文件夹的所有者 / 权限被错误修改
- SELinux / AppArmor 安全策略阻止写入
- 文件系统被挂载为只读
- Windows 下文件被其他程序锁定
3.3 region 文件损坏或锁定
触发条件:
- region 文件损坏:
.mca文件的头部损坏、扇区索引错误 - 文件被其他进程锁定:多个服务器实例误启动到同一世界目录
[!DANGER]
多个服务器实例同时运行到同一世界目录是最危险的情况 —— 两个StorageIoWorker同时写入同一个.mca文件,导致 region 文件损坏、实体 / POI 数据损坏、玩家数据损坏。
3.4 对比表格
3.5 与 Savestate 的关系
保存失败与 Savestate 现象 有本质区别:
- Savestate:保存成功但不完整(部分区块因保存冷却未写入)
- 保存失败:保存失败(IOException、权限、磁盘满),区块数据完全未写入
但两者可以叠加:磁盘满 + Watchdog 强退 = 部分区块成功保存 + 部分区块失败,造成更复杂的数据不一致。
4 OOM Savestate 与区块保存抑制
你可能听说过 "OOM 也能触发 Savestate"。这是一条和 Watchdog 强退完全不同的路径:Watchdog 导致跨区块的旧快照 A + 新快照 B 分裂,而 OOM 方法通过区块保存抑制导致特定区块被重新生成。
4.1 因果链
核心原理:在内存压力下,一个被塞入大量 NBT 数据的区块在保存时发生序列化、写入或读取失败,导致该区块的 .mca 数据损坏或无法读取。服务器重启后,无法从磁盘读出这个区块的 NBT,就回退到新建 ProtoChunk,然后重新生成这个区块。
完整路径:
- 玩家往某个区块的容器中塞入海量物品(例如潜影盒套娃、大量独特 NBT 附魔书),使这个区块的 NBT 数据膨胀到几 MB 甚至几十 MB。
- 服务端尝试保存该区块时,在序列化、写入或后续读取过程中发生 OOM、缓冲区溢出、或其他异常,导致
.mca条目被截断、损坏或根本没有写入。 - 服务器重启后,
RegionFile.getChunkInputStream(pos)无法返回有效流、StorageIoWorker.readChunkData(pos)捕获异常、或RegionBasedStorage.getTagAt(pos)返回null。 ThreadedAnvilChunkStorage.loadChunk(pos)发现 NBT 不可用,返回getProtoChunk(pos)创建一个新的ProtoChunk。- 后续
upgradeChunk()调用requiredStatus.runGenerationTask(...),重新生成该区块的地形、结构、生物群系等,整个区块被完全替换。
4.2 Java 数组长度限制的确定性 OOM
前文讲的 OOM 路径依赖 "内存压力",但还有一条更可靠的路径:利用 Java 数组长度限制,让单个区块的序列化数据超过 2.147 GB,触发确定性 OOM。
4.2.1 Java 数组的硬限制
Java 数组的最大长度是 Integer.MAX_VALUE - 8 = 2,147,483,639 个元素(约 2.147 GB)。这是 JVM 规范的硬限制,无论服务器有多少可用内存,都无法创建更大的数组。
当 ChunkSerializer.serialize() 将区块序列化为 NBT,然后 RegionFile 通过 GZIP 压缩后写入 .mca 文件时,如果压缩后的数据超过 2.147 GB,在尝试分配数组时会抛出:
java.lang.OutOfMemoryError: Required array length 2147483639 + 152 is too large
关键点:这个错误不是因为内存不足,而是因为Java 数组长度限制。即使服务器有 64 GB 可用内存,也会失败。
4.2.2 触发路径
完整的触发链路:
ServerWorld.save() / 关服
↓
ThreadedAnvilChunkStorage.save(chunk)
↓
ChunkSerializer.serialize(world, chunk) // 序列化为 NbtCompound
↓
setNbt(chunkPos, nbt)
↓
StorageIoWorker.setResult(chunkPos, nbt)
↓
worker 线程执行 writeChunk()
↓
RegionFile.writeChunk(pos, buf)
↓
GZIP 压缩 → 尝试分配超过 2.147 GB 的 byte[]
↓
OutOfMemoryError: Required array length ... is too large
↓
Future.completeExceptionally(exception)
↓
区块保存失败,数据未写入 .mca
4.2.3 如何构造 2.147 GB 的区块数据
要让单个区块的序列化数据超过 2.147 GB,需要:
4.2.4 预期行为与日志
服务器表现:
-
保存失败时,控制台会输出:
[19:10:30] [IO-Worker-27/ERROR]: Caught exception in thread Thread[IO-Worker-27,10,main] java.lang.OutOfMemoryError: Required array length 2147483639 + 152 is too large(具体时间、数组长度、线程 ID 会不同)
-
服务器不会崩溃,继续运行,但该区块的保存失败。
-
玩家在游戏中看不出异常,区块照常加载、运算。
重启后:
- 该区块的数据未写入
.mca文件,或者写入了损坏的数据。 RegionFile.getChunkInputStream(pos)返回null或抛出异常。ThreadedAnvilChunkStorage.loadChunk(pos)调用getProtoChunk(pos)创建新的ProtoChunk。- 该区块被重新生成为世界种子的初始状态 —— 地形、箱子、实体全部回到生成时的样子。
4.2.5 为什么需要 "几 GB 可用内存"
虽然 OOM 是由数组长度限制触发,但在序列化和压缩过程中,JVM 仍然需要分配临时内存:
ChunkSerializer.serialize()创建NbtCompound对象(可能几百 MB)- GZIP 压缩时创建中间缓冲区
RegionFile尝试分配目标数组时失败
如果服务器可用内存不足(如只有几百 MB),可能在达到数组长度限制之前就因为真正的内存不足而 OOM,导致:
- 其他区块也保存失败(不只是目标区块)
- 服务器崩溃或 Watchdog 触发
halt(1)
因此,建议有 2-4 GB 可用内存,确保只有目标区块因数组长度限制而失败,其他区块正常保存。
4.2.6 与 "内存压力 OOM" 的对比
4.2.7 实际应用场景
这种方法常用于:
- 测试区块重新生成机制:验证服务器在区块损坏时的降级行为。
- 清除特定区块的数据:如果一个区块因为 bug 或误操作陷入异常状态,可以通过触发重新生成来 "重置" 该区块。
- 研究 Savestate 现象:对比 Watchdog 强退(跨区块分裂)和 OOM 保存抑制(单区块重生)的不同表现。
4.3 和 Watchdog 的区别
- Watchdog Savestate:保存冷却导致某些区块保留旧快照,其他区块保存新快照,区块之间呈现不同时间线的快照分裂。旧区块的数据是一致的老快照,不是被重新生成的。
- OOM 区块保存抑制:目标区块因 NBT 损坏无法读取,整个区块被重新生成为世界种子的初始状态,地形、箱子、实体全部回到生成时的样子。非目标区块仍然读出正常快照。
对比表:
4.4 源码锚点
RegionFile.getChunkInputStream(pos) 在多个条件下返回 null(RegionFile.java:102-142):
无效版本标识也返回 null(RegionFile.java:157-165):
RegionBasedStorage.getTagAt(pos) 在流为 null 时返回 null(RegionBasedStorage.java:47-52):
StorageIoWorker.readChunkData(pos) 捕获异常并记录(StorageIoWorker.java:137-150):
ThreadedAnvilChunkStorage.loadChunk(pos) 过滤 NBT 并在缺失时返回 getProtoChunk(pos)(ThreadedAnvilChunkStorage.java:596-613):
异常恢复也返回 getProtoChunk(pos)(ThreadedAnvilChunkStorage.java:620-638):
getProtoChunk() 返回一个全新的 ProtoChunk,没有旧快照的任何状态。
ThreadedAnvilChunkStorage.upgradeChunk() 会检查 chunk 的当前状态,如果低于所需状态,就调用 requiredStatus.runGenerationTask(...) 重新生成(ThreadedAnvilChunkStorage.java:649-679):
写入路径在 RegionFile.writeChunk(pos, buf) 中(RegionFile.java:267-293)。如果写入过程中发生 OOM 或异常,.mca 条目可能被截断、损坏或根本没有写入,导致后续读取失败:
在内存压力下,任何 ByteBuffer.allocate()、channel.write() 或 writeHeader() 都可能失败,留下不完整的数据。
5 状态分叉的必然性
回到最初的因果链:
- 区块 A 在某个 tick 被正常保存,获得 10 秒冷却。
- 玩家在接下来几秒内改变了 A 和 B(例如从 A 的箱子移动物品到 B 的箱子)。
- 区块 B 在后续 tick 中正常保存(冷却已过或首次保存)。
- 区块 A 因冷却还在,保存被跳过,脏标记留在内存里。
- Watchdog 检测到主线程卡死,10 秒后强制
halt(1)。 - 最后的 flush 来不及执行。
- 服务器重启,区块 A 读出旧快照(步骤 1 时写的),区块 B 读出新快照(步骤 3 时写的)。
物品在旧的 A 快照和新的 B 快照中同时存在,因为两次保存之间没有原子性保证,冷却机制也不关心跨区块一致性。这不是 bug,而是节流设计和强退现实的交集。
6 小结
Chunk Savestate 是以下机制共同作用的结果:
- 增量保存:每 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 来不及执行,冷却跳过的区块保留旧快照。 - OOM 区块保存抑制:目标区块因大量 NBT 数据在保存时损坏,重启后无法读取,被重新生成(和 Watchdog 的跨区块旧 / 新快照分裂是不同的路径)。
- 普通 OOM 恢复:
finally会尝试shutdown(),但 OOM 环境中高度不可靠。
如果你需要跨区块旧 / 新快照分裂的 Savestate,用 Watchdog 强退(卡主线程让它超时)。如果你需要数据一致,用 /stop 或手动 save-all flush,或者理解冷却机制后用脚本构造跨区块操作的时序窗口,在正常关服前留够 10 秒让所有区块冷却到期并重新标记脏。
不要依赖普通 OOM 恢复路径作为 Savestate 手段:它的 "成功" 是偶然,不是保证。
