面向高并发业务的完整缓存穿透治理方案。本文不只讨论“如何避免查不存在的数据”,而是从系统设计、工程实现、监控治理、容量评估到生产落地,构建一套真正可上线、可演进、可观测的防护体系。
一、引言:为什么缓存穿透是线上系统的高危问题
在绝大多数业务系统中,缓存的目标都很明确:用更低延迟、更低成本承接大部分读请求,把数据库从高频访问中解放出来。
但很多系统只缓存“存在的数据”,却没有处理“不存在的数据”。一旦有人持续查询不存在的 Key,请求就会绕过缓存,直接打到数据库。这就是缓存穿透。
缓存穿透危险的地方在于,它并不是“缓存失效”那么简单,而是以下几个问题的叠加:
- 缓存层对非法或不存在请求没有形成有效拦截
- 数据库被迫承担本不应该承担的随机读压力
- 连接池、线程池、下游依赖被逐步拖垮
- 监控上经常只表现为命中率下降和空查询上升,具有隐蔽性
- 在恶意攻击场景下,攻击成本极低,破坏成本极高
很多团队第一次遇到缓存穿透,往往是在以下场景:
- 商品详情页被爬虫扫不存在的商品 ID
- 用户中心被撞库脚本批量试探 UID
- 内容平台被随机文章 ID、评论 ID 扫描
- 接口参数未校验,大量非法主键直接进入数据库查询
所以,缓存穿透治理不是一个“Redis 小技巧”,而是高并发架构中数据库保护体系的重要组成部分。
二、先厘清边界:缓存穿透、缓存击穿、缓存雪崩
这三个概念经常被混用,但它们对应的问题完全不同。
| 问题类型 |
本质 |
典型触发条件 |
影响范围 |
核心治理方向 |
| 缓存穿透 |
查不存在的数据 |
非法参数、恶意扫描、空洞数据 |
某类 key 持续绕过缓存 |
前置拦截、空值缓存、布隆过滤器 |
| 缓存击穿 |
热点 key 失效瞬间并发回源 |
热点数据过期、瞬时高并发 |
单个热点 key |
singleflight、互斥锁、逻辑过期 |
| 缓存雪崩 |
大量 key 同时失效 |
批量过期、Redis 故障、重启 |
大范围请求回源 |
TTL 打散、多级缓存、限流降级 |
一句话区分:
- 穿透:数据本来就不存在
- 击穿:数据存在,但热点缓存突然没了
- 雪崩:大量缓存同时不可用
很多线上事故并不是单一问题,而是“穿透 + 击穿 + 限流缺失 + 数据库保护不足”的复合故障。因此本文讨论缓存穿透时,会把它放进完整架构中统一分析。
三、缓存穿透的本质:为什么它会绕过缓存层
3.1 标准读链路
一个典型的缓存读链路如下:
请求 -> 本地缓存 -> Redis -> 数据库 -> 回填缓存 -> 返回结果
正常情况下:
- 缓存命中,快速返回
- 缓存未命中,查询数据库
- 数据存在,写入缓存
问题就出在“数据不存在”这条分支。
3.2 穿透链路
请求一个不存在的 ID
-> 本地缓存 miss
-> Redis miss
-> 数据库查询为空
-> 没有缓存任何结果
-> 下一次相同请求继续重复上述流程
本质上,缓存穿透是“负结果没有被缓存或拦截”。
如果用更架构化的语言来描述,缓存穿透代表:
- 缓存层只承载正向存在性结果
- 但系统缺少负向存在性结果的表达机制
- 导致查询链路在空洞数据区域无限回源
这个“负向存在性结果”可以有不同实现方式:
- 空对象缓存
- 布隆过滤器
- 参数范围校验
- 业务字典校验
- 网关限流与风控
本质都是在回答一个问题:
在真正访问数据库之前,我们能不能更早、更便宜地判断“这个请求大概率不该回源”?
四、缓存穿透的成因分析
缓存穿透不只有“恶意攻击”这一种来源,生产上至少有六类常见原因。
4.1 非法参数直接进入查询链路
例如:
- 商品 ID 小于等于 0
- 用户 ID 超出业务编号范围
- 订单号格式不合法
- 查询维度缺少租户或分片字段
这类问题最不应该进入 Redis,更不应该进入数据库,但在很多系统里,Controller 到 DAO 之间完全没有参数治理。
4.2 大量查询真实不存在的数据
例如:
- 用户输入不存在的券码
- 内容详情查询已经删除的资源
- 下游系统回调了过期 ID
这类请求不是攻击,却同样会形成穿透。
4.3 恶意扫描与爬虫攻击
常见模式:
- 顺序枚举主键
- 随机生成超大 ID
- 通过多 IP 分布式发起空查询
- 利用接口未鉴权或轻鉴权进行探测
4.4 缓存与业务主数据不同步
例如:
- 数据刚删除,缓存也删除了
- 但请求仍然持续访问该资源
- 删除后的空洞区域变成持续穿透点
4.5 布隆过滤器数据陈旧
如果系统使用布隆过滤器,但没有做好初始化、增量更新和重建机制,也会带来问题:
- 新数据未入过滤器,导致误拦截
- 删除数据仍在过滤器中,导致放行空查询
- 集群切换后过滤器丢失
4.6 业务扩展后,原有缓存设计不再成立
例如从单体走向微服务后:
- 原本只按
id 缓存,后来引入 tenantId + id
- 原本按单库主键查,后来按地域、版本、渠道查
- 原本热点均匀,后来活动期间某类空查询陡增
很多穿透问题不是技术方案不存在,而是业务模型变了、缓存模型没跟上。
五、事故视角:缓存穿透如何一步步拖垮系统
下面用一个电商商品详情场景看真实链路。
5.1 业务场景
- 商品详情接口 QPS 峰值 3 万
- Redis 命中率平时 97%
- 数据库采用 MySQL 主从
- 商品主键为自增 long
攻击者构造大量不存在的商品 ID:
/api/products/9000000001
/api/products/9000000002
/api/products/9000000003
...
5.2 故障演进路径
第一阶段:Redis 未命中率升高
- 因为查询的 key 根本不存在
- Redis 无法承接这些请求
第二阶段:数据库空查询暴涨
- 大量
select ... where id = ?
- 单条 SQL 也许不慢,但总量极大
第三阶段:连接池被打满
第四阶段:线程池堆积和超时扩散
- Tomcat/Netty 工作线程被占用
- 依赖服务调用超时
- 熔断、重试、回退进一步放大流量
第五阶段:数据库高负载引发全站性能恶化
- 正常业务请求也拿不到资源
- 从“单接口问题”演变成“全站事故”
这就是为什么缓存穿透的治理目标,从来不只是“省几次 DB 查询”,而是保护整个系统的承压边界。
六、治理原则:生产级方案的设计目标
真正能在生产上长期稳定运行的缓存穿透防护,通常遵循以下原则:
6.1 前置拦截优于回源后补救
能在网关、参数层、存在性过滤层拦掉的请求,不要让它进入数据库。
6.2 分层治理而不是单点依赖
不要迷信某一个方案,例如:
- 只靠空值缓存,不够
- 只靠布隆过滤器,也不够
- 只靠限流,更不够
必须是组合拳。
6.3 接受概率性结构,但必须有兜底
布隆过滤器速度快、内存省,但有误判率;因此它适合做“前置放行判断”,不适合作为唯一真相来源。
6.4 把数据库保护作为核心目标
指标上真正要看的不是“是否用了 Bloom Filter”,而是:
- 空查询是否下降
- 数据库 QPS 是否被有效保护
- 在攻击下系统是否还能优雅退化
6.5 方案必须可重建、可观测、可动态调优
如果你的防护组件上线后无法回答下面这些问题,它就还不算生产级:
- 过滤器当前容量和误判率是多少
- 空对象缓存的命中占比是多少
- 某类 key 的穿透来源是什么
- 过滤器何时初始化、何时重建
- Redis 故障时系统如何降级
七、方案对比:缓存空值、布隆过滤器、参数校验、限流分别解决什么问题
7.1 缓存空值
原理:
- 当数据库查询为空时,不是直接返回,而是把“空结果”以特殊占位对象写入缓存
- 相同请求再次到来时,直接命中空对象,不再回源
优点:
- 实现简单
- 结果准确,没有误判
- 对重复查询同一空 key 效果很好
缺点:
- 无法阻止“每次都换一个新不存在 key”的攻击
- 会占用部分缓存空间
- 需要处理空值序列化、TTL 和语义表达问题
适用:
- 空洞 key 重复访问较多
- 业务删除后有持续访问
- 需要强一致的负结果表达
7.2 布隆过滤器
原理:
- 用位图和多个哈希函数记录“一个元素可能存在”
- 查询时如果判定“不存在”,则一定不存在
- 如果判定“存在”,则只是“可能存在”
优点:
缺点:
- 有误判率
- 标准 Bloom 不支持删除
- 需要初始化和增量维护
适用:
- 海量 ID 存在性判断
- 高 QPS 前置拦截
- 对误判可接受,但不能接受大量回源
7.3 参数与业务规则校验
原理:
例如:
- ID 必须大于 0
- 用户名必须符合格式
- 商品类目必须在字典中
- 租户和渠道必须匹配
优点:
缺点:
- 只能挡住显式非法请求
- 挡不住“格式合法但实际不存在”的请求
7.4 网关限流、风控、黑白名单
原理:
适用:
- 恶意攻击
- 某一类 User-Agent、IP 段、设备指纹异常
优点:
缺点:
7.5 最佳实践结论
生产上推荐方案不是“四选一”,而是分层组合:
网关限流/风控
-> 参数合法性校验
-> Bloom Filter 存在性判断
-> Redis 正常缓存
-> Redis 空对象缓存
-> 单飞/互斥回源
-> 数据库
八、布隆过滤器原理与容量设计
很多文章只说“Bloom 很省内存”,却不解释怎么估算容量。生产落地时,这一步必须算清楚。
8.1 基本原理
布隆过滤器由两部分组成:
插入元素时:
- 对元素做
k 次哈希
- 把得到的
k 个位置都置为 1
判断元素是否存在时:
- 再做
k 次哈希
- 如果有任一位为 0,则一定不存在
- 如果全部为 1,则可能存在
8.2 关键特性
- 不存在假阴性:判断不存在,就一定不存在
- 存在假阳性:判断存在,只是可能存在
这正适合缓存穿透场景:
- 过滤掉大量确定不存在的请求
- 少量“误以为存在”的请求再交给 Redis/DB 兜底
8.3 参数公式
假设:
则位图大小 m 和哈希函数个数 k 近似为:
m = - (n * ln p) / (ln 2)^2
k = (m / n) * ln 2
8.4 示例估算
若系统需要容纳 1000 万个商品 ID,误判率设为 0.1%:
n = 10,000,000
p = 0.001
计算结果大约为:
- 位数组大小约 1.7e8 bit
- 折合约 20 MB
- 哈希函数个数约 10
这意味着:
- 只用 20MB 左右内存
- 就能为 1000 万主键提供存在性判断
- 对比直接缓存所有空值,内存收益非常可观
8.5 误判率如何选择
常见建议:
1e-2:适合一般业务
1e-3:适合生产常用配置
1e-4:适合对空查询特别敏感的场景
误判率越低:
实际工程中,一般优先在 1e-3 到 1e-4 之间取值。
九、生产级总体架构:不是一个组件,而是一条防线
下面给出一套适合中大型系统的缓存穿透治理架构。
┌────────────────────┐
请求进入 ----> │ API Gateway │
│ 限流/鉴权/黑名单 │
└─────────┬──────────┘
│
┌─────────▼──────────┐
│ 参数校验层 │
│ ID范围/格式/租户 │
└─────────┬──────────┘
│
┌─────────▼──────────┐
│ 本地热点缓存 │
│ Caffeine │
└─────────┬──────────┘
│
┌─────────▼──────────┐
│ Redis缓存层 │
│ 正常值 + 空对象 │
└─────────┬──────────┘
│
┌─────────▼──────────┐
│ Bloom Filter │
│ 存在性拦截 │
└─────────┬──────────┘
│
┌─────────▼──────────┐
│ singleflight/互斥锁 │
│ 防并发回源放大 │
└─────────┬──────────┘
│
┌─────────▼──────────┐
│ MySQL / ES / 下游 │
└────────────────────┘
9.1 每一层各自解决什么问题
- 网关:挡掉显著异常流量
- 参数校验:挡掉显式无效请求
- 本地缓存:降低 Redis 压力,承接热点
- Redis 正常缓存:承接主流存在数据
- 空对象缓存:挡住重复空查询
- Bloom:挡住海量随机不存在请求
- singleflight:防止缓存 miss 时并发回源击穿 DB
- 数据库:最终真相源
9.2 为什么 Bloom 放在 Redis 后面而不是前面
两种顺序都有人用,但生产里更推荐:
本地缓存 -> Redis -> Bloom -> DB
原因:
- Redis 命中时无需经过 Bloom
- 只有缓存 miss 才做存在性判断
- 减少不必要的过滤器访问
如果 Bloom 和 Redis 在同一实例中,性能差异可能不明显;但从链路语义上,先查缓存、再判是否值得回源更自然。
十、核心实现一:空对象缓存的生产级设计
很多示例代码把“查不到就缓存 null”写得很简单,但真正上线要处理四个问题:
- null 无法表达语义
- 序列化与反序列化问题
- TTL 如何设置
- 如何区分“真的不存在”和“系统异常”
10.1 统一缓存对象设计
package com.example.cache;
import java.io.Serializable;
import java.time.LocalDateTime;
public class CacheEnvelope<T> implements Serializable {
private boolean empty;
private T data;
private LocalDateTime expireAt;
private String version;
public static <T> CacheEnvelope<T> hit(T data, LocalDateTime expireAt, String version) {
CacheEnvelope<T> envelope = new CacheEnvelope<>();
envelope.empty = false;
envelope.data = data;
envelope.expireAt = expireAt;
envelope.version = version;
return envelope;
}
public static <T> CacheEnvelope<T> empty(LocalDateTime expireAt, String version) {
CacheEnvelope<T> envelope = new CacheEnvelope<>();
envelope.empty = true;
envelope.expireAt = expireAt;
envelope.version = version;
return envelope;
}
public boolean isEmpty() {
return empty;
}
public T getData() {
return data;
}
public LocalDateTime getExpireAt() {
return expireAt;
}
public String getVersion() {
return version;
}
}
设计要点:
- 用
empty 明确表达“空对象”
- 用
expireAt 支持逻辑过期扩展
- 用
version 预留缓存结构升级能力
10.2 TTL 策略
不要让空对象和正常对象用同样的过期时间。
推荐:
- 正常数据 TTL:30 分钟到 2 小时
- 空对象 TTL:30 秒到 5 分钟
- 两者都加随机抖动,避免同一时刻批量失效
原因:
- 正常数据通常稳定,适合较长缓存
- 空对象是负结果,只适合短缓存
- 如果对象后来被创建,过长的空缓存会带来可见延迟
10.3 什么时候不应该缓存空对象
以下场景要谨慎:
- 数据刚创建、传播有延迟时
- 最终一致性链路中,短时间内“现在不存在、马上存在”
- 对实时性极高的交易核心对象
此时可以:
- 缩短空对象 TTL
- 仅对部分业务缓存空对象
- 增加主动失效通知
十一、核心实现二:基于 Redisson 的 Bloom Filter
以下是一个接近生产的 Spring Boot 实现。
11.1 依赖示例
<dependency>
<groupId>org.redisson</groupId>
<artifactId>redisson-spring-boot-starter</artifactId>
<version>3.27.2</version>
</dependency>
11.2 配置
spring:
data:
redis:
host: 127.0.0.1
port: 6379
password: your_password
cache-protection:
bloom:
product:
name: bloom:product:id
expected-insertions: 10000000
false-probability: 0.001
ttl:
product: 1800s
product-empty: 90s
lock:
product: 3s
11.3 初始化代码
package com.example.cache.config;
import jakarta.annotation.PostConstruct;
import org.redisson.api.RBloomFilter;
import org.redisson.api.RedissonClient;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Component;
@Component
public class ProductBloomInitializer {
private final RedissonClient redissonClient;
@Value("${cache-protection.bloom.product.name}")
private String bloomName;
@Value("${cache-protection.bloom.product.expected-insertions}")
private long expectedInsertions;
@Value("${cache-protection.bloom.product.false-probability}")
private double falseProbability;
public ProductBloomInitializer(RedissonClient redissonClient) {
this.redissonClient = redissonClient;
}
@PostConstruct
public void init() {
RBloomFilter<Long> bloomFilter = redissonClient.getBloomFilter(bloomName);
bloomFilter.tryInit(expectedInsertions, falseProbability);
}
}
说明:
tryInit 只会在未初始化时生效
- 正式环境建议把“初始化”和“全量灌装”拆开,由运维任务或数据作业执行
- 应用启动时只做幂等检查,不要全量扫大表
11.4 全量灌装与增量维护
布隆过滤器不是“初始化完就不管了”,需要解决两个问题:
推荐策略:
- 全量初始化:离线任务扫描主表,批量写入 Bloom
- 增量更新:业务新增成功后,异步写入 Bloom
- 定期重建:按周或按月重建,修正历史漂移
新增写入示例:
package com.example.product.service;
import org.redisson.api.RBloomFilter;
import org.redisson.api.RedissonClient;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
@Service
public class ProductWriteService {
private final ProductRepository productRepository;
private final RedissonClient redissonClient;
@Value("${cache-protection.bloom.product.name}")
private String bloomName;
public ProductWriteService(ProductRepository productRepository, RedissonClient redissonClient) {
this.productRepository = productRepository;
this.redissonClient = redissonClient;
}
@Transactional
public Long createProduct(CreateProductCommand command) {
Product product = Product.create(command);
productRepository.save(product);
RBloomFilter<Long> bloomFilter = redissonClient.getBloomFilter(bloomName);
bloomFilter.add(product.getId());
return product.getId();
}
}
这里有一个工程细节:
- Bloom 是“只增不减”更安全
- 删除商品后,一般不建议立即从 Bloom 删除
- 因为标准 Bloom 不支持删除,强删需要 Counting Bloom 或重建机制
生产里更常见的做法是:
- 删除后保留 Bloom 中的存在记录
- Redis 层通过空对象缓存承接已删除商品的访问
- 周期性重建 Bloom,清掉无效历史数据
十二、核心实现三:单飞回源,避免并发 miss 放大数据库压力
缓存穿透与缓存击穿经常同时出现。
即使一个请求通过了 Bloom,如果 Redis miss,而这时并发很高,那么大量线程仍可能一起回源数据库。因此必须增加“单飞”或“互斥重建”能力。
12.1 目标
对于同一个 key:
- 只允许一个线程真正回源数据库
- 其他线程等待结果或快速失败
12.2 基于 Redisson 锁的实现
package com.example.product.service;
import com.example.cache.CacheEnvelope;
import com.fasterxml.jackson.core.type.TypeReference;
import com.fasterxml.jackson.databind.ObjectMapper;
import java.time.Duration;
import java.time.LocalDateTime;
import java.util.concurrent.TimeUnit;
import org.redisson.api.RBloomFilter;
import org.redisson.api.RLock;
import org.redisson.api.RedissonClient;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.data.redis.core.StringRedisTemplate;
import org.springframework.stereotype.Service;
@Service
public class ProductReadService {
private static final String CACHE_KEY_PREFIX = "product:";
private static final String LOCK_KEY_PREFIX = "lock:product:";
private final StringRedisTemplate redisTemplate;
private final ProductRepository productRepository;
private final RedissonClient redissonClient;
private final ObjectMapper objectMapper;
@Value("${cache-protection.bloom.product.name}")
private String bloomName;
public ProductReadService(StringRedisTemplate redisTemplate,
ProductRepository productRepository,
RedissonClient redissonClient,
ObjectMapper objectMapper) {
this.redisTemplate = redisTemplate;
this.productRepository = productRepository;
this.redissonClient = redissonClient;
this.objectMapper = objectMapper;
}
public ProductDTO getProduct(Long productId) {
validateProductId(productId);
String cacheKey = CACHE_KEY_PREFIX + productId;
CacheEnvelope<ProductDTO> cached = getFromCache(cacheKey);
if (cached != null) {
return cached.isEmpty() ? null : cached.getData();
}
RBloomFilter<Long> bloomFilter = redissonClient.getBloomFilter(bloomName);
if (!bloomFilter.contains(productId)) {
cacheEmpty(cacheKey);
return null;
}
String lockKey = LOCK_KEY_PREFIX + productId;
RLock lock = redissonClient.getLock(lockKey);
boolean locked = false;
try {
locked = lock.tryLock(100, 3000, TimeUnit.MILLISECONDS);
if (!locked) {
CacheEnvelope<ProductDTO> retryCached = getFromCache(cacheKey);
return retryCached == null || retryCached.isEmpty() ? null : retryCached.getData();
}
CacheEnvelope<ProductDTO> doubleCheck = getFromCache(cacheKey);
if (doubleCheck != null) {
return doubleCheck.isEmpty() ? null : doubleCheck.getData();
}
Product product = productRepository.findById(productId);
if (product == null) {
cacheEmpty(cacheKey);
return null;
}
ProductDTO dto = ProductDTO.from(product);
cacheData(cacheKey, dto, Duration.ofMinutes(30));
return dto;
} catch (InterruptedException ex) {
Thread.currentThread().interrupt();
throw new IllegalStateException("Interrupted while acquiring product lock", ex);
} finally {
if (locked && lock.isHeldByCurrentThread()) {
lock.unlock();
}
}
}
private void validateProductId(Long productId) {
if (productId == null || productId <= 0) {
throw new IllegalArgumentException("Invalid productId");
}
}
private CacheEnvelope<ProductDTO> getFromCache(String key) {
try {
String json = redisTemplate.opsForValue().get(key);
if (json == null || json.isBlank()) {
return null;
}
return objectMapper.readValue(json, new TypeReference<CacheEnvelope<ProductDTO>>() {});
} catch (Exception ex) {
return null;
}
}
private void cacheData(String key, ProductDTO data, Duration ttl) {
try {
CacheEnvelope<ProductDTO> envelope = CacheEnvelope.hit(
data,
LocalDateTime.now().plusSeconds(ttl.getSeconds()),
"v1"
);
redisTemplate.opsForValue().set(key, objectMapper.writeValueAsString(envelope), ttl);
} catch (Exception ex) {
// 生产中建议记录日志与指标,不影响主流程
}
}
private void cacheEmpty(String key) {
try {
Duration ttl = Duration.ofSeconds(90);
CacheEnvelope<ProductDTO> envelope = CacheEnvelope.empty(
LocalDateTime.now().plusSeconds(ttl.getSeconds()),
"v1"
);
redisTemplate.opsForValue().set(key, objectMapper.writeValueAsString(envelope), ttl);
} catch (Exception ex) {
// 生产中建议记录日志与指标,不影响主流程
}
}
}
这段代码做了四件重要的事:
- 参数前置校验
- 缓存 miss 后走 Bloom 拦截
- 回源前加分布式系统锁,防止并发放大
- 对空查询缓存短 TTL 空对象
这才是“可以上线”的基本形态,而不是只写一个 if (obj == null)。
十三、核心实现四:本地缓存 + Redis 的两级缓存设计
在高并发服务中,仅靠 Redis 还不够。因为 Redis 虽快,但仍然是网络调用;热点 key 极多时,Redis 自身也会成为瓶颈。
因此推荐两级缓存:
- L1:Caffeine 本地缓存
- L2:Redis 分布式缓存
读链路变为:
请求 -> Caffeine -> Redis -> Bloom -> DB
13.1 为什么本地缓存对穿透治理也有价值
它不仅能挡住热点存在数据,也能承接热点空对象:
- 某个不存在的商品 ID 被重复访问
- Redis 中已有空对象
- 读取后可回填到本地缓存
- 后续同机请求无需再访问 Redis
13.2 使用建议
- 正常对象本地 TTL:30 秒到 2 分钟
- 空对象本地 TTL:10 秒到 30 秒
- 本地缓存容量按实例内存预算严格限制
注意:
- 本地缓存不是一致性缓存
- 它承接的是热点与短期抖动,不承担强一致职责
十四、完整读流程:生产级伪代码
下面给出一份完整的读路径伪代码,便于在任意语言中迁移实现。
public ProductDTO queryProduct(Long productId) {
// 1. 参数校验
if (productId == null || productId <= 0) {
throw new BadRequestException("invalid productId");
}
// 2. 本地缓存
CacheEnvelope<ProductDTO> local = localCache.get(productId);
if (local != null) {
return local.isEmpty() ? null : local.getData();
}
// 3. Redis缓存
CacheEnvelope<ProductDTO> remote = redisCache.get(productId);
if (remote != null) {
localCache.put(productId, remote);
return remote.isEmpty() ? null : remote.getData();
}
// 4. Bloom快速判断
if (!bloom.mightContain(productId)) {
CacheEnvelope<ProductDTO> empty = CacheEnvelope.empty(nowPlusSeconds(30), "v1");
localCache.put(productId, empty);
redisCache.setEmpty(productId, empty, 90);
return null;
}
// 5. key级单飞
return singleflight.execute("product:" + productId, () -> {
CacheEnvelope<ProductDTO> recheck = redisCache.get(productId);
if (recheck != null) {
localCache.put(productId, recheck);
return recheck.isEmpty() ? null : recheck.getData();
}
Product product = repository.findById(productId);
if (product == null) {
CacheEnvelope<ProductDTO> empty = CacheEnvelope.empty(nowPlusSeconds(90), "v1");
redisCache.setEmpty(productId, empty, 90);
localCache.put(productId, empty);
return null;
}
ProductDTO dto = ProductDTO.from(product);
CacheEnvelope<ProductDTO> hit = CacheEnvelope.hit(dto, nowPlusMinutes(30), "v1");
redisCache.set(productId, hit, 1800 + random(0, 300));
localCache.put(productId, hit);
return dto;
});
}
这个流程体现了三个关键思想:
- 先过滤,再回源
- 回源只允许少数线程进行
- 正向结果和负向结果都要缓存
十五、真实业务场景拆解
15.1 场景一:商品详情页
特点:
- QPS 高
- 商品 ID 海量
- 下架、删除、预发商品较多
推荐方案:
- 商品 ID 放入 Bloom
- 已删除商品用空对象短缓存
- 热门商品走本地缓存 + Redis
- 活动期叠加网关限流与验证码策略
15.2 场景二:用户主页
特点:
推荐方案:
- 网关按 IP、设备指纹、账号维度限流
- 对 UID 做范围和格式校验
- 使用 Bloom 拦截不存在 UID
- 对敏感接口增加鉴权后查询
15.3 场景三:已删除内容页
特点:
推荐方案:
- 删除事件触发缓存失效
- Redis 写入短 TTL 空对象
- Bloom 保持“可能存在”,等待周期性重建
15.4 场景四:多租户系统
特点:
推荐方案:
- 缓存 key 必须包含租户维度
- Bloom 元素也要用复合键,例如
tenantId:id
- 参数校验层必须校验租户合法性
这是生产中最容易踩的坑之一。很多团队只把 id 放进 Bloom,结果租户隔离直接失效。
十六、高并发下的工程化升级
缓存穿透不是只写几个类就结束,真正的难点在工程化。
16.1 线程池与连接池保护
建议同时配置:
- Web 线程池最大并发上限
- DB 连接池最大连接数与超时
- Redis 客户端连接池
- 接口级别超时控制
目标不是“扛住所有流量”,而是“在异常流量下优雅失败”。
16.2 回源限流
即使 Bloom 误判或配置异常,也不能让数据库无限吃流量。
建议增加:
- 接口级回源限速
- 某类 key 的 DB 查询并发阈值
- 超阈值时返回兜底页、空结果或系统繁忙
16.3 TTL 打散
无论正常缓存还是空对象缓存,都应该增加随机抖动。
例如:
商品缓存 TTL = 1800s + random(0, 300)
空对象 TTL = 90s + random(0, 20)
避免大量 key 在同一秒过期,形成局部雪崩。
16.4 热 key 隔离
某些不存在 key 在攻击期间会变成“空热点 key”。
建议:
- 将热点空 key 放进本地缓存
- 必要时将极端 key 单独打标监控
- 对异常 key 做临时黑名单或更长负缓存
16.5 降级兜底
当 Redis 不可用时怎么办?
如果没有降级,系统会直接退化成“所有请求访问数据库”。
建议:
- 本地缓存继续承接短期热点
- 对高风险接口启用快速失败
- 关闭非核心查询
- 临时提高网关限流强度
十七、分布式与微服务场景下的设计要点
17.1 Bloom 应该由谁维护
推荐模式:
- 由业务服务在写路径上做增量写入
- 由独立离线任务做全量构建与周期重建
不要让每个消费方都各自维护一份不同版本的过滤器,否则数据一致性和容量评估都会失控。
17.2 多副本服务如何避免重复回源
单机 synchronized 没用,因为请求会落到不同实例。
因此需要:
- 分布式锁
- 或基于 singleflight 的 key 聚合组件
- 或通过消息/异步重建让写路径承担更多职责
17.3 多机房部署
跨机房下要考虑:
- Bloom 是否单中心还是多中心
- Redis 是否就近访问
- 机房切换后过滤器是否已同步
推荐:
- 过滤器数据与业务主数据同地域构建
- 机房内优先本地 Redis/Bloom
- 跨地域只做灾备,不做主链路频繁访问
17.4 分片数据库场景
如果主键不是全局连续,而是分库分表后的复合路由:
- Bloom 的元素不能只放
id
- 应放
bizType + tenantId + shardKey + id
否则“存在性判断”本身就不准确。
十八、数据一致性:新增、修改、删除如何影响防护体系
18.1 新增
顺序建议:
- 写数据库
- 事务成功后写 Bloom
- 删除或更新缓存
如果先写 Bloom 再写数据库,事务失败会导致过滤器提前放行,增加空回源。
18.2 修改
缓存穿透的关键不在修改,而在修改后缓存是否正确失效:
18.3 删除
删除最容易产生穿透。
建议:
- 数据库标记删除或物理删除
- 删除正常缓存
- 写入短 TTL 空对象缓存
- 保留 Bloom 标记到下一轮重建
这是线上最稳妥的做法。
18.4 为什么删除时不建议立即“移除 Bloom”
因为标准 Bloom 不支持删除,强行支持会引入以下复杂度:
- 需要 Counting Bloom
- 内存成本更高
- 并发更新复杂
- 误删风险更大
大多数业务没必要为“删除实时生效”引入这么高的复杂度,短 TTL 空对象 + 定期重建即可。
十九、监控与可观测性:没有指标就没有生产级治理
缓存穿透治理最怕一种情况:方案上线了,但团队并不知道它有没有生效。
至少要建设以下指标。
19.1 核心指标
- Redis 总命中率
- 空对象缓存命中次数
- Bloom 拦截次数
- Bloom 放行后 DB 空查询次数
- DB 查询 QPS
- DB 空查询占比
- 分布式锁竞争次数
- 接口 P95/P99 延迟
- 异常请求来源 IP/设备/租户分布
19.2 推荐监控埋点
meterRegistry.counter("cache.product.hit").increment();
meterRegistry.counter("cache.product.empty.hit").increment();
meterRegistry.counter("cache.product.bloom.reject").increment();
meterRegistry.counter("cache.product.db.query").increment();
meterRegistry.counter("cache.product.db.empty").increment();
meterRegistry.timer("cache.product.query.latency").record(duration);
19.3 告警建议
以下条件建议触发告警:
- Bloom 拦截量突增
- 空对象命中率在短时间内明显抬升
- 某接口 DB 空查询占比超过阈值
- Redis 命中率骤降
- 某类 key 的锁竞争异常升高
19.4 日志建议
不要把每次空查询都打印 error,否则攻击期间日志本身会把磁盘打爆。
建议:
- 对空查询记录采样日志
- 对异常来源记录聚合日志
- 对阈值突破记录结构化告警日志
二十、压测设计:如何验证方案真的有效
上线前至少做三类压测。
20.1 正常流量压测
目标:
关注:
- P95/P99
- CPU
- Redis RT
- Bloom 操作耗时
20.2 穿透流量压测
目标:
建议构造:
- 连续不存在 ID
- 随机不存在 ID
- 同一不存在 ID 高频重复访问
要看:
- Bloom 拦截率
- 空对象命中率
- 数据库空查询下降比例
20.3 混合故障压测
最有价值,也最容易被忽略。
建议模拟:
- Redis RT 升高
- Redis 节点短暂不可用
- Bloom 过滤器未初始化
- 数据库连接池阈值收紧
验证系统是否还能:
二十一、常见误区与踩坑清单
21.1 误区一:只要用了 Bloom 就万事大吉
错误原因:
- Bloom 只是前置存在性判断
- 它不能替代空对象缓存、回源保护和网关限流
21.2 误区二:Bloom 只在应用启动时全量加载一次
错误原因:
必须有:
21.3 误区三:空对象 TTL 设得很长
风险:
21.4 误区四:删除数据后立刻删缓存,不做空对象兜底
风险:
21.5 误区五:Bloom 元素维度不完整
例如:
- 多租户没加 tenantId
- 多渠道没加 channel
- 多版本没加 version
最终导致错误放行或错误拦截。
21.6 误区六:把所有空查询都当成攻击
不一定。
很多空查询来自:
- 用户输入错误
- 旧链接回访
- 数据已删除
- 下游调用延迟
所以治理要分层:既要防攻击,也要兼顾正常业务行为。
二十二、从单机到大规模系统的演进路线
阶段一:基础版
适合:
方案:
阶段二:进阶版
适合:
方案:
- 基础版能力
- Bloom Filter
- 本地缓存
- 分布式锁
阶段三:生产强化版
适合:
方案:
- 网关限流/风控
- Bloom 全量 + 增量 + 重建
- 两级缓存
- singleflight
- 完整监控与告警
阶段四:平台化版
适合:
方案:
- 抽象统一缓存 SDK
- 统一埋点
- 统一 Bloom 管理服务
- 统一规则平台
大型团队通常走到第四阶段,避免每个服务都重复造轮子。
二十三、生产级配置建议
以下参数没有绝对值,但可以作为初始参考。
23.1 TTL 建议
| 类型 |
建议值 |
说明 |
| 正常缓存 TTL |
30min - 2h |
结合业务更新频率 |
| 空对象 TTL |
30s - 5min |
尽量短,防负缓存过旧 |
| 本地缓存 TTL |
30s - 2min |
承接热点,弱一致 |
| 锁等待时间 |
50ms - 200ms |
避免线程长时间阻塞 |
| 锁持有时间 |
1s - 5s |
依据回源耗时设置 |
23.2 Bloom 建议
| 指标 |
建议 |
| 误判率 |
0.001 起步 |
| 预估容量 |
按未来 1.5 倍到 2 倍数据量规划 |
| 重建周期 |
每周或每月 |
| 维护方式 |
离线全量 + 在线增量 |
23.3 监控阈值建议
| 指标 |
建议告警阈值 |
| DB 空查询占比 |
超过 10% 持续 5 分钟 |
| Bloom 拦截量 |
较基线升高 3 倍以上 |
| Redis 命中率 |
较日常下降 20% 以上 |
| 锁竞争失败率 |
超过 5% |
这些值需要结合你自己的业务基线微调。
二十四、Kubernetes 与云原生落地建议
在云原生环境中,缓存穿透治理还要考虑部署层面的现实问题。
24.1 应用副本弹性扩缩容
副本数变化会带来:
- 本地缓存命中率波动
- 锁竞争模式变化
- 热 key 分布变化
因此要避免把一致性逻辑依赖在本地缓存上。
24.2 Redis 高可用
建议:
- Redis Cluster 或哨兵模式
- 设置合理超时和重试
- 严格限制重试次数,避免故障时放大流量
24.3 配置动态化
建议将以下配置动态下发:
- 空对象 TTL
- Bloom 开关
- 回源限流阈值
- 黑名单规则
- 降级策略开关
这样在攻击期间无需重启服务即可调优。
24.4 运维作业
建议把以下能力平台化:
- Bloom 初始化作业
- Bloom 重建作业
- 热 key 巡检
- 空查询报表
- 异常流量分析
二十五、一个完整的生产案例
下面给出一个更贴近真实场景的案例。
25.1 背景
某内容平台“文章详情接口”日均 QPS 8000,活动期间峰值 5 万。攻击者通过脚本扫描不存在文章 ID,数据库空查询一度占比超过 35%。
25.2 原系统问题
- 只做了 Redis 正常缓存
- 文章删除后直接删缓存
- 无 Bloom
- 无回源锁
- 监控只有 Redis 命中率,没有空查询指标
25.3 改造方案
第一阶段:
- 参数校验:文章 ID 必须 > 0
- 删除文章时写短 TTL 空对象
- 接口增加空查询埋点
第二阶段:
- 引入 Bloom,存量 3000 万文章 ID 全量灌装
- 写路径新增文章后异步增量写入
- 读路径增加 Bloom 拦截
第三阶段:
- 增加 Caffeine 本地缓存
- key 级分布式锁
- 网关按 IP/UA 限流
25.4 改造成果
- 数据库空查询下降 92%
- Redis 总命中率提升 11%
- 高峰期接口 P99 从 380ms 降到 75ms
- 攻击期间数据库 CPU 稳定在安全阈值内
25.5 关键经验
- 先补监控,再补方案,否则无法验证收益
- 删除场景一定要处理负缓存
- Bloom 不是一次性工程,必须重建
- 防护必须从网关到数据库形成闭环
二十六、文章级总结:如何构建一套真正可上线的缓存穿透防护体系
如果用一句话总结:
缓存穿透治理的核心,不是“把 null 缓存起来”,而是用分层拦截、负结果缓存、存在性过滤、并发控制和数据库保护,构建完整的读链路防线。
真正可靠的生产级方案,通常包含以下组合:
- 参数合法性校验
- 网关限流与风控
- Redis 正常缓存
- 空对象缓存
- Bloom Filter
- 本地热点缓存
- singleflight 或分布式锁
- DB 回源限流
- 完整监控、告警和重建机制
如果你的系统只做了其中一项,仍然有较大概率在高并发或攻击场景下暴露脆弱性。
二十七、落地 Checklist
上线前可以逐项自查。
- 是否对请求参数做了前置合法性校验
- 是否缓存了空结果而不是直接返回 null
- 是否为空对象设置了更短 TTL
- 是否为 TTL 增加了随机抖动
- 是否引入了 Bloom Filter 做前置存在性判断
- 是否有 Bloom 的全量初始化和增量维护机制
- 是否有 Bloom 的定期重建机制
- 是否对缓存 miss 的并发回源做了 singleflight 或互斥保护
- 是否有本地缓存承接热点
- 是否有限流、熔断、降级保护数据库
- 是否埋点统计了空查询、Bloom 拦截、锁竞争和 DB 空查占比
- 是否为攻击流量准备了动态调参能力
如果以上大部分都已具备,那么你的系统才算真正拥有了生产级缓存穿透防护能力。
附录 A:简化版接口示例
@RestController
@RequestMapping("/api/products")
public class ProductController {
private final ProductReadService productReadService;
public ProductController(ProductReadService productReadService) {
this.productReadService = productReadService;
}
@GetMapping("/{id}")
public ResponseEntity<ProductDTO> getById(@PathVariable("id") Long id) {
ProductDTO dto = productReadService.getProduct(id);
if (dto == null) {
return ResponseEntity.notFound().build();
}
return ResponseEntity.ok(dto);
}
}
附录 B:最终结论
缓存穿透从来不是 Redis 的单点问题,而是高并发系统中“无效请求治理”的一部分。
它考验的是一整套工程能力:
- 你能否在最前面识别无效请求
- 能否在缓存层表达负结果
- 能否在高并发下控制回源
- 能否在异常时期保护数据库
- 能否通过监控和重建维持方案长期有效
当你把这些能力组合起来,缓存穿透才真正从“线上隐患”变成“可控问题”。欢迎在云栈社区与更多开发者交流此类架构实践。