摘要:依赖冲突不是“包坏了”,而是约束系统无解。本文以“约束—求解—元数据—回滚”为主线,解释 apt 的--fix-broken与 dnf 的--nobest为何在多数场景下会放大故障,并给出可验证的判定流程、命令清单与回滚路径。全文强调:修复动作必须建立在“先取证、再收敛、后固化”的顺序之上,任何跳过取证的直接修复都只是在赌元数据的一致性。
目录
一、导语:一次“装不上任何包”的现场还原
典型的故障现场是这样的:某台生产机在执行一次常规升级后,apt install 任何包都返回 E: Unable to correct problems, you have held broken packages.;或者在一台 RHEL 系机器上,dnf install 报出 problem with installed package 与 conflicting requests。运维人员的第一反应往往是加上 --fix-broken 或 --nobest 再试一次,结果有时“好了”,有时把系统推入更深的不可用状态——甚至连 apt 自身都无法完成一次事务。
要理解这类故障,必须先接受一个前提:包管理器不是“文件复制器”,而是一个约束求解器。它面对的是由数千个包、数万个版本、数十万条依赖关系构成的约束系统。所谓“装不上任何包”,通常不是某个包损坏,而是当前已安装集合与仓库元数据之间已经不存在任何满足全部约束的解。此时任何“修复参数”都只是改变了求解器的搜索策略或约束松弛方式,而不是凭空创造出一个可行解。
本文的主线由此确立:把依赖冲突当作约束系统的可满足性问题来处理,把修复参数当作求解策略的开关来评估。围绕这条主线,本文依次讨论问题定义、求解原理、两个高频参数的真实语义、对比与局限、可操作的四步法、来自公开仓库与缺陷库的观察,以及不可变基础设施对依赖治理的长期影响。文中涉及的命令均给出可复现的取证步骤,涉及的数据均标注来源或明确标注为模拟/整合数据。
二、问题定义:依赖冲突的本质是约束系统不可满足
2.1 三个层次的概念区分
工程实践中,“依赖冲突”常被混用,实际上至少包含三个层次。第一层是元数据冲突:两个包在同一字段上声明了互斥关系,例如 Debian 的 Conflicts/Breaks,RPM 的 Conflicts/Obsoletes。第二层是版本约束冲突:包 A 要求 B >= 2.0,而包 C 要求 B < 2.0,且 A、C 必须共存。第三层是状态不一致:dpkg/rpm 数据库记录的已安装状态与文件系统实际内容不一致,例如上次事务被中断导致包处于 half-configured 或 unpacked 状态。
这三层的处理方式完全不同。元数据冲突需要调整仓库组合或替换包;版本约束冲突需要放宽或收紧版本区间,往往意味着要放弃某个包;状态不一致则需要先做数据库与文件系统的一致性修复。把三者混为一谈,正是“越修越坏”的认知根源。
2.2 形式化:一个带权重的可满足性问题
可以把一次安装事务写成如下形式。设候选包版本集合为 V,每个 v ∈ V 对应一个布尔变量 x_v 表示是否选中。依赖关系 Depends: p (>= 1.2) 展开为子句:若选中 v,则至少有一个满足版本区间的 p 的候选被选中。互斥关系 Conflicts: q 展开为:x_v + x_q ≤ 1。目标函数通常是“最小化变更量”,即尽量少地升级、降级或删除已安装包。
依赖子句: ¬x_v ∨ x_{p1} ∨ x_{p2} ∨ … ∨ x_{pk}
互斥子句: ¬x_v ∨ ¬x_q
目标函数: min Σ cost(v, 当前状态)
约束: x_v ∈ {0,1}
这是一个典型的加权 MaxSAT / 伪布尔优化问题。apt 使用的求解器(libapt-pkg 中的 pkgProblemResolver)与 dnf/libsolv 使用的 SAT 求解器在表达能力上接近,但目标函数与启发式不同。本文评述:正因为目标函数是“最小化变更”,求解器在无解时给出的诊断信息天然是“局部”的——它告诉你哪些子句冲突,却不会告诉你“应该删掉哪个包才能恢复可满足性”。这解释了为什么人工阅读报错往往比自动修复更可靠。
2.3 为什么“装不上任何包”会连锁发生
一旦系统中存在一个处于 broken 状态的包(例如依赖未满足的 libfoo),后续任何事务都会把它纳入约束。求解器发现“已安装集合本身不可满足”,于是对几乎所有新请求都返回失败。这就是连锁效应的机制:故障不在新包,而在基线状态。因此,正确的第一步永远是确认基线是否可满足,而不是急着安装新包。
dpkg --audit
dpkg -l | awk '$1 !~ /^ii/ {print}'
apt-get check
apt-cache policy | head -50
rpm -Va
dnf check
dnf repoquery --unsatisfied
dnf history list | head -20
三、原理层:SAT 求解、版本序与元数据演化
3.1 求解器的两条技术路线
apt 的依赖求解长期依赖自研的 pkgProblemResolver,其策略是“先尝试满足,再尝试删除,最后尝试保持”,本质上是一种带优先级的启发式搜索。dnf 则通过 libsolv 把依赖关系编译为 SAT 问题,使用 CDCL(冲突驱动子句学习)求解。两者的差异在工程上表现为:apt 的报错更贴近“人话”,dnf 的报错更结构化但更难读;apt 在复杂冲突下更容易给出“无法修正”的笼统结论,libsolv 则能给出更精确的冲突子句集合。
本文评述:把 libsolv 的 CDCL 能力与 apt 的“最小变更”目标函数结合,是近年来多个发行版探索的方向。但要注意,SAT 求解的完备性只保证“在给定约束下能找到解或证明无解”,并不保证“解是运维想要的”。约束建模时遗漏的隐式约束(例如 ABI 兼容、内核模块版本)不会被求解器感知,这正是自动修复最危险的盲区。
3.2 版本序:容易被忽视的冲突源
Debian 的版本比较规则(epoch:upstream-revision)与 RPM 的 epoch:version-release 并不相同。一个常见陷阱是:本地手工编译安装的包使用了非标准版本号(例如 1.0-local),其版本序可能高于仓库中的正式版本,导致求解器认为“已安装版本已满足”,从而拒绝升级,或在降级时产生意外。另一个陷阱是 epoch 被仓库维护者提升后,旧版本的比较结果整体翻转。
| 场景 | Debian 表现 | RPM 表现 |
|---|---|---|
| 本地版本号高于仓库 | 视为已满足,拒绝升级 | 可能触发 downgrade 提示 |
| epoch 提升 | 旧版本整体变小 | 旧版本整体变小 |
| 多架构(multiarch) | 同包不同 arch 可共存 | 需显式 multilib 策略 |
上表为笔者基于两套包管理文档整理的对比,属于整合性归纳,非某次实验的原始数据。读者可用 dpkg --compare-versions 与 rpmdev-vercmp 自行验证具体版本对。
3.3 元数据演化:冲突是“长出来”的
很多冲突并非设计缺陷,而是元数据随时间演化的副产品。典型路径是:上游项目拆分模块,打包者新增 Provides 与 Obsoletes;某个库提升 soname,旧包被 Breaks;安全更新回填到旧分支,导致同一版本号在不同仓库中内容不同。当多个第三方仓库(EPEL、Remi、第三方 apt source)叠加时,同一包的多个版本可能同时可见,求解空间急剧膨胀。
本文评述:把冲突归因于“某个包写得不好”是廉价的,真正可操作的视角是把仓库组合视为配置项。减少可见版本数量、固定优先级(apt 的 pinning、dnf 的 module/stream 与 priority),往往比事后修复更有效。这与数据库领域的“约束前置”思想一致:能在建模阶段消除的矛盾,不要留到运行时。
四、--fix-broken 的真实语义与适用边界
4.1 它到底做了什么
apt-get install -f 与 apt --fix-broken install 是同一语义:忽略命令行中“要安装什么”的意图,转而尝试修复当前系统中处于 broken 状态的包。它的核心动作是:对每个 broken 包,尝试安装其缺失依赖;若无法安装,则尝试删除该包;若删除会牵连其他包,则继续传播。换言之,它是一个以“恢复一致性”为目标的收敛过程,而不是“安装指定包”。
关键点在于:它默认接受“删除”作为解。这意味着在依赖图高度耦合的系统上,一次 -f 可能删掉一长串包,包括你并不想动的运行时组件。更隐蔽的是,它可能选择“降级”某个依赖来满足约束,而降级又会破坏其他包的版本要求,形成新的 broken 状态。
4.2 适用场景:状态不一致,而非版本冲突
根据前文的三层划分,--fix-broken 的正确定位是处理第三层:状态不一致。典型场景包括:上次 apt 事务被 Ctrl-C 或断电中断,dpkg 数据库中存在 half-installed、unpacked、half-configured 状态的包;手工 dpkg -i 安装了一个依赖不全的 deb;某个包的 postinst 脚本失败导致配置未完成。这些场景的共同特征是:约束本身是可满足的,只是当前状态偏离了可行解。
dpkg --audit确认 broken 包清单;apt-get install -f --dry-run查看将执行的动作(删除/安装/降级);- 若 dry-run 中出现“删除关键包”,立即中止,转为人工处理;
- 确认无误后执行,并记录
/var/log/apt/history.log与term.log。
4.3 不适用场景:版本约束冲突
当冲突来自第二层(版本区间互斥)时,-f 往往无能为力,或者以“删除一方”收场。例如 A 要求 B>=2,C 要求 B<2,且 A、C 都是系统必需组件。-f 会尝试删除 A 或 C,若两者都被其他包依赖,则传播删除,最终可能报告“无法修复”。此时继续反复执行 -f 不会改变结果,只会浪费时间并可能触发部分事务。
本文评述:把 -f 当作“万能修复”是运维中最常见的误用。更稳妥的做法是把它当作“一致性收敛器”,并且永远先 dry-run。dry-run 的输出就是求解器给出的“拟执行计划”,它是判断“会不会越修越坏”的最直接证据。
五、--nobest 的真实语义与适用边界
5.1 它到底做了什么
dnf 默认策略是“尽可能安装最新版本”(best candidate)。--nobest 告诉 dnf:如果最新版本无法满足全部约束,允许退回到较旧但仍满足约束的版本。它改变的是候选版本的搜索顺序,而不是约束本身。换言之,它把“必须用最新”这一软约束放松了。
# 查看 dnf 将选择哪个版本
dnf repoquery --latest-limit 1 --qf '%{name}-%{version}-%{release}.%{arch}'
# 用 --nobest 观察候选回退
dnf install --nobest --assumeno <pkg>
# 显式指定版本,比 --nobest 更可控
dnf install <pkg>-<version>-<release>
5.2 适用场景:最新版本引入回归,旧版本仍可用
--nobest 的合理场景是:仓库中最新版本的某个包与已安装组件存在已知不兼容(例如新版本提升了 soname,而依赖方尚未重建),但旧版本仍能满足全部约束。此时 --nobest 让事务得以完成,代价是暂时停留在旧版本。
另一个场景是模块化(module)与流(stream)并存的仓库。不同 stream 提供不同主版本,--nobest 可以在流切换未完成时提供回退空间。但要注意,模块状态本身是有状态的,跨流操作需要显式 dnf module 命令,--nobest 不能替代流管理。
5.3 不适用场景:依赖图整体不可满足
如果冲突不是“最新版本的问题”,而是依赖图整体无解(例如两个包在同一字段上硬互斥),--nobest 同样无解。更危险的是,它可能选中一个“看起来能装”的旧版本,而这个旧版本缺少安全补丁或与内核模块不匹配,把问题从“装不上”转化为“装上了但不安全/不稳定”。
本文评述:--nobest 的价值在于“给出一个可执行的次优解”,但它不告诉你“这个次优解是否可接受”。因此它必须与版本锁定、变更评审、以及明确的技术债登记配合使用。把它当作临时绕过手段可以,当作长期方案则会在下一次升级时集中爆发。
六、对比与局限:两种“修复”为何会越修越坏
6.1 一张对比表
| 维度 | --fix-broken(apt) | --nobest(dnf) |
|---|---|---|
| 改变的对象 | 当前系统状态(收敛一致性) | 候选版本搜索顺序 |
| 隐含风险 | 传播删除、意外降级 | 停留在旧版本、缺安全补丁 |
| 最佳适用 | 事务中断、half-configured | 最新版回归、旧版仍可用 |
| 无效场景 | 版本区间硬互斥 | 依赖图整体无解 |
| 可验证性 | dry-run 输出即计划 | repoquery 可核对版本 |
6.2 越修越坏的三种机制
机制一:传播删除的雪崩。 -f 删除一个 broken 包后,依赖它的包变成新的 broken,求解器继续删除,形成级联。若中间某个包是 init 系统或 SSH 服务,系统可能在事务结束后无法远程登录。
机制二:降级引入的隐性不兼容。 为满足约束而降级某个库,可能破坏依赖其新 ABI 的二进制。包管理器只检查声明的依赖,不检查运行时符号,因此“装上了”不等于“能跑”。
机制三:元数据漂移下的假解。 当多个仓库提供同名包的不同版本时,求解器可能选出一个“满足声明约束”的组合,但该组合从未被任何发行版测试过。这类“假解”在混合仓库环境中尤其常见。
本文评述:三种机制的共同点是求解器只对声明负责,不对语义负责。因此任何自动修复都必须配合“变更影响评估”。这也是为什么本文坚持“先取证、再收敛、后固化”的顺序:取证阶段收集的信息,正是评估变更影响的依据。
七、可操作路径:取证、收敛、固化、回滚四步法
7.1 第一步:取证(只读,不改状态)
取证的目标是回答三个问题:broken 包有哪些?它们为什么 broken?当前仓库可见哪些版本?在 Debian 系,dpkg --audit 给出 broken 清单,apt-cache policy <pkg> 给出候选版本与来源,apt-mark showhold 给出被 hold 的包(hold 是常见的人为冲突源)。在 RPM 系,dnf repoquery --unsatisfied 列出未满足依赖,dnf history info <id> 还原上次事务。
# Debian/Ubuntu
dpkg --audit
apt-mark showhold
apt-cache policy
grep -r '^deb ' /etc/apt/sources.list /etc/apt/sources.list.d/
# RHEL/Fedora
dnf check
dnf repoquery --unsatisfied
dnf repolist --all
dnf history list
7.2 第二步:收敛(最小变更)
收敛的原则是“一次只改一个变量”。优先处理状态不一致(用 -f 的 dry-run 确认后执行),再处理版本冲突(用 pinning 或显式版本锁定)。在 Debian 系,可以通过 /etc/apt/preferences.d/ 固定优先级;在 RPM 系,可以用 dnf versionlock 锁定版本。收敛过程中每一步都应记录变更前后的包集合,便于回滚。
# Debian: 固定某包来源优先级
Package: libfoo
Pin: origin repo.example.com
Pin-Priority: 900
# RPM: 锁定版本
dnf versionlock add libfoo-1.2.3-1.el9
7.3 第三步:固化(让解可重复)
收敛得到的“解”如果不固化,下次升级会重新漂移。固化的手段包括:把 pinning/versionlock 写入配置管理;把仓库组合写入镜像构建脚本;在 CI 中用 apt-get install --dry-run 或 dnf install --assumeno 做“依赖可满足性”回归测试。这一步是区分“临时修好”与“真正修好”的关键。
7.4 第四步:回滚(必须提前准备)
回滚能力应在修复前就位。Debian 系可依赖 /var/log/apt/history.log 与 term.log 重建操作序列;RPM 系可用 dnf history undo <id>。更稳妥的做法是在修复前对关键路径做文件系统快照(LVM、btrfs、ZFS 或云盘快照),因为包管理器的回滚不覆盖配置文件与数据目录。
dpkg -l / rpm -qa 的完整输出;记录关键服务状态;确认快照可用;确认带外管理(IPMI、云控制台)可登录。缺一不可。
八、案例与数据:来自发行版仓库与缺陷库的观察
8.1 公开缺陷库中的模式
Debian BTS 与 Red Hat Bugzilla 中长期存在与依赖求解相关的缺陷条目。笔者对公开可检索的条目做了主题归类(整合分析,非原始实验):一类是“求解器给出误导性错误信息”,一类是“-f 传播删除超出预期”,一类是“多仓库叠加导致版本选择异常”。这些条目的共同诉求是“希望求解器解释为什么无解”,而不是“希望自动修复”。
本文评述:缺陷库的分布说明,社区真正缺的不是更强的自动修复,而是更好的可解释性。libsolv 的 --debuglevel 与 apt 的 Debug::pkgProblemResolver 都能输出求解过程,但可读性差。把求解日志转化为“人类可读的冲突解释”,是工程上投入产出比很高的方向。
8.2 仓库元数据的规模感
以主流发行版的二进制仓库为例,单个发行版主仓库的包数量在数万量级,依赖关系条目在数十万到百万量级。这个规模意味着:人工穷举不可行,必须依赖求解器;但求解器的输出又必须被人审核。二者之间的张力,正是依赖治理的核心难点。具体数字随发行版与版本变化,读者可用 apt-cache stats 或 dnf repoquery --count 在本机获取准确值。
# 获取本机可见的元数据规模
apt-cache stats
dnf repoquery --count
dnf repoquery --qf '%{name}' | sort -u | wc -l
8.3 一个可复现的“假解”构造(模拟)
为说明“假解”,可以在隔离环境中构造如下场景(模拟数据,仅用于演示机制):仓库 R1 提供 libfoo-1.0,仓库 R2 提供 libfoo-2.0 与 app-2.0,app-2.0 声明依赖 libfoo >= 2.0。若 R1 的优先级高于 R2,求解器可能选中 libfoo-1.0 并拒绝 app-2.0;若优先级反转,则可能装上 app-2.0 但系统中其他依赖 libfoo < 2.0 的包被删除。两种结果都“满足声明约束”,但都不是运维想要的。
本文评述:这个构造的价值在于说明优先级本身就是约束的一部分。很多“越修越坏”的案例,根因是优先级配置与仓库组合不匹配,而不是求解器能力不足。修复优先级配置,往往比反复执行修复参数更有效。
九、前沿预判:不可变镜像、内容寻址与供应链签名
9.1 不可变基础设施对依赖冲突的消解
容器与不可变镜像的普及,正在把“运行时修复依赖”转变为“构建时锁定依赖”。镜像一旦构建完成,运行时不执行包安装,依赖冲突被前移到 CI。这个转变的实质是:把 SAT 求解从“每次部署都做”变成“构建时做一次并固化”。它显著降低了生产环境的故障面,但也把风险集中到构建流水线——构建时的“假解”会被批量复制到所有实例。
本文评述:不可变镜像不是银弹。它把“修一台机器”变成“修一个镜像”,要求团队具备镜像版本管理、回滚与灰度能力。对于必须原地升级的传统系统,本文的四步法仍然是主线。
9.2 内容寻址与可验证构建
内容寻址(如 OCI digest、Nix store path)让“同一个包”有了密码学意义上的唯一标识,从而消除了“同版本号不同内容”的漂移问题。配合可验证构建(reproducible builds),可以做到“给定输入,输出可复现”。这对依赖治理的意义是:求解器的输入(元数据)与输出(包内容)都可被审计,冲突诊断不再依赖“猜”。
9.3 供应链签名与仓库信任
签名机制(apt 的 InRelease、RPM 的 GPG 签名、Sigstore 的透明日志)解决的是“包是否来自可信来源”,与“依赖是否可满足”是两个正交问题。但在混合仓库场景中,二者会耦合:不可信仓库的元数据可能故意制造冲突,诱导求解器删除安全组件。因此,仓库信任策略应作为依赖治理的前置条件,而不是事后补丁。
本文评述:把依赖治理拆成“可满足性”与“可信性”两条线分别建设,是更清晰的架构。前者靠求解器与配置管理,后者靠签名与策略引擎。二者在 CI 中汇合,形成“可满足且可信”的构建门禁。
十、结论与展望
回到最初的问题:--fix-broken 与 --nobest 分别在什么场景下用才不会越修越坏?本文的结论是:--fix-broken 适用于“状态不一致”的收敛,前提是 dry-run 确认不会传播删除关键包;--nobest 适用于“最新版本回归、旧版本仍可用”的回退,前提是明确登记技术债并安排后续升级。两者都不是“版本约束硬冲突”的解药,也都不能替代取证与回滚准备。
更一般地,依赖冲突的治理应遵循“约束前置、取证先行、最小变更、可回滚、可复现”的原则。随着不可变镜像、内容寻址与可验证构建的成熟,依赖求解将越来越多地发生在构建时而非运行时,但“求解器只对声明负责”这一根本局限不会消失。工程上的应对,是让人的判断嵌入到求解流程的关键节点,而不是把判断权完全交给自动修复参数。
拓展阅读建议:Debian 政策手册中关于依赖与冲突的章节、dnf 官方文档中关于 best candidate 与 modularity 的说明、libsolv 项目的求解器设计文档,以及各发行版关于 pinning/versionlock 的实践指南。这些资料能帮助读者把本文的方法落到具体发行版的操作细节上。
主要参考文献
- Debian Project. Debian Policy Manual, Chapter 7: Declaring relationships between packages. 持续更新,2024.
- Debian Project. APT User's Guide 与 apt-get(8) 手册页. 2024.
- Red Hat. DNF Command Reference: best candidate, --nobest, versionlock. 2024.
- libsolv 项目文档:SAT-based dependency solving 设计说明. GitHub, 2024.
- openSUSE. libzypp 与 libsolv 的依赖求解实践文档. 2023–2024.
- Reproducible Builds 项目. 可验证构建与内容寻址相关规范. 2024.
- OCI Image Spec. Content-addressable image digest 规范. 2024.
- Sigstore 项目文档. 透明日志与供应链签名实践. 2024.
- 各发行版 Bugzilla/BTS 中关于依赖求解可解释性的公开讨论条目(整合分析,2023–2025)。
说明:本文涉及的数据以公开文档、手册页与公开缺陷库的整合分析为主;文中标注“模拟”的构造仅用于机制演示,不代表任何真实系统状态。涉及具体版本号与包数量时,请以读者本机 apt-cache stats / dnf repoquery 的输出为准。

