✅ 核心价值:本文从 Redis 缓存三大问题的本质原因出发,提供源码级解决方案,附带基于 Redis + Lua 的实战代码,以及生产环境中的监控告警配置,彻底解决缓存问题,同时覆盖面试官高频追问的核心考点。

✅ 适用人群:Java/Python/Go 后端开发工程师、架构师,以及准备面试的程序员。

一、前言

在高并发系统中,Redis 缓存是提升性能的核心组件,但如果使用不当,会引发缓存穿透、缓存击穿、缓存雪崩三大问题,严重时会导致数据库宕机,系统不可用。

这三大问题也是大厂面试的高频考点,面试官不仅会问你 “什么是缓存三大问题”,还会追问 “如何解决?”“生产环境中你是怎么落地的?”“有没有更好的方案?”。

本文将从问题定义、产生原因、源码级解决方案、生产环境监控四个维度,彻底讲透缓存三大问题,附带可直接落地的代码,让你在面试中吊打面试官,在工作中解决实际问题。

二、缓存穿透:请求不存在的数据,绕过缓存直接攻击数据库

2.1 问题定义

缓存穿透是指用户请求的数据在缓存中不存在,且在数据库中也不存在,导致每次请求都绕过缓存,直接攻击数据库。当大量这样的请求涌入时,会导致数据库压力过大,甚至宕机。

2.2 产生原因

  1. 恶意攻击:黑客构造大量不存在的 key(如 user:1000000),发起请求,穿透缓存攻击数据库。
  2. 业务逻辑问题:用户请求的数据在数据库中确实不存在,但前端没有做参数校验,导致大量无效请求进入后端。

2.3 源码级解决方案

缓存穿透的核心解决思路是阻断无效请求,常用方案有:参数校验、缓存空值、布隆过滤器,其中布隆过滤器是生产环境中的首选方案。

方案 1:参数校验(基础方案)

在前端和后端接口层,对请求参数进行严格校验,过滤掉明显不存在的参数(如用户 ID 为负数、商品 ID 超过范围等)。

Java 示例代码(Spring Boot)

java

运行

@RestController
@RequestMapping("/user")
public class UserController {
    @GetMapping("/{userId}")
    public String getUserInfo(@PathVariable Long userId) {
        // 参数校验:过滤掉无效的用户 ID
        if (userId == null || userId <= 0 || userId > 100000) {
            return "{\"code\":400,\"msg\":\"无效的用户 ID\",\"data\":null}";
        }
        // 后续逻辑:查询缓存 → 查询数据库
        return userService.getUserInfo(userId);
    }
}
方案 2:缓存空值(简单有效,但有风险)

当请求的数据在数据库中不存在时,将空值(或默认值)缓存到 Redis 中,并设置较短的过期时间(如 5 分钟),这样后续相同的请求会被缓存拦截。

风险:如果大量不存在的 key 涌入,会导致 Redis 中缓存大量空值,占用内存。

Java 示例代码(结合 RedisTemplate)

java

运行

@Service
public class UserServiceImpl implements UserService {
    @Autowired
    private RedisTemplate<String, String> redisTemplate;
    @Autowired
    private UserMapper userMapper;

    private static final String CACHE_KEY_PREFIX = "user:";
    private static final long CACHE_NULL_TTL = 300; // 空值缓存过期时间:5 分钟

    @Override
    public String getUserInfo(Long userId) {
        String key = CACHE_KEY_PREFIX + userId;
        // 1. 查询缓存
        String userInfo = redisTemplate.opsForValue().get(key);
        if (userInfo != null) {
            // 2. 缓存存在,直接返回(包括空值)
            return userInfo;
        }
        // 3. 缓存不存在,查询数据库
        User user = userMapper.selectById(userId);
        if (user == null) {
            // 4. 数据库中不存在,缓存空值
            redisTemplate.opsForValue().set(key, "{\"code\":404,\"msg\":\"用户不存在\",\"data\":null}", CACHE_NULL_TTL, TimeUnit.SECONDS);
            return "{\"code\":404,\"msg\":\"用户不存在\",\"data\":null}";
        }
        // 5. 数据库中存在,缓存数据(设置较长的过期时间)
        String userJson = JSON.toJSONString(user);
        redisTemplate.opsForValue().set(key, userJson, 3600, TimeUnit.SECONDS);
        return userJson;
    }
}
方案 3:布隆过滤器(生产环境首选,高效阻断无效请求)

布隆过滤器是一种空间效率极高的概率型数据结构,用于判断一个元素是否在一个集合中。它的核心特点是:不存在的元素一定能被过滤掉,存在的元素有一定的概率被误判,但可以通过调整参数降低误判率

核心思路

  1. 系统启动时,将数据库中所有存在的 key(如用户 ID、商品 ID)加载到布隆过滤器中。
  2. 当请求到来时,先通过布隆过滤器判断 key 是否存在,如果不存在,直接返回错误,无需查询缓存和数据库。

优势:占用内存小,查询效率高,能有效阻断大量无效请求。

实战步骤

  1. 引入依赖(使用 Redisson 实现的布隆过滤器,集成 Redis,支持分布式):

xml

<dependency>
    <groupId>org.redisson</groupId>
    <artifactId>redisson-spring-boot-starter</artifactId>
    <version>3.23.5</version>
</dependency>
  1. 配置布隆过滤器

java

运行

@Configuration
public class RedissonConfig {
    @Bean
    public RedissonClient redissonClient() {
        Config config = new Config();
        config.useSingleServer().setAddress("redis://localhost:6379");
        return Redisson.create(config);
    }

    @Bean
    public RBloomFilter<Long> userBloomFilter(RedissonClient redissonClient) {
        // 初始化布隆过滤器
        RBloomFilter<Long> bloomFilter = redissonClient.getBloomFilter("userBloomFilter");
        // 参数1:预计添加的元素数量;参数2:误判率(0.01 表示 1% 的误判率)
        bloomFilter.tryInit(1000000, 0.01);
        // 系统启动时,加载数据库中所有用户 ID 到布隆过滤器(实际项目中可通过批量查询实现)
        List<Long> userIds = userMapper.selectAllUserIds();
        for (Long userId : userIds) {
            bloomFilter.add(userId);
        }
        return bloomFilter;
    }
}
  1. 在业务中使用布隆过滤器

java

运行

@Service
public class UserServiceImpl implements UserService {
    @Autowired
    private RBloomFilter<Long> userBloomFilter;
    @Autowired
    private RedisTemplate<String, String> redisTemplate;
    @Autowired
    private UserMapper userMapper;

    @Override
    public String getUserInfo(Long userId) {
        // 1. 使用布隆过滤器判断用户 ID 是否存在
        if (!userBloomFilter.contains(userId)) {
            return "{\"code\":404,\"msg\":\"用户不存在\",\"data\":null}";
        }
        // 2. 查询缓存 → 查询数据库(后续逻辑同方案2)
        String key = "user:" + userId;
        String userInfo = redisTemplate.opsForValue().get(key);
        if (userInfo != null) {
            return userInfo;
        }
        User user = userMapper.selectById(userId);
        if (user == null) {
            redisTemplate.opsForValue().set(key, "{\"code\":404,\"msg\":\"用户不存在\",\"data\":null}", 300, TimeUnit.SECONDS);
            return "{\"code\":404,\"msg\":\"用户不存在\",\"data\":null}";
        }
        String userJson = JSON.toJSONString(user);
        redisTemplate.opsForValue().set(key, userJson, 3600, TimeUnit.SECONDS);
        return userJson;
    }
}

2.4 生产环境监控告警

通过 Prometheus + Grafana 监控 Redis 的缓存穿透率,当穿透率超过阈值(如 1%)时,触发告警(钉钉 / 邮件 / 短信)。

监控指标redis_key_miss_rate(缓存未命中率),当 缓存未命中率 > 1%数据库查询次数 > 1000 时,判定为缓存穿透。

三、缓存击穿:请求热点数据,缓存过期后大量请求攻击数据库

3.1 问题定义

缓存击穿是指某个热点数据的缓存过期后,大量请求同时涌入,由于缓存未命中,这些请求都直接攻击数据库,导致数据库压力骤增,甚至宕机。

3.2 产生原因

  1. 热点数据缓存过期:热点数据(如热门商品、秒杀商品)的缓存过期时间到了,缓存被自动删除。
  2. 大量并发请求:同一时间有大量请求访问该热点数据,缓存未命中后,所有请求都直接查询数据库。

3.3 源码级解决方案

缓存击穿的核心解决思路是保证热点数据的缓存不失效对热点数据的请求进行限流,常用方案有:设置热点数据永不过期、加互斥锁、使用分布式锁

方案 1:设置热点数据永不过期(简单有效)

对于热点数据(如秒杀商品、热门活动),设置缓存永不过期,由后台定时任务更新缓存数据,这样就不会出现缓存过期的情况。

Java 示例代码

java

运行

@Service
public class GoodsServiceImpl implements GoodsService {
    @Autowired
    private RedisTemplate<String, String> redisTemplate;
    @Autowired
    private GoodsMapper goodsMapper;

    private static final String CACHE_KEY_PREFIX = "goods:";

    @Override
    public String getGoodsInfo(Long goodsId) {
        String key = CACHE_KEY_PREFIX + goodsId;
        String goodsInfo = redisTemplate.opsForValue().get(key);
        if (goodsInfo != null) {
            return goodsInfo;
        }
        // 缓存不存在,查询数据库并缓存(设置永不过期)
        Goods goods = goodsMapper.selectById(goodsId);
        if (goods == null) {
            return "{\"code\":404,\"msg\":\"商品不存在\",\"data\":null}";
        }
        String goodsJson = JSON.toJSONString(goods);
        redisTemplate.opsForValue().set(key, goodsJson); // 永不过期
        return goodsJson;
    }

    // 后台定时任务:更新热点商品缓存(每隔 10 分钟执行一次)
    @Scheduled(fixedRate = 600000)
    public void updateHotGoodsCache() {
        List<Long> hotGoodsIds = goodsMapper.selectHotGoodsIds(); // 查询热门商品 ID 列表
        for (Long goodsId : hotGoodsIds) {
            Goods goods = goodsMapper.selectById(goodsId);
            String key = CACHE_KEY_PREFIX + goodsId;
            String goodsJson = JSON.toJSONString(goods);
            redisTemplate.opsForValue().set(key, goodsJson);
        }
    }
}
方案 2:加互斥锁(分布式场景首选,保证只有一个请求查询数据库)

当缓存未命中时,使用分布式锁保证只有一个请求能查询数据库,其他请求等待锁释放后,从缓存中获取数据,从而避免大量请求攻击数据库。

核心思路

  1. 缓存未命中时,尝试获取分布式锁。
  2. 如果获取到锁,查询数据库并更新缓存,然后释放锁。
  3. 如果未获取到锁,等待一段时间后,重新查询缓存。

Java 示例代码(使用 Redisson 实现分布式锁)

java

运行

@Service
public class GoodsServiceImpl implements GoodsService {
    @Autowired
    private RedissonClient redissonClient;
    @Autowired
    private RedisTemplate<String, String> redisTemplate;
    @Autowired
    private GoodsMapper goodsMapper;

    private static final String CACHE_KEY_PREFIX = "goods:";
    private static final String LOCK_KEY_PREFIX = "lock:goods:";

    @Override
    public String getGoodsInfo(Long goodsId) {
        String key = CACHE_KEY_PREFIX + goodsId;
        String goodsInfo = redisTemplate.opsForValue().get(key);
        if (goodsInfo != null) {
            return goodsInfo;
        }
        // 缓存未命中,尝试获取分布式锁
        RLock lock = redissonClient.getLock(LOCK_KEY_PREFIX + goodsId);
        try {
            // 获取锁:等待 3 秒,锁过期时间 10 秒
            if (lock.tryLock(3, 10, TimeUnit.SECONDS)) {
                // 再次查询缓存(防止其他请求已经更新了缓存)
                goodsInfo = redisTemplate.opsForValue().get(key);
                if (goodsInfo != null) {
                    return goodsInfo;
                }
                // 查询数据库并更新缓存
                Goods goods = goodsMapper.selectById(goodsId);
                if (goods == null) {
                    return "{\"code\":404,\"msg\":\"商品不存在\",\"data\":null}";
                }
                String goodsJson = JSON.toJSONString(goods);
                redisTemplate.opsForValue().set(key, goodsJson, 3600, TimeUnit.SECONDS);
                return goodsJson;
            } else {
                // 未获取到锁,等待 100 毫秒后重新查询缓存
                Thread.sleep(100);
                return getGoodsInfo(goodsId);
            }
        } catch (InterruptedException e) {
            e.printStackTrace();
            return "{\"code\":500,\"msg\":\"系统异常\",\"data\":null}";
        } finally {
            // 释放锁
            if (lock.isHeldByCurrentThread()) {
                lock.unlock();
            }
        }
    }
}

3.4 生产环境监控告警

监控热点数据的缓存命中情况,当某个 key 的缓存未命中率突然升高(如超过 50%)时,触发告警,及时排查是否发生缓存击穿。

监控指标redis_key_hit_rate(缓存命中率),redis_key_request_count(key 的请求次数)。

四、缓存雪崩:大量缓存同时过期,导致大量请求攻击数据库

4.1 问题定义

缓存雪崩是指大量缓存数据在同一时间过期,导致大量请求同时涌入数据库,数据库无法承受巨大的压力,从而宕机,引发系统雪崩。

4.2 产生原因

  1. 缓存过期时间设置过于集中:大量缓存数据的过期时间设置为相同的值(如都设置为 1 小时),导致它们在同一时间过期。
  2. Redis 集群宕机:Redis 主从集群发生故障,导致整个缓存系统不可用,所有请求都直接攻击数据库。

4.3 源码级解决方案

缓存雪崩的核心解决思路是避免缓存同时过期提高缓存系统的可用性,常用方案有:设置随机过期时间、Redis 主从集群 + 哨兵模式、多级缓存

方案 1:设置随机过期时间(避免缓存同时过期)

在设置缓存过期时间时,给每个 key 的过期时间增加一个随机值(如 0-600 秒),这样可以避免大量缓存同时过期。

Java 示例代码

java

运行

@Service
public class OrderServiceImpl implements OrderService {
    @Autowired
    private RedisTemplate<String, String> redisTemplate;
    @Autowired
    private OrderMapper orderMapper;

    private static final String CACHE_KEY_PREFIX = "order:";
    private static final long BASE_TTL = 3600; // 基础过期时间:1 小时
    private static final long RANDOM_TTL = 600; // 随机过期时间:0-10 分钟

    @Override
    public String getOrderInfo(Long orderId) {
        String key = CACHE_KEY_PREFIX + orderId;
        String orderInfo = redisTemplate.opsForValue().get(key);
        if (orderInfo != null) {
            return orderInfo;
        }
        Order order = orderMapper.selectById(orderId);
        if (order == null) {
            return "{\"code\":404,\"msg\":\"订单不存在\",\"data\":null}";
        }
        String orderJson = JSON.toJSONString(order);
        // 设置过期时间:基础过期时间 + 随机过期时间
        long ttl = BASE_TTL + new Random().nextLong(RANDOM_TTL);
        redisTemplate.opsForValue().set(key, orderJson, ttl, TimeUnit.SECONDS);
        return orderJson;
    }
}
方案 2:Redis 主从集群 + 哨兵模式(提高缓存系统可用性)

搭建 Redis 主从集群,主节点负责写操作,从节点负责读操作,同时部署哨兵模式,当主节点宕机时,哨兵自动将从节点提升为主节点,保证缓存系统的高可用。

核心架构

plaintext

客户端 → 哨兵集群 → 主节点(写)→ 从节点1(读)→ 从节点2(读)

优势:即使主节点宕机,从节点也能提供读服务,避免缓存系统完全不可用。

方案 3:多级缓存(本地缓存 + Redis 缓存)

搭建多级缓存体系,包括本地缓存(如 Caffeine、Guava Cache)和Redis 缓存,当 Redis 缓存不可用时,本地缓存可以提供兜底服务,避免大量请求攻击数据库。

核心思路

  1. 优先查询本地缓存,如果本地缓存存在,直接返回。
  2. 如果本地缓存不存在,查询 Redis 缓存,如果 Redis 缓存存在,更新本地缓存并返回。
  3. 如果 Redis 缓存也不存在,查询数据库,更新 Redis 缓存和本地缓存并返回。

Java 示例代码(使用 Caffeine 作为本地缓存)

java

运行

@Service
public class GoodsServiceImpl implements GoodsService {
    // 本地缓存:Caffeine
    private final Cache<Long, String> localCache = Caffeine.newBuilder()
            .maximumSize(10000) // 最大缓存数量
            .expireAfterWrite(5, TimeUnit.MINUTES) // 写入后 5 分钟过期
            .build();

    @Autowired
    private RedisTemplate<String, String> redisTemplate;
    @Autowired
    private GoodsMapper goodsMapper;

    private static final String CACHE_KEY_PREFIX = "goods:";

    @Override
    public String getGoodsInfo(Long goodsId) {
        // 1. 查询本地缓存
        String goodsInfo = localCache.getIfPresent(goodsId);
        if (goodsInfo != null) {
            return goodsInfo;
        }
        // 2. 查询 Redis 缓存
        String key = CACHE_KEY_PREFIX + goodsId;
        goodsInfo = redisTemplate.opsForValue().get(key);
        if (goodsInfo != null) {
            // 更新本地缓存
            localCache.put(goodsId, goodsInfo);
            return goodsInfo;
        }
        // 3. 查询数据库
        Goods goods = goodsMapper.selectById(goodsId);
        if (goods == null) {
            return "{\"code\":404,\"msg\":\"商品不存在\",\"data\":null}";
        }
        goodsInfo = JSON.toJSONString(goods);
        // 更新 Redis 缓存和本地缓存
        redisTemplate.opsForValue().set(key, goodsInfo, 3600, TimeUnit.SECONDS);
        localCache.put(goodsId, goodsInfo);
        return goodsInfo;
    }
}

4.4 生产环境监控告警

  1. 监控 Redis 集群的健康状态,当主节点宕机或从节点不可用时,触发告警,及时进行故障转移。
  2. 监控缓存的整体命中率,当命中率突然下降(如低于 90%)时,触发告警,排查是否发生缓存雪崩。

监控指标redis_cluster_health(集群健康状态),redis_cache_hit_rate(整体缓存命中率)。

五、总结

Redis 缓存三大问题(穿透、击穿、雪崩)是高并发系统中必须解决的核心问题,它们的本质都是缓存未命中导致大量请求攻击数据库,但产生原因和解决思路各有不同:

问题类型 核心原因 核心解决思路 生产环境首选方案
缓存穿透 请求不存在的数据 阻断无效请求 布隆过滤器 + 参数校验
缓存击穿 热点数据缓存过期 保证热点数据缓存不失效或限流 分布式锁 + 热点数据永不过期
缓存雪崩 大量缓存同时过期或 Redis 集群宕机 避免缓存同时过期 + 提高缓存可用性 随机过期时间 + Redis 主从集群 + 多级缓存

在实际项目中,需要结合业务场景,选择合适的解决方案,并通过完善的监控告警体系,及时发现和解决问题,保证系统的高可用和高性能。

六、拓展阅读

  1. 《Redis 设计与实现》:深入理解 Redis 的底层原理,包括数据结构、持久化、集群等核心特性。
  2. 《高并发系统设计实战》:讲解高并发系统的核心设计原则和技术,包括缓存、消息队列、分布式锁等。
  3. 《Redisson 官方文档》:学习 Redisson 提供的分布式锁、布隆过滤器、缓存等功能的详细用法。
Logo

汇聚全球AI编程工具,助力开发者即刻编程。

更多推荐