缓存原理:命中率、层级与失效策略

说实话,我见过不少团队兴致勃勃地给 CI 加上缓存,结果反而变慢了。问题出在哪?多半是没搞懂缓存最基础的三个东西:命中率、层级、失效策略。这一章我就把这些底层逻辑拆开讲透,顺便聊聊我踩过的坑。

缓存原理三层结构图 缓存原理核心结构 缓存命中率 命中 → 提速 10x+ 未命中 → 回退全量构建 缓存层级 本地缓存(内存/磁盘) 远程缓存(Redis/S3) 失效策略 TTL 过期 LRU 淘汰 LFU 频率 FIFO 先进先出 手动失效 CI 场景下通常组合使用:本地 LRU + 远程 TTL + 手动失效

命中率:衡量缓存效率的核心指标

先问个问题:你往缓存里塞了一堆东西,结果每次都读不到,那不白干了吗?命中率就是干这个用的。

命中率 = 缓存命中的请求数 / 总请求数。比如你构建了 100 次依赖,有 80 次直接从缓存拿,那命中率就是 80%。剩下 20% 没命中的,就叫「未命中率」。

我自己的经验:刚做 CI 优化那会儿,我闭着眼给所有依赖包都加了缓存,结果命中率才 30%。一查才发现 —— 每次构建都换了不同的分支,缓存 key 里面带了分支名,导致根本共享不了。

影响命中率的几个关键因素:

  • 缓存粒度:太粗(整个 node_modules)更新时不方便;太细(单个文件)管理开销大。我个人习惯用 lock 文件的 hash 做 key。
  • 不变性:依赖版本锁死之后,缓存基本不会失效。如果频繁升级,命中率自然低。
  • 存储时间:TTL 设太短,缓存还没用就被清掉了;设太长又占空间。

计算命中率的公式很简单,但真正关键的是你如何设计 key 让它“尽量命中”。

缓存层级:本地 vs 远程

缓存不只有一种形态。你在单台机器上用内存存一份,叫本地缓存;在多台机器之间共享一份,叫远程缓存。两者各有各的脾气。

特性本地缓存远程缓存
访问速度纳秒级(内存)毫秒级(网络开销)
数据一致性仅对当前节点全局共享
存储容量受限于内存/磁盘通常较大(分布式存储)
典型实现LRU Cache、Guava、node-cacheRedis、Memcached、S3
CI 适用场景同一 runner 的多次构建多 runner 共享依赖

你想啊,如果团队有 10 个并发 runner,都在拉相同的依赖,远程缓存能省掉 90% 的下载时间。本地缓存则更像“走后门”—— 同一个 runner 连续构建,直接从内存拿,连网络都不走。

避坑指南:我曾经在 CI 上只配了本地缓存,结果 runner 每次随机分配,缓存根本没用上。后来加了一层远程缓存(Redis + S3),命中率从 15% 直接升到 85%。

缓存失效策略

有缓存就要有失效。不然你改了一行代码,它还把旧结果给你,那就麻烦了。常见的失效策略有 5 种,CI 场景里最常用的是前 3 种。

  1. TTL(Time To Live):给缓存设一个过期时间,比如 10 分钟。到期自动清掉。简单粗暴,但没法应对“代码刚改完就过期”的尴尬。
  2. LRU(Least Recently Used):最近最少使用淘汰。空间满了,把最久没被访问的缓存删掉。很适合依赖这种“热数据”场景。
  3. LFU(Least Frequently Used):根据访问频率淘汰。访问次数最少的先移除。适合某些依赖被极少使用的场景,不过实现稍复杂。
  4. FIFO(First In First Out):先进先出。先存的先淘汰。不常用在 CI 缓存,因为有可能把热数据也排出去。
  5. 手动失效:由人或脚本触发。比如每次推送代码时,把对应分支的缓存 key 删掉——这是最精准的方式。
// 示例:Node.js 本地缓存 + LRU(使用 lru-cache 库)
const LRU = require('lru-cache');
const cache = new LRU({
  max: 500,           // 最大条目
  maxAge: 1000 * 60 * 10  // 10分钟 TTL
});

function getDeps(hash) {
  if (cache.has(hash)) {
    return cache.get(hash);   // 命中
  }
  const deps = installDeps(); // 拉取依赖(耗时)
  cache.set(hash, deps);
  return deps;
}

注意:CI 流水线里千万别只用纯内存 LRU。多 runner 场景下,内存不一致会导致重复构建。我建议你把 LRU 作为本地首层,远程用 Redis(支持 TTL 和手动失效),两层结合。

实战:CI 流水线中的缓存策略组合

说来说去,到底该怎么选?我直接给结论:

  • 依赖缓存(npm/maven/gradle):用 lock 文件 hash 做 key,远程 Redis 存压缩包,本地 runner 解压后做 LRU 加速二次构建。TTL 设 24 小时,配合手动失效(比如 package.json 变动时触发)。
  • 构建产物缓存(dist、target):按 commit 或 tag 做 key,远程 S3 存储,本地不用持久化,因为每次全量构建后直接覆盖。
  • Docker 镜像层缓存:使用 buildkit 的 --cache-from,远程 registry 存层,本地利用 overlayfs 的缓存。
# GitLab CI 远程缓存配置示例
cache:
  key: ${CI_COMMIT_REF_SLUG}-deps
  paths:
    - node_modules/
  policy: pull-push   # 先尝试拉取,构建后推送更新

# 手动失效:通过 API 删除缓存
# curl -X DELETE "https://gitlab.example.com/api/v4/projects/1/cache?key=main-deps"

我个人最喜欢的组合是:本地 LRU(速) + 远程 TTL(稳) + 手动失效(准)。三层互相兜底,基本不会出现“缓存污染”或“缓存雪崩”。

一句话总结:命中率决定缓存有没有用,层级决定能省多少时间,失效策略决定缓存会不会“过期不候”。这三样搞定了,你的 CI 流水线就能跑得又快又稳。