Composer依赖管理原理剖析
发布日期: 2026/08/09 阅读总量: 0

先说我踩的坑

凌晨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.6s389MB
composer update(无lock)2.7.1(PHP8.3.2)21.8s96MB
composer install(有lock)2.7.1(PHP8.3.2)3.2s58MB

数据是在同一台 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.27Composer 2.7.1下降幅度
空缓存 update173.6s21.8s87.4%
有缓存 update89.2s9.4s89.5%
峰值内存389MB96MB75.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.031s0.48s(含元数据加载)
200 个包,700 条依赖1.27s3.12s(含元数据加载)
500 个包,2100 条依赖11.4s8.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 硬闯,那是在掩盖问题。