Minecraft 设计思想
卷三 · 技术实现世界如何存在

存储与序列化

Region 与 Anvil 怎么打包区块,NBT 凭什么当了十几年通用层,世界、玩家、结构三套数据如何分工;一次版本迁移为什么贵——存档兼容是对玩家时间的伦理。

本章命题:世界必须比引擎活得更久,所以存储层真正要维护的不是文件格式,而是迁移——每一次改动存档格式,成本都以玩家的时间与风险结算。若某次升级让大量旧存档打不开或不可逆损坏,这个承诺即告破产;反之,若迁移便宜到玩家毫无感觉,把「伦理」搬出来就是夸大。

1问题

2012 年 3 月生成的存档,今天双击还能进。那个月 Minecraft 刚完成一次存档格式的大更换,此后十多年里界面重做过、渲染器重写过、世界生成器几乎每个大版本都动,唯独世界文件的格式沿用到了今天。在软件行业,一份格式用了十几年、换了引擎还能打开的数据屈指可数——很多游戏的存档,活不过游戏自己下架。

还有一件玩家都见过的事:把旧存档搬进升级后的版本,第一次进入时会出现「转换世界中」的等待。那几十秒里游戏在干什么?为什么必须等?为什么有的一等就是好几分钟?

要理解这两件事,得先认识一个词:序列化。游戏运行时,世界是一大团活的对象——方块、光照、生物、箱子里的东西,散在内存里,相互引用,每一刻都在变动。内存里的东西一断电就没了,所以要把它们变成一串能写进硬盘的字节,下次再变回来,这个「变过去再变回来」的过程就是序列化,而存档格式,就是变过去之后长什么样。Minecraft 的特殊之处在于:这串字节不只被要求「能读回来」,还被要求十年后、换了引擎之后依然能读回来。本章沿着这条要求问下去:世界为什么必须比引擎活得更久?这个承诺让引擎付出了什么?

2世界比客户端活得长

先看一组不对称。客户端的寿命以「周」计:每周一个快照,每年一个大版本。存档的寿命以「年」计:一个存档可以被开十年,被四五个大版本轮番升级。被要求「永远可读」的,恰恰是变动最慢、又最不能出错的那份数据。

为什么非如此不可?因为 Minecraft 的存档不是开发者设计好的「关卡」,而是玩家时间的沉淀。一座建了几百小时的城堡、一条横跨地图的铁路、一台用红石搭出来的计算器,都不在游戏程序里,只在这个世界的方块里。对大多数游戏,删掉存档损失的是「进度」;对 Minecraft,删掉存档损失的是那几百小时本身。这个游戏的核心乐趣就是积累与改造,存档本身就是产品——联机服务器上一个被几十人共同居住多年的世界,承载的东西早已超出「进度」两个字(见《作为文化遗产的 Minecraft》)。

于是,存储格式成了过去与未来之间唯一的契约。引擎可以重写——卷四整卷都在讨论怎么重写——契约里的东西却不能作废。这个前提怎样算错?如果玩家普遍接受「升级即弃档」,或者社区从来没有为旧存档写过任何迁移工具,「世界比引擎活得长」就只是一厢情愿。现实恰好相反:这大概是被玩家捍卫得最顽强的一条。

上一章把世界分成「被记住的过去」(已生成的区块)和「被重新解释的未来」(未生成的区域)。本章只关心前一半:「被记住」这三个字,在硬盘上靠什么兑现。

3区域文件简史

最早的版本里世界不大,一张地图就是一个压缩文件,存在哪都行。世界变无限之后,问题来了:一个没有边界的世界,怎么放?

第一个答案很直白:切成区块,一个区块存一个小文件,按坐标散进几层子目录。这个方案在逻辑上无懈可击,在文件系统上是灾难。硬盘不是按「内容」管理文件,而是按「文件」:每打开一个文件都有固定开销,几十万个小文件把目录拖得臃肿,机械硬盘来回寻道,备份软件得一条一条复制这些小文件——对机械硬盘和备份软件都是折磨。当年加载存档慢,一多半的账要记在这里。

但反方向也走不通。把整个世界装进一个巨型文件,区块就没法独立压缩、独立替换——否则你敲掉一块草方块,就得重写整张地图。所以答案是折中:把文件变大,但不要太大;让每个区块仍能被单独找到、单独替换。

2010 年,社区开发者 Scaevolus 写了一个叫 McRegion 的模组,给出了这个折中的标准答案:把 32×32 共 1024 个区块打包进一个文件(覆盖 512×512 格),文件开头放一张「目录卡」,记录每个区块存在文件里的哪个位置;每个区块各自压缩,各写各的位置,互不打扰。2011 年初的 Beta 1.3,官方把它收编为正式存档格式1。

收编时几乎原样采纳,唯一改动是加了一张区块更新时间表——社区方案直接成了官方地基。

一年多之后,格式又换了一次。2012 年 3 月的 1.2.1 引入 Anvil 格式取代 McRegion,扩展名从 .mcr 变成 .mca——只差一个字母,内部却动了结构:世界高度上限从 128 加倍到 256,每根区块列被竖着切成一节一节(每节 16 格高),方块和光照数据按节存放,要读哪一节就读哪一节2。

为什么这么快就再换一次?因为高度墙撞死了。128 格的上限在玩家眼里是设计限制,在存储层是写死的数组尺寸,不拆结构,高度就加倍不了。Anvil 的「节」就是为这次加倍付出的结构改造,顺便也为后来更多的方块状态腾出了地方。值得记一笔的是打包思路的稳定:从 McRegion 到 Anvil,改的是文件内部的排布,不是「打包 + 目录卡」这件事本身。一个 2012 年 3 月生成的存档,就是从这一格起,一路活到今天。

4NBT 作为通用层

区域文件解决「放哪」,但打包袋里装的每个区块,内容还得有个统一的写法。这就是 NBT 登场的地方。

NBT(Named Binary Tag,命名的二进制标签)是 Notch 早年写下的一页规格说明,思路可以理解成「自带标签的收纳盒」:每个格子上贴着名字和类型,盒子里还能套小盒子,一层套一层组成一棵树。存坐标的格子写着「这是坐标」,存名字的格子写着「这是名字」,读的人不必知道全貌,按标签走就行。整棵树用 gzip 压缩后落盘。世界元数据 level.dat、每个玩家的数据文件、每个区块的内容、导出的结构模板,全是这套收纳盒3。

它凭什么能当十几年通用层?关键在自描述:读取程序只要认识那十来种基本类型的格子,就能读任何 NBT 文件——遇到不认识的名字,按类型跳过即可,不会读乱。所以加字段是安全的:新版本往盒子里放新格子,老版本跳过它们照样工作。反过来,格子本身的种类极少动:从 Indev 时代沿用的基本类型算起,十几年里只新增过两种整数数组(2012 年一种、2017 年一种),没有一次改动是推倒重来。格子的种类稳,格式才能稳。

自描述优先于紧凑

地基格式要陪世界活十几年 → 字段会增删改名 → 读者必须能安全跳过不认识的内容 → 用体积和解析成本换可演进
NBT 的标签让每个值都自带说明书,文件因此比定长的紧凑格式啰嗦,解析也更慢——这是明码标价的保费,换来的是老版本读得懂新文件、新版本读得懂老文件。拿掉这条会怎样:换成最省字节的固定结构,每加一个字段都要所有版本同时升级,存档与引擎的寿命只能保住短的那个。

当然,自描述只保证「读得懂结构」,不保证「内容对得上」——写着「羊毛」的格子,在新版本里可能已经改名成「白色羊毛」。结构之下的语义变迁,要等本章最后一节的转换器来还。

把世界交给数据库

另一条路是把整个存档塞进数据库,按主键读写区块。基岩版真的这么做了:它用 LevelDB 这个键值数据库装世界,代价是存档基本没有回到旧版本打开的余地(见《Bedrock 作为另一次实现》)。Java 版留在「散文件 + 收纳盒」这边,理由很务实:存档是玩家看得见、拷得走的文件夹,出了问题社区工具能直接打开修;不装任何额外服务,双击就能跑。散落的小文件是缺点,也是开放性本身。

5世界 / 玩家 / 结构:三套数据的分工

现在可以打开一个存档文件夹看一眼了——它不是一个文件,是一个小数据库:level.dat 记着世界的身份证(种子、游戏时间、这世界由哪个数据版本写出),旁边躺着一份旧的 level.dat_old 备份;region/ 里是打包好的地形;playerdata/ 里是每个玩家的位置、血量和背包;advancements/ 与 stats/ 里是进度和统计;generated/ 里是结构方块导出的模板。谁住哪个房间,是十几年里一层层长出来的分工。

世界本体是最大的一份:按区块打包在区域文件里,随探索增长,随版本被转换,迁移最贵。

玩家数据是分量最小的:早年按登录名存成 players/名字.dat。2014 年 4 月的 1.7.6 支持玩家改名,登录名从此不再是可靠的身份标识,玩家数据随之改用 UUID 命名,住进 playerdata/ 文件夹;进度和统计也按 UUID 各自成文件4。这条改动值得停一下:存储层早就明白「名字会变,身份要稳」——很多互联网产品后来都要重学的教训,一个游戏在 2014 年就用对了。

结构文件是最年轻的:结构方块在 1.9 加入(最初是个没有界面的内部方块,1.10 才真正交给玩家用),能把一段建筑连同方块信息导出成 .nbt 模板,存进世界的 generated/ 文件夹5。模板不大,可以分享、可以贴进别的世界,是三套数据里唯一以「交换」为目的的,也因此最脆:模板里每个格子都写着方块名,方块一改名,模板就得靠同一套转换器救助。

三份数据,三种命运:世界的大而缓慢增长,玩家的小而高频读写(位置每一瞬间都在变),结构的轻而整进整出。写它们、读它们、迁移它们的节奏各不相同——把它们塞进同一种容器,才是真正的浪费。

6版本迁移成本:升级一次世界,引擎要做什么

回到「转换世界中」那块进度条。它每走一格,引擎都在做同一件事:把旧版本写出的数据,翻译成当前版本的语言。

翻译靠一张「出厂标签」和一架梯子。数据落盘时,会标着自己由哪个数据版本写出。Mojang 把版本之间的翻译工作做成了一套独立的开源库 DataFixerUpper,自述「为在 Java 版不同版本之间转换游戏数据而创建」6:每一级修复只管相邻两个版本之间的一小步,比如「这种方块改名叫那个名字」「这个数值挪进那个格子」;读取时,引擎拿着存档上的版本号,一级一级往上爬,爬到当前版本为止。十来年、几百个版本的跨度,就这样串成一条自动的梯子。

迁移为什么贵?把账摊开,是三层。

**第一层:改的常常不是字段,是语义。**2018 年 7 月的 1.13「扁平化」是典型案例:多年以来,同一种方块靠数字「附加值」区分变体——一份羊毛数据,附加值 0 到 15 分别对应十六种颜色。扁平化把这层数字彻底废掉,十六种羊毛各自成为有名字的方块,大量方块被拆分、合并、改名7。对引擎来说这是还债,对存档则意味着所有旧数据都要重新过一遍:每个箱子里的东西、每个刷怪笼的配置、每条写进命令方块的指令,都要逐个重写。格式改动一旦碰到语义,就是全世界的地毯式翻译。

**第二层:翻译是单向门。**升级那一刻起,存档就成了新引擎的方言;旧版本没有往回修的转换器,要么干脆拒绝打开,要么读出一个错乱的世界。玩家唯一的回退手段,是自己提前复制一份文件夹——游戏从不承诺这条路能往回走。

升级是单向门

凑合旧引擎没有往回修的转换器 → 新格式旧引擎读不懂 → 升级不可逆 → 回退手段只剩玩家的手动备份 → 兼容的账单全部压在升级那一刻
它撑住了十几年,但把风险交给了最不该承担的一方:不知道要备份的玩家,升级就是赌博。拿掉这条会怎样——如果格式允许双向翻译,每个版本都得永远维护完整的双向转换链,那是养不起的负担;所以真正的出路只剩一条:让向前的那一步足够小心。

**第三层:规模按区块计,按年计。**翻译虽然自动,却要一个区块一个区块地过。引擎的做法是懒迁移:level.dat 这类小件在进世界前一次转完,每个老区块则等到首次被加载时才单独翻译。这就是为什么旧存档玩起来,偶尔会在你走到一块多年没去过的老地方时顿一下——那块地正在被翻译。存档越大、放得越久,这一层就摊得越长。

2021 年 11 月的 1.18 把这三层账一次集齐。世界高度从 0 到 255 扩成 −64 到 319,每个老区块的方块数据都要重新排布;新旧地形的交界要做「混合」,让新长出来的山脉无缝接上你 2015 年的平原;官方更新说明甚至写明,老区块里的玩家建筑「可能完好,可能被埋,也可能风化」8——这是存档兼容史上少见的诚实:承诺从来不是「毫发无损」,而是「尽力保存,损失坦白」。

打开旧存档 读出厂版本号每份数据各带一份 数据转换器逐级爬到当前版本 小件转完放行进入世界 每个老区块首次加载时各自翻译 按新格式落盘

现在可以正面回答那个伦理论断了。**存档兼容为什么是对玩家时间的伦理?**论证有三步。其一,存档是玩家时间的凝结,几百小时不可再生。其二,格式改动的收益——更干净的数据模型、更高的世界、更快的读写——几乎全部归引擎的未来;成本却以三种方式落在玩家头上:等待是直接扣掉的时间,风险是不可逆那一步的赌注,回退是本可以不必做的功课。收益与承担者错位,所以「改不改格式」从来不只是技术决策,而是向玩家时间负责的决策。其三,正因如此,正当的格式改革必须同时交付四样东西:自动迁移,不让玩家手工翻译;懒迁移,不把玩家钉在进度条前一个小时;混合与保存,旧地形旧建筑尽量原样保留;转换器开源,让社区工具与官方共用一套翻译,玩家的备份在游戏之外也用得上。这四样,Mojang 都做了——不是恩惠,是还账。

这个论断怎样算错?三种情形只要成立一种,它就破产:某次升级造成大规模存档损坏且没有回退手段;迁移便宜到玩家毫无感知,「伦理」二字就是自抬身价;或者玩家用脚投票,普遍停在旧版本拒绝升级,兼容策略就算输了。十几年过去,三种情形都没有成为现实:Anvil 格式从 2012 年一路用到今天,1.18 的混合带保住了旧地形,转换器开源让模组社区的存档工具与官方同频。命题至今成立——但每次大版本升级,都是对它的一次重新考试。这笔账在「整颗引擎重写」时又该怎么算,卷四另有专章(见《兼容策略》)。

7这一章留下什么

  • 爱好者:下一次看到「转换世界中」,你知道那是引擎在替你翻译过去;升级前把存档文件夹整个复制一份,是对自己几百小时最便宜的保险——单向门的另一侧,只有这份备份能带你回来。
  • 开发者:能抄走的是这套契约:自描述格式、随数据落盘的版本号、逐级转换器、开源的迁移链。抄不走的是纪律:每一级台阶都要有人为十几年间的每一次改名、每一次结构调整写下翻译,而且新功能落地那天就得写——迁移成本是在改格式那天决定的,不是在玩家升级那天。

注释

  1. Minecraft Wiki,《Region file format》(存档系统「基于 Scaevolus 的 McRegion 模组」;自 Beta 1.3 起成为 Java 版官方格式,「几乎原样采纳,仅增加区块更新时间表」;Anvil 将扩展名由 .mcr 改为 .mca),minecraft.wiki,检索于 2026-09-18;见 https://minecraft.wiki/w/Region_file_format ↩

  2. Minecraft Wiki,《Java Edition 1.2.1》(2012 年 3 月 1 日发布;「加入 Anvil 文件格式,取代 MCRegion」;高度上限由 128 提升至 256),minecraft.wiki,检索于 2026-09-18;见 https://minecraft.wiki/w/Java_Edition_1.2.1 ↩

  3. Minecraft Wiki,《NBT format》(Named Binary Tag,「由 Notch 在一份简短规格说明中描述」;「NBT 文件是一个压缩的复合标签」,通常以 gzip 压缩;level.dat、玩家数据、区块内容与保存的结构均为 NBT;自 Indev 起使用,标签类型在 1.2 与 1.12 各新增一种),minecraft.wiki,检索于 2026-09-18;见 https://minecraft.wiki/w/NBT_format ↩

  4. Minecraft Wiki,《Java Edition 1.7.6》(2014 年 4 月 9 日发布;「实现新的皮肤系统并支持玩家改名」;玩家数据此后改以 UUID 归档于 playerdata/ 目录),minecraft.wiki,检索于 2026-09-18;见 https://minecraft.wiki/w/Java_Edition_1.7.6 ↩

  5. Minecraft Wiki,《Structure Block》(15w31a 即 1.9 加入,「当时仅能通过命令获取、无界面」;16w20a 即 1.10 起在游戏中可用;17w47a 即 1.13 起结构保存于世界的 generated/ 文件夹、扩展名 .nbt),minecraft.wiki,检索于 2026-09-18;见 https://minecraft.wiki/w/Structure_Block ↩

  6. Mojang,《DataFixerUpper》(GitHub 仓库;自述「为在 Minecraft: Java Edition 不同版本之间转换游戏数据而创建」;核心为 Schema 与 DataFix;MIT 协议开源),GitHub,检索于 2026-09-18;见 https://github.com/Mojang/DataFixerUpper ↩

  7. Minecraft Wiki,《Java Edition 1.13》(2018 年 7 月 18 日发布;「扁平化」:数字方块元数据完全弃用,改用方块状态;大量方块、方块状态与物品被拆分、合并、改名),minecraft.wiki,检索于 2026-09-18;见 https://minecraft.wiki/w/Java_Edition_1.13 ↩

  8. Minecraft Wiki,《Java Edition 1.18》(2021 年 11 月 30 日发布;高度上限扩至 384 格,自 −64 至 319;新旧区块「地形与群系无缝混合」;玩家建筑「可能完好、被埋或风化」),minecraft.wiki,检索于 2026-09-18;见 https://minecraft.wiki/w/Java_Edition_1.18 ↩

On this page