0%

Linux 包管理进化史:每个答案,都是上一个问题逼出来的

Linux 包管理进化史:每个答案,都是上一个问题逼出来的

上篇 · 包管理的诞生:从手工到自动解依赖

你大概敲过成百上千次 yum install nginx。回车,等几秒,装好了。它顺滑得像呼吸一样,以至于你从没想过去问:这中间到底发生了什么?

可一旦它出错——比如某天它告诉你”两个包在抢同一个文件,事务被中止”——你会突然发现,这个每天都在用的命令,背后藏着一套你从未真正理解的机器。你不知道它凭什么自动找到了依赖,不知道它怎么知道哪个文件归谁,不知道为什么有的软件能装两个版本、有的却水火不容。

这篇文章想带你做一件事:把这台机器拆开,但不是按零件拆,而是按时间拆。

因为 Linux 的包管理,不是某个天才一次设计出来的。它是三十年里,一代工具踩着上一代的尸体长出来的。每一个你今天觉得理所当然的功能,当年都是为了解决一个让人痛不欲生的问题才被逼出来的。而麻烦的是——每解决一个问题,几乎都会催生出一个新的、更隐蔽的问题。 正是这条”问题——答案——新问题”的链条,把包管理一步步推到了今天的模样。

读懂了这条链条,你就不再需要”背”任何命令。你会发现每一个工具的存在,都是顺理成章、甚至是不得不然的。

我们从一切的起点开始——那个还没有”包”的蛮荒时代。

一、蛮荒时代:一切靠手的混沌

最早,在 Linux 上装一个软件,是这样的:

1
2
3
./configure
make
make install

你下载一份源代码,自己编译,然后把编出来的文件,一个个塞进系统该去的地方。可执行文件丢进 /usr/bin,库文件丢进 /usr/lib,配置丢进 /etc,文档丢进 /usr/share/doc……

这套办法能用。但它有三个会随着时间慢慢要你命的毛病。

第一,系统失忆了。 你今天 make install 了一个软件,它把几十个文件散布到了系统各个角落。三个月后,这些文件还在,但**没有任何地方记录着”它们是谁、从哪来、属于哪个软件”**。系统对自己装了什么,一无所知。

第二,卸载是一场灾难。 正因为没有记录,当你想删掉这个软件时,你根本不知道要删哪些文件。那几十个散落各处的文件,你只能凭记忆一个个去找、去删。删漏了,留下垃圾;删错了,搞坏系统。make install 给了你安装的能力,却没给你干净撤销的能力。

第三,也是最致命的——依赖。 你想装软件 A,编译到一半,它报错:缺少 libssl。于是你去下载 libssl 的源码,编译它,结果 libssl 又说:我需要另一个库。你顺着这条线越挖越深,像在挖一口望不到底的井。等你终于把所有底层库都编译装好,几个小时已经过去了。

这三个毛病的根子,其实是同一件事:软件的”文件”和软件的”身份信息”是分离的。 系统只看到一堆散落的文件,却不知道这些文件合起来是一个有名字、有版本、有出处、有依赖关系的整体。

于是,第一个需求被逼了出来,而且无比清晰:

我们需要一个”盒子”,把一个软件的所有文件,连同它的身份信息(叫什么、什么版本、谁做的、它需要谁),整个打包在一起。安装,就是拆这个盒子;卸载,就是凭着盒子里的清单,把当初装进去的东西原样取走。

这个盒子,就是 .rpm(以及 Debian 世界的 .deb)。

二、盒子纪元:rpm,与一条悄悄立下的铁律

.rpm 文件,本质上就是上面那个”盒子”。把它拆开,里面装着三样东西:

  • 文件载荷:这个软件要装进系统的所有文件,以及它们各自该去的路径。
  • 元数据:这个包叫什么、什么版本、谁打的、它依赖哪些别的包、它和谁冲突、它带来的完整文件清单……
  • 脚本:安装前后、卸载前后要执行的一小段代码(术语叫 scriptlet)——比如创建用户、注册服务。它在排错和安全审计时很值得留意,但不在我们这条主线上,本文不展开。

管理这些盒子的工具,就叫 rpm

1
2
rpm -i 软件.rpm      # install,拆盒子装进去
rpm -e 软件名 # erase,凭清单原样卸载

蛮荒时代的前两个毛病,当场被治好了

系统不再失忆——因为 rpm 在安装时,会把盒子里的元数据,完整地登记进一个本地的数据库(在 RPM 世界,它住在 /var/lib/rpm,我们后面会专门请它登场)。

从此你随时能查:这个软件装了哪些文件(rpm -ql)?这个文件到底是哪个软件带来的(rpm -qf)?这台机器上一共装了些什么?

这份登记还藏着一个常被忽略、却在安全场景里极有分量的能力:校验。既然数据库里记着每个文件原本的大小、权限、甚至内容摘要,那 rpm -V(verify)就能把磁盘上文件的现状和当初登记的”标准答案”逐一比对,告诉你哪些文件被改动过了——配置被篡改、权限被悄悄放大、二进制被替换,一查便知。

例如你运行 rpm -V curl 没有返回信息,说明你的系统中 curl 软件包非常健康、原汁原味,不需要进行任何修复或处理

做等保合规、做完整性核查时,这是一把内置的、几乎零成本的尺子。换句话说,这份账本不只记录”装了什么”,还默默守着”它们有没有被动过手脚”。

卸载也不再是灾难——因为有了那份登记在案的文件清单,rpm -e 能精确地知道当初装了哪些文件,原路撤销,一个不多一个不少。

到这里,请你记住一个不起眼、但将贯穿这整篇文章的设计。

为了让”哪个文件属于哪个包”这件事不出乱子,RPM 立下了一条铁律:

系统里的任何一个文件,在同一时刻,只能属于一个包。

这条铁律此刻看起来平淡无奇,甚至理所当然,一个文件当然只能有一个主人,不然卸载的时候,删还是不删?可正是这条不起眼的规则,会在后面亲手制造出我们最棘手的麻烦,也会成为整部进化史里一次次被挑战、被绕开、最终被彻底颠覆的主角。先把它记在心里,我们继续。

盒子治好了失忆和卸载。但你有没有注意到,蛮荒时代那三个毛病里,最致命的第三个,我们还没碰?

依赖。

盒子确实把”我需要 libssl”这条信息,工工整整地写进了元数据里。可问题是——rpm 这个工具,只会读这条信息,却不会替你去做任何事

rpm -i 一个需要 libssl 的包,它一看:哦,你缺 libssl。然后呢?然后它就报错、罢工、停在那里。它会忠实地告诉你”缺了什么”,但绝不会主动去帮你找来、装上。

于是你又得自己去翻 libssl 的盒子。装上 libssl,它又说还缺别的。你一个盒子一个盒子地手动追下去——这场景是不是有点眼熟?没错,蛮荒时代那口望不到底的依赖之井,又回来了。 只不过这次,井底躺的是一个个 .rpm 盒子,而不是一份份源码。

这就是无数老 Linux 用户闻之色变的四个字:

依赖地狱(Dependency Hell)。

盒子解决了”单个软件怎么装、怎么卸”,却暴露出一个它本身无力解决的新问题:软件之间是结网的,而 rpm 只会处理一个孤立的点。 它知道每个点需要连到哪些别的点,却不会自己去把这张网走通。

新的痛点,逼出了新的需求,而且同样清晰:

我们需要一个更聪明的工具。它得知道”所有可用的盒子都在哪、各自又需要谁”,然后当我说”我要装 A”时,它能自动把 A 牵连出的整张依赖网都走一遍,算出”要让 A 跑起来,总共得装哪些盒子”,然后一次性、一个不漏地装好。

这个工具,就是 yum(以及它后来的继任者 dnf)。

顺带说一句这个名字的来历,它本身就是一部微缩的传承史:YUM 是 Yellowdog Updater, Modified 的缩写。注意那个 “Modified(修改版)”——它说明 YUM 不是凭空造的,而是改写自一个更早的前身 YUP(Yellowdog UPdater),最初为 Yellow Dog Linux 而生,后来由杜克大学物理系的 Seth Vidal 等人彻底重写,用来管理他们的 Red Hat 系统。名字里就刻着一句话:我是上一代的改良版。

而为了让它能”知道所有盒子在哪”,一个全新的概念,也必须随之诞生——**仓库(repository)**。

三、大求解时代:yum,与依赖地狱的终结

要终结依赖地狱,光有一个聪明的工具还不够。它得先有”情报”——它必须知道这世上存在哪些盒子、每个盒子又需要谁。否则,再聪明的大脑,面对一片空白也无从算起。

所以 yum 的出现,其实是两个东西捆在一起登场的,缺一不可:

其一,仓库。 这是一个集中存放所有 .rpm 盒子的地方(通常是一台服务器),更关键的是,它还附带一份索引——把所有盒子的元数据(谁需要谁、谁和谁冲突)预先汇总成了一张总表。有了这张总表,工具不必把每个盒子都下载下来拆开看,只需读一遍索引,就掌握了全局的依赖关系。这就是你在 .repo 文件里配置的那些 baseurl——它们指向的,正是一个个仓库。

其二,依赖求解器(resolver)。 这才是 yum 真正的大脑。当你说”装 nginx”时,它做的事情是:从仓库索引里查到 nginx,发现它需要 A、B;再查 A,发现 A 需要 C;再查 B……它顺着这张网一路走下去,直到把所有牵连到的盒子全部找齐,在脑子里拼出一个完整的安装清单,然后才动手,一次性全部装好。

那场困扰了 Linux 用户许多年的依赖地狱,到这里,终于被终结了。 你只需要说出你想要什么,剩下那张错综复杂的依赖网,交给机器去走。

这里顺带解开一个很多人都纳闷过的现象:为什么我只 dnf install 一个小工具,它却”自作主张”地装上了一堆我没要的东西?这是因为依赖并不只有”非装不可”这一种。RPM 把依赖分了强弱:硬依赖(Requires)是”缺了就跑不起来”,必须装;而弱依赖(Recommends)是”装上体验更好、但没有也能跑”——比如某个工具推荐的插件、文档、可选后端。默认情况下,dnf 会连弱依赖一起装,让你开箱即用。那一堆”我没要的东西”,多半就是这些弱依赖。知道了这个,你就明白为什么有时想要个最精简的安装,得加上 --setopt=install_weak_deps=False 把弱依赖关掉。

这是一次巨大的解放。但我想请你停下来,看一眼这个”求解”的过程——因为这里藏着一个绝大多数人从未意识到的真相。

yum 解依赖,看起来只是”顺着箭头找下去”那么简单吗?

不是的。真实世界的依赖网,远比”A 需要 B”复杂。它充满了带版本约束的要求(A 需要 1.2 以上的 B)、互相打架的冲突(C 和 D 势不两立,不能共存)、多条可选路径(满足这个需求的包不止一个,该选哪个?)。在这样一张网里,要找出一个所有约束同时被满足、谁也不和谁矛盾的安装方案,这本质上是一道经典的计算机科学难题——约束求解,与逻辑学里的 SAT(布尔可满足性)问题同源,属于计算复杂度最高的那一类问题之一。

这不是一个煽情的比喻,而是一个被正式证明过的结论。早在 2005 年,就有研究者给出了严格的证明:软件包的安装,是一个 NP 完全问题——他们正是用 SAT 的方式,把 Debian 和 RPM 的依赖关系编码了出来。换句话说,你 install 时机器在做的事,和那些最硬核的算法难题,是同一个量级的。

换句话说:

你每一次轻描淡写的 yum install,背后其实是机器在解一道数学题。

理解了这一点,你就理解了为什么早期的 yum 有时慢得让人抓狂——它的求解算法还很原始,面对庞大复杂的依赖网,它得吭哧吭哧算很久,有时还算不出最优解。

你看,熟悉的剧情又上演了:yum 解决了”依赖要不要手动追”这个老问题,但它解决的方式(求解一道数学难题),本身又变成了一个新问题——这道题怎么解得又快又好?

这个新问题,会把我们引向下一代工具 dnf,以及它背后那个真正的”数学引擎”。

四、引擎革命:dnf,与求解的工业化

如果说 yum 第一次让机器替我们解依赖这道数学题,那么 dnf(Dandified YUM)做的事,就是把这道题交给了一个专业的解题引擎

这个引擎叫 libsolv

而 dnf 这个名字更是把血缘写在了脸上:它是 Dandified YUM 的缩写——“Dandified” 是”精装、翻新”的意思,而后半截直接就是 YUM。也就是说,它的全名等于明牌承认:我是 YUM 的翻新继任者。(有些文档还会俏皮地把三个字母拆成 DaNdiFied YUM,把 D、N、F 都从 Dandified 里抠出来——属于事后凑的趣味写法,不是正式词源。)

它不是 dnf 团队随手写的一段查找逻辑,而是一个独立的、专门用来求解”包依赖”这类约束问题的库——本质上,它就是一个为软件包量身定做的 SAT 求解器。还记得我们刚说的吗:解依赖,本质是解一道布尔可满足性问题。既然如此,那就请真正擅长解这类问题的算法来上场。

换上 libsolv 之后,变化是肉眼可见的:求解更快,内存占用更省,而且面对那些有多条可选路径的复杂局面,它能给出更合理的方案,而不是早期 yum 那种磕磕绊绊、有时还把自己绕进死胡同的笨办法。

所以你今天在 RHEL 9、Rocky、openEuler、Kylin 上敲的 yum,其实几乎都是 dnf 在背后干活——yum 如今多半只是一个指向 dnf 的别名,保留这个名字,只是为了照顾几代人的肌肉记忆。这本身就是进化史的一个温柔注脚:新引擎换上了,旧名字留着,谁也没察觉。

到引擎革命为止,我们似乎已经把”怎么把软件弄进系统”这件事做到极致了:盒子能装能卸,依赖能自动求解,求解还又快又准。

而且 dnf 还顺手带来了一个被严重低估的能力——时光倒流。它把每一次安装、升级、卸载都当成一笔”事务”完整地记录下来(dnf history 能看到这本流水账),更关键的是,它允许你整笔撤销:dnf history undo <ID> 能把某一次操作连根拔起(那次装了什么就卸掉、卸了什么就装回来),dnf history rollback <ID> 甚至能把系统”回退”到某次操作之后的整体状态。你 dnf update 完发现某个服务起不来了?一条 undo 就能退回去。这背后正是”事务”这个思想在撑腰——操作不是一去不回的泼水,而是可追溯、可回滚的账目。

不过它也有边界,得心里有数:内核、glibc、selinux 这类核心包的降级不在支持之列;而且一旦仓库里旧版本的包已经被删掉、找不到了,回滚就会失败——这恰恰说明,在严肃的生产环境里,留存一份带历史版本的本地源有多重要。

故事讲到这里,似乎可以圆满收场了。安装、依赖、升级、回滚——软件管理的方方面面,都被这套越来越精密的机器照顾到了。一个 Linux 管理员,似乎再没有什么可发愁的。

但请你回头看一眼,我们这一路走来,**关注的始终是同一个方向——怎么把东西”弄进来”**。从蛮荒时代手动编译,到盒子,到 yum,到 dnf,我们解决的全都是”安装”这个动作。

而真正的风暴,恰恰不在”怎么装进来”,而在”装进来之后,这台机器怎么记住这一切、又怎么守住秩序”。

有一个角色,从盒子纪元诞生那一刻起,就一直沉默地站在幕后。我们提过它一次,然后就把它搁下了。它不声不响地记着每一笔账,平时你几乎感觉不到它的存在——可一旦出事,所有的腥风血雨,主角都是它。

接下来你将看到:一条三十年来从未被打破的铁律,如何因为它而存在;一次再普通不过的 yum install,又如何因为它而当场崩盘,逼停整个系统。

那个沉默的主角,就是——数据库

中篇 · 包管理的秩序:账本、铁律与版本共存

五、账本时代:数据库,与铁律的第一次开火

让我们把镜头拉回到盒子纪元。还记得吗?当 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 包生成的条目,我原样贴出来:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
<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>

字段一目了然:名字、架构、版本(注意 epochverrel 是三个独立字段,这正是前面「大求解时代」里版本比较的依据)、校验和、它提供什么(provides)、它需要什么(requires)、它会往系统铺哪些文件(file)、以及——去哪下载它(location href)。 求解器之所以能解那道依赖数学题,靠的就是把仓库里所有包的 providesrequires 这两段读进来,相互匹配。

这里其实又藏着一段微缩的进化史,值得停下来看一眼。

如果你 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)里其实是两层东西:一层是一张张”索引表”(PackagesBasenames 等,就是前面我们扒出来的那些表名);另一层是每个包记录(Packages 表的 blob)内部、由 RPM 标签(tag)定义的几百个字段——包名、版本、文件清单这些”字段”,严格说是存在 blob 里的 RPM tag,而不是 sqlite 的列

/var/lib/rpm 为每个包记录的核心字段

序号 字段名称 作用
1 Name 包名,如 nginx。包的唯一标识主体
2 Epoch 纪元号,版本比较的最高优先级位(用于强制版本排序)
3 Version 上游软件版本号,如 1.20.1
4 Release 发行版打包版本号,如 14.el9(同一上游版本的第几次打包)
5 Arch 适配架构,如 x86_64aarch64
6 Summary / Description 一句话简介 / 详细描述
7 License 软件许可证,如 BSDGPLv2
8 URL 软件官方主页
9 Vendor / Packager 打包厂商 / 打包者信息
10 InstallTime 安装到本机的时间戳(财产清单独有,商品目录没有)

一个典型的目录/var/lib/rpm/包括

1
2
3
4
5
6
7
8
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 真的装进系统后,你随时可以查它:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
$ 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 密钥也导入了,一切看起来都在顺利推进。然后,在最后那道事务测试里,沙盘推演发现了致命问题:

1
2
3
4
5
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 包的元数据里,**根本没有声明”我要 Obsoletesnginx-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 日志里看到的 baseosappstream 这两个仓库名,根源就在这里。地基归地基,应用归应用,两者不同的节奏,被装进了两个不同的盒子。

其次,在 AppStream 这个框架里,它又配上了一套具体的实现技术——“模块(Module)”和”流(Stream)”。

光把应用单独拎出来还不够,还得解决”同一个应用、多个版本”的共存问题。于是有了两个关键概念:

  • 模块(Module):可以理解为”某个应用的整套打包方案”。比如 nginx 是一个模块,python 是一个模块。它把一个应用相关的一堆 RPM 包,捆成一个有意义的整体来管理。
  • 流(Stream):这才是点睛之笔。一个模块下面,可以有多条”流”,每条流代表一个大版本线。比如 nginx 模块下面有 1.20 流、1.22 流;python 模块下面有 3.6 流、3.9 流。

于是,那个原本无解的诉求,现在有了优雅的表达方式:

1
2
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 这本事实账保持对齐,于是”模块流”与”普通包”裂成两套并行世界,交界处滋生出大量难以排查的故障,认知和运维成本陡增。

更要命的是,那三样好处没有一样非它不可:

  1. 锁版本,一个 versionlock 插件就够;

  2. 整组安装,本就有元包(meta package)能一键带全套;

  3. 收窄求解,普通用户根本无感。

模块采用的技术和朴素的”卸旧装新”没有任何区别,铁律一刻没被突破,它并没有解决任何旧机制解决不了的真问题,只是把已有的手段,换了一套更复杂、还要多养一本账的方式重新包装了一遍。

6.2 共存的秘密,全在路径上

说到这里,你心里可能升起一个尖锐的反问:既然铁律这么死板,一个路径只能一个主人,那为什么我的系统里,明明能同时躺着好几个版本的同一个库——libfoo.so.1libfoo.so.2 安然共存,从不打架?它们不是”同一个软件的多个版本”吗,怎么就不冲突?

答案藏在一个不起眼却极聪明的设计里:soname(共享库的版本化命名)。共享库的开发者早就预见了这个矛盾,于是约定:把”主版本号”直接编进文件名。于是 libfoo.so.1libfoo.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 包管理进化史;这,也是一切工程演化的宿命。

下篇 · 把模型用起来:看穿真实的坑,与一张命令地图

前两篇像一部历史剧,我们看着这套机器在一次次”问题逼出答案”中长成今天的模样。但读懂历史的真正价值,不在于谈资,而在于——当你撞上一个诡异的包管理故障时,能用脑子里的模型瞬间推演出它的根因,而不是慌乱地上网搜命令。

这一篇就来兑现这件事。我们先用前面建立的模型,去解剖几个真实世界里高频的坑,每一个都配上可以照着敲的命令;再把 rpm 和 dnf 的常用命令,按它们在模型里的”身份”重新组织成一张地图。你会发现,当你真正理解了机器,命令就不再需要死记——你只要问自己”我现在的问题属于哪一层”,答案自然浮现。

说明:下面实战里的命令都可以直接敲,但输出为节省篇幅做了精简、是示意性的,你在自己机器上跑到的具体包名和版本号会有出入——重要的是看懂”该问什么、怎么读结果”,而不是记住某一行输出。

九、用模型看穿真实世界的坑

下面每一个坑,结构都一样:先看现象,再上手诊断,然后回响前面某一章的模型点破根因,最后给解法。 你会一次次体验到那种”啊,原来文章里讲的那个机制,就是我上次踩坑的元凶”的快感。

9.1 坑一:dnf 替你做了你没要的”降级”

现象

你只想装个小工具 mytool,dnf 却在事务清单里,列出要把一个本来好好的、已装的包降级,或者给你选了一个并非最新的版本:

1
2
3
4
5
6
$ dnf install mytool
...
Downgrading:
libcommon x86_64 1.2-3 some-thirdparty-repo →(你明明没动它)
Installing:
mytool x86_64 2.0-1 some-thirdparty-repo

你心里一紧:我没让你动 libcommon 啊,怎么还给我降级了?

上手诊断

别急,先问清楚这些候选到底来自哪个仓库——很多时候问题就出在”源”上:

1
2
3
4
5
# 看 mytool 和它牵连的包,各自来自哪个仓库
dnf repoquery --qf '%{name}-%{version} 来自 %{reponame}' mytool

# 反过来看,是谁要求了那个被降级的版本
dnf repoquery --requires mytool --resolve

如果你发现 mytool 来自某个第三方源,而它要求的 libcommon 版本恰好和官方源里的不一致,真相就浮出来了。

根因(回响「引擎革命」的求解器模型)

还记得吗——dnf install 不是”按你说的做”,而是在所有启用的仓库的约束下,解一道数学题(SAT),找出一个它认为”全局自洽”的方案。当某个第三方源提供的 mytool 死死要求一个较低版本的 libcommon,求解器为了让”所有约束同时成立”,就只能把 libcommon 降级——这在它看来是唯一的合法解,不是 bug,是它忠实履职的结果,只是不合你意

解法

既然根子在”多个源的约束打架”,对策就是收窄它的选择空间:用 --repo 限定这次只从可信的源里选;或者给仓库设 priority(数字越小越优先),让官方源压过第三方源;真不需要那个第三方源,就直接 dnf config-manager --disable 关掉它。一句话:当 dnf 的决定让你意外,先别怪它,去看看你给了它哪些自相矛盾的源。

9.2 坑二:改过的配置,升级后”失效”了

现象

你给某个服务改过配置(调了端口、加了参数),跑得好好的。一次例行升级之后,你发现新版的功能没生效,或者诡异的是——你改的那些参数像是”没保存”一样,服务行为又变回了默认。

上手诊断

第一反应不该是改回配置,而是先扫一眼系统里有没有”升级时被搁置的新配置”:

1
2
# 揪出所有升级遗留的待处理配置
find /etc -name '*.rpmnew' -o -name '*.rpmsave'

很可能你会看到这样的东西:

1
/etc/myservice/myservice.conf.rpmnew

根因(回响「账本时代」的文件归属模型)

这个 .rpmnew 就是答案。还记得吗——你改过的配置文件被 %config(noreplace) 保护着,升级时 RPM 不敢覆盖你的心血,于是保留了你的旧文件,把新版自带的那份改名成 .rpmnew 搁在旁边。所以系统此刻用的还是你那份旧配置:如果新功能需要新配置项,它自然不生效。反过来,若你看到的是 .rpmsave,则是另一种场景下你的旧文件被备份了。这一切的根子,都是那本账本在恪尽职守——它清楚每个配置归谁、谁动过,于是在”尊重你的修改”和”交付新版”之间小心翼翼。

解法

把新旧两份对比,手动合并:

1
2
diff /etc/myservice/myservice.conf /etc/myservice/myservice.conf.rpmnew
# 把新版引入的新配置项,合并进你正在用的那份,然后删掉 .rpmnew

养成升级后扫一眼 .rpmnew/.rpmsave 的习惯,能躲掉无数”明明升级了却没生效”的玄学故障。

9.3 坑三:关键服务被”偷偷”升级了大版本

现象

一次再普通不过的 dnf update 之后,某个关键服务起不来了——一查,它被升级到了一个不兼容的大版本。你从没主动要求过这次大版本跳跃,它却自己发生了。

上手诊断

先回看这次到底动了什么——升级是一笔可追溯的事务:

1
2
dnf history                    # 找到刚才那笔 update 的事务 ID
dnf history info <ID> # 看清这一笔到底升了哪些包、从什么版本到什么版本

你大概会看到 nginx 从一个保守版本,被升到了某个第三方源提供的、高得多的版本。

根因(回响「大求解时代」的版本比较 + 仓库模型)

你启用了一个提供更高版本的第三方源(典型如 nginx 官网的 mainline 主线源),而 **dnf 的天职就是”找最高版本”**。于是每次 dnf update,它都忠实地把你往那条更高的线上拽——你以为只是例行打补丁,它却顺手把大版本也换了。

解法。 既然 dnf 永远追最高版,那就明确告诉它”这个包别动”——用 versionlock 把它焊死(注意它是个插件,需先安装):

1
2
3
dnf install python3-dnf-plugin-versionlock      # 先装插件
dnf versionlock add nginx # 给 nginx 上锁
# 之后任何 dnf update 都会绕开 nginx,直到你 dnf versionlock delete nginx 解锁

万一已经被升坏了,别慌——升级是事务,可以整笔退回:

1
dnf history undo <ID>          # 撤销那笔闯祸的升级

(前提是旧版本的包在源里还找得到。这也是为什么严肃生产环境值得留一份带历史版本的本地源。)

9.4 坑四:两个软件,抢同一个库的不同版本

现象

你要装 app-C,dnf 报依赖冲突死活装不上:app-C 要某个库的 2.x,可系统里 app-B 正占着这个库的 1.x,两边僵住。

上手诊断——破案三连

这种”版本打架”,核心是揪出到底是谁、把版本写死成了什么。三条命令依次问下去:

1
2
3
4
5
6
7
8
9
10
11
# 1. 先看 app-C 到底要求哪个版本
dnf repoquery --requires app-C
# 假设输出含:libfoo >= 2.0

# 2. 再揪出系统里谁正死死占着旧版本
dnf repoquery --installed --whatrequires libfoo
# 假设输出:app-B、some-old-tool

# 3. 最关键:确认这个库本身,到底支不支持多版本共存
dnf repoquery --provides libfoo
# 若同时提供 libfoo.so.1 和 libfoo.so.2 → 库本身支持共存

根因(回响「共存纪元」的 soname 模型)

还记得吗——如果一个库做了规范的 soname 版本化,libfoo.so.1libfoo.so.2 各占各的路径,本可以和平共存。

所以第 3 步是分水岭:若库明明提供了两个 soname,冲突就不是库的错,而是某个包(比如 app-B)把依赖写死成了”我就要这一个具体版本号”,亲手堵死了共存的可能。

这正是”依赖地狱”在共享库层面的现代残留。

解法

看破案结果下药:如果是某个包依赖写得太死,换一个版本要求更宽松的同类包,或换打包更规范的源;如果死占旧库的是个你其实用不上的老古董(some-old-tool),评估能否直接卸掉,枷锁就解了。但如果两个都是关键业务、又真的共存不了——那就是依赖地狱的死结,这时该请出终章那一跳:别在一个系统里硬解,用容器给它们各自一个世界。

9.5 坑五:隐藏的最高位Epoch,版本号里看不见的那张王牌

前面排查升级问题时,我们一直默认”版本号大的就是新的”。但有一个隐藏字段,能凌驾于版本号之上、悄悄改写这个判断——它就是 Epoch。不认识它,你迟早会撞上一桩”版本号明明一样,dnf 却非要升级”的怪事。

先说它为什么存在。正常情况下比较版本天经地义:1.201.18 新。可总有意外——比如某个软件上游改了版本号的命名方式,新版本号在字符串比较里反而”变小”了(典型如从日期号 20231001 改成语义号 1.0,而 1.0 按位比较居然小于 20231001)。这时只看版本号,包管理器会犯傻:它认为”新版更旧”,于是死活不肯升级,甚至想给你降级。Epoch 就是为这种尴尬准备的一张王牌。

它的规则极其简单粗暴:比较两个包谁新谁旧时,先比 Epoch,Epoch 大的直接获胜,后面的版本号根本不看。 完整的比较顺序是 E-V-R 三段依次比——先 Epoch(没写默认为 0),Epoch 相同才比 Version(上游版本号),Version 还相同才比 Release(发行版打包次数,就是 .el9 前那个 -28)。Epoch 处在最高位,一票否决。所以打包者只要把新包的 Epoch 抬高一级,就等于强行宣布:”别管版本号字面大小了,我说了算,这个就是更新的。”

这套机制,正是你可能踩过的那个坑的真相。还记得在 Rocky 9 上 dnf info nginx 看到的对照吗:

1
2
已装:  Epoch: 1   Version: 1.20.1   Release: 14.el9
可用: Epoch: 2 Version: 1.20.1 Release: 28.el9_8.2.rocky.0.1

注意——两个的 Version 一模一样,都是 1.20.1。如果只看版本号,它俩”一样新”,dnf 根本无从判断。可关键就在那个隐藏字段:可用版的 Epoch 是 2,已装的是 12 > 1,于是 dnf 一锤定音判定”可用版更新”,要把你从 Epoch 1 拉到 Epoch 2——哪怕主版本号一个字都没变。Rocky 的打包者用这一手,就是在强制声明:”我这个回移植了安全补丁的 -28 包,优先级高于任何 Epoch 1 的 nginx,请务必升上来。”

最后是它最坑人的地方:Epoch 平时是隐身的。nginx-1.20.1-28.el9 这样写、这样看,完全看不到它的踪影。只有几个地方能让它现形——dnf info 里那一行 Epoch:、带冒号的完整写法 1:1.20.1-14.el9(冒号前那个数字就是 Epoch)、或者你主动去查:

bash

1
rpm -q --qf '%{epoch}:%{version}-%{release}\n' nginx

所以,当你遇到”两个版本号看起来一样、dnf 却坚持说有新版”这类怪事时,第一个该去看的就是 Epoch。它是版本号里那张看不见、却最大的牌——平时归零隐身,一旦打包者需要在版本号本身会误导的情况下”钦定谁更新”,它就被翻出来,一锤定音。

9.6 所以,别再无脑 dnf update

走完这五个场景,你或许已经回过味来:它们的肇事者,很多时候是同一个习惯——对着系统无脑敲 dnf update,然后祈祷一切安好。

很多教程会告诉你”定期 dnf update 保持系统最新就好”。这话对个人桌面无妨,但放到生产环境,它悄悄撤掉了你和系统之间最重要的一道防线。

因为一次 dnf update,可能同时引爆前面所有的坑:

它会顺着第三方源把关键服务偷偷升上不兼容的大版本(坑三);

会让一批配置悄悄变成 .rpmnew 而你浑然不觉(坑二);

会在求解时做出你没预料的降级或替换(坑一、坑四)。

而最致命的是”批量”——它一次动几十上百个包,真出了事,你几乎无法第一时间定位到底是哪一个干的。

那该怎么办?其实前面这台精密的机器,早就把工具都给你备好了:

  • 升级前,先 dnf check-update 看清这次要动哪些包,心里有数,而不是闭眼回车;
  • 关键生产组件,用 dnf versionlock 焊死,把”绝不能动的”挡在 update 之外;
  • 把每次升级当成一笔事务:出事就 dnf history undo 整笔退回(前提是本地留着旧版本);
  • 国产化、离线、内网场景尤其如此——用一个版本可控的本地源,有节制地升级,而不是对着公网一把梭。

说到底:无脑 dnf update 的危险,不在命令本身,而在于它撤掉了”我清楚我在改什么”这道防线。

我前面两篇讲的那台机器——事务、铁律、账本、求解器——自始至终都在保护你;但它保护的前提是,你得知道自己在让它做什么。

这,才是这五个实战真正想交给你的东西:不是五条命令,而是一种”动手前先想清楚”的态度。

而当你需要具体某条命令时,下面这张地图,随时备查。

十、一张按”模型”组织的命令地图

动手前先说明一个版本前提:下面的命令以 RHEL 8/9 的 DNF4 为基准(国产化环境如 openEuler、Kylin 多数也基于此)。RHEL 10、Fedora 41+ 改用了 DNF5,绝大多数命令照旧能用,但少数命令的子命令或旗标形态有变化——关键处我会在该条下注明。

如果你在 RHEL 10 上敲某条命令报”未知参数”,多半就是 DNF4 与 DNF5 的差异,留意对应注释即可。

最后,是这份命令清单。但请注意,它刻意不按”安装/卸载/查询”那种烂大街的分法——那种清单网上有一万份。

我们按前面建立的心智模型来组织:每一条命令,都告诉你它”住在哪一层、回答什么问题”。这样你查命令时,顺带又复习了一遍模型;而懂了模型的人,看分类就知道该用哪一类命令。

第一类 · 查”财产清单”(rpm 读本地数据库 /var/lib/rpm)

这一类全是 rpm -q(query)开头,问的都是”这台机器已经装了什么”。它们只读本地账本,不联网、不碰仓库。

  • rpm -qa:列出已安装的全部包
  • rpm -qi <包>:看某个包的身份卡(版本、来源、安装时间……)
  • rpm -ql <包>:这个包往系统铺了哪些文件
  • rpm -qf <文件路径>:反查这个文件归哪个包所有(铁律的体现)
  • rpm -qc <包> / rpm -qd <包>:只看它的配置文件 / 文档文件
  • rpm -q --requires <包> / --provides <包>:它需要谁 / 它提供什么
  • rpm -V <包>:校验文件是否被改动过(完整性核查,合规利器)

第二类 · 查”商品目录”(dnf 读远端仓库元数据)

这一类问的是”世界上存在什么、能装什么”,dnf 会去读仓库的 primary.xml

  • dnf search <关键词>:按关键词搜包
  • dnf info <包>:看仓库里某个包的详情
  • dnf list --installed / dnf list --available:列已装的 / 可装的
  • dnf provides <文件或命令>:”哪个包能提供这个文件/命令?”(装某个缺失命令时极有用)
  • dnf repolist:看当前启用了哪些仓库

第三类 · 解依赖、动系统(dnf 求解器 + 事务)

这一类会真正改变系统状态,每一次都是一笔可追溯的”事务”。

  • dnf install <包> / dnf remove <包>:安装 / 卸载(自动解依赖)
  • dnf upgrade [<包>]:升级全部或指定包
  • dnf downgrade <包>:降级
  • dnf check-update:只看有哪些可升级,不动手(升级前先心里有数)
  • dnf install --setopt=install_weak_deps=False <包>:只装硬依赖,不要弱依赖(最精简安装)
  • dnf history:查看事务流水账
  • dnf history undo <ID>:撤销某一笔事务
  • dnf history rollback <ID>:撤销此 ID 之后的所有事务(注意:不是”回到这一笔”,而是”抹掉这一笔以后的”)

第四类 · 管模块与流(共存纪元的遗产)

注意:RHEL 10 起模块化已被弃用,以下命令主要用于 RHEL 8/9 及仍在用模块的系统。

  • dnf module list:列出所有模块及其流、状态
  • dnf module enable/disable <模块>:启用 / 禁用一个模块
  • dnf module install <模块:流/profile>:安装某条流的某个 profile
  • dnf module switch-to <模块:流>:切换到另一条流(注意:switch-to 需较新版本 dnf;老系统上切流需先 dnf module resetenable)
  • dnf module reset <模块>:重置模块到初始状态

第五类 · 诊断与破案(把模型变成探照灯)

这一类不改变系统,专门用来”看清真相”,是上一节那些坑的解药。

  • dnf repoquery --whatrequires <包>:谁依赖了它(判断能不能安全删;加 --recursive 连间接依赖一起查,加 --installed 只看已装的)
  • dnf repoquery --requires <包> --resolve:它依赖谁(--resolve 显示实际对应的包)
  • dnf repoquery --whatprovides <能力>:谁提供了某个能力
  • dnf repoquery --provides <包>:它提供了哪些能力 / soname(判断库能否多版本共存)
  • dnf versionlock add/delete <包>:给包上锁 / 解锁(需先装 python3-dnf-plugin-versionlock)
  • find /etc -name '*.rpmnew' -o -name '*.rpmsave':升级后,揪出待合并的配置

第六类 · 仓库与离线源(把”商品目录”搬到本地)

  • dnf config-manager --add-repo <url>:添加一个新仓库(添加后默认启用)

    启用 / 禁用某个已有仓库:RHEL 8/9(DNF4)用 dnf config-manager --set-enabled <repo> / --set-disabled <repo>;RHEL 10(DNF5)语法已变,改用 dnf config-manager setopt <repo>.enabled=1(=1 启用,=0 禁用)

  • createrepo_c <目录>:把一个装满 rpm 的目录建成仓库(离线/内网源的基石)

  • createrepo_c --update <目录>:增量刷新仓库元数据

  • dnf clean all / dnf makecache:清空 / 重建本地元数据缓存

为什么 RHEL 10 要改成 setopt?

这不是随手换了个写法。老的 --set-enabled 干的事,是跑去你的 .repo 文件里把 enabled=0 原地改成 1——直接改写你的原始配置。隐患你现在应该一眼能看穿:原件被升级覆盖时改动可能丢(还记得 .rpmnew 那个坑吗?),而且发行版给的和你改的混在一起,分不清。DNF5 的 setopt 换了思路:不碰原文件,而是在专门的覆盖目录里叠加一层设置,读取时”原件 + 覆盖”叠起来生效。于是原始配置不可变、你的调整可追溯、想撤销很干净。 你品品——这跟 rpmdb 从 BDB 换 SQLite、模块化退回普通 RPM、乃至 Nix”不在原地改”是同一种韵律:软件工程在一次次教训里,越来越不肯”在原地改全局状态”,而是宁可”加一层、不碰原件”。 连改个仓库开关这种小事,都逃不过这条进化的引力。

最后请记住这张地图的用法:遇到问题,先别想”用哪个命令”,先想”我的问题属于哪一层”。

这,就是这部进化史最终想交到你手里的东西:不是一堆待背诵的命令,而是一副能看穿机器的眼睛,和一张随时可查的地图。

十一、终极复盘:敲下 dnf install 之后,到底发生了什么?

读到这里,你已经分别认识了这套机器的每一个零件:记录”装了什么”的本地账本、缓存”世界上有什么”的仓库元数据、解依赖数学题的求解器、保证安全的事务、守护文件归属的铁律。现在,是时候把它们串成一条完整的流水线了。

我们用全文的老主角做例子——dnf install nginx。当你按下回车,屏幕上不过是滚动几行进度,然后提示成功。但在这短短几秒里,七个角色按一套精密的时序协同工作了一遍。下面这张时序图,把整个过程拆成了 16 步,你可以对照着看;我把它讲成一个连贯的故事。

第一幕:dnf 先搞清楚”世界上有什么”(步骤 1~5)。

你敲下 dnf install nginx(步骤 1),dnf 这个前端调度器接过指挥棒。它做的第一件事不是上网,而是先读 /etc/yum.repos.d/ 下的 .repo 配置(步骤 2)——搞清楚”我手上有哪些仓库可用”。然后它去看本地缓存 /var/cache/dnf 里的仓库元数据还新不新(步骤 3):如果过期了,就向远端仓库发起 HTTP 请求,把 repomd.xmlprimary.xml(图里画的是经典的 .gz 压缩,较新的源也可能是 .zst/.zck)这套”商品目录”拉下来(步骤 4),写进本地缓存(步骤 5)。到这里,dnf 手里就有了那本”商品目录”——世界上存在哪些包、它们彼此什么依赖关系,全在里面。

第二幕:dnf 再搞清楚”这台机器现状如何”(步骤 6)。

光知道”世界上有什么”还不够。dnf 转身查询本地数据库 /var/lib/rpm(步骤 6),把”这台机器已经装了哪些包”这本财产清单读进来。现在,它两本账都齐了——这正是前面反复强调的:dnf 比 rpm 聪明,就聪明在它一手攥着远端的商品目录,一手攥着本地的财产清单。

第三幕:把难题交给求解器(步骤 7~9)。

接下来是整台机器最烧脑的一步。dnf 把”已装清单 + 仓库依赖图”两份数据,一起喂给专业的 SAT 求解器 libsolv(步骤 7)。libsolv 在内部解这道我们讲过的、属于 NP 完全的依赖数学题(步骤 8)——在所有约束下,算出一个”全局自洽”的最小变更集:到底要装哪些、升哪些、会不会和已装的东西冲突。算完,它把这份变更清单交回给 dnf(步骤 9)。

第四幕:征求你的同意,然后下载(步骤 10~12)。

dnf 拿到变更集,不会擅自动手。它把”将要安装/升级哪些包”列出来给你看,停下来等你敲 y 确认(步骤 10)——这是你最后的刹车机会。你一旦同意,dnf 才向远端仓库下载那些选中的 .rpm 包(步骤 11),存进本地缓存 /var/cache/dnf(步骤 12)。注意:到此刻为止,你的系统还没有被改动分毫,一切都还在”准备”阶段。

第五幕:真正动手,而且全程受铁律与事务保护(步骤 13~16)。

万事俱备,dnf 把最终的执行权,交给最底层的 rpm(步骤 13)。rpm 按依赖顺序,把每个包解开、运行其中的 pre/post 安装脚本(步骤 14)——这一步如果发现要铺的文件已经名花有主,铁律就会开火、整个事务回滚(这正是那场冲突惨案的发生点)。一切顺利的话,rpm 把新装包的元数据、文件清单、以及那个让 rpm -qf 能反查归属的索引,统统写进本地数据库 /var/lib/rpm(步骤 15)——账本被更新,这台机器从此”记得”自己多了一个 nginx。 最后,成功的回执一路返回到你的终端(步骤 16),屏幕上打印出 Complete!

回头看这一条流水线,你会发现它就是前两篇所有概念的一次集体亮相:

.repo 配置决定了”能去哪进货”;仓库元数据是”商品目录”;本地 /var/lib/rpm 是”财产清单”;libsolv 是解依赖难题的”大脑”;那句 y/N 确认背后是”事务”的审慎;rpm 落盘那一刻,既在执行铁律的检查,也在更新账本。所谓”敲一条 dnf install”,其实是这七个角色,沿着一条三十年演化出来的精密流水线,合力跑完的一场接力。 你之所以能云淡风轻地敲下回车、然后只看几行进度滚过,正是因为这套机器,已经把所有的复杂、所有的”上一个问题”,都默默替你消化在了这十六步里。