熬夜两周,我终于搞懂了 Redis 的缓存穿透、击穿和雪崩

一名 Java 实习生的踩坑实录,从青铜到钻石的进阶之路


前言:那次让我"社死"的代码评审

大家好,我是小明,一名刚入职三个月的 Java 实习生。说出来不怕大家笑话,上周我自信满满地提交了一段自认为"完美"的代码,结果被技术总监(我的导师)问得哑口无言。那种感觉,就像考试时觉得自己能拿满分,结果被老师当众指出算错了一道大题——真的是脚趾都能抠出三室一厅来。

事情是这样的:我的任务是给商品详情页加一个缓存,因为每次用户访问商品信息都要查数据库,压力太大了。我心想,这不就是简单的读缓存、缓存没有就读数据库、然后把数据写回缓存吗?于是我三下五除二就写完了,代码大概是这个样子的:

@Autowired
private StringRedisTemplate stringRedisTemplate;

public Product getProductById(Long productId) {
    // 1. 先查缓存
    String cacheKey = "product:" + productId;
    String cacheValue = stringRedisTemplate.opsForValue().get(cacheKey);
    
    // 2. 缓存命中,直接返回
    if (StringUtils.hasText(cacheValue)) {
        return JSON.parseObject(cacheValue, Product.class);
    }
    
    // 3. 缓存未命中,查数据库
    Product product = productMapper.selectById(productId);
    
    // 4. 把数据库结果写入缓存,设置过期时间为30分钟
    if (product != null) {
        stringRedisTemplate.opsForValue().set(cacheKey, 
            JSON.toJSONString(product), 30, TimeUnit.MINUTES);
    }
    
    return product;
}

导师看完后,眉头一皱,问了我三个灵魂拷问:

“小明啊,如果有人故意用一个数据库里根本不存在的商品ID来请求,比如 productId = -1,你的这段代码会怎么做?”

“如果某个热门商品突然过期了,一瞬间一万个请求同时打进来,你的数据库扛得住吗?”

“如果今晚零点是双十一活动开始,你缓存里的一大堆商品信息都在零点过期了,数据库会变成什么样?”

我当时整个人都懵了,心里只有一个想法:完了,我写的这是啥玩意儿啊!

带着这三个问题,我开始了为期两周的 Redis 缓存"补习班"。今天这篇文章,就是我这两周学习的总结,希望能帮到和我一样正在学习 Redis 缓存机制的朋友们。


一、青铜选手:我以为缓存就是简单的读写

在我导师的灵魂拷问之前,我对 Redis 缓存的理解就是三个字:读和写。读取的时候先看看缓存里有没有,有就直接返回,没有就读数据库然后写进去。这种简单的模式确实能解决大部分问题,但这只是冰山一角。

1.1 最基本的缓存模式

让我先给大家看一下最基础也是最经典的缓存使用模式,这个模式叫做 Cache-Aside Pattern(旁路缓存模式)。这是业界使用最广泛的一种缓存策略,它的工作流程可以用一张图来概括:

用户请求 → 查缓存(Redis) 
           ↓ 有数据     ↓ 没数据
         直接返回     查询数据库(MySQL)
                      ↓
                    写入缓存
                      ↓
                    返回数据

这个模式的核心思想很简单:应用层首先尝试从缓存读取数据,如果缓存命中(Hit),就直接返回缓存中的数据;如果缓存未命中(Miss),就去数据库查询,然后把查询结果写入缓存,以便下次查询时能够命中。

1.2 我的"青铜版"代码

在我学习初期,我写的代码就是这样的:

@Service
public class ProductServiceImpl implements ProductService {
    
    @Autowired
    private StringRedisTemplate stringRedisTemplate;
    
    @Autowired
    private ProductMapper productMapper;
    
    private static final String PRODUCT_CACHE_PREFIX = "product:";
    
    @Override
    public Product getProductById(Long id) {
        String cacheKey = PRODUCT_CACHE_PREFIX + id;
        
        // 第一步:尝试从缓存获取
        String cachedData = stringRedisTemplate.opsForValue().get(cacheKey);
        
        if (cachedData != null) {
            // 缓存命中,直接返回
            log.info("缓存命中,商品ID: {}", id);
            return JSON.parseObject(cachedData, Product.class);
        }
        
        // 第二步:缓存未命中,查询数据库
        log.info("缓存未命中,查询数据库,商品ID: {}", id);
        Product product = productMapper.selectById(id);
        
        // 第三步:将数据库结果写入缓存
        if (product != null) {
            // 设置过期时间为30分钟
            stringRedisTemplate.opsForValue().set(
                cacheKey, 
                JSON.toJSONString(product),
                30, 
                TimeUnit.MINUTES
            );
        }
        
        return product;
    }
}

当时我还沾沾自喜,觉得自己写得挺规范的,又是日志又是异常处理的,应该没问题的。结果导师告诉我,这段代码存在三个严重的问题:

问题一:缓存穿透
如果有大量请求查询一个数据库根本不存在的商品ID,每次请求都会穿过缓存直接打到数据库上,导致数据库压力过大。

问题二:缓存击穿
如果某个热门商品的缓存过期了,在过期的那个瞬间,大量并发请求会同时发现缓存失效,然后全部涌向数据库,造成数据库压力骤增。

问题三:缓存雪崩
如果大量缓存在同一时间过期(比如批量设置缓存时使用了相同的过期时间),那么在这一刻,大量请求会同时穿透到数据库,数据库很可能直接挂掉。

听完这三个问题,我的心态是崩溃的,心想:怎么一个简简单单的缓存功能,会有这么多坑啊!但是没办法,谁让我是实习生呢,学呗!


二、白银选手:搞定缓存穿透

带着导师的问题,我开始了第一轮学习。首先解决的是缓存穿透问题。

2.1 什么是缓存穿透?

缓存穿透,听起来挺吓人的,其实用白话解释就是:有人专门查询数据库里没有的数据

这种情况可能有两种原因:

原因一:恶意攻击
有些人可能故意用一个不存在的 ID 来请求你的接口,比如 productId = -1,或者一个很大的数字比如 99999999。如果你的系统没有做好防护,每一次请求都会穿过缓存直接打到数据库上。如果攻击者短时间内发起大量这样的请求,数据库的 CPU 就会飙升,严重的话整个系统都会挂掉。

原因二:业务逻辑问题
有时候可能是前端传错了参数,或者后端代码有 bug,导致查询了一个实际不存在的对象。这种情况虽然不是恶意攻击,但同样会导致大量无效的数据库查询。

我导师给我举了一个形象的比喻:这就像你去图书馆找一本书,管理员告诉你这本书不存在(数据库未命中),然后你就走了。但如果有十万个人同时来找这本不存在的书,管理员就算不累死,也会被烦死。缓存穿透就是这个道理,每一次请求都要"打扰"数据库这位"图书管理员"。

2.2 解决方案一:缓存空值

最简单直接的解决方案是:当数据库查询不到数据时,也在缓存中存一个空值或者一个特殊的标记

这样做的好处是:下次再有人查询这个不存在的 ID 时,缓存直接返回空值,不需要再查数据库了。

@Override
public Product getProductById(Long id) {
    String cacheKey = PRODUCT_CACHE_PREFIX + id;
    
    // 第一步:尝试从缓存获取
    String cachedData = stringRedisTemplate.opsForValue().get(cacheKey);
    
    if (cachedData != null) {
        // 这里要判断是否是空值标记
        if ("NULL".equals(cachedData)) {
            log.warn("缓存空值,商品ID: {} 不存在于数据库", id);
            return null;
        }
        log.info("缓存命中,商品ID: {}", id);
        return JSON.parseObject(cachedData, Product.class);
    }
    
    // 第二步:缓存未命中,查询数据库
    Product product = productMapper.selectById(id);
    
    // 第三步:将结果写入缓存
    if (product != null) {
        stringRedisTemplate.opsForValue().set(
            cacheKey, 
            JSON.toJSONString(product),
            30, 
            TimeUnit.MINUTES
        );
        log.info("写入商品缓存,商品ID: {}", id);
    } else {
        // 数据库也没有查到,缓存一个空值标记
        // 设置一个较短的过期时间,比如5分钟
        stringRedisTemplate.opsForValue().set(
            cacheKey, 
            "NULL",
            5, 
            TimeUnit.MINUTES
        );
        log.warn("数据库未找到商品,缓存空值,商品ID: {}", id);
    }
    
    return product;
}

这个方案的优点是实现简单,不需要引入额外的组件。缺点是需要额外处理空值的逻辑,而且在空值过期前,如果数据库中新增了这个商品,缓存和数据库就会不一致。

2.3 解决方案二:布隆过滤器

除了缓存空值,还有一种更优雅的解决方案:布隆过滤器(Bloom Filter)

布隆过滤器是一种空间效率极高的概率型数据结构,它可以用来判断一个元素是否可能存在于集合中。注意,这里说的是"可能存在",而不是"确定存在"。布隆过滤器可能会把不存在的元素判断为存在(假阳性),但不会把存在的元素判断为不存在(假阴性)。

简单来说,布隆过滤器就像是一个"黑名单":如果布隆过滤器判断这个 ID 不存在,那它就一定不存在;如果判断存在,那它可能存在也可能不存在,需要再查一次数据库确认。

在 Java 中,我们可以使用 Google Guava 提供的 BloomFilter 或者 Redisson 提供的分布式布隆过滤器。

// 使用 Guava 的布隆过滤器
@Component
public class BloomFilterConfig {
    
    @Autowired
    private ProductMapper productMapper;
    
    private BloomFilter<Long> bloomFilter;
    
    @PostConstruct
    public void init() {
        // 初始化布隆过滤器,预期数据量 10000,误判率 0.01
        bloomFilter = BloomFilter.create(Funnels.longFunnel(), 10000, 0.01);
        
        // 将数据库中所有商品 ID 放入布隆过滤器
        List<Long> allProductIds = productMapper.selectAllIds();
        allProductIds.forEach(bloomFilter::put);
    }
    
    public boolean mightContain(Long productId) {
        return bloomFilter.mightContain(productId);
    }
}
// 在查询方法中使用布隆过滤器
@Service
public class ProductServiceImpl implements ProductService {
    
    @Autowired
    private BloomFilterConfig bloomFilterConfig;
    
    @Override
    public Product getProductById(Long id) {
        String cacheKey = PRODUCT_CACHE_PREFIX + id;
        String cachedData = stringRedisTemplate.opsForValue().get(cacheKey);
        
        if (cachedData != null) {
            if ("NULL".equals(cachedData)) {
                return null;
            }
            return JSON.parseObject(cachedData, Product.class);
        }
        
        // 使用布隆过滤器判断
        if (!bloomFilterConfig.mightContain(id)) {
            log.warn("布隆过滤器判断商品ID {} 不存在,直接返回null", id);
            // 缓存一个空值,避免穿透到数据库
            stringRedisTemplate.opsForValue().set(cacheKey, "NULL", 5, TimeUnit.MINUTES);
            return null;
        }
        
        // 布隆过滤器判断可能存在,查询数据库
        Product product = productMapper.selectById(id);
        
        if (product != null) {
            stringRedisTemplate.opsForValue().set(cacheKey, JSON.toJSONString(product), 30, TimeUnit.MINUTES);
        } else {
            // 数据库确实不存在,缓存空值
            stringRedisTemplate.opsForValue().set(cacheKey, "NULL", 5, TimeUnit.MINUTES);
        }
        
        return product;
    }
}

布隆过滤器的优点是空间效率极高,几 MB 的内存就能存储上亿个 ID。缺点是需要额外维护布隆过滤器的数据同步问题(比如新商品入库时需要把 ID 加入过滤器)。

2.4 我的选择

作为一个实习生,我最终选择了缓存空值的方案。原因很简单:布隆过滤器虽然高大上,但对于我们这种小系统来说,引入额外的数据结构会增加复杂度。而且缓存空值的方案已经能很好地解决缓存穿透问题了。

当然,如果你的系统面临大规模的恶意攻击,或者数据量非常大,那布隆过滤器肯定是更好的选择。这就叫做"看菜吃饭,量体裁衣"——适合自己的才是最好的。


三、黄金选手:搞定缓存击穿

搞定缓存穿透之后,我信心满满地去找导师汇报。结果导师说:“不错,缓存穿透的问题你理解了。但你再想想,如果某个热门商品的缓存过期了那一瞬间,会发生什么?”

我愣住了。热门商品过期?那不就是缓存失效吗?失效了再查一次数据库不就好了?

导师叹了口气,开始给我解释缓存击穿的概念。

3.1 什么是缓存击穿?

缓存击穿(也叫"热点key失效")是指:一个非常热门的缓存key,在某个时刻突然过期了,导致大量并发请求同时发现缓存失效,然后全部涌向数据库

用一个形象的比喻:如果把数据库比作一个正在休息的病人,缓存就是给病人吃的药。正常情况下,每隔几个小时吃一次药,病人就能保持健康。但如果某一次吃药的时候,药突然失效了,所有人就会立刻冲进病房给病人喂药——这一瞬间的冲击,病人能受得了吗?

这就是缓存击穿的可怕之处。热门商品的访问量可能是普通商品的几百倍甚至几千倍,一旦缓存过期,这瞬间的并发量足以让数据库崩溃。

3.2 解决方案:互斥锁

解决缓存击穿的核心思路是:让只有一个请求能去查数据库,其他请求等待这个请求把缓存写好之后,直接从缓存取

这就要用到分布式锁的概念了。在分布式系统中,我们需要一种机制来保证在同一时刻,只有一个线程(或服务实例)能够执行某段代码。

在 Redis 中,最常用的分布式锁实现方式是使用 SETNX 命令(SET if Not eXists)。SETNX 的作用是:如果 key 不存在,就设置这个 key 的值并返回 1;如果 key 已经存在,就不做任何操作返回 0。

让我来看看我的改进代码:

@Override
public Product getProductById(Long id) {
    String cacheKey = PRODUCT_CACHE_PREFIX + id;
    String cachedData = stringRedisTemplate.opsForValue().get(cacheKey);
    
    if (cachedData != null) {
        if ("NULL".equals(cachedData)) {
            return null;
        }
        return JSON.parseObject(cachedData, Product.class);
    }
    
    // 尝试获取锁
    String lockKey = "lock:product:" + id;
    Boolean lockAcquired = stringRedisTemplate.opsForValue()
        .setIfAbsent(lockKey, "lock", 10, TimeUnit.SECONDS);
    
    if (Boolean.TRUE.equals(lockAcquired)) {
        try {
            // 获取锁成功,查询数据库
            Product product = productMapper.selectById(id);
            
            if (product != null) {
                stringRedisTemplate.opsForValue().set(cacheKey, 
                    JSON.toJSONString(product), 30, TimeUnit.MINUTES);
            } else {
                stringRedisTemplate.opsForValue().set(cacheKey, "NULL", 5, TimeUnit.MINUTES);
            }
            
            return product;
        } finally {
            // 释放锁
            stringRedisTemplate.delete(lockKey);
        }
    } else {
        // 获取锁失败,等待一段时间后重试
        try {
            Thread.sleep(100);
        } catch (InterruptedException e) {
            Thread.currentThread().interrupt();
        }
        return getProductById(id);
    }
}

这段代码的核心逻辑是:

  1. 当缓存未命中时,首先尝试获取分布式锁
  2. 如果获取锁成功,说明我是第一个到达的请求,去查数据库并写入缓存
  3. 如果获取锁失败,说明已经有其他请求在查数据库了,我等待一段时间后重试
  4. 无论查询成功与否,最后都要释放锁

关键点说明

  1. 为什么锁要设置过期时间?
    如果一个服务实例获取锁后突然崩溃了,没有释放锁,其他服务就会永远拿不到锁,死锁了。所以锁必须设置一个过期时间,即使服务崩溃,锁也会自动释放。

  2. 为什么 finally 里要释放锁?
    为了保证锁一定会被释放,避免死锁。

  3. 为什么获取锁失败要 sleep 后重试?
    因为其他请求正在查数据库,我们只需要等它把缓存写好,我们就能从缓存读到数据了。

  4. 锁的过期时间设置多长合适?
    一般设置为预计查询数据库所需时间的 2-3 倍。如果查询数据库需要 1 秒,锁就设置 3 秒。

3.3 更优雅的方案:Redisson

上面的代码虽然能工作,但实现起来比较繁琐,而且还有不少细节需要处理(比如锁续期、锁的可重入性等)。在生产环境中,我更推荐使用 Redisson 这个成熟的分布式锁解决方案。

Redisson 是 Redis 官方推荐的 Java 客户端,它在 Redis 基础上提供了很多分布式功能的封装,其中就包括分布式锁。

使用 Redisson 的方式是这样的:

@Configuration
public class RedissonConfig {
    
    @Bean
    public RedissonClient redissonClient() {
        Config config = new Config();
        config.useSingleServer()
            .setAddress("redis://127.0.0.1:6379");
        return Redisson.create(config);
    }
}
@Service
public class ProductServiceImpl implements ProductService {
    
    @Autowired
    private RedissonClient redissonClient;
    
    @Override
    public Product getProductById(Long id) {
        String cacheKey = PRODUCT_CACHE_PREFIX + id;
        String cachedData = stringRedisTemplate.opsForValue().get(cacheKey);
        
        if (cachedData != null) {
            if ("NULL".equals(cachedData)) {
                return null;
            }
            return JSON.parseObject(cachedData, Product.class);
        }
        
        // 获取分布式锁
        String lockKey = "lock:product:" + id;
        RLock lock = redissonClient.getLock(lockKey);
        
        try {
            // 尝试获取锁,最多等待10秒,锁持有时间30秒
            boolean acquired = lock.tryLock(10, 30, TimeUnit.SECONDS);
            
            if (acquired) {
                try {
                    // 双重检查,防止在等待锁期间缓存已经被写入
                    cachedData = stringRedisTemplate.opsForValue().get(cacheKey);
                    if (cachedData != null) {
                        if ("NULL".equals(cachedData)) {
                            return null;
                        }
                        return JSON.parseObject(cachedData, Product.class);
                    }
                    
                    // 查数据库
                    Product product = productMapper.selectById(id);
                    
                    if (product != null) {
                        stringRedisTemplate.opsForValue().set(cacheKey, 
                            JSON.toJSONString(product), 30, TimeUnit.MINUTES);
                    } else {
                        stringRedisTemplate.opsForValue().set(cacheKey, "NULL", 5, TimeUnit.MINUTES);
                    }
                    
                    return product;
                } finally {
                    lock.unlock();
                }
            } else {
                // 获取锁失败,等待后重试
                Thread.sleep(100);
                return getProductById(id);
            }
        } catch (InterruptedException e) {
            Thread.currentThread().interrupt();
            throw new RuntimeException("获取商品信息失败", e);
        }
    }
}

使用 Redisson 的好处是:

  1. 自动续期:如果业务执行时间超过了锁的持有时间,Redisson 会自动延长锁的持有时间,避免锁被提前释放
  2. 可重入:同一个线程可以多次获取同一把锁
  3. 公平锁:可以按照请求顺序获取锁,避免锁饥饿
  4. 读写锁:支持读锁和写锁的分离,提高并发性能

导师告诉我:“不要重复造轮子,在分布式锁这种复杂的问题上,使用成熟的开源方案比自己的实现要可靠得多。” 这一点我深以为然。


四、铂金选手:搞定缓存雪崩

搞定了缓存击穿之后,我以为已经天下太平了。结果导师又问了我一个问题:

“小明啊,如果今晚零点是双十一活动开始,你缓存里的一大堆商品信息都在零点过期了,你觉得会发生什么?”

我想了想,脸色大变——这不就是缓存雪崩吗!

4.1 什么是缓存雪崩?

缓存雪崩是指:大量缓存 key 在同一个时间点过期,导致大量请求同时穿透到数据库

和缓存击穿不同,雪崩不是针对某一个热门 key,而是针对大量 key。如果缓存的过期时间都是 30 分钟,那么 30 分钟后,这些 key 会同时失效。想象一下,如果你的系统有 10 万个商品缓存都在同一时间过期,在那一瞬间,10 万个请求会同时冲向数据库——这就是灾难性的缓存雪崩。

缓存雪崩的原因通常有:

  1. 缓存集中过期:在设置缓存时使用了相同的过期时间
  2. Redis 集群故障:如果 Redis 集群中的某个节点挂了,请求会打到数据库上
  3. 系统重启:服务重启导致缓存全部失效

4.2 解决方案一:随机过期时间

最简单直接的方案是:在设置缓存过期时间时,加上一个随机值

@Override
public Product getProductById(Long id) {
    String cacheKey = PRODUCT_CACHE_PREFIX + id;
    String cachedData = stringRedisTemplate.opsForValue().get(cacheKey);
    
    if (cachedData != null) {
        if ("NULL".equals(cachedData)) {
            return null;
        }
        return JSON.parseObject(cachedData, Product.class);
    }
    
    Product product = productMapper.selectById(id);
    
    if (product != null) {
        // 基础过期时间30分钟 + 随机偏移量(1-5分钟)
        long baseExpireTime = 30 * 60; // 30分钟
        long randomOffset = ThreadLocalRandom.current().nextLong(1, 5 * 60); // 1-5分钟
        long finalExpireTime = baseExpireTime + randomOffset;
        
        stringRedisTemplate.opsForValue().set(
            cacheKey, 
            JSON.toJSONString(product),
            finalExpireTime, 
            TimeUnit.SECONDS
        );
    } else {
        stringRedisTemplate.opsForValue().set(cacheKey, "NULL", 5, TimeUnit.MINUTES);
    }
    
    return product;
}

这样设置之后,每个缓存的过期时间都不完全一样,大大降低了同时过期的概率。

4.3 解决方案二:Redis 高可用

如果 Redis 本身是高可用的(比如 Redis Sentinel 或 Redis Cluster),那么单点故障的概率会大大降低。即使某个 Redis 节点挂了,其他节点也能继续提供服务,不会导致所有请求都打到数据库上。

# Redis Sentinel 配置示例
redis:
  host: localhost
  password: yourpassword
  sentinel:
    master: mymaster
    nodes:
      - 127.0.0.1:26379
      - 127.0.0.1:26380
      - 127.0.0.1:26381

4.4 解决方案三:服务降级

如果缓存真的出了问题(比如 Redis 完全不可用了),我们还可以通过服务降级来保护数据库。常见的做法是:

  1. 使用本地缓存:在服务本地维护一个小的缓存,缓存 Redis 不可用时的热点数据
  2. 熔断机制:使用 Hystrix 或 Sentinel 等组件,当检测到数据库压力过大时,直接返回兜底数据
  3. 限流:通过限流组件(如 Sentinel、RateLimiter)限制并发请求数量
@Service
public class ProductServiceImpl implements ProductService {
    
    @Autowired
    private StringRedisTemplate stringRedisTemplate;
    
    @Autowired
    private ProductMapper productMapper;
    
    // 本地缓存,使用 Caffeine
    @Autowired
    private Cache<Long, Product> localCache;
    
    @Override
    public Product getProductById(Long id) {
        try {
            // 1. 先查本地缓存
            Product localProduct = localCache.get(id);
            if (localProduct != null) {
                return localProduct;
            }
            
            // 2. 查 Redis 缓存
            String cacheKey = PRODUCT_CACHE_PREFIX + id;
            String cachedData = stringRedisTemplate.opsForValue().get(cacheKey);
            
            if (cachedData != null) {
                if ("NULL".equals(cachedData)) {
                    return null;
                }
                Product product = JSON.parseObject(cachedData, Product.class);
                // 写入本地缓存
                localCache.put(id, product);
                return product;
            }
            
            // 3. 查数据库
            Product product = productMapper.selectById(id);
            
            if (product != null) {
                stringRedisTemplate.opsForValue().set(cacheKey, JSON.toJSONString(product), 30, TimeUnit.MINUTES);
                localCache.put(id, product);
            } else {
                stringRedisTemplate.opsForValue().set(cacheKey, "NULL", 5, TimeUnit.MINUTES);
            }
            
            return product;
        } catch (Exception e) {
            // Redis 异常时,从本地缓存或数据库获取
            log.error("Redis服务异常,使用降级方案,商品ID: {}", id, e);
            
            Product localProduct = localCache.get(id);
            if (localProduct != null) {
                return localProduct;
            }
            
            Product product = productMapper.selectById(id);
            if (product != null) {
                localCache.put(id, product);
            }
            return product;
        }
    }
}

4.5 多级缓存架构

对于高并发系统,我们还可以采用多级缓存的架构:

用户请求 → Nginx缓存 → 本地缓存(Caffeine/Guava) → Redis缓存 → 数据库

越靠近用户的缓存速度越快,但容量越小。通过多级缓存的组合,我们可以在保证响应速度的同时,减小后端数据库的压力。


五、总结:三种缓存问题的对比

经过两周的学习,我终于把 Redis 缓存的三个"老大难"问题给搞清楚了。让我用一个表格来总结一下:

问题类型 产生原因 解决方案 难度
缓存穿透 查询不存在的数据,请求直接打到数据库 1. 缓存空值 2. 布隆过滤器 ⭐⭐
缓存击穿 热门key过期,大量并发同时请求数据库 1. 互斥锁 2. Redisson分布式锁 ⭐⭐⭐
缓存雪崩 大量key同时过期,请求穿透到数据库 1. 随机过期时间 2. Redis高可用 3. 服务降级 ⭐⭐⭐⭐

核心代码模板

最后,给大家分享一个相对完整的缓存工具类模板,这个模板综合考虑了三种缓存问题,可以作为项目中的参考:

@Component
public class CacheService {
    
    @Autowired
    private StringRedisTemplate stringRedisTemplate;
    
    @Autowired
    private RedissonClient redissonClient;
    
    @Autowired
    private BloomFilterConfig bloomFilterConfig;
    
    /**
     * 带缓存的查询方法
     * 综合处理缓存穿透、击穿、雪崩问题
     */
    public <T> T getWithCache(String cacheKey, Class<T> clazz, 
            Supplier<T> databaseQuery, long baseExpireSeconds) {
        
        // 1. 尝试从缓存获取
        String cachedValue = stringRedisTemplate.opsForValue().get(cacheKey);
        if (cachedValue != null) {
            if ("NULL".equals(cachedValue)) {
                return null;
            }
            return JSON.parseObject(cachedValue, clazz);
        }
        
        // 2. 尝试获取分布式锁
        String lockKey = "lock:" + cacheKey;
        RLock lock = redissonClient.getLock(lockKey);
        
        try {
            if (lock.tryLock(10, 30, TimeUnit.SECONDS)) {
                try {
                    // 双重检查
                    cachedValue = stringRedisTemplate.opsForValue().get(cacheKey);
                    if (cachedValue != null) {
                        if ("NULL".equals(cachedValue)) {
                            return null;
                        }
                        return JSON.parseObject(cachedValue, clazz);
                    }
                    
                    // 3. 查询数据库
                    T result = databaseQuery.get();
                    
                    // 4. 写入缓存(带随机过期时间防止雪崩)
                    if (result != null) {
                        long randomExpire = ThreadLocalRandom.current()
                            .nextLong(60, 300); // 1-5分钟随机偏移
                        stringRedisTemplate.opsForValue().set(
                            cacheKey, 
                            JSON.toJSONString(result),
                            baseExpireSeconds + randomExpire, 
                            TimeUnit.SECONDS
                        );
                    } else {
                        // 缓存空值防止穿透
                        stringRedisTemplate.opsForValue().set(
                            cacheKey, 
                            "NULL",
                            300, 
                            TimeUnit.SECONDS
                        );
                    }
                    
                    return result;
                } finally {
                    lock.unlock();
                }
            } else {
                // 获取锁失败,等待后重试
                Thread.sleep(100);
                return getWithCache(cacheKey, clazz, databaseQuery, baseExpireSeconds);
            }
        } catch (InterruptedException e) {
            Thread.currentThread().interrupt();
            throw new RuntimeException("获取缓存失败", e);
        }
    }
}

六、后记:从青铜到铂金的感悟

写完这篇文章,我突然想起了两周前那段被导师"吊打"的代码。那个时候的我,天真地以为缓存就是简单的读写,完全没有意识到背后有这么多"坑"。

经过这两周的学习,我深刻体会到了几个道理:

  1. 理论要和实践结合:光看书是不够的,必须自己动手写代码、踩坑,才能真正理解问题的本质。

  2. 不要重复造轮子:像分布式锁这种复杂的问题,应该优先使用成熟的开源方案,而不是自己造一个可能有 bug 的轮子。

  3. 考虑问题要全面:写代码的时候要想想各种边界情况,比如恶意请求、服务异常、数据不存在等。

  4. 持续学习的重要性:技术日新月异,今天学的东西可能明天就过时了。只有保持学习的热情,才能在这个行业立足。

最后,感谢我的导师(虽然他看不到这篇文章)对我这个实习生的耐心指导。也希望这篇文章能帮助到和我一样正在学习 Redis 的朋友们。

如果这篇文章对你有帮助的话,记得点赞、收藏、关注哦!你们的支持是我继续创作的最大动力!


Logo

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

更多推荐