欢迎光临
我们一直在努力

缓存穿透与击穿防御:一个 3 秒的空值标记和一把按 key 分的锁(四)

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */
#content_views .toc,
/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */
#content_views.markdown_views > p:empty:has(+ .toc),
#content_views.markdown_views > .toc + p:empty,
/* 富文本旧版目录标记 */
#content_views.htmledit_views #main-toc,
#content_views.htmledit_views #hr-toc,
#content_views.htmledit_views p[id*=\”-toc\”] {
display: none !important;
}
/* 目录去掉后,紧跟的首个标题不再多出一块上边距 */
#content_views.markdown_views > .toc + h1,
#content_views.markdown_views > .toc + h2,
#content_views.markdown_views > .toc + h3,
#content_views.markdown_views > .toc + h4,
#content_views.markdown_views > .toc + p:empty + h1,
#content_views.markdown_views > .toc + p:empty + h2,
#content_views.markdown_views > .toc + p:empty + h3,
#content_views.markdown_views > .toc + p:empty + h4 {
margin-top: 0 !important;
}

缓存命中率不降反升,数据库连接池却被占满——这是穿透。热点 key 一到过期时间就周期性打穿——这是击穿。两个问题看起来像,解法完全不同,而且都只在 get(key, loader) 这条路径上生效。


一、痛点:两种打穿,一种表现

1.1 现象

穿透:缓存命中率正常,数据库却在挨打

监控上有一次很怪的告警:数据库 CPU 从 20% 涨到 95%,连接池排队超时,但缓存命中率从 92% 掉到了 91%——几乎没变。

查日志才发现,有人在扫接口:

GET /api/user?id=1 → 200
GET /api/user?id=999998 → 404
GET /api/user?id=999999 → 404
GET /api/user?id=1000000 → 404

2 分钟内约 4 万个不同 ID,全部不存在。

为什么命中率看不出来:这些 key 本来就查不到结果,也就从来没被缓存过。它们的请求永远 miss——但因为总量里"查不到的 key"占比小,命中率的分母变大、命中数不变,比率只是轻微下降。这个指标对穿透几乎不敏感,这是它最坑的地方。

击穿:每到整点,数据库就抖一下

另一个现象更规律:每小时的第 0 分钟,数据库出现一次 3–5 秒的 QPS 尖刺,平时平稳。

查下来是首页配置的缓存 key,TTL 设了 3600 秒。到期那一刻,所有请求同时 miss,全部回源同一条 SQL。

关键特征:周期性、可预测、只打一条 SQL。

1.2 为什么会这样

两个问题的机制完全不同:

穿透击穿
触发条件 查询的数据不存在 数据存在,但缓存刚好过期
频率 取决于攻击者 取决于 TTL(周期性)
单次影响 每次 1 次无效查询 瞬间 N 次相同查询
缓存层现状 空结果无法被缓存 并发未命中没有收敛

根因是一句话:Spring Cache 的语义是「有值就缓存,没值就算了」,它不负责「把’没有’这个事实也记住」,也不负责「同一时刻只让一个线程去查」。

把两种打穿的触发路径画在一起,差异会更清楚:

#mermaid-svg-AurLNBqcqMNEQVvH{font-family:\”trebuchet ms\”,verdana,arial,sans-serif;font-size:16px;fill:#333;}@keyframes edge-animation-frame{from{stroke-dashoffset:0;}}@keyframes dash{to{stroke-dashoffset:0;}}#mermaid-svg-AurLNBqcqMNEQVvH .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-AurLNBqcqMNEQVvH .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-AurLNBqcqMNEQVvH .error-icon{fill:#552222;}#mermaid-svg-AurLNBqcqMNEQVvH .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-AurLNBqcqMNEQVvH .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-AurLNBqcqMNEQVvH .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-AurLNBqcqMNEQVvH .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-AurLNBqcqMNEQVvH .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-AurLNBqcqMNEQVvH .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-AurLNBqcqMNEQVvH .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-AurLNBqcqMNEQVvH .marker{fill:#333333;stroke:#333333;}#mermaid-svg-AurLNBqcqMNEQVvH .marker.cross{stroke:#333333;}#mermaid-svg-AurLNBqcqMNEQVvH svg{font-family:\”trebuchet ms\”,verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-AurLNBqcqMNEQVvH p{margin:0;}#mermaid-svg-AurLNBqcqMNEQVvH .label{font-family:\”trebuchet ms\”,verdana,arial,sans-serif;color:#333;}#mermaid-svg-AurLNBqcqMNEQVvH .cluster-label text{fill:#333;}#mermaid-svg-AurLNBqcqMNEQVvH .cluster-label span{color:#333;}#mermaid-svg-AurLNBqcqMNEQVvH .cluster-label span p{background-color:transparent;}#mermaid-svg-AurLNBqcqMNEQVvH .label text,#mermaid-svg-AurLNBqcqMNEQVvH span{fill:#333;color:#333;}#mermaid-svg-AurLNBqcqMNEQVvH .node rect,#mermaid-svg-AurLNBqcqMNEQVvH .node circle,#mermaid-svg-AurLNBqcqMNEQVvH .node ellipse,#mermaid-svg-AurLNBqcqMNEQVvH .node polygon,#mermaid-svg-AurLNBqcqMNEQVvH .node path{fill:#ECECFF;stroke:#9370DB;stroke-width:1px;}#mermaid-svg-AurLNBqcqMNEQVvH .rough-node .label text,#mermaid-svg-AurLNBqcqMNEQVvH .node .label text,#mermaid-svg-AurLNBqcqMNEQVvH .image-shape .label,#mermaid-svg-AurLNBqcqMNEQVvH .icon-shape .label{text-anchor:middle;}#mermaid-svg-AurLNBqcqMNEQVvH .node .katex path{fill:#000;stroke:#000;stroke-width:1px;}#mermaid-svg-AurLNBqcqMNEQVvH .rough-node .label,#mermaid-svg-AurLNBqcqMNEQVvH .node .label,#mermaid-svg-AurLNBqcqMNEQVvH .image-shape .label,#mermaid-svg-AurLNBqcqMNEQVvH .icon-shape .label{text-align:center;}#mermaid-svg-AurLNBqcqMNEQVvH .node.clickable{cursor:pointer;}#mermaid-svg-AurLNBqcqMNEQVvH .root .anchor path{fill:#333333!important;stroke-width:0;stroke:#333333;}#mermaid-svg-AurLNBqcqMNEQVvH .arrowheadPath{fill:#333333;}#mermaid-svg-AurLNBqcqMNEQVvH .edgePath .path{stroke:#333333;stroke-width:2.0px;}#mermaid-svg-AurLNBqcqMNEQVvH .flowchart-link{stroke:#333333;fill:none;}#mermaid-svg-AurLNBqcqMNEQVvH .edgeLabel{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-AurLNBqcqMNEQVvH .edgeLabel p{background-color:rgba(232,232,232, 0.8);}#mermaid-svg-AurLNBqcqMNEQVvH .edgeLabel rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-AurLNBqcqMNEQVvH .labelBkg{background-color:rgba(232, 232, 232, 0.5);}#mermaid-svg-AurLNBqcqMNEQVvH .cluster rect{fill:#ffffde;stroke:#aaaa33;stroke-width:1px;}#mermaid-svg-AurLNBqcqMNEQVvH .cluster text{fill:#333;}#mermaid-svg-AurLNBqcqMNEQVvH .cluster span{color:#333;}#mermaid-svg-AurLNBqcqMNEQVvH div.mermaidTooltip{position:absolute;text-align:center;max-width:200px;padding:2px;font-family:\”trebuchet ms\”,verdana,arial,sans-serif;font-size:12px;background:hsl(80, 100%, 96.2745098039%);border:1px solid #aaaa33;border-radius:2px;pointer-events:none;z-index:100;}#mermaid-svg-AurLNBqcqMNEQVvH .flowchartTitleText{text-anchor:middle;font-size:18px;fill:#333;}#mermaid-svg-AurLNBqcqMNEQVvH rect.text{fill:none;stroke-width:0;}#mermaid-svg-AurLNBqcqMNEQVvH .icon-shape,#mermaid-svg-AurLNBqcqMNEQVvH .image-shape{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-AurLNBqcqMNEQVvH .icon-shape p,#mermaid-svg-AurLNBqcqMNEQVvH .image-shape p{background-color:rgba(232,232,232, 0.8);padding:2px;}#mermaid-svg-AurLNBqcqMNEQVvH .icon-shape .label rect,#mermaid-svg-AurLNBqcqMNEQVvH .image-shape .label rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-AurLNBqcqMNEQVvH .label-icon{display:inline-block;height:1em;overflow:visible;vertical-align:-0.125em;}#mermaid-svg-AurLNBqcqMNEQVvH .node .label-icon path{fill:currentColor;stroke:revert;stroke-width:revert;}#mermaid-svg-AurLNBqcqMNEQVvH :root{–mermaid-font-family:\”trebuchet ms\”,verdana,arial,sans-serif;}

击穿:热点 key 过期

热点 key TTL 到期

N 个并发请求同时 miss

N 个线程同时回源同一条 SQL

数据库瞬时尖刺

穿透:数据不存在

请求不存在的 key

缓存 miss

回源查库 → null

null 不进缓存

1.3 我最初的三个错误做法

错误一:以为加了 @Cacheable 就挡住了

@Cacheable(value = "userCache", key = "#id")
public User findById(Long id) { ... }

对穿透完全无效。因为查不到时方法返回 null,而默认配置下 null 不进缓存——下次请求还是 miss。

错误二:给热点 key 设很长的 TTL

把首页配置的 TTL 从 1 小时改成 24 小时。击穿频率从每小时一次降到每天一次,但每次仍然是全量打穿。只是把问题延后,没有解决。

错误三:在 Service 层加 synchronized

public synchronized User load(Long id) { ... }

两个问题:

  • 锁粒度是方法级,把所有 key 的加载都串行化了,吞吐直接腰斩;
  • 多实例部署下,每个实例各有一把锁,N 个实例就是 N 次回源——击穿只是从 N 次变成 N 次。

二、问题拆解:四个子问题

子问题约束现成方案为什么不够判定标准
① 空结果怎么表示 要能区分「缓存了不存在」和「没有缓存」 ConcurrentHashMap / Caffeine 都不允许 value 为 null 空结果能命中,且能区分于未命中
② 空结果活多久 不能跟主 TTL 一样长 数据后来被创建了,缓存里的「不存在」还在,会造成「创建成功但查不到」 比主 TTL 短一个数量级
③ 并发回源怎么收敛 只在多实例 + 单 key 热点下有意义 全局分布式锁成本太高(每次 miss 都要一次 Redis RTT) 单实例内同 key 只回源一次
④ 锁怎么不泄漏 key 数量无上限 ConcurrentHashMap 只增不减 → 内存泄漏 key 用完即删

这四个问题合起来意味着什么:穿透的解法是「让’不存在’也占用缓存空间,但占用时间很短」;击穿的解法是「让 N 个并发请求收敛成 1 次回源,但只在单进程内收敛」。

第二句里的「只在单进程内」是一个重要取舍,后面 3.3 会展开论证。


四个子问题之间的依赖关系可以这样理解:

#mermaid-svg-OQBLbEOHWd8d6t2S{font-family:\”trebuchet ms\”,verdana,arial,sans-serif;font-size:16px;fill:#333;}@keyframes edge-animation-frame{from{stroke-dashoffset:0;}}@keyframes dash{to{stroke-dashoffset:0;}}#mermaid-svg-OQBLbEOHWd8d6t2S .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-OQBLbEOHWd8d6t2S .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-OQBLbEOHWd8d6t2S .error-icon{fill:#552222;}#mermaid-svg-OQBLbEOHWd8d6t2S .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-OQBLbEOHWd8d6t2S .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-OQBLbEOHWd8d6t2S .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-OQBLbEOHWd8d6t2S .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-OQBLbEOHWd8d6t2S .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-OQBLbEOHWd8d6t2S .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-OQBLbEOHWd8d6t2S .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-OQBLbEOHWd8d6t2S .marker{fill:#333333;stroke:#333333;}#mermaid-svg-OQBLbEOHWd8d6t2S .marker.cross{stroke:#333333;}#mermaid-svg-OQBLbEOHWd8d6t2S svg{font-family:\”trebuchet ms\”,verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-OQBLbEOHWd8d6t2S p{margin:0;}#mermaid-svg-OQBLbEOHWd8d6t2S .label{font-family:\”trebuchet ms\”,verdana,arial,sans-serif;color:#333;}#mermaid-svg-OQBLbEOHWd8d6t2S .cluster-label text{fill:#333;}#mermaid-svg-OQBLbEOHWd8d6t2S .cluster-label span{color:#333;}#mermaid-svg-OQBLbEOHWd8d6t2S .cluster-label span p{background-color:transparent;}#mermaid-svg-OQBLbEOHWd8d6t2S .label text,#mermaid-svg-OQBLbEOHWd8d6t2S span{fill:#333;color:#333;}#mermaid-svg-OQBLbEOHWd8d6t2S .node rect,#mermaid-svg-OQBLbEOHWd8d6t2S .node circle,#mermaid-svg-OQBLbEOHWd8d6t2S .node ellipse,#mermaid-svg-OQBLbEOHWd8d6t2S .node polygon,#mermaid-svg-OQBLbEOHWd8d6t2S .node path{fill:#ECECFF;stroke:#9370DB;stroke-width:1px;}#mermaid-svg-OQBLbEOHWd8d6t2S .rough-node .label text,#mermaid-svg-OQBLbEOHWd8d6t2S .node .label text,#mermaid-svg-OQBLbEOHWd8d6t2S .image-shape .label,#mermaid-svg-OQBLbEOHWd8d6t2S .icon-shape .label{text-anchor:middle;}#mermaid-svg-OQBLbEOHWd8d6t2S .node .katex path{fill:#000;stroke:#000;stroke-width:1px;}#mermaid-svg-OQBLbEOHWd8d6t2S .rough-node .label,#mermaid-svg-OQBLbEOHWd8d6t2S .node .label,#mermaid-svg-OQBLbEOHWd8d6t2S .image-shape .label,#mermaid-svg-OQBLbEOHWd8d6t2S .icon-shape .label{text-align:center;}#mermaid-svg-OQBLbEOHWd8d6t2S .node.clickable{cursor:pointer;}#mermaid-svg-OQBLbEOHWd8d6t2S .root .anchor path{fill:#333333!important;stroke-width:0;stroke:#333333;}#mermaid-svg-OQBLbEOHWd8d6t2S .arrowheadPath{fill:#333333;}#mermaid-svg-OQBLbEOHWd8d6t2S .edgePath .path{stroke:#333333;stroke-width:2.0px;}#mermaid-svg-OQBLbEOHWd8d6t2S .flowchart-link{stroke:#333333;fill:none;}#mermaid-svg-OQBLbEOHWd8d6t2S .edgeLabel{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-OQBLbEOHWd8d6t2S .edgeLabel p{background-color:rgba(232,232,232, 0.8);}#mermaid-svg-OQBLbEOHWd8d6t2S .edgeLabel rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-OQBLbEOHWd8d6t2S .labelBkg{background-color:rgba(232, 232, 232, 0.5);}#mermaid-svg-OQBLbEOHWd8d6t2S .cluster rect{fill:#ffffde;stroke:#aaaa33;stroke-width:1px;}#mermaid-svg-OQBLbEOHWd8d6t2S .cluster text{fill:#333;}#mermaid-svg-OQBLbEOHWd8d6t2S .cluster span{color:#333;}#mermaid-svg-OQBLbEOHWd8d6t2S div.mermaidTooltip{position:absolute;text-align:center;max-width:200px;padding:2px;font-family:\”trebuchet ms\”,verdana,arial,sans-serif;font-size:12px;background:hsl(80, 100%, 96.2745098039%);border:1px solid #aaaa33;border-radius:2px;pointer-events:none;z-index:100;}#mermaid-svg-OQBLbEOHWd8d6t2S .flowchartTitleText{text-anchor:middle;font-size:18px;fill:#333;}#mermaid-svg-OQBLbEOHWd8d6t2S rect.text{fill:none;stroke-width:0;}#mermaid-svg-OQBLbEOHWd8d6t2S .icon-shape,#mermaid-svg-OQBLbEOHWd8d6t2S .image-shape{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-OQBLbEOHWd8d6t2S .icon-shape p,#mermaid-svg-OQBLbEOHWd8d6t2S .image-shape p{background-color:rgba(232,232,232, 0.8);padding:2px;}#mermaid-svg-OQBLbEOHWd8d6t2S .icon-shape .label rect,#mermaid-svg-OQBLbEOHWd8d6t2S .image-shape .label rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-OQBLbEOHWd8d6t2S .label-icon{display:inline-block;height:1em;overflow:visible;vertical-align:-0.125em;}#mermaid-svg-OQBLbEOHWd8d6t2S .node .label-icon path{fill:currentColor;stroke:revert;stroke-width:revert;}#mermaid-svg-OQBLbEOHWd8d6t2S :root{–mermaid-font-family:\”trebuchet ms\”,verdana,arial,sans-serif;}

穿透查不存在的数据

① 空结果怎么表示

② 空结果活多久

③ 并发回源怎么收敛

④ 锁怎么不泄漏

防穿透:空值标记

防击穿:按 key 的锁

get(key, loader) 路径

三、实现:空值标记 + 按 key 的锁

3.1 整体结构

#mermaid-svg-1kqmw42IiXw8jqnD{font-family:\”trebuchet ms\”,verdana,arial,sans-serif;font-size:16px;fill:#333;}@keyframes edge-animation-frame{from{stroke-dashoffset:0;}}@keyframes dash{to{stroke-dashoffset:0;}}#mermaid-svg-1kqmw42IiXw8jqnD .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-1kqmw42IiXw8jqnD .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-1kqmw42IiXw8jqnD .error-icon{fill:#552222;}#mermaid-svg-1kqmw42IiXw8jqnD .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-1kqmw42IiXw8jqnD .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-1kqmw42IiXw8jqnD .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-1kqmw42IiXw8jqnD .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-1kqmw42IiXw8jqnD .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-1kqmw42IiXw8jqnD .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-1kqmw42IiXw8jqnD .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-1kqmw42IiXw8jqnD .marker{fill:#333333;stroke:#333333;}#mermaid-svg-1kqmw42IiXw8jqnD .marker.cross{stroke:#333333;}#mermaid-svg-1kqmw42IiXw8jqnD svg{font-family:\”trebuchet ms\”,verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-1kqmw42IiXw8jqnD p{margin:0;}#mermaid-svg-1kqmw42IiXw8jqnD .label{font-family:\”trebuchet ms\”,verdana,arial,sans-serif;color:#333;}#mermaid-svg-1kqmw42IiXw8jqnD .cluster-label text{fill:#333;}#mermaid-svg-1kqmw42IiXw8jqnD .cluster-label span{color:#333;}#mermaid-svg-1kqmw42IiXw8jqnD .cluster-label span p{background-color:transparent;}#mermaid-svg-1kqmw42IiXw8jqnD .label text,#mermaid-svg-1kqmw42IiXw8jqnD span{fill:#333;color:#333;}#mermaid-svg-1kqmw42IiXw8jqnD .node rect,#mermaid-svg-1kqmw42IiXw8jqnD .node circle,#mermaid-svg-1kqmw42IiXw8jqnD .node ellipse,#mermaid-svg-1kqmw42IiXw8jqnD .node polygon,#mermaid-svg-1kqmw42IiXw8jqnD .node path{fill:#ECECFF;stroke:#9370DB;stroke-width:1px;}#mermaid-svg-1kqmw42IiXw8jqnD .rough-node .label text,#mermaid-svg-1kqmw42IiXw8jqnD .node .label text,#mermaid-svg-1kqmw42IiXw8jqnD .image-shape .label,#mermaid-svg-1kqmw42IiXw8jqnD .icon-shape .label{text-anchor:middle;}#mermaid-svg-1kqmw42IiXw8jqnD .node .katex path{fill:#000;stroke:#000;stroke-width:1px;}#mermaid-svg-1kqmw42IiXw8jqnD .rough-node .label,#mermaid-svg-1kqmw42IiXw8jqnD .node .label,#mermaid-svg-1kqmw42IiXw8jqnD .image-shape .label,#mermaid-svg-1kqmw42IiXw8jqnD .icon-shape .label{text-align:center;}#mermaid-svg-1kqmw42IiXw8jqnD .node.clickable{cursor:pointer;}#mermaid-svg-1kqmw42IiXw8jqnD .root .anchor path{fill:#333333!important;stroke-width:0;stroke:#333333;}#mermaid-svg-1kqmw42IiXw8jqnD .arrowheadPath{fill:#333333;}#mermaid-svg-1kqmw42IiXw8jqnD .edgePath .path{stroke:#333333;stroke-width:2.0px;}#mermaid-svg-1kqmw42IiXw8jqnD .flowchart-link{stroke:#333333;fill:none;}#mermaid-svg-1kqmw42IiXw8jqnD .edgeLabel{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-1kqmw42IiXw8jqnD .edgeLabel p{background-color:rgba(232,232,232, 0.8);}#mermaid-svg-1kqmw42IiXw8jqnD .edgeLabel rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-1kqmw42IiXw8jqnD .labelBkg{background-color:rgba(232, 232, 232, 0.5);}#mermaid-svg-1kqmw42IiXw8jqnD .cluster rect{fill:#ffffde;stroke:#aaaa33;stroke-width:1px;}#mermaid-svg-1kqmw42IiXw8jqnD .cluster text{fill:#333;}#mermaid-svg-1kqmw42IiXw8jqnD .cluster span{color:#333;}#mermaid-svg-1kqmw42IiXw8jqnD div.mermaidTooltip{position:absolute;text-align:center;max-width:200px;padding:2px;font-family:\”trebuchet ms\”,verdana,arial,sans-serif;font-size:12px;background:hsl(80, 100%, 96.2745098039%);border:1px solid #aaaa33;border-radius:2px;pointer-events:none;z-index:100;}#mermaid-svg-1kqmw42IiXw8jqnD .flowchartTitleText{text-anchor:middle;font-size:18px;fill:#333;}#mermaid-svg-1kqmw42IiXw8jqnD rect.text{fill:none;stroke-width:0;}#mermaid-svg-1kqmw42IiXw8jqnD .icon-shape,#mermaid-svg-1kqmw42IiXw8jqnD .image-shape{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-1kqmw42IiXw8jqnD .icon-shape p,#mermaid-svg-1kqmw42IiXw8jqnD .image-shape p{background-color:rgba(232,232,232, 0.8);padding:2px;}#mermaid-svg-1kqmw42IiXw8jqnD .icon-shape .label rect,#mermaid-svg-1kqmw42IiXw8jqnD .image-shape .label rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-1kqmw42IiXw8jqnD .label-icon{display:inline-block;height:1em;overflow:visible;vertical-align:-0.125em;}#mermaid-svg-1kqmw42IiXw8jqnD .node .label-icon path{fill:currentColor;stroke:revert;stroke-width:revert;}#mermaid-svg-1kqmw42IiXw8jqnD :root{–mermaid-font-family:\”trebuchet ms\”,verdana,arial,sans-serif;}

命中

未命中

命中

未命中

回源得到 null

cache.get(key, loader)

① 快速路径无锁 lookup 命中?

返回

② keyLocks.computeIfAbsent取该 key 的 ReentrantLock

lock()

③ double check再次 lookup?

返回

④ valueLoader.call()唯一线程回源

⑤ put(key, value)

⑥ finally: unlock + remove(key)

返回

toStoreValue(null)→ NullValue.INSTANCETTL = 3s

看图要点:① 快速路径不能省(99% 的请求走这里,不能加锁);③ double check 不能省(否则变串行击穿);⑥ remove 必须在 finally 里(否则内存泄漏)。

3.2 穿透:用 NullValue 标记 + 3 秒独立 TTL

先解决「空结果怎么存」。底层存储(ConcurrentHashMap、Caffeine)都不允许 value 为 null,直接 put(key, null) 会抛 NullPointerException。

Spring Cache 提供了现成的解法:继承 AbstractValueAdaptingCache,构造时传 allowNullValues = true。它会在 toStoreValue(null) 时写入一个私有标记对象 NullValue.INSTANCE,读取时 fromStoreValue 再还原成 null。

于是读路径上就有了三种状态:

API未命中命中空值命中正常值
Cache.ValueWrapper null 非 null 非 null
wrapper.get() —— null 实际值

这就是「能区分」的实现:有 wrapper 说明缓存里有东西,wrapper.get() 为 null 说明缓存的就是"不存在"。

/**
* null值过期时间(秒)
*/

private static final int NULL_VALUE_TIMEOUT_SECONDS = 3;

为什么是 3 秒? 这是全文最需要论证的一个数字。我试过的取值和它们的问题:

候选值会发生什么
跟随主 TTL(如 1 小时) 「先查不存在 → 再创建 → 再查」在 1 小时内全部查不到。这是线上事故,不是理论风险
1 秒 挡不住扫描:攻击者每秒换一批 ID,1 秒窗口内每个 ID 只被打一次,等于没挡
3 秒 能挡住「同一 ID 的连续重试」(脚本的典型行为);同时把「写后读」的不一致窗口限制在 3 秒内
30 秒 开始影响正常业务:用户创建后 30 秒内看不到自己的数据

3 秒卡在两个需求的中间:足够长让脚本的重试落在窗口内(脚本通常几十毫秒到几百毫秒一次),足够短让真实的「写后读」不受伤(正常人从点击创建到看到列表不会在 3 秒内完成)。

这个值必须可配。不同业务的写后读容忍度差别很大——交易类可能要求 1 秒,内容类 30 秒也无所谓。

写入时,空值走独立的短 TTL:

Duration nullTimeout = isNullValue
? Duration.ofSeconds(NULL_VALUE_TIMEOUT_SECONDS)
: null;

case HYBRID -> {
if (isNullValue) {
// 空值:远程 + 本地都用同一个短 TTL,且强制 TTL 策略
remoteCache.put(key, value, nullTimeout, CacheExpiryPolicy.TTL);
localCache.put(key, value, nullTimeout, CacheExpiryPolicy.TTL);
} else if (cacheConfig.isForceLocalExpiry()) {
// 本地强制更短的 TTL,远程沿用全局策略
remoteCache.put(key, value);
localCache.put(key, value, cacheConfig.getForceExpiryTime(), CacheExpiryPolicy.TTL);
} else {
remoteCache.put(key, value);
localCache.put(key, value);
}
}

注意这里空值分支强制使用 CacheExpiryPolicy.TTL,即使这个 cache 配置的是 TTI。原因在 3.4 说明。

还有一个容易漏掉的点:空值不能参与 TTI 续期。

// lookup() 中,本地命中的分支
if (!isNullValue(value)
&& cacheConfig.getExpiryPolicy() == CacheExpiryPolicy.TTI
&& cacheType == CacheType.HYBRID) {
asyncRefreshRemoteTti(key);
}

!isNullValue(value) 这个前置判断是防穿透的关键。如果没有它,空值在 TTI 模式下会被本地命中不断续期——"不存在"这个事实永远不会过期,防穿透机制自己把自己废掉了。

3.3 击穿:按 key 粒度的 ReentrantLock + double check

/**
* 用于防止缓存击穿的分段锁映射,按 key 粒度加锁
*/

private final ConcurrentHashMap<Object, ReentrantLock> keyLocks = new ConcurrentHashMap<>();

@Override
public <T> T get(Object key, Callable<T> valueLoader) {
// ① 第一次无锁查找(快速路径)
Object cachedValue = lookup(key);
if (cachedValue != null) {
@SuppressWarnings("unchecked")
T result = (T) fromStoreValue(cachedValue);
return result;
}

// ② 按 key 粒度的锁
ReentrantLock lock = keyLocks.computeIfAbsent(key, k -> new ReentrantLock());
lock.lock();
try {
// ③ Double-check:可能其他线程已经加载完成
cachedValue = lookup(key);
if (cachedValue != null) {
@SuppressWarnings("unchecked")
T result = (T) fromStoreValue(cachedValue);
return result;
}

T loadedValue = valueLoader.call(); // ④ 只有第一个线程走到这里
put(key, loadedValue);
@SuppressWarnings("unchecked")
T result = (T) fromStoreValue(loadedValue);
return result;
} catch (Exception e) {
throw new RuntimeException("加载缓存值失败,缓存名称: " + name + ", 原始键: " + key, e);
} finally {
lock.unlock();
keyLocks.remove(key); // ⑤ 用完即删
}
}

四个细节,缺一个都不行:

① 快速路径不能省。

99% 的请求是命中的。如果每次都 computeIfAbsent + lock(),等于给所有缓存读加了一把锁——缓存本来是为了减少开销,结果额外引入了锁竞争。我第一版就漏了这条,压测时 QPS 反而比不用缓存还低。

② 锁粒度必须是 key 级,不是方法级。

computeIfAbsent(key, k -> new ReentrantLock()) 保证同一个 key 拿到同一把锁,不同 key 互不阻塞。这是相对「Service 层 synchronized」的核心改进。

③ double check 不能省。

假设 A 线程拿锁回源并 put 后释放。B 线程此前已经在 lock() 上阻塞,醒来后如果直接回源,就会再查一次——击穿从"并发打穿"变成"串行打穿",只是把峰值拉平了,总查询数没变。

④ remove 的竞态必须诚实面对。

finally 里 unlock() 紧接着 remove(key),这两步之间存在竞态:

线程A: unlock() → remove(key) // 锁对象被丢弃
线程C: computeIfAbsent → 拿到一把【新】锁
线程B: 仍持有【旧】锁,正在 valueLoader 里

结果:B 和 C 可能同时回源。

这个竞态是可接受的,理由:

  • 它只在「极端并发 + 恰好落在 remove 那一刻」出现;
  • 退化为「偶发 2 次回源」,而不是内存泄漏或死锁;
  • 内存泄漏是确定性的、会累积的,偶发 2 次回源是概率性的、不累积的。

如果确实要求严格单飞,有两条路:

方案做法代价
引用计数的锁表 每个 key 记录等待线程数,归零才 remove 实现复杂,要维护计数与锁的原子性
换用 LoadingCache Caffeine 的 get(key, loader) 自带单飞语义 只能用于 Caffeine,绕过了 Spring Cache 抽象

⑤ 锁只对 get(key, loader) 生效。

这一点必须写进文档,否则会被误解。MultiLevelCache 里只覆写了 get(Object key, Callable<T> valueLoader) 这一个重载;无 loader 的 get(Object key) 直接继承自 AbstractValueAdaptingCache,最终调用的是本类的 lookup(key),完全不加锁。

这是设计选择而不是遗漏:

  • get(key) 的语义是「查一下,没有就算了」,调用方自己决定要不要回源——它没有 loader,即使加了锁,锁内也无事可做;
  • 如果给 get(key) 加锁,只是白白多一次锁竞争,把缓存读退化成锁读。

推论:用 @Cacheable 的路径(走 get(key, loader))受保护;直接注入 CacheManager 自己调 get(key) 的路径不受保护。混用两种调用方式的项目要特别注意。

3.4 两者的配合:为什么击穿锁能顺便收敛穿透

这两个机制不是独立的,它们叠加起来才完整:

Redis数据库MultiLevelCache攻击者脚本Redis数据库MultiLevelCache攻击者脚本#mermaid-svg-oDiR6d1AwnGLLo9Y{font-family:\”trebuchet ms\”,verdana,arial,sans-serif;font-size:16px;fill:#333;}@keyframes edge-animation-frame{from{stroke-dashoffset:0;}}@keyframes dash{to{stroke-dashoffset:0;}}#mermaid-svg-oDiR6d1AwnGLLo9Y .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-oDiR6d1AwnGLLo9Y .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-oDiR6d1AwnGLLo9Y .error-icon{fill:#552222;}#mermaid-svg-oDiR6d1AwnGLLo9Y .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-oDiR6d1AwnGLLo9Y .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-oDiR6d1AwnGLLo9Y .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-oDiR6d1AwnGLLo9Y .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-oDiR6d1AwnGLLo9Y .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-oDiR6d1AwnGLLo9Y .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-oDiR6d1AwnGLLo9Y .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-oDiR6d1AwnGLLo9Y .marker{fill:#333333;stroke:#333333;}#mermaid-svg-oDiR6d1AwnGLLo9Y .marker.cross{stroke:#333333;}#mermaid-svg-oDiR6d1AwnGLLo9Y svg{font-family:\”trebuchet ms\”,verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-oDiR6d1AwnGLLo9Y p{margin:0;}#mermaid-svg-oDiR6d1AwnGLLo9Y .actor{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:#ECECFF;}#mermaid-svg-oDiR6d1AwnGLLo9Y text.actor>tspan{fill:black;stroke:none;}#mermaid-svg-oDiR6d1AwnGLLo9Y .actor-line{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);}#mermaid-svg-oDiR6d1AwnGLLo9Y .innerArc{stroke-width:1.5;stroke-dasharray:none;}#mermaid-svg-oDiR6d1AwnGLLo9Y .messageLine0{stroke-width:1.5;stroke-dasharray:none;stroke:#333;}#mermaid-svg-oDiR6d1AwnGLLo9Y .messageLine1{stroke-width:1.5;stroke-dasharray:2,2;stroke:#333;}#mermaid-svg-oDiR6d1AwnGLLo9Y #arrowhead path{fill:#333;stroke:#333;}#mermaid-svg-oDiR6d1AwnGLLo9Y .sequenceNumber{fill:white;}#mermaid-svg-oDiR6d1AwnGLLo9Y #sequencenumber{fill:#333;}#mermaid-svg-oDiR6d1AwnGLLo9Y #crosshead path{fill:#333;stroke:#333;}#mermaid-svg-oDiR6d1AwnGLLo9Y .messageText{fill:#333;stroke:none;}#mermaid-svg-oDiR6d1AwnGLLo9Y .labelBox{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:#ECECFF;}#mermaid-svg-oDiR6d1AwnGLLo9Y .labelText,#mermaid-svg-oDiR6d1AwnGLLo9Y .labelText>tspan{fill:black;stroke:none;}#mermaid-svg-oDiR6d1AwnGLLo9Y .loopText,#mermaid-svg-oDiR6d1AwnGLLo9Y .loopText>tspan{fill:black;stroke:none;}#mermaid-svg-oDiR6d1AwnGLLo9Y .loopLine{stroke-width:2px;stroke-dasharray:2,2;stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);}#mermaid-svg-oDiR6d1AwnGLLo9Y .note{stroke:#aaaa33;fill:#fff5ad;}#mermaid-svg-oDiR6d1AwnGLLo9Y .noteText,#mermaid-svg-oDiR6d1AwnGLLo9Y .noteText>tspan{fill:black;stroke:none;}#mermaid-svg-oDiR6d1AwnGLLo9Y .activation0{fill:#f4f4f4;stroke:#666;}#mermaid-svg-oDiR6d1AwnGLLo9Y .activation1{fill:#f4f4f4;stroke:#666;}#mermaid-svg-oDiR6d1AwnGLLo9Y .activation2{fill:#f4f4f4;stroke:#666;}#mermaid-svg-oDiR6d1AwnGLLo9Y .actorPopupMenu{position:absolute;}#mermaid-svg-oDiR6d1AwnGLLo9Y .actorPopupMenuPanel{position:absolute;fill:#ECECFF;box-shadow:0px 8px 16px 0px rgba(0,0,0,0.2);filter:drop-shadow(3px 5px 2px rgb(0 0 0 / 0.4));}#mermaid-svg-oDiR6d1AwnGLLo9Y .actor-man line{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:#ECECFF;}#mermaid-svg-oDiR6d1AwnGLLo9Y .actor-man circle,#mermaid-svg-oDiR6d1AwnGLLo9Y line{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:#ECECFF;stroke-width:2px;}#mermaid-svg-oDiR6d1AwnGLLo9Y :root{–mermaid-font-family:\”trebuchet ms\”,verdana,arial,sans-serif;}接下来 3 秒内3 秒后get(badId, loader)lookup 未命中取锁 → double check 未命中回源(唯一一次)nullput(NullValue, TTL=3s)nullget(badId, loader)lookup 命中 NullValuenull(不进锁、不回源)get(badId, loader)未命中 → 取锁回源(又是唯一一次)

看图要点:空值标记把「回源频率」从 QPS 降到 1/3s;击穿锁保证这 1 次只有 1 个线程。两者叠加,攻击者打 500 次只造成 1 次 DB 查询。

用一句话概括:空值标记是「限流」,击穿锁是「单飞」。

3.5 关键决策:为什么击穿锁是进程内的,不是分布式的

这是本文最需要展开论证的取舍。

假设 3 个实例,某个热点 key 过期瞬间来 500 个请求:

方案回源次数每次回源的额外成本
无锁 500 0
进程内锁 ≤ 3(每实例 1 次) 0
分布式锁 1 每次 miss 都要 1 次 Redis 加锁 + 1 次解锁

选进程内锁的理由:

  • 收敛效果已经足够:500 → 3 是一个数量级以上的改善。从 3 优化到 1,收益只有 3 倍,但成本是每次 miss 多两次 Redis RTT。
  • 分布式锁会把压力转移:击穿的本质是"数据库被打",而分布式锁把压力转给了 Redis——如果 Redis 本身就是瓶颈(热点 key 场景常见),这是雪上加霜。
  • 失败模式更糟:分布式锁超时、续期失败、主从切换丢锁,每一种都会带来新的故障模式。进程内锁最坏情况只是"多回源几次"。
  • 什么时候该换分布式锁:实例数非常多(比如 50+),每实例回源 1 次也会给数据库造成明显压力。这时候可以考虑,但要同时评估 Redis 的承载能力。


    进程内锁与分布式锁的取舍,可以用一张决策图概括:

    #mermaid-svg-8HJEy3XxNHMD5xeT{font-family:\”trebuchet ms\”,verdana,arial,sans-serif;font-size:16px;fill:#333;}@keyframes edge-animation-frame{from{stroke-dashoffset:0;}}@keyframes dash{to{stroke-dashoffset:0;}}#mermaid-svg-8HJEy3XxNHMD5xeT .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-8HJEy3XxNHMD5xeT .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-8HJEy3XxNHMD5xeT .error-icon{fill:#552222;}#mermaid-svg-8HJEy3XxNHMD5xeT .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-8HJEy3XxNHMD5xeT .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-8HJEy3XxNHMD5xeT .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-8HJEy3XxNHMD5xeT .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-8HJEy3XxNHMD5xeT .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-8HJEy3XxNHMD5xeT .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-8HJEy3XxNHMD5xeT .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-8HJEy3XxNHMD5xeT .marker{fill:#333333;stroke:#333333;}#mermaid-svg-8HJEy3XxNHMD5xeT .marker.cross{stroke:#333333;}#mermaid-svg-8HJEy3XxNHMD5xeT svg{font-family:\”trebuchet ms\”,verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-8HJEy3XxNHMD5xeT p{margin:0;}#mermaid-svg-8HJEy3XxNHMD5xeT .label{font-family:\”trebuchet ms\”,verdana,arial,sans-serif;color:#333;}#mermaid-svg-8HJEy3XxNHMD5xeT .cluster-label text{fill:#333;}#mermaid-svg-8HJEy3XxNHMD5xeT .cluster-label span{color:#333;}#mermaid-svg-8HJEy3XxNHMD5xeT .cluster-label span p{background-color:transparent;}#mermaid-svg-8HJEy3XxNHMD5xeT .label text,#mermaid-svg-8HJEy3XxNHMD5xeT span{fill:#333;color:#333;}#mermaid-svg-8HJEy3XxNHMD5xeT .node rect,#mermaid-svg-8HJEy3XxNHMD5xeT .node circle,#mermaid-svg-8HJEy3XxNHMD5xeT .node ellipse,#mermaid-svg-8HJEy3XxNHMD5xeT .node polygon,#mermaid-svg-8HJEy3XxNHMD5xeT .node path{fill:#ECECFF;stroke:#9370DB;stroke-width:1px;}#mermaid-svg-8HJEy3XxNHMD5xeT .rough-node .label text,#mermaid-svg-8HJEy3XxNHMD5xeT .node .label text,#mermaid-svg-8HJEy3XxNHMD5xeT .image-shape .label,#mermaid-svg-8HJEy3XxNHMD5xeT .icon-shape .label{text-anchor:middle;}#mermaid-svg-8HJEy3XxNHMD5xeT .node .katex path{fill:#000;stroke:#000;stroke-width:1px;}#mermaid-svg-8HJEy3XxNHMD5xeT .rough-node .label,#mermaid-svg-8HJEy3XxNHMD5xeT .node .label,#mermaid-svg-8HJEy3XxNHMD5xeT .image-shape .label,#mermaid-svg-8HJEy3XxNHMD5xeT .icon-shape .label{text-align:center;}#mermaid-svg-8HJEy3XxNHMD5xeT .node.clickable{cursor:pointer;}#mermaid-svg-8HJEy3XxNHMD5xeT .root .anchor path{fill:#333333!important;stroke-width:0;stroke:#333333;}#mermaid-svg-8HJEy3XxNHMD5xeT .arrowheadPath{fill:#333333;}#mermaid-svg-8HJEy3XxNHMD5xeT .edgePath .path{stroke:#333333;stroke-width:2.0px;}#mermaid-svg-8HJEy3XxNHMD5xeT .flowchart-link{stroke:#333333;fill:none;}#mermaid-svg-8HJEy3XxNHMD5xeT .edgeLabel{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-8HJEy3XxNHMD5xeT .edgeLabel p{background-color:rgba(232,232,232, 0.8);}#mermaid-svg-8HJEy3XxNHMD5xeT .edgeLabel rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-8HJEy3XxNHMD5xeT .labelBkg{background-color:rgba(232, 232, 232, 0.5);}#mermaid-svg-8HJEy3XxNHMD5xeT .cluster rect{fill:#ffffde;stroke:#aaaa33;stroke-width:1px;}#mermaid-svg-8HJEy3XxNHMD5xeT .cluster text{fill:#333;}#mermaid-svg-8HJEy3XxNHMD5xeT .cluster span{color:#333;}#mermaid-svg-8HJEy3XxNHMD5xeT div.mermaidTooltip{position:absolute;text-align:center;max-width:200px;padding:2px;font-family:\”trebuchet ms\”,verdana,arial,sans-serif;font-size:12px;background:hsl(80, 100%, 96.2745098039%);border:1px solid #aaaa33;border-radius:2px;pointer-events:none;z-index:100;}#mermaid-svg-8HJEy3XxNHMD5xeT .flowchartTitleText{text-anchor:middle;font-size:18px;fill:#333;}#mermaid-svg-8HJEy3XxNHMD5xeT rect.text{fill:none;stroke-width:0;}#mermaid-svg-8HJEy3XxNHMD5xeT .icon-shape,#mermaid-svg-8HJEy3XxNHMD5xeT .image-shape{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-8HJEy3XxNHMD5xeT .icon-shape p,#mermaid-svg-8HJEy3XxNHMD5xeT .image-shape p{background-color:rgba(232,232,232, 0.8);padding:2px;}#mermaid-svg-8HJEy3XxNHMD5xeT .icon-shape .label rect,#mermaid-svg-8HJEy3XxNHMD5xeT .image-shape .label rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-8HJEy3XxNHMD5xeT .label-icon{display:inline-block;height:1em;overflow:visible;vertical-align:-0.125em;}#mermaid-svg-8HJEy3XxNHMD5xeT .node .label-icon path{fill:currentColor;stroke:revert;stroke-width:revert;}#mermaid-svg-8HJEy3XxNHMD5xeT :root{–mermaid-font-family:\”trebuchet ms\”,verdana,arial,sans-serif;}

    少于 10

    10 ~ 50

    能

    不能

    50 以上

    是

    否

    热点 key 过期,N 个并发请求

    实例数是多少?

    进程内锁每实例回源 1 次

    数据库能否承受每实例 1 次回源?

    评估 Redis 承载能力

    Redis 是否已是瓶颈?

    分布式锁回源收敛到 1 次

    四、权衡、边界与坑

    4.1 取舍表

    决策选择备选代价 / 边界
    空值时长 3 秒(可配) 主 TTL / 30 秒 写后读有 3 秒窗口;业务要求更实时就调到 1–2 秒
    空值过期策略 强制 TTL 跟随 cache 配置 TTI 会让空值被不断续期,防穿透失效
    击穿锁范围 进程内 分布式锁 多实例下回源次数 = 实例数;实例数 > 50 时考虑分布式
    锁清理 finally remove 引用计数 / 不清理 极端并发下偶发 2 次回源;不清理会 OOM
    穿透手段 空值标记 布隆过滤器 布隆过滤器不支持删除,数据创建后仍假阳性(见 4.3)

    4.2 踩过的四个坑

    坑 1:把 null 直接 put 进 Caffeine

    • 现象:cache.put(key, null) 抛 NullPointerException。
    • 定位:Caffeine 与 ConcurrentHashMap 一样不允许 null value——它们用 null 表示「不存在」,无法再表达「存在且为空」。
    • 修法:用 NullValue.INSTANCE 占位(AbstractValueAdaptingCache 已经封装好了,继承即可)。

    坑 2:空值走了 TTI 续期,防穿透自动失效

    • 现象:打过一个不存在的 ID 之后,它在缓存里长期存在,后续 1 小时内都不会再回源(看起来是好事),但数据被创建后仍然查不到。
    • 定位:HYBRID + TTI 模式下,本地命中会异步 remoteCache.get(key) 刷新 idle timer——空值也被当成正常值续期了。
    • 修法:isNullValue(value) 为 true 时跳过 TTI 刷新,并且空值写入时强制用 TTL 策略。

    这个坑的阴险之处在于它看起来像功能正常:攻击被挡住了,日志也没有异常。只有等业务方反馈「创建成功但查不到」时才会发现。

    坑 3:keyLocks 内存泄漏

    • 现象:服务运行几天后 heap 里 ReentrantLock 实例数以百万计。
    • 定位:第一版只在 try 块末尾 remove,valueLoader.call() 抛异常时这条路径被跳过,锁对象永久留在 map 里。
    • 修法:remove 放进 finally。

    坑 4:只给 get(key, loader) 加了锁,业务却用 get(key)

    • 现象:加了击穿锁,但某些接口仍然周期性打穿。
    • 定位:那些接口直接注入了 CacheManager,自己调 cache.get(key) 然后手动回源——绕过了受保护的路径。
    • 修法:统一走 get(key, loader);或在文档里明确标注「get(key) 无击穿保护」。

    4.3 为什么没上布隆过滤器

    这是被问得最多的一个问题。

    布隆过滤器确实能挡「绝对不存在」的查询,但:

  • 不支持删除。数据被创建后,过滤器里的旧位还在,仍然会误判为「可能存在」(假阳性)——依然要回源,只是从必回源变成大概率回源。
  • 需要额外的位图存储与多实例同步。布隆过滤器本身要占内存,多实例还要保证位图一致,复杂度明显高于「3 秒空值」。
  • 误判率与容量耦合。容量估小了误判率飙升,估大了浪费内存,而这个容量在业务增长中一直在变。
  • 适用边界:超高 QPS + 数据集合相对静态(比如商品 ID 白名单、活动 ID 池)。这种场景下布隆过滤器的"不删除"反而无所谓。

    对绝大多数业务,「3 秒空值标记」用几行代码解决了 80% 的问题,性价比高得多。

    4.4 什么时候你不该用这套方案

    第一,单实例部署。

    击穿锁在单实例下依然有效(能收敛并发),但如果连缓存本身都不必要(比如数据量小、QPS 低),引入这套机制是纯成本。

    第二,能容忍分钟级不一致。

    如果业务允许「创建后 1 分钟才可见」,直接给空值设 60 秒 TTL 更简单。3 秒的价值在于「几乎不伤害业务」,如果你的业务本来就不在乎,不需要卡这么紧。

    第三,强一致要求。

    缓存本身就不该用于强一致场景。这类需求应该直接查库 + 分布式锁,或者用数据库的事务与约束来保证。

    4.5 这套方案防不住什么

    攻击方式能否防住说明
    随机 ID 扫描(同 ID 重复) ✅ 3 秒空值标记把回源降到 1/3s
    随机 ID 扫描(每次换新 ID) ⚠️ 部分 每个新 ID 仍会打一次库;需要配合限流/风控
    热点 key 过期瞬间打穿 ✅ 进程内锁收敛到「每实例 1 次」
    大量 key 同时过期(雪崩) ❌ 不在本文范围,需要随机化 TTL 或分级过期
    缓存与数据库不一致 ❌ 这是缓存一致性范畴(见 01-01)
    恶意构造的 key 撑爆本地缓存 ❌ 需要本地缓存的容量上限(Caffeine 的 maximumSize)

    特别说明:第 4 行「雪崩」是一个看起来像但机制不同的问题——穿透是「查不存在的数据」,雪崩是「大量真实 key 同时过期」。解法是给 TTL 加随机抖动,与本文的两个机制无关。


    五、可以直接抄走的清单

  • 空值用 NullValue.INSTANCE 占位(继承 AbstractValueAdaptingCache + allowNullValues=true),不能用 null。
  • 空值 TTL 独立配置,量级在秒,不要跟随主 TTL。默认 3 秒是个不错的起点。
  • 空值不参与 TTI 续期(!isNullValue(value) 前置判断),否则防穿透自动失效。
  • 空值写入时强制用 TTL 策略,即使该 cache 配置的是 TTI。
  • 击穿锁按 key 粒度(ConcurrentHashMap<Object, ReentrantLock>),不是方法级。
  • 保留无锁快速路径,否则缓存读退化为锁读。
  • 加锁后必须 double check,否则变成串行击穿。
  • unlock + remove 都放 finally,防内存泄漏。
  • 接受 remove 的轻微竞态——偶发 2 次回源远好于内存泄漏。
  • 明确写下:get(key) 不加锁,只有 get(key, loader) 加锁。
  • 实例数少于 10 时不要上分布式锁,进程内足够。
  • 布隆过滤器只在「超高 QPS + 数据集合静态」时考虑,普通业务用空值标记更划算。
  • 赞(0)
    未经允许不得转载:171主机测评 » 缓存穿透与击穿防御:一个 3 秒的空值标记和一把按 key 分的锁(四)
    分享到: 更多 (0)

    评论 抢沙发

    • 昵称 (必填)
    • 邮箱 (必填)
    • 网址