一、缓存穿透:当请求绕过缓存直奔数据库

想象一下,你开了一家图书馆(数据库),为了快速找书,你建了一个索引卡片柜(Redis缓存)。正常情况下,读者先查卡片,找到书号再去书架上拿。但有个捣蛋鬼,天天来查一本根本不存在的书(比如《如何在一周内成为世界首富》)。卡片柜里肯定没有,他每次都得让你把整个图书馆翻个底朝天,结果当然是白费力气。你的体力(数据库资源)被白白消耗,这就是“缓存穿透”。

核心问题: 大量请求查询一个数据库中根本不存在的数据,导致请求直接打到数据库上。

解决方案:

  1. 缓存空对象: 即使数据库没有,也在缓存里存一个空值(比如NULL),并设置一个较短的过期时间。这样后续的相同请求在缓存层就被拦截了。
  2. 布隆过滤器: 在缓存之前,加一个“预检员”。这个“预检员”很聪明,它能快速告诉你“这本书肯定不存在”或者“可能存在”。对于“肯定不存在”的请求,直接返回,保护数据库。

技术栈:Java (Spring Boot)

@Service
public class ProductService {

    @Autowired
    private RedisTemplate<String, Object> redisTemplate;
    @Autowired
    private ProductMapper productMapper; // 假设是MyBatis的Mapper

    /**
     * 查询商品信息,解决缓存穿透方案:缓存空对象
     * @param productId 商品ID
     * @return 商品信息,若不存在返回null
     */
    public Product getProductById(Long productId) {
        String cacheKey = "product:" + productId;
        // 1. 先从缓存中取
        Product product = (Product) redisTemplate.opsForValue().get(cacheKey);
        
        if (product != null) {
            // 2. 缓存命中,判断是否是空对象(我们约定一个特殊值,比如id为-1的对象)
            if (product.getId() == -1L) {
                return null; // 缓存中存的是空对象,直接返回null
            }
            return product; // 返回缓存中的真实数据
        }
        
        // 3. 缓存未命中,查询数据库
        product = productMapper.selectById(productId);
        
        if (product == null) {
            // 4. 数据库也不存在,缓存一个空对象(过期时间设短点,比如60秒)
            Product nullProduct = new Product();
            nullProduct.setId(-1L); // 用特殊ID标记为空对象
            redisTemplate.opsForValue().set(cacheKey, nullProduct, 60, TimeUnit.SECONDS);
            return null;
        } else {
            // 5. 数据库存在,写入缓存(设置正常过期时间,比如30分钟)
            redisTemplate.opsForValue().set(cacheKey, product, 30, TimeUnit.MINUTES);
            return product;
        }
    }
}

二、缓存击穿:热点数据的“猝死”

你的图书馆里有一本超级畅销书(热点数据),比如《哈利波特与魔法石》。这本书的索引卡片(缓存)设置了1小时自动更新。1小时到了,卡片刚好被清空。这时,成千上万的读者同时来借这本书,他们发现卡片柜里没有,于是所有人一窝蜂地冲向同一个书架,瞬间把书架挤塌了(数据库压力激增)。这就是“缓存击穿”。

核心问题: 一个访问量巨大的缓存key在过期瞬间,大量请求直接击穿缓存,落到数据库上。

解决方案:

  1. 永不过期: 对极少数真正的热点key,设置为逻辑上永不过期。通过后台任务异步更新其值。
  2. 互斥锁: 只让一个请求去数据库查询和重建缓存,其他请求等待。这就像只派一个管理员去书库取书,其他人排队。

技术栈:Java (Spring Boot)

@Service
public class HotNewsService {

    @Autowired
    private RedisTemplate<String, Object> redisTemplate;
    @Autowired
    private StringRedisTemplate stringRedisTemplate; // 用于分布式锁
    @Autowired
    private NewsMapper newsMapper;

    /**
     * 获取热点新闻,解决缓存击穿方案:分布式互斥锁
     * @param newsId 新闻ID
     * @return 新闻详情
     */
    public News getHotNews(Long newsId) throws InterruptedException {
        String cacheKey = "hot_news:" + newsId;
        // 1. 尝试从缓存获取
        News news = (News) redisTemplate.opsForValue().get(cacheKey);
        if (news != null) {
            return news;
        }

        // 2. 缓存未命中,尝试获取锁
        String lockKey = "lock:hot_news:" + newsId;
        String clientId = UUID.randomUUID().toString(); // 生成唯一标识,防止误删别人的锁
        // 使用SET命令的NX(不存在才设置)和PX(过期时间)参数实现分布式锁
        Boolean isLocked = stringRedisTemplate.opsForValue()
                .setIfAbsent(lockKey, clientId, 10, TimeUnit.SECONDS);

        if (Boolean.TRUE.equals(isLocked)) {
            // 3. 获取锁成功,再次检查缓存(双重检查,防止其他线程已经重建好)
            try {
                news = (News) redisTemplate.opsForValue().get(cacheKey);
                if (news != null) {
                    return news;
                }
                // 4. 查询数据库(模拟耗时)
                Thread.sleep(100);
                news = newsMapper.selectById(newsId);
                if (news != null) {
                    // 5. 写入缓存,设置过期时间
                    redisTemplate.opsForValue().set(cacheKey, news, 1, TimeUnit.HOURS);
                } else {
                    // 处理数据库不存在的情况,可以缓存空值防穿透
                    redisTemplate.opsForValue().set(cacheKey, new News(), 5, TimeUnit.MINUTES);
                }
                return news;
            } finally {
                // 6. 释放锁,确保锁是自己加的才删除(Lua脚本保证原子性)
                String luaScript = "if redis.call('get', KEYS[1]) == ARGV[1] then return redis.call('del', KEYS[1]) else return 0 end";
                redisTemplate.execute(new DefaultRedisScript<>(luaScript, Long.class), 
                                     Collections.singletonList(lockKey), clientId);
            }
        } else {
            // 7. 获取锁失败,说明有其他线程在重建缓存,等待一小段时间后重试
            Thread.sleep(50);
            return getHotNews(newsId); // 递归重试,也可以改为循环
        }
    }
}

三、缓存雪崩:大量缓存同时“罢工”

想象一下,图书馆里大部分图书的索引卡片(缓存)有效期都设成了相同的1小时。1小时到了,所有卡片同时失效。这时涌进来一大批读者,发现卡片柜几乎全空了,于是所有人同时冲向书库,瞬间造成灾难性的拥堵,系统可能直接崩溃。这就是“缓存雪崩”。

核心问题: 大量缓存key在同一时间大面积失效,导致所有请求涌向数据库。

解决方案:

  1. 差异化过期时间: 这是最有效的方法。给缓存设置过期时间时,加上一个随机因子,让它们在相近但不同的时间点失效。
    // 设置过期时间为 基础时间 + 随机偏移量
    int expireTime = 3600 + new Random().nextInt(600); // 3600秒 ± 600秒随机
    redisTemplate.opsForValue().set(key, value, expireTime, TimeUnit.SECONDS);
    
  2. 构建高可用缓存集群: 使用Redis Sentinel或Cluster,避免单点故障导致整个缓存层不可用。
  3. 服务降级与熔断: 当数据库压力过大时,通过Hystrix、Sentinel等组件暂时屏蔽部分非核心请求,或返回默认值,保护数据库。

四、数据一致性难题:缓存和数据库谁说了算?

你更新了图书馆里某本书的信息(比如作者简介),你修改了书架上的书(数据库),但忘了更新索引卡片(缓存)。下次有人通过卡片查这本书,看到的就是过时的信息。反之亦然。保持缓存和数据库的数据同步,是个经典难题。

核心策略:

  • 先更新数据库,再删除缓存: 这是更常用的策略,通常能更好地保证一致性。但删除缓存可能失败,需要重试机制。
  • 先删除缓存,再更新数据库: 在并发极高时可能产生脏数据,但可以通过“延迟双删”等策略优化。

技术栈:Java (Spring Boot)

@Service
@Transactional
public class OrderService {

    @Autowired
    private OrderMapper orderMapper;
    @Autowired
    private RedisTemplate<String, Object> redisTemplate;
    @Autowired
    private RabbitTemplate rabbitTemplate; // 使用消息队列进行异步重试

    private static final String ORDER_CACHE_PREFIX = "order:";

    /**
     * 更新订单状态,并处理缓存一致性(先更新DB,再删缓存,异步重试)
     * @param orderId 订单ID
     * @param newStatus 新状态
     */
    public void updateOrderStatus(Long orderId, String newStatus) {
        // 1. 更新数据库
        orderMapper.updateStatus(orderId, newStatus);
        
        // 2. 删除缓存
        String cacheKey = ORDER_CACHE_PREFIX + orderId;
        Boolean deleteSuccess = redisTemplate.delete(cacheKey);
        
        // 3. 如果删除失败,发送消息到MQ进行异步重试删除
        if (Boolean.FALSE.equals(deleteSuccess)) {
            Map<String, Object> message = new HashMap<>();
            message.put("cacheKey", cacheKey);
            message.put("retryCount", 0);
            rabbitTemplate.convertAndSend("cache.delete.retry.exchange", 
                                         "cache.delete.key", message);
            System.out.println("缓存删除失败,已发送重试消息: " + cacheKey);
        }
    }

    /**
     * 消息队列的消费者,负责重试删除缓存
     */
    @RabbitListener(queues = "cache.delete.retry.queue")
    public void handleCacheDeleteRetry(Map<String, Object> message) {
        String cacheKey = (String) message.get("cacheKey");
        Integer retryCount = (Integer) message.get("retryCount");
        
        if (retryCount < 3) { // 最多重试3次
            Boolean success = redisTemplate.delete(cacheKey);
            if (Boolean.FALSE.equals(success)) {
                message.put("retryCount", retryCount + 1);
                // 再次发送到延迟队列,等待一段时间后重试
                rabbitTemplate.convertAndSend("cache.delete.delay.exchange", 
                                             "cache.delete.key", message,
                                             msg -> {
                                                 msg.getMessageProperties().setDelay(5000); // 延迟5秒
                                                 return msg;
                                             });
            } else {
                System.out.println("重试删除缓存成功: " + cacheKey);
            }
        } else {
            System.out.println("重试删除缓存失败超过3次,请人工介入: " + cacheKey);
            // 可以发送报警邮件或短信
        }
    }
}

五、内存管理与淘汰:房子满了怎么办?

Redis就像你电脑的内存,空间是有限的。当缓存数据把内存塞满时,新的数据就进不来了。Redis提供了几种“清理房间”的策略,叫做内存淘汰策略。你需要根据业务特点来选择。

常见策略:

  • noeviction: 新写入操作会报错。(默认策略,生产环境慎用)
  • allkeys-lru: 在所有key中,移除最近最少使用的key。这是最常用的通用策略。
  • volatile-lru: 在设置了过期时间的key中,移除最近最少使用的。
  • allkeys-random: 随机移除某个key。
  • volatile-ttl: 在设置了过期时间的key中,移除即将过期的。

如何选择: 如果你的应用有明显的热点数据(如20%的数据承载80%的访问),allkeys-lru 是很好的选择。如果你的数据都有过期时间,且重要性不同,volatile-ttl 可能更合适。务必在 redis.conf 配置文件中根据业务场景进行设置。

六、慢查询与Big Key:隐藏在细节里的性能杀手

有时候Redis变慢,不是因为并发高,而是因为某个操作本身就很耗时。

  • 慢查询: 执行时间过长的命令,比如对一个包含百万成员的Set执行SMEMBERS,或者模糊匹配KEYS *(生产环境绝对禁止!应用SCAN代替)。
  • Big Key: 指一个key对应的value非常大,比如一个5MB的String,或者一个包含几十万元素的List/Hash。这种key在传输、序列化/反序列化、删除时都会非常耗时,可能阻塞Redis。

排查与解决:

  1. 使用 SLOWLOG GET 命令查看慢查询日志。
  2. 使用 redis-cli --bigkeys 扫描大key(注意在从节点执行,避免影响主节点性能)。
  3. 对于Big Key,考虑拆分。比如一个大的Hash可以按字段前缀拆分成多个小的Hash;一个大的List可以按范围拆分成多个List。

应用场景、技术优缺点、注意事项与总结

应用场景: Redis缓存广泛应用于需要快速响应的场景,如:网页内容缓存(文章详情)、会话存储(Session)、排行榜(Sorted Set)、计数器(原子递增)、消息队列(List/Stream)、社交关系(Set)等。它是提升系统性能、降低数据库负载的利器。

技术优缺点:

  • 优点: 性能极高(内存操作),数据结构丰富,支持持久化,功能强大(发布订阅、Lua脚本、事务等)。
  • 缺点: 作为缓存,数据可能丢失(取决于持久化策略);内存成本较高;集群配置和维护有一定复杂度。

注意事项:

  1. 不是银弹: 缓存适用于“读多写少”的热点数据。写频繁的数据可能带来严重的一致性问题。
  2. 容量规划: 必须根据业务数据量规划好内存大小,并设置合理的淘汰策略。
  3. 监控告警: 必须对Redis的内存使用率、连接数、命中率、慢查询等关键指标进行监控。
  4. 备份与持久化: 理解RDB和AOF的优缺点,根据数据重要性配置合适的持久化方案。
  5. 安全: 设置密码,禁用高危命令(如FLUSHALL, KEYS),绑定网络接口。

文章总结: 使用Redis缓存就像给系统加装了一个高速缓冲区,能极大提升性能。但用好它,必须警惕穿透、击穿、雪崩这“三兄弟”的破坏,妥善处理数据一致性难题,并做好内存管理和性能监控。理解这些常见故障背后的原理,掌握相应的排查和解决方案,是每一位开发者在构建稳健、高性能系统时的必修课。记住,缓存是门艺术,核心在于平衡:速度与一致性、空间与时间、复杂度与收益之间的平衡。