3. CRL缓存策略:从失效模型到多层架构

CRL(证书吊销列表)的缓存策略,说白了就是解决一个核心矛盾:既要保证吊销信息的实时性,又不能把CA服务器打爆。我早年在一个金融PKI项目里吃过亏——当时缓存策略没设计好,CA每天被查询请求冲垮三次。从那以后,我对缓存这块就特别较真。

这一章我们拆成三个部分来讲:缓存什么时候失效(TTL与NextUpdate)、缓存怎么分层(L1/L2/L3)、以及缓存怎么预热和更新。嗯,咱们一个一个来。

3.1 缓存失效模型:TTL与NextUpdate

CRL的失效模型和普通HTTP缓存不太一样。普通资源你可以随便设个max-age,但CRL不行——它自带一个NextUpdate字段,这是CA签发的“硬性过期时间”。

核心原则:CRL缓存的有效期 = min(TTL配置, NextUpdate - 当前时间)。

绝对不能只依赖TTL,否则可能缓存已吊销的证书。

我建议把失效模型分成三种模式:

模式 TTL配置 NextUpdate处理 适用场景
严格模式 不设TTL,完全依赖NextUpdate 到期立即拉取新CRL 金融、CA根节点
宽松模式 TTL = NextUpdate的50%~80% TTL到期后异步预拉取 企业内网、边缘节点
激进模式 TTL固定值(如1小时) 忽略NextUpdate,到期强制刷新 高安全要求、短生命周期证书

我个人习惯用宽松模式。为什么?因为严格模式下,如果CA在NextUpdate时刻恰好挂了,所有缓存同时失效,那就是一场雪崩。宽松模式留了缓冲时间,就算CA短暂不可用,缓存还能撑一阵。

实战技巧:在宽松模式下,TTL到期后不要直接删除缓存,而是标记为“过期但可用”。新请求优先返回旧数据,同时后台发起刷新。这叫“stale-while-revalidate”,Nginx和Varnish都支持。

3.2 缓存层级设计:L1/L2/L3

单层缓存扛不住大规模分发。我见过最典型的反例:所有CDN节点都直接从源站拉CRL,结果NextUpdate一到,源站带宽被打满,响应延迟从10ms飙到5秒。

解决方案就是分层。我一般设计三层:

CRL缓存三层架构 L3: 源站CA L2: 区域缓存节点 L1: 边缘节点A L1: 边缘节点B L1: 边缘节点C 客户端 客户端 客户端 L1缓存秒级响应,L2缓存分钟级,L3源站按NextUpdate刷新。L1未命中查L2,L2未命中回源。

各层职责很清晰:

  • L1(边缘节点):离用户最近,缓存时间最短(TTL=5~15分钟)。容量小,只存热数据。我习惯用Redis或本地内存,命中率目标80%以上。
  • L2(区域节点):每个地理区域部署1~2个。缓存时间中等(TTL=30~60分钟)。用Nginx或Varnish,命中率目标95%。
  • L3(源站/CA):真正的CRL签发者。缓存时间最长(按NextUpdate)。这里要加限流和熔断,防止被冲垮。

注意:L1和L2之间不要用同步更新。L1过期后异步去L2拉,L2过期后异步回源。同步更新会导致连锁反应——我曾经在一个项目中用了同步刷新,结果L1集体回源,L2瞬间被打满,源站也跟着挂了。

3.3 缓存预热与更新策略

缓存预热这事,很多人不重视。觉得“用户访问到了自然就缓存了”。但CRL不一样——NextUpdate一到,旧缓存瞬间失效,如果没预热,所有请求同时回源,那就是一场灾难。

我总结了一套预热策略:

  1. 定时预热:在NextUpdate到达前5分钟,由调度中心下发预热指令。L2节点主动去源站拉取新CRL,然后推送给所属的L1节点。
  2. 增量预热:只拉取变更的证书序列号,而不是整个CRL。这需要CA支持delta-CRL。我建议能开就开,带宽能省80%。
  3. 按需预热:根据历史访问频率,只预热热门的CRL分区。比如按证书颁发机构或按证书类型分组。

我的习惯:预热不要一次性全量推送。分批次,每批间隔10~20秒。这样就算预热过程中出问题,影响范围也有限。我曾经用“滚动预热”把一次CRL更新的影响从5分钟降到了20秒。

更新策略上,我推荐“双缓冲”模式:

// 伪代码:双缓冲CRL缓存更新
class CRLCache {
    activeCRL = null;   // 当前服务中的CRL
    standbyCRL = null;  // 后台预加载的CRL
  
    function refresh() {
        // 1. 后台拉取新CRL
        newCRL = fetchFromSource();
        // 2. 写入备用缓冲区
        standbyCRL = newCRL;
        // 3. 原子切换
        atomicSwap(activeCRL, standbyCRL);
        // 4. 旧CRL延迟回收(给正在使用的请求留时间)
        scheduleCleanup(oldCRL, 30s);
    }
}

这样做的好处是:切换瞬间不会丢请求。旧CRL还在内存里,新请求走新CRL,正在验证的请求继续用旧CRL,30秒后安全回收。

关键指标:缓存更新要做到“三不”——不丢请求、不重复验证、不阻塞业务。双缓冲+延迟回收是经过生产验证的方案。

最后提一下监控。我每个缓存节点都会上报三个指标:

  • 缓存命中率(低于70%报警)
  • 缓存过期延迟(超过TTL的20%报警)
  • 回源请求量(突增300%报警)

这三个指标能帮你快速定位缓存问题。我记得有一次报警发现某个区域的回源量突然飙升,查了半天发现是L2节点的预热脚本挂了,导致所有L1都直接回源。修复预热脚本后,回源量立刻降了90%。

嗯,缓存策略这块就讲这么多。核心就三句话:失效模型要灵活,层级设计要清晰,预热更新要平滑。你想想看,是不是这个理?