先说我踩的坑
凌晨1点47分,Jenkins构建卡在 Updating dependencies (including require-dev) 这一步。进度条纹丝不动,我盯着屏幕看了20分钟。查了 git log 才发现,有人把 composer.lock 加进了 .gitignore。于是 CI 里执行的 composer install 实际变成了 composer update——Composer 要在没有 lock 文件的情况下,从零解析整个依赖图,解一道约束满足问题。那次发布延迟了3个半小时。
问题不是下载慢。几百个包的网络下载,分布式CDN半小时内肯定能拉完。真正卡死的是解析器:它要判断「包A的版本约束和包B的版本约束是否存在交集、同时满足所有依赖是否存在一个组合」。这在算法上等价于一个 SAT 问题,最坏情况下是指数级复杂度。
这篇文章就把 Composer 2.x 的依赖解析原理拆开,给你一个能跑的最小解析器,再给出一组性能数据。最后是实际项目中我踩过的7个坑。
两种方案:让 Composer 自己飞 vs 锁死版本
生产环境里最常见的场景是两种:
- 方案A:没有 lock 文件,执行
composer update。Composer 要从包池里重新解析所有版本,生成新的 lock。耗时几分钟到几小时不等。 - 方案B:提交 lock 文件,执行
composer install。Composer 直接按 lock 里的哈希规则下载对应版本,不经过求解器。
用一个 Laravel 11 项目实测,项目信息:78个直接依赖,412个间接依赖,PHP 8.3.2。
| 操作 | Composer版本 | 耗时 | 峰值内存 |
|---|---|---|---|
| composer update(无lock) | 1.10.27(PHP7.4.33容器) | 173.6s | 389MB |
| composer update(无lock) | 2.7.1(PHP8.3.2) | 21.8s | 96MB |
| composer install(有lock) | 2.7.1(PHP8.3.2) | 3.2s | 58MB |
数据是在同一台 Linux 服务器上连续跑3次取中位数。composer install 快不是因为它下载快,而是它跳过了整个依赖求解环节,直接按 lock 里记录的精确版本号去拉包。
所以核心结论:lock 文件就是依赖求解结果的缓存。丢失 lock 等于让每次发布都重新做一次完整求解。
Composer 2.x 的依赖解析器内部结构
Composer 2 重写了 1.x 的解析器。关键源码在 composer/composer 这个包里,版本 2.7.1,相关文件:
src/Composer/DependencyResolver/PoolBuilder.php:构建包池src/Composer/DependencyResolver/RuleSetGenerator.php:生成规则集src/Composer/DependencyResolver/Solver.php:求解器src/Composer/DependencyResolver/Decisions.php:决策队列src/Composer/DependencyResolver/Transaction.php:计算安装/更新/移除操作
一条 require 关系会被转化成一条规则。规则不是什么魔法,就是逻辑表达式:
如果安装 guzzlehttp/guzzle 7.8.1
则必须安装 guzzlehttp/promises ^2.0
反过来表达就是:
guzzlehttp/guzzle 7.8.1 ===> guzzlehttp/promises ^2.0
整个依赖图就是几百条这种规则。Composer 的 Solver 要做的事:找一组包版本,让所有规则同时成立。
1.x 的做法是递归回溯:先选一个包版本,再看它的依赖,遇到冲突就回退到上一层换版本。包一多,回溯路径呈指数增长。这就是「卡死」的根源。
2.x 做了两个关键优化:
- 约束区间传播:所有版本约束先被解析成区间,比如
^7.8直接变成>=7.8.0 <8.0.0。求解时不是一个个版本试,而是对整个区间做交集运算。一个版本区间不满足就直接剪掉,不进入回溯。 - Backjump(回跳):回溯时不回到上一层决策点,而是直接跳到导致冲突的那一层。传统回溯是一层一层往上退,backjump 是一次性跳几层。
Composer 2 在实际解析时还会用并行 HTTP 请求去拉包元数据。PoolBuilder 会在求解前把可能涉及的包元数据全部拉下来,再把这些元数据放进 rules。所以即便最后发现冲突,元数据也已经缓存在本地,不会反复请求。
一个可运行的简化版解析器
下面这个类用 PHP 写了 Composer 求解器的核心逻辑:候选域 + 约束传播 + 最小域分支回溯。
它支持三种版本约束:^1.2、>=1.0、~1.2。不处理 pre-release tag,不处理互斥冲突。够跑通一个依赖图。
<?php
// SimplifiedResolver.php
class SimplifiedResolver
{
private array $pool;
public function __construct(array $pool)
{
$this->pool = $pool;
}
public function solve(array $rootRequires): ?array
{
$domains = [];
foreach ($rootRequires as $name => $constraint) {
$candidates = $this->candidates($name, $constraint);
if (!$candidates) {
return null;
}
$domains[$name] = $candidates;
}
return $this->backtrack($domains);
}
private function candidates(string $name, string $constraint): array
{
if (!isset($this->pool[$name])) {
return [];
}
$result = [];
foreach ($this->pool[$name] as $version => $deps) {
if ($this->satisfies($version, $constraint)) {
$result[$version] = $deps;
}
}
return $result;
}
private function satisfies(string $version, string $constraint): bool
{
if ($constraint === '*' || $constraint === '') {
return true;
}
if (str_starts_with($constraint, '^')) {
// ^1.2 => >=1.2.0 <2.0.0;^0.3 => >=0.3.0 <0.4.0
$target = substr($constraint, 1);
[$major, $minor] = array_pad(explode('.', $target), 2, '0');
$upper = $major === '0'
? '0.' . ((int) $minor + 1) . '.0'
: ((int) $major + 1) . '.0.0';
return version_compare($version, $target, '>=')
&& version_compare($version, $upper, '<');
}
if (str_starts_with($constraint, '>=')) {
return version_compare($version, substr($constraint, 2), '>=');
}
if (str_starts_with($constraint, '~')) {
$target = substr($constraint, 1);
$major = explode('.', $target)[0];
$upper = ((int) $major + 1) . '.0.0';
return version_compare($version, $target, '>=')
&& version_compare($version, $upper, '<');
}
return $version === $constraint;
}
private function backtrack(array $domains): ?array
{
if (!$this->prune($domains)) {
return null;
}
$allSingle = true;
foreach ($domains as $candidates) {
if (count($candidates) !== 1) {
$allSingle = false;
break;
}
}
if ($allSingle) {
$result = [];
foreach ($domains as $name => $candidates) {
$result[$name] = array_key_first($candidates);
}
return $result;
}
// 选候选数量最少的包做分支,减少回溯次数
$branchName = null;
$branchVersions = null;
foreach ($domains as $name => $candidates) {
if (count($candidates) > 1
&& ($branchVersions === null || count($candidates) < count($branchVersions))) {
$branchName = $name;
$branchVersions = $candidates;
}
}
foreach (array_keys($branchVersions) as $tryVersion) {
$testDomains = $domains;
$testDomains[$branchName] = [$tryVersion => $this->pool[$branchName][$tryVersion]];
$result = $this->backtrack($testDomains);
if ($result !== null) {
return $result;
}
}
return null;
}
/**
* 约束传播,动态把间接依赖加进候选域,并剔除不可能的版本
*/
private function prune(array &$domains): bool
{
$changed = true;
while ($changed) {
$changed = false;
foreach ($domains as $name => $candidates) {
foreach ($candidates as $version => $deps) {
foreach ($deps as $depName => $constraint) {
if (!isset($domains[$depName])) {
$depCandidates = $this->candidates($depName, $constraint);
if (!$depCandidates) {
unset($domains[$name][$version]);
$changed = true;
if (empty($domains[$name])) {
return false;
}
continue 2;
}
$domains[$depName] = $depCandidates;
$changed = true;
}
$hasCandidate = false;
foreach ($domains[$depName] as $possibleVersion => $_) {
if ($this->satisfies($possibleVersion, $constraint)) {
$hasCandidate = true;
break;
}
}
if (!$hasCandidate) {
unset($domains[$name][$version]);
$changed = true;
if (empty($domains[$name])) {
return false;
}
continue 2;
}
}
}
}
}
return true;
}
}
来看一个会触发回溯的测试用例:
<?php
require 'SimplifiedResolver.php';
$pool = [
'app/legacy' => [
'1.0.0' => ['vendor/lib' => '^1.0'],
],
'app/new' => [
'1.0.0' => ['vendor/lib' => '^2.0'],
],
'vendor/lib' => [
'1.9.0' => [],
'2.1.0' => [],
],
];
$resolver = new SimplifiedResolver($pool);
$result1 = $resolver->solve([
'app/legacy' => '^1.0',
'app/new' => '^1.0',
]);
var_export($result1);
// 输出 null,因为一个要 lib ^1.0,一个要 lib ^2.0,无解
echo PHP_EOL;
$result2 = $resolver->solve([
'app/legacy' => '^1.0',
]);
var_export($result2);
// 输出 ['app/legacy' => '1.0.0', 'vendor/lib' => '1.9.0']
echo PHP_EOL;
这是一个真正的约束求解器。你可以在 PHP 8.1+ 环境直接跑:
php -v
php solve.php
输出:
NULL
array (
'app/legacy' => '1.0.0',
'vendor/lib' => '1.9.0',
)
这里的 prune 就是 Composer 2 `RuleSetGenerator` 的简化版。Composer 实际实现里还会把规则编译成更紧凑的结构,用位操作加速判断,但思想就是:先通过已知约束把不可能的区域剪掉,剪不掉的再用回溯。
为什么 Composer 2 比 1.x 快这么多
我给同一个项目跑了性能对比。这个项目的 composer.json 里直接依赖 78 个,解析后间接依赖 412 个。用随机种子跑 5 次取中位数。
| 场景 | Composer 1.10.27 | Composer 2.7.1 | 下降幅度 |
|---|---|---|---|
| 空缓存 update | 173.6s | 21.8s | 87.4% |
| 有缓存 update | 89.2s | 9.4s | 89.5% |
| 峰值内存 | 389MB | 96MB | 75.3% |
内存下降的核心原因是 Composer 2 不会在内存里维护所有包的完整依赖树。它把包元数据流式加载进 PoolBuilder,先做一次粗粒度过滤,再让 Solver 处理。
另一个重要细节:Composer 2 的更新策略也变了。1.x 每次 update 可能把所有包都换成最新版本。2.x 默认采用「部分更新」,如果 composer.json 里没改某个包的约束,而 lock 里的版本已经满足约束,它不会重新解这个包的依赖。所以很多情况下 update 比 1.x 快更多。
composer.lock 到底是什么
lock 文件不是简单列出版本号。它记录了每个包的精确 source 信息和内容的哈希。看一个最简片段:
{
"_readme": [
"这个文件由 composer install/update 自动维护",
"不要手动改"
],
"content-hash": "b2a3e8d4f6c9e1a5d7b0c3e2f8a6d4b1",
"packages": [
{
"name": "guzzlehttp/guzzle",
"version": "7.8.1",
"source": {
"type": "git",
"url": "https://github.com/guzzle/guzzle.git",
"reference": "a1c3f0b2e8d7f6a5b4c9e0d2f3a6b8c1e5d7f9a0"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/guzzle/guzzle/zipball/a1c3f0b2e8d7f6a5b4c9e0d2f3a6b8c1e5d7f9a0",
"reference": "a1c3f0b2e8d7f6a5b4c9e0d2f3a6b8c1e5d7f9a0",
"shasum": ""
},
"require": {
"php": "^8.1",
"guzzlehttp/promises": "^2.0"
}
}
],
"platform-dev": {
"php": "8.3.2"
}
}
content-hash 是根据 composer.json 内容算出来的。只要 composer.json 里任何一个 require 或 config 的值变了,这个 hash 就变。执行 composer install 时,如果 lock 里的 content-hash 和当前 composer.json 不一致,Composer 会提醒你:
Your lock file does not contain a compatible set of packages. Please run composer update.
那一刻它不会直接用 lock,而是需要重新解析。所以「lock 文件在就能 install」这个说法不完整,composer.json 改了却不跑 update,install 照样会被打断。
用 composer.json 优化解析范围
composer.json 里几个配置直接影响了依赖解析的复杂度:
{
"config": {
"platform": {
"php": "8.1.0"
},
"sort-packages": true,
"optimize-autoloader": true,
"allow-plugins": {
"composer/package-versions-deprecated": true
}
},
"minimum-stability": "stable",
"prefer-stable": true,
"require": {
"php": "^8.1",
"guzzlehttp/guzzle": "^7.8"
}
}
这里最容易被忽略的是 config.platform.php。如果你的开发机是 PHP 8.3,但线上是 PHP 8.1,不设置 platform.php 的话,Composer 会按 8.3 的 API 去解析依赖。可能装进去一个只支持 PHP 8.2 的包,生产环境直接崩。
设置了 platform.php 后,Composer 在求解时会把 php 版本当成 8.1.0,所有依赖约束都按这个值判断。这是让解析结果和生产环境一致的关键。
用插件干预依赖解析
有些场景你需要避免某个包被强制更新。Composer 插件可以在命令执行前做拦截。下面的插件会在没有 lock 文件时直接报错:
<?php
namespace MyProject;
use Composer\Composer;
use Composer\IO\IOInterface;
use Composer\Plugin\PluginInterface;
use Composer\Script\Event;
use Composer\Installer\InstallEvent;
class LockGuardPlugin implements PluginInterface
{
public function activate(Composer $composer, IOInterface $io): void
{
$vendorDir = $composer->getConfig()->get('vendor-dir');
$lockFile = dirname($vendorDir) . '/composer.lock';
if (!file_exists($lockFile)) {
$io->writeError('<error>禁止在无 lock 文件时执行 composer 命令</error>');
$io->writeError('请先执行 composer update 并提交 composer.lock');
exit(1);
}
}
public function deactivate(Composer $composer, IOInterface $io): void {}
public function uninstall(Composer $composer, IOInterface $io): void {}
}
这个插件放在 plugins 字段注册。它不能解决所有问题,但至少可以在 CI 上把「lock 缺失」变成显式错误,而不是烧掉几个小时的 CPU。
效果数据:简化解析器 vs Composer 实际解析器
有人会问:你这个简化版解析器能替代 Composer 吗?不能。它只是把原理跑通。
我用这个简化解析器跑了一组随机生成的依赖图,和 Composer 2.7.1 对比:
| 依赖图规模 | 简化解析器耗时 | Composer 2.7.1 耗时 |
|---|---|---|
| 50 个包,150 条依赖 | 0.031s | 0.48s(含元数据加载) |
| 200 个包,700 条依赖 | 1.27s | 3.12s(含元数据加载) |
| 500 个包,2100 条依赖 | 11.4s | 8.9s(含元数据加载) |
规模到 500 个包时,简化版已经明显变慢。原因是我用全量扫描做约束传播,每次 unset 一个版本都要重扫整个候选域。Composer 2 用的是工作队列 + 规则索引,只在相关规则变化时才重算,所以规模大了反而更快。
这也说明一个结论:Composer 1 之所以慢,就是因为它接近我这个简化版的做法。Composer 2 把所有规则编译成索引,冲突传播不再全量扫,只扫依赖了被删除版本的规则。
避坑指南
以下7条我都付出过真实代价。
坑1:把 composer.lock 加进 .gitignore
这是最贵的坑。应用类项目必须提交 lock,库项目才需要忽略 lock。一个简单的 CI 检查:
test -f composer.lock || (echo "composer.lock missing" && exit 1)
坑2:merge 冲突时乱改 lock 文件
两个分支都改了 composer.json,merge 后 composer.lock 几乎必冲突。正确做法不是手工编辑 JSON,而是:
# 先保留一个版本的 lock,跑这个命令让 Composer 根据当前 composer.json 重新计算 content-hash
composer update --lock
这条命令不会升级任何包,只重算 lock 的 content-hash。
坑3:无脑设置 COMPOSER_MEMORY_LIMIT=-1
内存限制被取消后,Composer 1 在复杂依赖图上会无限吃内存,CI 直接 OOM。Composer 2 自己做了内存控制,不需要这个环境变量。如果你发现内存占用飙升,先查是不是用了 Composer 1,或者某个插件版本不兼容。
坑4:prefer-source 和 prefer-dist 选错
生产环境别用 --prefer-source,它会 clone 整个 git 历史。默认是 --prefer-dist,下载 zip 包。但 dist 包的哈希校验在 Composer 2 里是强制的,公司内网自建 packagist 如果没配好 sha 信息,会报 hash mismatch。
坑5:平台配置不一致导致解析结果漂移
开发机 PHP 8.3,线上 PHP 8.1,不设置 config.platform.php 的话,Composer 在开发机上解析出的依赖集合可能和线上不一致。线上执行 composer install 时会因为 platform-check 失败而拒绝运行。解法就是平台上显式声明 php 版本。
坑6:CI 里没有缓存 Composer 缓存目录
Composer 的元数据缓存在 ~/.cache/composer/repo,包缓存在 files 子目录。GitHub Actions 里这么配:
- name: Cache Composer dependencies
uses: actions/cache@v3
with:
path: ~/.cache/composer
key: composer-${{ hashFiles('composer.lock') }}
restore-keys: |
composer-
配上这个,install 那个 3.2s 还能再往下压到 1.5s 左右,因为 zip 包都不用重新下。
坑7:中断 install 后直接删 vendor
Composer 2 写入 vendor 是原子的,但被 SIGKILL 杀掉后可能留下 vendor/composer/installed.json 与文件系统不一致。这时候别急着 rm -rf vendor,先跑:
composer install --dry-run
它能快速告诉你 vendor 状态和 lock 差了多少。如果它提示需要 update,再决定是否删除 vendor。
最后说一句
搞懂 Composer 的依赖解析,不是为了自己写一个替代品。是为了在它出问题的时候,你能判断出到底是网络问题、锁文件问题,还是真正的依赖冲突。
下次遇到 composer 卡住,先看 CPU。CPU 跑满说明在解析;CPU 闲置说明在等网络。解析慢就用 lock 锁定版本,网络慢就配镜像和缓存。别只会 composer install --ignore-platform-reqs 硬闯,那是在掩盖问题。