Redis 缓存穿透,我在线上被教做人的全过程(附 3 种方案 + 代码)

上周四凌晨两点,我被电话叫醒了。

运维说线上某个查询接口 QPS 飙到平时的 10 倍,MySQL 直接扛不住,CPU 打到 97%。我爬起来看监控,Redis 命中率从 98% 掉到了 30% 左右。经典缓存穿透,有人拿不存在的 ID 疯狂刷接口。

说实话面试背过八股文,什么布隆过滤器、空值缓存,但真到线上出事,手还是抖的。这篇是我这次真实的排查和修复过程,踩了不少坑。

先说结论

方案 适用场景 实现难度 推荐指数
缓存空值 数据量小、攻击规模不大 应急首选
布隆过滤器 数据量大、需长期防御 ⭐⭐⭐ 生产推荐
参数校验 + 限流 所有场景的第一道防线 ⭐⭐ 必须做

最后线上用的是参数校验 + 布隆过滤器 + 缓存空值三层组合,下面一个一个说。

先搞清楚缓存穿透是啥

很多人把缓存穿透、缓存击穿、缓存雪崩搞混,简单说下区别:

  • 缓存穿透:查一个根本不存在的数据,缓存里没有,DB 里也没有,每次请求都打到 DB
  • 缓存击穿:某个热点 key 过期的瞬间,大量请求同时打到 DB
  • 缓存雪崩:大量 key 同时过期,DB 被集体暴打

这次就是穿透------有人拿 id=-1id=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 内存

小结

经验就几条:参数校验是第一道防线,很多穿透请求在入口就能挡掉;缓存空值是最快的应急手段,但不是长期方案;布隆过滤器是正经的生产方案,注意参数设置和预热时机。三层组合起来才稳。

话说回来,这次事故的根本原因是接口没做鉴权和限流。一个不需要登录就能调的查询接口,连基本的频率限制都没有,被人扫到了不爆才怪。技术方案能兜底,但安全意识得先到位。

代码都是从项目里脱敏出来的,应该可以直接跑。有问题评论区聊。

相关推荐
安逸sgr2 小时前
【端侧 AI 实战】BitNet 详解:1-bit LLM 推理优化从原理到部署!
人工智能·python·scrapy·fastapi·ai编程·claude
我爱学习好爱好爱2 小时前
ELK日志分析平台(三):Logstash 7.17.10 独立节点部署与基础测试(基于Rocky Linux 9.6)
linux·python·elk
yangminlei2 小时前
openclaw对接飞书
开发语言·python·飞书
minglie12 小时前
Amaranth HDL
python·fpga开发
weixin199701080163 小时前
搜好货商品详情页前端性能优化实战
java·前端·python
王夏奇3 小时前
python-pytest学习
python·学习·pytest
BUG?不,是彩蛋!3 小时前
从 Q-Learning 到 LLM:我把 AI 的“大脑”换成了 GPT,发生了什么?
人工智能·python·gpt
XiYang-DING3 小时前
【Java SE】Java代码块详解
java·开发语言·python