/* 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。
于是读路径上就有了三种状态:
| 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 次解锁 |
选进程内锁的理由:
什么时候该换分布式锁:实例数非常多(比如 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 为什么没上布隆过滤器
这是被问得最多的一个问题。
布隆过滤器确实能挡「绝对不存在」的查询,但:
适用边界:超高 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 加随机抖动,与本文的两个机制无关。



