场景:为什么一台机器装不下两个项目
设想你同时维护两个 Python 服务:A 依赖 requests==2.20,B 依赖某个间接拉起 urllib3>=2 的新库,而旧版 requests 与新版 urllib3 不兼容。如果两者都装进全局 site-packages,后装的会覆盖先装的,先跑起来的那个服务在某次 pip install 之后毫无征兆地崩了。这就是"依赖地狱"最朴素的形态:全局命名空间里同一个包只能有一个版本。
虚拟环境与依赖管理工具,本质上都是在解决两个正交的问题:隔离(每个项目有自己的包集合)与可复现(换台机器能装出一模一样的环境)。把这两件事分开看,很多工具的设计取舍就清晰了。
机制一:虚拟环境到底隔离了什么
很多人以为 venv 是个沙箱,其实它非常轻。创建一个虚拟环境后看目录结构:
1 | python -m venv .venv |
关键在两点。第一,pyvenv.cfg 里的 home 指向真实的基础解释器——虚拟环境不复制 Python 本体,只是软链或记录路径,所以创建极快、占用极小。第二,activate 做的事极其简单:把 .venv/bin 插到 PATH 最前面,并设置 VIRTUAL_ENV。于是你敲 python、pip 时命中的是环境内的可执行文件。
真正让隔离生效的是解释器启动时的 site 模块:它读取 sys.prefix(由 pyvenv.cfg 推导),把 site-packages 路径锚定到环境内部,从而 import 只在这里找包。默认情况下虚拟环境不继承全局第三方包(除非建环境时加 --system-site-packages)。
一个常被忽略的事实:activate 不是必须的。直接调用 .venv/bin/python script.py 等价于激活后运行——因为隔离靠的是解释器路径,不是环境变量魔法。这在写 systemd unit、Dockerfile、CI 脚本时很有用,避免 source 一个 shell 脚本的麻烦。
机制二:依赖解析是个约束满足问题
pip install 看起来是"下载并解包",难点其实在版本解析。当你写下 pip install flask pandas,pip 要为每个直接依赖及其传递依赖挑一个具体版本,使所有版本约束同时成立。这本质是个回溯搜索(backtracking)问题,理论上是 NP 难的。
现代 pip(启用 backtracking resolver 之后)的工作流大致是:
1 | 1. 取一个待定包,按版本从新到旧尝试 |
这里有个隐性性能陷阱:旧式打包(sdist)的依赖信息写在 setup.py 里,必须执行才能拿到。所以解析时 pip 可能要下载并构建一堆包只为读它们的依赖,这就是有时 pip install 卡很久还疯狂下载的原因。wheel(二进制分发格式)把元数据放在 METADATA 文件里,无需执行即可读取,解析快得多——这也是 wheel 成为事实标准的工程动因之一。
机制三:锁文件与可复现
requirements.txt 里写 flask>=2.0 是抽象依赖,每次安装可能解析出不同版本,不可复现。真正可复现需要锁文件:记录整棵依赖树每个包的精确版本,外加哈希值。
1 | # 抽象(声明意图) # 锁定(记录事实) |
哈希校验是供应链安全的关键:即使 PyPI 上某版本被恶意替换,哈希对不上就拒绝安装。pip install --require-hashes 会强制这一点。Poetry 的 poetry.lock、PDM 的 pdm.lock、pip-tools 编译出的 requirements.txt 都是这个思路。区别在于解析器质量和是否区分"应用锁全树"与"库只声明范围"。
记住一条原则:应用(部署的服务)要提交锁文件,库(被别人依赖的包)只声明宽松范围。库若锁死版本,会把约束强加给所有下游,引发不必要的冲突。
工程权衡:工具选型
- venv + pip + pip-tools:标准库自带 venv,pip-tools 用
pip-compile把.in编译成带哈希的锁文件。零额外学习成本,组合灵活,但解析器是 pip 的,复杂依赖偶尔解得慢或解不动。 - Poetry:一体化(解析、锁、虚拟环境、构建发布)。
pyproject.toml单文件声明,自带较强解析器。代价是它对 PEP 标准的偏离曾引发争议,且大项目锁文件更新慢。 - uv:用 Rust 写的新一代工具,解析与安装速度有数量级提升,靠的是并行下载、全局缓存硬链接复用、以及不执行 setup.py 的元数据获取策略。兼容 pip 接口,正在快速蚕食市场。
性能维度上,安装速度的瓶颈通常不是网络而是解包与磁盘 IO。uv 用硬链接把全局缓存里的文件链到环境里而非复制,省掉大量 IO,这是它快的核心原因之一。
常见踩坑
坑一:把 pip freeze 当锁文件。 pip freeze 导出的是当前环境所有包的扁平列表,分不清哪些是你直接依赖、哪些是传递依赖,也没有哈希。一旦想升级某个直接依赖,你不知道该删哪些行。正确做法是直接依赖写在一个文件、锁文件单独生成。
坑二:在 venv 里 sudo pip install。 sudo 会切到 root 的环境,绕过你的虚拟环境装到全局,而且可能损坏系统 Python。虚拟环境内永远不需要 sudo。
坑三:Docker 镜像里没复用 layer 缓存。 把 COPY . . 放在 pip install 之前,会导致任何代码改动都让依赖层缓存失效、重装全部依赖。正确顺序是先只 COPY 锁文件、装依赖,再 COPY 源码:
1 | COPY requirements.txt . |
坑四:跨平台锁文件。 某些包有平台相关的传递依赖(如 Windows 才需要的 pywin32)。在 mac 上生成的锁文件直接拿到 Linux 容器里可能装不上或缺包。解决办法是在目标平台(通常是容器内)生成锁文件,或用支持多平台标记的工具。
小结
虚拟环境解决隔离,靠的是解释器路径锚定而非沙箱,因而轻量;依赖管理解决可复现,核心是把抽象声明编译成带哈希的锁文件。理解了"解析是回溯搜索"“wheel 元数据可静态读取”"锁文件区分应用与库"这几条机制,无论用 pip-tools、Poetry 还是 uv,你都能判断它在帮你做什么、代价在哪,而不是把工具当黑箱。