上周四凌晨两点,我被电话叫醒了。
运维说线上某个查询接口 QPS 飙到平时的 10 倍,MySQL 直接扛不住,CPU 打到 97%。我爬起来看监控,Redis 命中率从 98% 掉到了 30% 左右。经典缓存穿透,有人拿不存在的 ID 疯狂刷接口。
说实话面试背过八股文,什么布隆过滤器、空值缓存,但真到线上出事,手还是抖的。这篇是我这次真实的排查和修复过程,踩了不少坑。
先说结论
| 方案 | 适用场景 | 实现难度 | 推荐指数 |
|---|---|---|---|
| 缓存空值 | 数据量小、攻击规模不大 | ⭐ | 应急首选 |
| 布隆过滤器 | 数据量大、需长期防御 | ⭐⭐⭐ | 生产推荐 |
| 参数校验 + 限流 | 所有场景的第一道防线 | ⭐⭐ | 必须做 |
最后线上用的是参数校验 + 布隆过滤器 + 缓存空值三层组合,下面一个一个说。
先搞清楚缓存穿透是啥
很多人把缓存穿透、缓存击穿、缓存雪崩搞混,简单说下区别:
- 缓存穿透:查一个根本不存在的数据,缓存里没有,DB 里也没有,每次请求都打到 DB
- 缓存击穿:某个热点 key 过期的瞬间,大量请求同时打到 DB
- 缓存雪崩:大量 key 同时过期,DB 被集体暴打
这次就是穿透------有人拿 id=-1、id=999999999 这种明显不存在的值来刷。Redis 查不到,每次都穿透到 MySQL,MySQL 直接被打趴。
方案一:缓存空值(应急,5 分钟上线)
凌晨两点没时间搞布隆过滤器,先用最粗暴的方式顶住:查不到就往 Redis 里写一个空值,设个短过期时间。
java
public Product getProductById(Long id) {
String cacheKey = "product:" + id;
// 1. 先查缓存
String cached = redisTemplate.opsForValue().get(cacheKey);
// 注意这里要区分 "key不存在" 和 "key存在但值为空标记"
if (cached != null) {
if ("NULL_PLACEHOLDER".equals(cached)) {
// 之前查过了,DB 里没有,直接返回
return null;
}
return JSON.parseObject(cached, Product.class);
}
// 2. 缓存没有,查 DB
Product product = productMapper.selectById(id);
if (product != null) {
// 正常数据,缓存 30 分钟
redisTemplate.opsForValue().set(cacheKey, JSON.toJSONString(product), 30, TimeUnit.MINUTES);
} else {
// 关键:DB 也没有,缓存一个空标记,过期时间短一点
redisTemplate.opsForValue().set(cacheKey, "NULL_PLACEHOLDER", 5, TimeUnit.MINUTES);
}
return product;
}
这个方案 5 分钟上线,线上立刻稳了。但问题也很明显:如果攻击者用大量随机不存在的 ID 来刷,每个 ID 都不一样,Redis 会被塞满垃圾 key。我们算了下,对方用 UUID 级别的随机 key 来打,一小时能塞几百万个空值进去。
所以这只是应急方案,第二天就开始搞布隆过滤器。
方案二:布隆过滤器(正经的长期方案)
布隆过滤器的原理说烂了不重复,一句话:它能告诉你「这个数据一定不存在 」或者「这个数据可能存在」。
对缓存穿透场景,「一定不存在」这个判断就够了------直接拦掉,不让它碰 DB。
用 Redisson 实现(生产推荐)
项目用的 Spring Boot + Redisson,直接用 Redisson 自带的布隆过滤器,数据存在 Redis 里,多实例共享。
先加依赖:
xml
<dependency>
<groupId>org.redisson</groupId>
<artifactId>redisson-spring-boot-starter</artifactId>
<version>3.32.0</version>
</dependency>
初始化布隆过滤器:
java
@Configuration
public class BloomFilterConfig {
@Autowired
private RedissonClient redissonClient;
@Bean
public RBloomFilter<Long> productBloomFilter() {
RBloomFilter<Long> bloomFilter = redissonClient.getBloomFilter("product:bloom");
// 预计元素数量 100 万,误判率 1%
// 这两个参数很重要,下面踩坑会讲
bloomFilter.tryInit(1_000_000L, 0.01);
return bloomFilter;
}
}
改造查询逻辑:
java
@Service
public class ProductService {
@Autowired
private RBloomFilter<Long> productBloomFilter;
@Autowired
private RedisTemplate<String, String> redisTemplate;
@Autowired
private ProductMapper productMapper;
public Product getProductById(Long id) {
// 第一层:布隆过滤器拦截
if (!productBloomFilter.contains(id)) {
// 一定不存在,直接返回,不碰 DB
return null;
}
String cacheKey = "product:" + id;
String cached = redisTemplate.opsForValue().get(cacheKey);
if (cached != null) {
if ("NULL_PLACEHOLDER".equals(cached)) {
return null;
}
return JSON.parseObject(cached, Product.class);
}
// 布隆过滤器说可能存在,查 DB 确认
Product product = productMapper.selectById(id);
if (product != null) {
redisTemplate.opsForValue().set(cacheKey, JSON.toJSONString(product), 30, TimeUnit.MINUTES);
} else {
// 误判了,缓存空值兜底
redisTemplate.opsForValue().set(cacheKey, "NULL_PLACEHOLDER", 5, TimeUnit.MINUTES);
}
return product;
}
// 新增商品时同步更新布隆过滤器
public void addProduct(Product product) {
productMapper.insert(product);
productBloomFilter.add(product.getId());
}
}
数据预热
布隆过滤器建好是空的,得把现有数据灌进去。写了个启动任务:
java
@Component
public class BloomFilterWarmer implements CommandLineRunner {
@Autowired
private RBloomFilter<Long> productBloomFilter;
@Autowired
private ProductMapper productMapper;
@Override
public void run(String... args) {
long start = System.currentTimeMillis();
// 分批加载,别一次性全查出来把内存撑爆
int pageSize = 5000;
long lastId = 0;
int total = 0;
while (true) {
List<Long> ids = productMapper.selectIdsBatchAfter(lastId, pageSize);
if (ids.isEmpty()) {
break;
}
for (Long id : ids) {
productBloomFilter.add(id);
}
lastId = ids.get(ids.size() - 1);
total += ids.size();
}
long cost = System.currentTimeMillis() - start;
log.info("布隆过滤器预热完成,共加载 {} 条数据,耗时 {} ms", total, cost);
}
}
对应的 Mapper:
java
@Select("SELECT id FROM product WHERE id > #{lastId} ORDER BY id ASC LIMIT #{pageSize}")
List<Long> selectIdsBatchAfter(@Param("lastId") long lastId, @Param("pageSize") int pageSize);
80 万条商品数据,预热跑了 12 秒,完全可以接受。
方案三:接口层参数校验 + 限流(第一道防线)
方案一和方案二都是缓存层的解决方案,但如果在接口入口就能把非法请求挡掉,后面的压力会小很多。
java
@RestController
@RequestMapping("/api/product")
public class ProductController {
@GetMapping("/{id}")
public Result<Product> getProduct(@PathVariable Long id) {
// 最基本的参数校验:我们的商品 ID 是自增长的正整数
if (id == null || id <= 0) {
return Result.fail("参数非法");
}
// 可以加一个上界,比如当前最大 ID 是 100 万
// 这个值可以缓存起来定期更新
if (id > productIdUpperBound) {
return Result.fail("参数非法");
}
Product product = productService.getProductById(id);
return Result.success(product);
}
}
配合限流,我用的 Sentinel,也可以用 Guava 的 RateLimiter 做个简单的:
java
// 单机限流,每秒最多 100 次查询
private final RateLimiter rateLimiter = RateLimiter.create(100.0);
@GetMapping("/{id}")
public Result<Product> getProduct(@PathVariable Long id) {
if (!rateLimiter.tryAcquire()) {
return Result.fail("请求太频繁,稍后再试");
}
// ... 后续逻辑
}
分布式环境可以用 Redis + Lua 做滑动窗口限流,方案很多,不展开了。
踩坑记录
坑 1:布隆过滤器参数设错了
一开始把预期元素数量设成了 10 万,实际数据量是 80 万。误判率飙到 30% 以上,大量请求穿过布隆过滤器打到 DB,等于没用。
后来查 Redisson 源码才搞明白,tryInit 一旦执行过就不会重新初始化。改了参数没生效,是因为 Redis 里那个 key 已经存在了。
解决办法:先删掉 Redis 里的 product:bloom 这个 key,重新初始化。或者换个 key 名。
bash
redis-cli DEL product:bloom
坑 2:布隆过滤器不支持删除
标准布隆过滤器不能删除元素。商品下架了,布隆过滤器里还存着,会导致已删除的数据被当成「可能存在」,然后查 DB 查不到。
不过对缓存穿透这个场景,这其实不是大问题------最多多查一次 DB,然后被空值缓存兜住。
如果业务确实需要删除,可以用 Counting Bloom Filter,或者定期重建布隆过滤器。我选的后者,搞了个凌晨 3 点的定时任务每天重建一次。
坑 3:缓存空值的过期时间
一开始把空值缓存设成了 30 分钟,和正常数据一样。结果运营反馈说新上架的商品半小时内查不到------上架前用户搜过这个 ID,被缓存了空值。
后来改成 2 到 5 分钟的随机过期时间(随机是为了防止大量空值同时过期引发雪崩):
java
// 随机 2~5 分钟
int ttl = ThreadLocalRandom.current().nextInt(2, 6);
redisTemplate.opsForValue().set(cacheKey, "NULL_PLACEHOLDER", ttl, TimeUnit.MINUTES);
坑 4:预热任务和正常流量并发
服务启动时布隆过滤器还在预热,但接口已经开始接请求了。那段时间布隆过滤器里数据不全,会把正常请求也拦掉。
我的处理比较粗暴------预热完成前不走布隆过滤器判断:
java
private volatile boolean bloomReady = false;
public Product getProductById(Long id) {
// 预热完成后才启用布隆过滤器
if (bloomReady && !productBloomFilter.contains(id)) {
return null;
}
// ... 正常缓存查询逻辑
}
预热完成后把 bloomReady 设为 true。用 volatile 保证可见性就行,不需要加锁。
上线后的效果
三层防御全部上线后,盯了一周监控:
- Redis 命中率回到 97%+
- 同样规模的异常请求打过来,DB 的 QPS 几乎没有波动
- 布隆过滤器拦截率 99.2% 左右(那 0.8% 是误判,被空值缓存兜住了)
- 80 万数据的布隆过滤器在 Redis 里只占了约 1.2MB 内存
小结
经验就几条:参数校验是第一道防线,很多穿透请求在入口就能挡掉;缓存空值是最快的应急手段,但不是长期方案;布隆过滤器是正经的生产方案,注意参数设置和预热时机。三层组合起来才稳。
话说回来,这次事故的根本原因是接口没做鉴权和限流。一个不需要登录就能调的查询接口,连基本的频率限制都没有,被人扫到了不爆才怪。技术方案能兜底,但安全意识得先到位。
代码都是从项目里脱敏出来的,应该可以直接跑。有问题评论区聊。