中篇 · 包管理的秩序:账本、铁律与版本共存
五、账本时代:数据库,与铁律的第一次开火
让我们把镜头拉回到盒子纪元。还记得吗?当 rpm 安装一个盒子时,它会把盒子里的元数据,登记进一台机器上的一个本地数据库(在 RPM 世界,它住在 /var/lib/rpm)。
多说一句:这份账本的底层存储格式,本身也演进过——早年用的是 Berkeley DB(你在老系统上会看到 /var/lib/rpm/Packages 这种文件),后来因为 BDB 上游维护停滞、许可证又对发行版不友好,RPM 团队把默认后端迁到了如今更稳健通用的 SQLite(/var/lib/rpm/rpmdb.sqlite)。连存放账本的”本子”本身,都在被更好的方案逼着替换——这又是一次微缩的进化。
随着系统里装的包从几个变成几百上千个,这个数据库的分量,才真正显现出来。
它是什么?它是这台机器”装了什么”的唯一真相源(single source of truth)。
但要真正讲透它,得先纠正一个几乎人人都有的模糊认识——整个包管理,其实牵着两本账,而不是一本。
一本,就是我们刚说的这个本地数据库 /var/lib/rpm,它记录”这台机器,已经装了什么“。
另一本,在远端的仓库服务器上,记录”世界上,存在哪些包“。
前者像你家的财产清单(我家里现在有哪些东西),后者像一份商品目录(商店里能买到哪些东西)。两本账记录的对象、所在的位置、服务的目的都不同,一定不能混为一谈。可偏偏它们记录的字段又长得很像,所以最容易让人糊涂。
要彻底看清它们,最好的办法就是把两本账都翻开,逐字段对照着看。
5.1 远端账本:世界上有哪些软件
我们先从远端那本”商品目录”开始,因为它的结构更直白。
你配置的每个 yum 源,服务器上都有一个 repodata/ 目录。它的入口是一个永远以明文存在的小文件 repomd.xml——你可以把它理解成”清单的清单”,里面用一条条 <data type="..."> 记录,指向真正的元数据文件在哪、校验和是多少。其中 type="primary" 那一条,指向的就是整个仓库的”商品目录”——它的真身,是一个压缩过的文件:老仓库里通常是 primary.xml.gz(gzip),较新的仓库——比如 openEuler 24.03、以及新版 createrepo_c 的默认——用的是压缩率更高的 primary.xml.zst(zstd);而 Fedora 那一脉则偏爱 primary.xml.zck(zchunk——它内部其实也是 zstd,但把文件切成了小块,好处是 dnf update 时只需下载变化的那几块,省流量)。无论哪种,解压开,里面都是一段段这样的 XML。
下面这段,就是这样一段真实的 XML——它是用 createrepo_c(把一个装满 rpm 的目录”建成 yum 仓库”的工具,会扫描所有包、生成上面说的 repodata/ 索引;你做离线内网源时几乎必用它)为一个 nginx 包生成的条目,我原样贴出来:
<package type="rpm">
<name>nginx</name>
<arch>x86_64</arch>
<version epoch="1" ver="1.20.1" rel="14.el9"/>
<checksum type="sha256" pkgid="YES">88fcf52b...d7f58</checksum>
<summary>A high performance web server and reverse proxy server</summary>
<size package="7199" installed="71" archive="860"/>
<location href="nginx-1.20.1-14.el9.x86_64.rpm"/> <!-- 去哪下载 -->
<format>
<rpm:provides> <!-- 我能提供什么 -->
<rpm:entry name="webserver"/>
<rpm:entry name="nginx" flags="EQ" epoch="1" ver="1.20.1" rel="14.el9"/>
</rpm:provides>
<rpm:requires> <!-- 我需要谁 -->
<rpm:entry name="openssl-libs" flags="GE" epoch="0" ver="1.1.1"/>
<rpm:entry name="pcre2"/>
<rpm:entry name="zlib"/>
</rpm:requires>
<file>/usr/sbin/nginx</file> <!-- 我会铺哪些文件 -->
<file>/etc/nginx/nginx.conf</file>
</format>
</package>
字段一目了然:名字、架构、版本(注意 epoch、ver、rel 是三个独立字段,这正是前面「大求解时代」里版本比较的依据)、校验和、它提供什么(provides)、它需要什么(requires)、它会往系统铺哪些文件(file)、以及——去哪下载它(location href)。 求解器之所以能解那道依赖数学题,靠的就是把仓库里所有包的 provides 和 requires 这两段读进来,相互匹配。
这里其实又藏着一段微缩的进化史,值得停下来看一眼。
如果你 ll 一个老仓库的 repodata/,会发现 primary 这份数据居然有两个版本并存:一个 primary.xml.gz(XML 版),一个 primary.sqlite.bz2(SQLite 版)。
为什么要存两份?因为早年 yum 嫌每次都解析 XML 太慢,于是仓库额外预生成了一份建好索引的 SQLite,让 yum 直接查、省去解析。这是典型的”用空间换时间”。可故事没完——后来 dnf 带着自己的求解库 libsolv 上场,它直接吃 XML 就够快,根本不稀罕那份预建的 sqlite。
于是新版 createrepo_c 做了个决定:默认不再生成 SQLite,只留 XML。 这就是为什么你在较新的 Fedora、RHEL、openEuler 仓库里,只剩一个 primary.xml(以 .gz/.zst/.zck 之一压缩),那个 .sqlite.bz2 不见了。
你品品:为提速而生的 SQLite 版,造出来、用了许多年,最后又被更聪明的下一代亲手淘汰,退回到只用 XML。这和我们后面会看到的”模块化兜一圈又退回普通 RPM”、以及前面”rpmdb 从 BDB 换到 SQLite”是同一种韵律——每一层优化,都在被它的下一层重新审视。
5.2 本地账本:你家的财产清单
看清了”商品目录”,现在翻开第二本账——本地这一端,/var/lib/rpm,你家的”财产清单”。
/var/lib/rpm(现在是 rpmdb.sqlite)里其实是两层东西:一层是一张张”索引表”(Packages、Basenames 等,就是前面我们扒出来的那些表名);另一层是每个包记录(Packages 表的 blob)内部、由 RPM 标签(tag)定义的几百个字段——包名、版本、文件清单这些”字段”,严格说是存在 blob 里的 RPM tag,而不是 sqlite 的列
/var/lib/rpm 为每个包记录的核心字段
|
|
|
|
|---|---|---|
|
|
|
nginx。包的唯一标识主体 |
|
|
|
|
|
|
|
1.20.1 |
|
|
|
14.el9(同一上游版本的第几次打包) |
|
|
|
x86_64、aarch64 |
|
|
|
|
|
|
|
BSD、GPLv2 |
|
|
|
|
|
|
|
|
|
|
|
|
一个典型的目录/var/lib/rpm/包括
ll /var/lib/rpm -a
total 74260
drwxr-xr-x. 2 root root 91 Nov 16 2022 .
drwxr-xr-x. 41 root root 4096 Jun 2 13:42 ..
-rw-r--r--. 1 root root 76005376 Jun 2 13:42 rpmdb.sqlite
-rw-r--r--. 1 root root 32768 Jun 5 23:07 rpmdb.sqlite-shm
-rw-r--r--. 1 root root 0 Jun 2 13:42 rpmdb.sqlite-wal
-rw-r--r--. 1 root root 0 Apr 18 2023 .rpm.lock
当这个 nginx 真的装进系统后,你随时可以查它:
$ rpm -qi nginx # 这个包的身份卡
Name : nginx
Epoch : 1
Version : 1.20.1
Release : 14.el9
Install Date: Fri Jun 5 2026 ← 注意:多了"安装时间"
$ rpm -ql nginx # 它往系统铺了哪些文件
/etc/nginx/nginx.conf
/usr/sbin/nginx
/var/log/nginx
$ rpm -qf /usr/sbin/nginx # 【关键】这个文件,到底归谁?
nginx-1.20.1-14.el9.x86_64
两本账翻完,把它们对照着看,一件很有意思的事浮现出来:它们记录的字段,大体上是同一套东西——名字、版本、依赖、文件清单,几乎重合。这也正是它们容易被混淆的原因。但仔细看,有两个决定性的差异,恰好暴露了各自的使命:
-
仓库的”商品目录”里有 location(去哪下载),因为它要告诉你”世界上存在这个包,你可以来取”; -
本地的”财产清单”里有 Install Date(何时装的),以及一个商品目录永远不会有的能力——**rpm -qf反查文件归属**,因为它要记录”这台机器此刻拥有什么、每个文件归谁”。
一句话:商品目录面向”未来可能装什么”,财产清单面向”现在已经装了什么”。
你可能会冒出一个很自然的疑问:这两份数据库,是不是就对应”rpm 命令查的那份”和”dnf 命令查的那份”?
不是。 它们的分界不在”用哪个命令”,而在”在哪台机器、记录什么”:
-
本地数据库 /var/lib/rpm在你自己机器上,是全系统共用的财产清单。rpm读它(rpm -qf查归属就是在读它),dnf也读它——因为 dnf 必须先知道”系统现状”,才能算出”该装什么、会不会撞车”。 -
仓库元数据(就是那个 primary.xml,以 gz/zst/zck 之一压缩)在远端源服务器上(用到时才缓存到本地/var/cache/dnf/),主要是dnf在用。rpm这个单包工具压根不碰仓库,它只管你手里那个孤立的.rpm盒子。
所以真正的分野是:rpm 只盯着本地这一份,而 dnf 两份都握在手里。 这恰恰是 dnf 比 rpm “聪明”的根源——它一手拿着远端的”商品目录”算出该装哪些包,一手拿着本地的”财产清单”核对会不会和已装的东西冲突。
记住这句”dnf 两份都握着”。几段之后那场 nginx 惨案,正是它把两份一对照、当场撞出来的
而那个 rpm -qf——”这个文件到底归谁”——看似平平无奇,却是整个系统账本的命根子。它之所以总能给出一个确定的答案,靠的是一条从盒子纪元就埋下、并请你记在心里的铁律,从未被破坏过:
系统里的任何一个文件,在同一时刻,只能属于一个包。
现在你该明白这条铁律为什么非守不可了:如果 /usr/sbin/nginx 这个文件同时属于包 A 和包 B,那当你卸载 A 时,这个文件删还是不删?删了,B 就坏了;不删,A 就没卸干净。财产清单一旦允许”一物多主”,整个系统的账就乱了,可信度归零。所以数据库把这条铁律当成生命线来守。
它怎么守?靠的是一道在真正动手之前的安全演习。
5.3 事务:动手之前,先在沙盘上推演一遍
当你 dnf install 某个东西,求解器算出完整方案后,dnf并不会立刻往你的磁盘上写文件。它会先做一件极其聪明的事——**事务测试(transaction test)**。
它把所有即将安装、升级、删除的包,在内存里”空跑”一遍:模拟这些文件铺到系统上之后,会不会有哪个文件撞上了已有的主人?会不会有依赖在最后一刻断裂?这就像一场实弹演习前,先在沙盘上把整个流程推演一遍,确认万无一失,才下令真正开火。
这背后,其实是一种非常深刻的工程思想——事务的原子性,也就是数据库领域常说的 ACID 里的那个 “A”:要么全部成功,要么全部不发生,绝不允许出现”装了一半”的残废状态。
dnf 对待你的系统,就像数据库对待一笔转账:宁可整笔回滚,也绝不留下一个账目不平的烂摊子。
这个设计的价值,只有在出事的那一刻才会显现。而现在,就是出事的那一刻。
5.4 铁律开火:一场真实的 nginx 惨案
让我把一个真实的事故摆在你面前。
一台 Rocky Linux 9 的机器,系统自带的官方源里,已经装了一个 nginx,版本 1.20.1。它的主程序和配置,实际是由一个叫 nginx-core 的包提供的——也就是说,/usr/sbin/nginx、/etc/nginx/nginx.conf 这些文件,此刻的合法主人是 nginx-core,这笔账清清楚楚记在数据库里。
这时,运维同学为了用上更新的特性,配置了 nginx 官方仓库,然后敲下 yum install nginx。求解器一比较版本,从官方仓库里挑中了一个高得多的版本:1.31.1,包的尾巴带着 .ngx 标记——这是 nginx 官方打包体系的印记,和系统那套 .el9(发行版官方打包)是两套互不相识的体系。
包下载好了,GPG 密钥也导入了,一切看起来都在顺利推进。然后,在最后那道事务测试里,沙盘推演发现了致命问题:
Error: Transaction test error:
file /usr/sbin/nginx from install of nginx-2:1.31.1-1.el9.ngx.x86_64
conflicts with file from package nginx-core-1:1.20.1-14.el9.x86_64
file /etc/nginx/nginx.conf ... conflicts with ...
...
翻译过来就是:那个 .ngx 的新 nginx,想把自己的主程序也放到 /usr/sbin/nginx、配置也放到 /etc/nginx/nginx.conf——可这些位置,**已经名花有主了,主人是 nginx-core**。
铁律,开火了。
这里你可能会冒出一个合理的反问:升级软件不是天天发生吗?从 nginx 1.20 升到 1.22,新版不也要占用 /usr/sbin/nginx 这个被旧版占着的路径吗?为什么平时升级不冲突,这次就撞了?
答案藏在一个 RPM 的关键机制里——**Obsoletes(废弃声明)**。
正常的同源升级,新版包的元数据里会明确写一句:”我 Obsoletes(取代)某某旧包。”这相当于一份正式的所有权交接书:RPM 一看,哦,这是合法的接班,旧包让位、新包接管那批文件,所有权平稳过户——不算冲突。这就是为什么 dnf upgrade 平时顺顺当当。
可这次的两个 nginx,来自两套互不相识的打包体系:旧的 nginx-core 是发行版官方打的(.el9),新的是 nginx 官网自己打的(.ngx)。那个 .ngx 包的元数据里,**根本没有声明”我要 Obsoletes 掉 nginx-core“**——它压根不知道 nginx-core 的存在。于是在 RPM 眼里,这不是”合法接班”,而是”两个陌生的包,同时来抢同一批文件”。没有交接书,就是硬冲突。而”一个文件只能属于一个包”这条生命线不容侵犯,于是数据库当场判定冲突,整个事务被中止。
请注意这场事故最精彩的地方:它没有造成任何损坏。
因为冲突是在”沙盘推演”阶段被发现的,真正的磁盘写入根本还没开始。系统里的 nginx 还是原来那个完好的 1.20.1,一个文件都没被动过。这正是事务原子性的胜利——
这不是”装坏了”,而是”dnf 拦住了一次会把系统装坏的操作”。它在保护你。
那个让无数人第一次见到时一头雾水的报错,本质上不是故障,而是那条贯穿三十年的铁律,在尽职尽责地守护着你系统账本的完整性。
5.5 一个连带的暗坑:你改过的配置,升级后去哪了?
既然聊到了”文件归属”,顺手揭开一个几乎每个运维都踩过、却很少有人讲清的暗坑。
想象一下:某个软件的配置文件 /etc/xxx/xxx.conf 是它的包带来的,归这个包所有。可你上线后,亲手改过这个配置(调了端口、加了参数)。现在新版本来了,新包里也带着一份它自己的、全新的 xxx.conf。问题来了:你改过的那份,和新包自带的那份,该听谁的?
RPM 的处理堪称细腻。打包者会给配置文件标记一个属性,最常见的是 %config(noreplace)——”升级时别覆盖用户改过的”。于是 RPM 这样裁决:
-
如果你没动过这个配置,直接用新版的,无声替换; -
如果你改过,RPM 不敢擅自覆盖你的心血,于是保留你的原文件不动,把新版那份改名存成 xxx.conf.rpmnew放在旁边——意思是”这是新版的样板,你自己看着要不要合并”。 -
反过来,在某些 %config(不带 noreplace)或卸载场景下,它会把你的旧文件备份成 **xxx.conf.rpmsave**。
这就解释了那个经典困惑:**”我明明升级了,怎么新功能没生效?”**——很可能是新配置静静躺在 .rpmnew 里,而系统还在用你那份旧的。也解释了另一个反向的惊吓:”我的配置怎么被改回去了?”——也许该看看有没有 .rpmsave。养成升级后 find /etc -name '*.rpmnew' -o -name '*.rpmsave' 扫一眼的习惯,能躲掉无数玄学故障。这一切的根子,依然是那本账本在恪尽职守:它清楚每个配置文件归谁、谁动过,于是在”尊重你的修改”和”交付新版”之间,小心翼翼地两头都不得罪。
5.6 惨案背后,一个无解的新问题
事故平息了,但它留下了一个发人深省的问题。
回头看:这位运维同学其实只想做一件再正常不过的事——在同一台机器上,用上一个更新版本的 nginx。 这个诉求过分吗?一点都不。
可经典的打包模型,却结构性地做不到。原因恰恰还是那条铁律:无论 1.20 还是 1.31,它们都想占用 /usr/sbin/nginx 这同一个文件路径。而一个路径只能有一个主人。所以在经典模型里,同一个软件,同一时刻,整个系统只能存在一个版本。新旧两个版本,天生水火不容。
这在过去不是大问题。但时代变了。
如今的企业级发行版,生命周期动辄长达十年(比如 RHEL 一个大版本要维护到地老天荒)。可十年里,nginx、Python、MySQL 这些软件,早就迭代了好几个大版本。于是一个尖锐的矛盾浮出水面:
系统的底座要十年不变,以求稳定;可上面跑的应用,却需要不断用上新版本,以求先进。一个要慢,一个要快,而经典模型里”一个软件只能有一个版本”的铁律,逼着你只能二选一。
这是一个靠前面所有机制——盒子、仓库、求解器、数据库——都无法解决的新问题。它不是”怎么把包弄进来”的问题,而是一个全新的维度:怎么让同一个软件的多个大版本,在同一个系统里和平共存、还能按需切换?
要回答它,我们必须想办法绕开、甚至部分推翻那条统治了一切的铁律。
而这,正是下一段历史登场的理由。
RHEL 8 给出的回应分两层:先是一个新的仓库框架——应用流(Application Stream,即 AppStream),专门用来容纳”可以有多个版本”的上层应用;然后,为了在这个框架里真正实现”同一软件多版本共存”,它又配上了一种叫**模块化(Modularity)**的具体打包技术。
请记住这个”框架 + 实现”的分层——它是这一章结尾那个意味深长的反转的关键。
六、共存纪元:模块化,绕开铁律的第一次尝试
我们先把那个矛盾,逼到它最尖锐的形态,这样你才能体会解法的精妙。
矛盾的核心是:发行版想给你一个版本的 Python(比如 3.6),并承诺为它提供长达十年的安全维护;可你的新项目,偏偏要 Python 3.9。在经典模型里,这是死局——因为 python3 这个命令、它的库文件,路径就那么几个,两个版本一定会撞车,而铁律说一个文件只能一个主人。
那么,Red Hat 在 RHEL 8 里给出的解法是什么?
它的思路不是去打破铁律(那会动摇整个 RPM 体系的根基),而是非常聪明地绕开它。绕开的办法,是引入一个全新的概念层次,叠在经典的”包”之上。我们一层层来看。
首先,它把仓库劈成了两半。
RHEL 8 之后,你会发现系统的软件来源,分成了两个性质截然不同的仓库:
-
BaseOS:操作系统的底座。内核、systemd、基础工具链……这些东西只有一个版本,稳定如磐石,十年不动。这是你脚下的地基,绝不能晃。 -
AppStream(Application Stream,应用流):跑在底座上的应用软件,Python、Node.js、数据库、nginx……这里,允许多版本共存。
这个切分本身就是一个深刻的设计哲学:把”必须稳定的”和”需要灵活的”在物理上彻底分开。 nginx 日志里看到的 baseos、appstream 这两个仓库名,根源就在这里。地基归地基,应用归应用,两者不同的节奏,被装进了两个不同的盒子。
其次,在 AppStream 这个框架里,它又配上了一套具体的实现技术——”模块(Module)”和”流(Stream)”。
光把应用单独拎出来还不够,还得解决”同一个应用、多个版本”的共存问题。于是有了两个关键概念:
-
模块(Module):可以理解为”某个应用的整套打包方案”。比如 nginx是一个模块,python是一个模块。它把一个应用相关的一堆 RPM 包,捆成一个有意义的整体来管理。 -
流(Stream):这才是点睛之笔。一个模块下面,可以有多条”流”,每条流代表一个大版本线。比如 nginx 模块下面有 1.20流、1.22流;python 模块下面有3.6流、3.9流。
于是,那个原本无解的诉求,现在有了优雅的表达方式:
dnf module enable nginx:1.22 # 我选 nginx 的 1.22 这条流
dnf module install nginx
你不再是”安装某个版本的包”,而是**”在多条流里,选定一条,然后顺着它走”**。系统保证:同一个模块,同一时刻只有一条流是激活的。你想换版本,就切换到另一条流。
发现没有?铁律其实根本没有被破坏。
在任何一个确定的时刻,系统里 nginx 依然只有一个版本占用着 /usr/sbin/nginx——铁律安然无恙。模块化做的,不是让两个版本同时挤在一个路径上(那是不可能的),而是在更高的层次上,管理”此刻该让哪条流来占用这些路径”,并让切换变得干净、可控、有据可查。
这就是模块化的精髓:它没有正面推翻”一个文件只能属于一个包”那条物理铁律,而是在它之上,搭了一层”版本线的开关”。铁律管的是”此刻谁在位”,模块化管的是”该让谁上位、怎么换人”。
这是一种极高明的工程智慧:当一条底层规则无法撼动时,不要硬碰它,而是在更高的抽象层上,为它加一个调度器。
6.1 多养一本账的代价
要看清模块化到底值不值,得先知道它为了实现”共存”,在 /var/lib/rpm 这本老账本之外,额外背上了什么。它引入了两份新数据:一份在仓库侧,叫 modules.yaml(随仓库元数据下发、缓存在 /var/cache/dnf/ 下),用 YAML 定义”有哪些模块、每个模块有哪些流、每条流含哪些包、有哪些 profile 组合”;另一份在本地侧,是 /etc/dnf/modules.d/ 目录下的一堆 .module 文件,记录”这台机器为每个模块启用了哪条流”。关键的分野就在这里:老账本 /var/lib/rpm 记的是”实际装了哪个包、文件归谁”,而这两份新账记的是”我想用哪条版本线”——前者是事实,后者是偏好,分属完全不同的层。
平心而论,凭着这两份新账,模块化确实换来了三样像样的便利。其一是版本线锁定:启用 nginx:1.22 后,dnf update 只会在 1.22 这条线内部收安全补丁,绝不会擅自跳到 1.24 这样的大版本,等于自动替你挡住了”被偷偷升级”的坑。其二是整组打包切换:一句 dnf module install postgresql:13/server,就能拿到该版本配套的一整组包和预设好的安装组合,既不必自己拼凑,也不会新旧混装。其三是收窄求解:它等于告诉求解器”这软件只准在这条流里选”,让依赖结果更可预测。这些便利不能说不真实。
但把这三样便利放到天平另一头,代价立刻显得过重。多出来的 /etc/dnf/modules.d/ 这本意愿账,必须时刻和 /var/lib/rpm 这本事实账保持对齐,于是”模块流”与”普通包”裂成两套并行世界,交界处滋生出大量难以排查的故障,认知和运维成本陡增。
更要命的是,那三样好处没有一样非它不可:
-
锁版本,一个 versionlock插件就够; -
整组安装,本就有元包(meta package)能一键带全套; -
收窄求解,普通用户根本无感。
模块采用的技术和朴素的”卸旧装新”没有任何区别,铁律一刻没被突破,它并没有解决任何旧机制解决不了的真问题,只是把已有的手段,换了一套更复杂、还要多养一本账的方式重新包装了一遍。
6.2 共存的秘密,全在路径上
说到这里,你心里可能升起一个尖锐的反问:既然铁律这么死板,一个路径只能一个主人,那为什么我的系统里,明明能同时躺着好几个版本的同一个库——libfoo.so.1、libfoo.so.2 安然共存,从不打架?它们不是”同一个软件的多个版本”吗,怎么就不冲突?
答案藏在一个不起眼却极聪明的设计里:soname(共享库的版本化命名)。共享库的开发者早就预见了这个矛盾,于是约定:把”主版本号”直接编进文件名。于是 libfoo.so.1 和 libfoo.so.2 从一开始就是两个不同的文件名、占着两个不同的路径——铁律压根没被触犯,它们当然能共存。需要 1.x 的老程序去链接 .so.1,需要 2.x 的新程序去链接 .so.2,各取所需,互不干扰。
这下你就看穿了一件事:为什么库能轻松多版本共存,而 nginx 那样的可执行程序却不能? 因为库从设计之初就把版本编进了文件名、主动给自己分了路径。
而 /usr/sbin/nginx 这个可执行文件的路径是写死的、不带版本的——两个版本必然抢同一个名字。
共存能不能实现,从来取决于一件事:它们占的是不是同一个路径。 soname 是”在文件名层面自己分路径”的优雅解法,模块化是”在更高层调度谁来占路径”的笨重解法,而我们后面会看到的 Nix,则是”干脆给所有东西都分配独立路径”的终极解法——殊途同归,都是在和那条铁律周旋。
6.3 但这一次,解法本身成了新的麻烦
按我们这部进化史一以贯之的剧情,你现在应该已经预感到了:模块化解决了多版本共存,但它自己,又带来了新的问题。而且这一次的问题,争议大到最后动摇了它自己的命运。
第一个麻烦是复杂度的暴涨。经典模型里,你只要想”装哪个包”。模块化之后,你得先想”启用哪个模块、激活哪条流”,再想”装哪个包”。多出来的这一层,让无数管理员栽了跟头——尤其是当模块的流和普通仓库里的包发生纠缠时,求解器会给出一些极其反直觉的结果。你或许在nginx 配置里见过的 module_hotfixes=true 吗?那行配置存在的意义,恰恰就是为了告诉 dnf:对这个第三方仓库,请绕过模块化的某些限制——它本身就是模块化带来的复杂度,逼出来的一块补丁。
第二个麻烦更微妙:模块和经典包,是两套并行的世界,它们的交界处充满了陷阱。 一个被模块”接管”的应用,和一个从普通仓库来的同名包,该听谁的?这种边界上的模糊,带来了大量难以排查的故障。
顺带说一个和”版本控制”一脉相承的实用机制。模块化用”锁定一条流”来固定大版本,但如果你不用模块、只是单纯想钉死某个包的版本,不让它被 dnf update 偷偷升级,有一个更直接的工具:versionlock。装上 dnf-plugin-versionlock,一句 dnf versionlock add nginx,就给这个包上了把锁——之后任何升级都会绕开它,直到你亲手解锁。这是把”我不要这次升级”的意愿,从”每次小心翼翼”变成”一次声明、长期生效”。生产环境里那些”绝不能动”的关键组件,值得用它焊死。
这些麻烦累积到什么程度呢?程度是:Red Hat 最终亲手把这套机制送进了历史。 而且退得干净利落、有明确的时间表——从 RHEL 9 开始,官方就逐步停止创建新模块;到了 RHEL 10,模块化作为一种打包技术被彻底放弃,官方文档的措辞不留余地:RHEL 10 不再分发任何模块化内容。
那么,多版本共存的需求消失了吗?并没有。这里要特别说清一件事,否则容易误解:被放弃的,只是”模块化”这一种实现技术,而不是 AppStream 这个框架。 AppStream 活得好好的,RHEL 10 里它依然是那个容纳上层应用的仓库——变的只是它”内部怎么提供多版本”。
那么,RHEL 10 改用什么来填充这个框架了呢?Red Hat 给出的答案,说出来你可能会愣一下——回归”带版本号的普通 RPM 包”,直接用 dnf install 安装。 也就是说,框架(AppStream)留下了,但它最初那套精巧的内部实现(模块、流),兜了整整一大圈,最后被官方亲口判定:太复杂、太难维护、得不偿失,我们还是回到最朴素的老办法吧。
请你品一品这个结局。模块化——一个曾被寄予厚望、作为 RHEL 8 旗舰特性隆重推出的实现技术,不到两个大版本,就被它的创造者判定为”不值得”,然后亲手拆掉,让那个它本想服务的框架,改回用最朴素的方式运转。这在软件史上并不常见,却也最诚实。它把这部进化史的灵魂,赤裸裸地摆在了你面前:
没有任何一个解法是终点。每一个答案,都只是”在当时的约束下所能找到的最不坏的那个”;而它带来的新问题,终将催生下一次进化——哪怕那次进化,是退回到起点。
那么,有没有一种更彻底的思路?一种不在那条铁律之上修修补补,而是从根上让”一个路径一个主人”这个前提本身不再成立的办法?
如果连”所有软件都共享同一个 /usr“这个延续了三十年的大前提,都可以被推翻呢?
这,就是我们这部进化史最后,也是最激进的一跳。
七、终章前夜:那条铁律,能不能从根上被废掉?
让我们做一次彻底的回溯。
从蛮荒时代到模块化,这一路所有的进化,无论看起来多么不同,其实都站在同一块地基上——这块地基,我们从盒子纪元起就反复在敲打:
所有软件,共享同一个文件系统;系统里的每一个路径,同一时刻只能有一个主人。
/usr/sbin/nginx 只有一个,/etc/nginx/nginx.conf 只有一个。rpm 的卸载、dnf 的求解、数据库的账本、事务测试的冲突检查、乃至模块化那套精巧的”版本线开关”——全部,都是在这块”共享的、可变的、全局唯一的文件系统”之上做文章。它们或修补、或调度、或绕行,但从没有一个,敢去动这块地基本身。
那么,一个近乎异端的问题浮现了:
如果这块地基,本身就是错的呢?
如果”一个路径一个主人”这件事,根本不必成立呢?如果可以让 nginx 1.20 和 1.31 真正地、物理地同时存在于系统里,谁也不挤占谁的路径呢?
那样的话,前面三十年里所有的痛苦——依赖冲突、版本互斥、升级把配置覆盖、装坏了难回滚——会不会有相当一部分,从根上就不存在了?
这不是空想。已经有人这么干了,而且给出了好几种截然不同的答案。它们共同构成了包管理进化史当下最前沿、也最激动人心的一跳。我们不展开细节(那足够再写一整篇),只看它们各自是怎么对那条铁律下手的:
第一种思路:让每个包住进自己的地址,从此不再有”同一个路径”。 这是 Nix 与 Guix 的世界。它们彻底抛弃了”软件都装进 /usr“的传统,转而给每一个包、每一个版本,都分配一个由内容哈希决定的、独一无二的安装路径。nginx 1.20 住在它自己的哈希目录里,1.31 住在另一个——它们从不共享任何路径,于是”一个路径一个主人”这条铁律,在这里直接失去了用武之地:根本不存在需要争夺的公共路径。版本共存不再是需要精巧调度的难题,而是天经地义的默认状态。代价是:整套思维方式都要重建,学习曲线陡峭。
第二种思路:干脆给每个应用发一个独立的世界。 这是容器(Docker 那一脉)的答案。它不去解决”如何在一个系统里共存”,而是反问:为什么非要挤在一个系统里?给每个应用打包一个自带依赖的、隔离的运行环境,让它在自己的小世界里独享一套文件系统。铁律依然在每个容器内部成立,但容器与容器之间,老死不相往来——冲突自然也就无从谈起。
第三种思路:把整个系统冻成一块只读的铁板。 这是不可变操作系统(immutable OS)的方向。它釜底抽薪地拿掉了铁律里最危险的那个词——”可变”。既然”共享可变全局态”是万恶之源,那就让系统盘只读,更新时不再是”在原地改改补补”,而是整体地、原子地换上一个新镜像,要回滚就整体换回旧的。系统状态从此干净、可预测、可复现。
你看出这三种思路的共同点了吗?它们不再像前辈那样,小心翼翼地在铁律之上修补、调度、绕行。它们做的是更狠的事——把铁律赖以成立的那个前提(“共享的、可变的、全局唯一的文件系统”)本身,给拆了。
这是三十年来,这部进化史第一次,把矛头指向了它自己的地基。
八、尾声:进化没有终点
我们从一句最朴素的 make install 出发,走到了今天。
回望整条路,你会发现它惊人地遵循着同一种韵律:手工编译的混沌,逼出了盒子;盒子的孤立,逼出了仓库与求解;求解的迟钝,逼出了更强的引擎;而要管好这一切,逼出了账本与那条铁律;铁律带来的版本死锁,逼出了模块化;模块化的复杂,又让那套实现退回原点;直到今天,有人开始追问——能不能把铁律的地基本身换掉。
每一个答案,都精确地长在上一个问题的伤口上。没有一步是凭空设计的,每一步都是被前一步的疼痛逼出来的。
所以,回到我们最初的那个问题:为什么你每天敲的那句 yum install,背后是这样一台层层叠叠、精密又笨重的机器?
因为它不是被”设计”出来的,而是被”逼”出来的。它身上每一道看似多余的褶皱——epoch 字段、事务测试、模块的流、.rpmnew 文件、那条文件归属的铁律——都是某一次真实疼痛留下的疤痕。读懂了这些疤痕,你就读懂了:你面对的从来不是一堆需要背诵的命令,而是一部仍在继续书写的、关于”如何与复杂性共处”的历史。
而它还远没有写完。Nix 会不会成为主流?容器会不会进一步吞并传统包管理?不可变 OS 会不会成为服务器的新常态?没有人知道。我们唯一能确定的是那条贯穿全文的铁律——不是”一个文件只能属于一个包”那条,而是更高的那一条:
每一个答案,都只是下一个问题的开始。
这,就是 Linux 包管理进化史;这,也是一切工程演化的宿命。
转自:张先生的深夜课堂
版权申明:内容来源网络,版权归原创者所有,如有侵权请联系删除
想了解更多干货,可通过下方扫码关注

可扫码添加上智启元官方客服微信👇

17认证网








