4. 依赖缓存:npm/pip/maven等包管理器的缓存机制与配置
4.1 为什么依赖缓存是CI的命门
你想想看,一个项目从零开始构建,最耗时的环节是什么?
我个人经验,编译和测试反而好预测,真正让人抓狂的是依赖下载。尤其遇到网络抖动,npm install 跑个三分钟,pip 卡在某个源上超时,Maven 把中央仓库拉个底朝天——整个流水线的时间瞬间翻倍。
我在项目中遇到过好几次:业务团队急着发布,CI 却因为依赖下载失败而重试,一拖就是半小时。后来我们算了笔账,依赖下载占 CI 总耗时的 40%~70%。你品,你细品。
说白了,依赖缓存就是给 CI 装了个"本地仓库"。第一次下载后把包存起来,下次直接取,网络不通也不怕。但这东西配置起来有讲究,不是简单设个路径就能跑起来的。
4.2 npm:从 .npm 缓存到 CI 配置
npm 的缓存机制,核心在于 ~/.npm 目录。它把下载过的 tarball 存成 .cache 文件,安装时先看本地有没有。
但这有个前提——你得有 package-lock.json。没这个锁文件,npm 会重新解析版本范围,缓存命中的概率大打折扣。
npm CI 配置要点:
- 缓存路径:
~/.npm(Linux/Mac)或%AppData%/npm-cache(Windows) - 关键参数:
--prefer-offline优先用缓存,实在没有才去网络拉 - 缓存键:建议用
package-lock.json的哈希值
我习惯在 GitHub Actions 里这样配:
# .github/workflows/ci.yml
- name: Cache npm
uses: actions/cache@v3
with:
path: ~/.npm
key: ${{ runner.os }}-npm-${{ hashFiles('package-lock.json') }}
restore-keys: |
${{ runner.os }}-npm-
- name: Install dependencies
run: npm ci --prefer-offline --no-audit --no-fund
npm ci 比 npm install 快不少,它严格走 lockfile,不会去解析版本范围。配上 --prefer-offline,只要缓存里有就直接拿,基本不碰网络。
小技巧: 如果项目里同时有 package-lock.json 和 yarn.lock,建议统一用一种包管理器,不然缓存键会打架。我踩过这个坑……嗯,后来老老实实只留一个。
4.3 pip:wheel 缓存与 CI 实践
pip 的缓存藏在 ~/.cache/pip 里,存的是 wheel 包和 HTTP 响应缓存。wheel 是 Python 的二进制分发格式,pip 会优先尝试从缓存里拿 wheel,省去编译步骤。
为什么会快?因为 wheel 是编译好的,不用从源码 build。我第一次意识到这个差距,是在一个依赖了 numpy、pandas、scikit-learn 的项目里——不开缓存,pip install 跑了 8 分钟;开了缓存,20 秒结束。
# .gitlab-ci.yml
variables:
PIP_CACHE_DIR: "$CI_PROJECT_DIR/.cache/pip"
cache:
key: "$CI_JOB_NAME-$CI_COMMIT_REF_SLUG"
paths:
- .cache/pip
before_script:
- pip install --upgrade pip
- pip install --cache-dir $PIP_CACHE_DIR -r requirements.txt
注意: 别把 --cache-dir 指向临时目录,否则每次 CI 运行都白缓存了。我见过有人把路径写成了 /tmp/pip-cache……结果缓存永远用不上。
pip 也支持 lock 文件,推荐用 pip freeze > requirements.lock 锁定版本。虽然没有 npm 那么严格的 resolver,但至少版本不会乱跳。
4.4 Maven:本地仓库与 CI 集成
Maven 的缓存就是 ~/.m2/repository,按 groupId:artifactId:version 的坐标结构组织。第一次拉取后,jar 包和 pom 文件就留在本地了。
Maven 的缓存策略比较"朴实"——它相信本地仓库里的东西是可靠的,只要坐标对上,就直接用,不会去远程校验 checksum。除非你显式执行 mvn dependency:purge-local-repository。
<!-- settings.xml 片段 -->
<settings>
<localRepository>${CI_PROJECT_DIR}/.m2/repository</localRepository>
<profiles>
<profile>
<id>ci</id>
<repositories>
<repository>
<id>central</id>
<url>https://repo.maven.apache.org/maven2</url>
<releases><enabled>true</enabled></releases>
<snapshots><enabled>false</enabled></snapshots>
</repository>
</repositories>
</profile>
</profiles>
</settings>
我建议在 CI 里把 localRepository 指向项目目录下的 .m2 文件夹,方便缓存系统统一管理。同时关掉 snapshot 仓库,避免每次构建都去拉快照版本。
加速技巧: 用 mvn dependency:go-offline 提前把所有依赖拉下来,后面跑 mvn install -o 就可以完全离线了。对于网络不稳定的 CI 环境,这招很管用。
4.5 缓存策略对比与最佳实践
三种包管理器的缓存机制各有侧重,我整理了一张对比表,方便你对照配置:
| 对比维度 | npm | pip | Maven |
|---|---|---|---|
| 缓存路径 | ~/.npm | ~/.cache/pip | ~/.m2/repository |
| 缓存内容 | .cache tarball | wheel + HTTP 缓存 | jar + pom |
| 锁文件 | package-lock.json | requirements.lock | pom.xml + maven-dependency-plugin |
| CI 缓存键建议 | lockfile 哈希 | requirements 哈希 + Python 版本 | pom.xml + settings.xml 哈希 |
| 离线模式 | --prefer-offline | --no-index (配合 wheelhouse) | -o (offline 模式) |
缓存键设计是核心中的核心。我用三个原则来把控:
- 精确匹配:锁文件变了,缓存必须失效。用哈希值做 primary key。
- 兜底恢复:如果 primary key 没命中,用
restore-keys回退到最近一次缓存,至少能省一部分。 - 区分运行环境:操作系统、Python/Node/Java 版本不同,缓存不能混用。把
runner.os和语言版本拼进 key。
4.6 避坑指南
这些年我在依赖缓存上交过的学费,分享给你:
坑一:缓存过期策略没配好
我曾经在 GitLab CI 里只配了 key: "$CI_JOB_NAME",结果所有分支共用一个缓存。开发分支改了依赖,master 的构建居然用了开发分支的缓存包……版本对不上,编译直接挂。后来我强制把锁文件哈希加进了 key。
坑二:缓存膨胀失控
npm 的 ~/.npm 会随着时间越积越大。有个项目跑了大半年,缓存飙到 2GB,每次 CI 恢复缓存比直接下载还慢。现在我定期用 npm cache clean --force 做清理,或者干脆在 CI 里设置缓存保留天数。
坑三:pip 的 wheel 缓存命中率低
如果你每次都用 pip install --no-cache-dir,那 pip 根本不会写缓存。有人为了省磁盘空间这么干,结果每次 CI 都从源码编译 numpy…… 我建议用 --cache-dir 显式指定,并且确保路径在缓存范围内。
我的三个"铁律":
- 锁文件必须进版本管理,而且作为缓存键的唯一输入。
- 缓存路径不要用默认的 home 目录,统一归到项目目录下,方便 CI 系统识别。
- 每季度或每半年清一次缓存,避免"缓存垃圾"拖慢恢复速度。
依赖缓存这件事,配好了是加速器,配不好就是隐形炸弹。我建议你配完之后,手动触发一次"缓存 miss"和一次"缓存 hit",对比一下时间差异。看到构建时间从 8 分钟降到 1 分钟,那种感觉……嗯,挺爽的。