AI摘要

文章聚焦Redis日常开发四大高频陷阱:分布式锁需原子加过期与Lua删锁;雪崩用随机TTL+预热;击穿给热点key永不过期或互斥锁;穿透缓存空值并布隆过滤器拦截。给出可直接落地的Java代码与规范,强调“开发时多一步,生产少一次事故”。

作为一名有7年Java开发经验的程序员,在日常开发中,Redis的使用几乎贯穿了大部分后端项目——不管是做数据缓存、分布式协调,还是实现分布式锁,Redis都以高性能、高可用的优势成为首选。但在长期的开发调试中,也发现Redis在使用过程中很容易踩坑,尤其是分布式锁的实现细节、缓存雪崩、击穿、穿透这几个高频问题,看似基础,却常常因为考虑不周全,导致代码上线后出现隐藏bug,影响接口稳定性。

不同于网上很多“生产环境突发故障排查”的文章,这篇博客主要聚焦于日常开发中就能发现、就能规避的问题,结合实际开发场景,一步步拆解原理、分析问题产生的原因,以及可落地的解决方案,全程不堆砌理论,只讲真实开发中会用到的知识点和避坑要点。

先说明前提:本文基于Redis 6.x版本,结合Java开发场景(Spring Boot + RedisTemplate),所有案例都是日常开发中高频出现的场景,不涉及复杂的集群部署特殊场景,适合大多数后端开发参考。

一、Redis分布式锁:日常开发中容易忽略的细节

在单体应用中,我们用synchronized或者Lock就能解决并发问题,但随着项目拆分微服务,多个服务实例同时操作同一个资源(比如库存扣减、订单状态更新),本地锁就失去了作用——因为不同服务实例运行在不同的JVM中,本地锁只能控制自身JVM内的并发,无法跨服务、跨节点控制。这时候,Redis分布式锁就成了最常用的解决方案。

日常开发中,很多人会直接用Redis的setnx命令来实现分布式锁,看似简单,却隐藏着不少问题,这些问题不是生产环境才会暴露,而是在本地调试、测试环境就能发现,只是容易被忽略。

1.1 最基础的实现方式(存在明显问题)

最开始接触Redis分布式锁时,很多人会这样写:用setnx(set if not exists)命令,当key不存在时,设置key-value,表示获取锁;释放锁时,直接用del命令删除key。

核心代码(Java + RedisTemplate):

// 获取锁
Boolean lock = redisTemplate.opsForValue().setIfAbsent("lock:order:1001", "1");
if (Boolean.TRUE.equals(lock)) {
    // 执行业务逻辑(比如扣减库存)
    try {
        // 业务代码...
    } finally {
        // 释放锁
        redisTemplate.delete("lock:order:1001");
    }
} else {
    // 获取锁失败,返回提示
    return "当前操作过于频繁,请稍后再试";
}

这段代码在本地调试时,看似能正常运行,但仔细思考就会发现两个明显问题,这些问题在多线程调试(比如用Jmeter模拟多请求)时就能复现:

问题1:锁未设置过期时间,导致死锁。如果执行业务逻辑时,服务突然宕机(比如本地调试时强制停止程序),del命令就不会执行,key会一直存在于Redis中,其他服务实例永远无法获取锁,造成死锁。

问题2:释放锁时,可能释放别人的锁。比如,服务A获取锁后,业务逻辑执行时间过长,超过了锁的过期时间(后面会加过期时间),锁自动释放,此时服务B获取到了同一把锁,而服务A执行完业务后,会执行del命令,把服务B的锁删掉——这就导致服务B的锁被误删,引发并发问题。

这两个问题,都是日常开发中不注意细节就会踩的坑,不需要等到生产环境,本地多线程调试就能发现,只是很多人在开发时图省事,忽略了这些细节。

1.2 优化方案:设置过期时间 + 原子释放锁

针对上面的两个问题,我们逐步优化,优化后的方案的是日常开发中最常用、最稳妥的方式,也是能直接落地的方案。

优化点1:给锁设置过期时间,避免死锁。Redis的setnx命令本身不支持设置过期时间,但我们可以用set命令的组合参数,实现“setnx + 过期时间”的原子操作——Redis 2.6.12版本后,支持set key value nx ex seconds命令,一次性完成“判断key是否存在、存在则不设置、不存在则设置并设置过期时间”,避免了先setnx再expire的非原子操作(如果setnx成功后,expire失败,依然会导致死锁)。

优化点2:释放锁时,判断是否是自己的锁,避免误删。我们可以在设置锁的时候,把value设置为一个唯一标识(比如UUID + 线程ID),释放锁时,先获取锁的value,判断是否和自己的唯一标识一致,如果一致,再删除key;如果不一致,说明锁已经被其他服务实例获取,不执行删除操作。

这里需要注意:“获取value + 判断 + 删除”这三个操作,必须是原子操作,否则依然会出现问题——比如,服务A获取到value后,判断是自己的锁,但在执行del命令之前,锁过期了,服务B获取到了锁,此时服务A再执行del命令,还是会删掉服务B的锁。所以,我们需要用Redis的Lua脚本,把这三个操作封装成一个原子操作。

优化后的核心代码:

// 生成唯一标识(避免误删其他锁)
String lockValue = UUID.randomUUID().toString() + ":" + Thread.currentThread().getId();
// 原子操作:获取锁 + 设置过期时间(过期时间根据业务逻辑调整,这里设为30秒)
Boolean lock = redisTemplate.opsForValue().setIfAbsent("lock:order:1001", lockValue, 30, TimeUnit.SECONDS);
if (Boolean.TRUE.equals(lock)) {
    // 执行业务逻辑
    try {
        // 业务代码...(比如扣减库存、更新订单状态)
    } finally {
        // 用Lua脚本原子释放锁:判断value是否一致,一致则删除
        String script = "if redis.call('get', KEYS[1]) == ARGV[1] then return redis.call('del', KEYS[1]) else return 0 end";
        redisTemplate.execute(new DefaultRedisScript<Integer>(script, Integer.class),
                Collections.singletonList("lock:order:1001"),
                lockValue);
    }
} else {
    // 获取锁失败,返回提示
    return "当前操作过于频繁,请稍后再试";
}

到这里,分布式锁的核心问题就解决了,但日常开发中还有一个细节需要注意:锁的过期时间设置多少合适?如果业务逻辑执行时间超过了过期时间,锁还是会被自动释放,依然会引发并发问题。

解决方案:给锁加“续期”机制。比如,用一个后台线程(比如定时任务),每隔10秒检查一次,如果当前服务还持有锁(即key还存在,且value是自己的唯一标识),就把锁的过期时间重新设置为30秒——这个机制,也叫“锁的续命”。日常开发中,我们可以自己实现这个逻辑,也可以直接使用Redisson框架(Redisson已经封装好了分布式锁,包含自动续期功能),但建议先理解底层原理,再使用框架,避免“知其然不知其所以然”。

补充:日常开发中,不建议自己手写分布式锁的完整逻辑(尤其是复杂场景),优先使用Redisson,因为它不仅解决了上述问题,还支持公平锁、可重入锁、联锁等多种锁类型,适配更多场景,而且经过了大量实践验证,稳定性更高。但手写逻辑的过程,能帮助我们理解分布式锁的核心原理,避免使用框架时踩坑。

1.3 日常开发中的其他注意点

  1. 锁的key命名规范:建议采用“lock:业务模块:资源标识”的格式(比如lock:order:1001、lock:stock:1002),避免key冲突,也方便后续排查问题。
  2. 避免过度使用分布式锁:不是所有跨服务并发场景都需要用分布式锁,只有当多个服务实例需要操作同一个“临界资源”(比如同一商品的库存、同一订单的状态)时,才需要使用;如果是不同资源的操作,不需要加锁,避免影响性能。
  3. 锁的粒度要尽量小:比如,扣减商品库存时,锁的key应该是“lock:stock:商品ID”,而不是“lock:stock”——这样只有操作同一商品的请求会竞争锁,操作不同商品的请求可以并行执行,提升性能。如果锁的粒度太大,会导致大量请求阻塞,影响接口响应速度。

二、缓存雪崩:日常开发中容易忽略的“批量失效”问题

缓存雪崩,简单来说,就是大量的缓存key在同一时间过期,导致所有请求都直接穿透到数据库,造成数据库压力骤增,甚至出现数据库宕机的情况。很多人觉得缓存雪崩是生产环境才会出现的问题,但其实在日常开发中,只要不注意缓存过期时间的设置,就很容易触发,比如本地调试时,批量插入大量缓存,且设置了相同的过期时间,到期后就会出现“批量失效”的情况。

不同于生产环境的“突发流量+缓存批量失效”,日常开发中的缓存雪崩,更多是因为开发时的“懒政”——比如,为了图方便,给所有缓存key设置了相同的过期时间,或者过期时间设置得不合理,导致某一时刻大量缓存失效,进而影响接口性能(虽然测试环境流量小,不会导致数据库宕机,但会出现接口响应变慢的情况,这也是日常开发中需要规避的)。

2.1 缓存雪崩的产生原因(日常开发场景)

结合日常开发,缓存雪崩的产生,主要有以下3种常见原因,都是开发时容易忽略的细节:

原因1:所有缓存key设置相同的过期时间。比如,开发一个商品列表接口,缓存所有商品数据时,统一设置过期时间为1小时,那么1小时后,所有商品的缓存都会同时失效,后续的请求都会直接访问数据库,导致数据库压力增大,接口响应变慢——这种情况,在本地调试时,只要批量查询商品列表,等待1小时后再查询,就能明显发现接口响应时间变长。

原因2:缓存过期时间设置过短,且没有预热。比如,某接口缓存过期时间设置为1分钟,而接口的QPS较高(即使是测试环境,用Jmeter模拟100QPS),1分钟后缓存失效,大量请求穿透到数据库,导致数据库查询压力增大,甚至出现查询超时的情况。

原因3:缓存服务宕机。比如,本地开发时,Redis服务意外停止(比如手动关闭Redis),所有缓存请求都无法命中,只能穿透到数据库——这种情况虽然不是“缓存过期”导致的,但本质上也是缓存无法使用,属于广义上的缓存雪崩,日常开发中也需要考虑到(比如Redis服务故障后的降级处理)。

2.2 日常开发中的解决方案(可直接落地)

针对上述原因,我们在日常开发中,只要做好以下几点,就能有效规避缓存雪崩问题,这些方案不需要复杂的配置,开发时就能直接实现:

方案1:给缓存key设置“随机过期时间”。这是最常用、最有效的方案,也是日常开发中最容易实现的。在设置过期时间时,不要设置固定的时间,而是在基础过期时间的基础上,增加一个随机值(比如10~60秒),这样就能避免大量key在同一时间过期。

示例代码(Java):

// 基础过期时间:1小时(3600秒)
long baseExpire = 3600;
// 随机过期时间:10~60秒
long randomExpire = new Random().nextInt(51) + 10;
// 最终过期时间 = 基础过期时间 + 随机过期时间
redisTemplate.opsForValue().set("cache:goods:" + goodsId, goodsDTO, baseExpire + randomExpire, TimeUnit.SECONDS);

这样一来,不同的商品缓存,过期时间会分布在3610~3660秒之间,不会出现大量key同时过期的情况,有效避免缓存雪崩。

方案2:设置缓存预热。缓存预热,就是在系统启动时,提前将热点数据加载到Redis中,避免系统启动后,大量请求直接访问数据库。日常开发中,我们可以在Spring Boot的启动类中,通过CommandLineRunner接口,实现缓存预热逻辑——比如,启动时查询所有热点商品数据,加载到Redis中,并设置好过期时间。

示例代码(Java):

@Component
public class CacheWarmUp implements CommandLineRunner {

    @Autowired
    private RedisTemplate redisTemplate;

    @Autowired
    private GoodsService goodsService;

    @Override
    public void run(String... args) throws Exception {
        // 1. 查询热点商品数据(比如销量前100的商品)
        List<GoodsDTO> hotGoods = goodsService.getHotGoods(100);
        // 2. 加载到Redis中,设置随机过期时间
        long baseExpire = 3600;
        for (GoodsDTO goods : hotGoods) {
            long randomExpire = new Random().nextInt(51) + 10;
            redisTemplate.opsForValue().set("cache:goods:" + goods.getId(), goods, baseExpire + randomExpire, TimeUnit.SECONDS);
        }
        System.out.println("缓存预热完成,共加载" + hotGoods.size() + "条热点商品数据");
    }
}

方案3:给缓存设置“永不过期”的兜底数据。对于一些变化频率极低的热点数据(比如商品分类、地区信息),可以设置为永不过期(不设置expire时间),这样就不会出现过期失效的情况,避免穿透到数据库。但需要注意:如果数据发生变化,必须手动更新缓存,否则会出现缓存数据不一致的问题——日常开发中,我们可以在更新数据的接口中,同步更新Redis缓存。

方案4:实现缓存降级。缓存降级,就是当Redis服务宕机或者缓存失效时,不直接访问数据库,而是返回预设的兜底数据(比如空数据、默认数据),避免数据库压力过大。日常开发中,我们可以通过try-catch捕获Redis的异常,或者判断缓存是否命中,实现降级逻辑。

示例代码(Java):

public GoodsDTO getGoodsById(Long goodsId) {
    try {
        // 1. 先查询缓存
        String cacheKey = "cache:goods:" + goodsId;
        GoodsDTO goodsDTO = (GoodsDTO) redisTemplate.opsForValue().get(cacheKey);
        if (goodsDTO != null) {
            return goodsDTO;
        }
        // 2. 缓存未命中,查询数据库
        goodsDTO = goodsService.selectById(goodsId);
        if (goodsDTO != null) {
            // 3. 数据库查询到数据,写入缓存
            long baseExpire = 3600;
            long randomExpire = new Random().nextInt(51) + 10;
            redisTemplate.opsForValue().set(cacheKey, goodsDTO, baseExpire + randomExpire, TimeUnit.SECONDS);
        } else {
            // 4. 数据库也查询不到数据,写入空缓存(避免缓存穿透,后面会讲)
            redisTemplate.opsForValue().set(cacheKey, null, 60, TimeUnit.SECONDS);
        }
        return goodsDTO;
    } catch (Exception e) {
        // 缓存服务异常,降级处理,直接返回数据库数据(或兜底数据)
        log.error("Redis查询异常,进行降级处理", e);
        return goodsService.selectById(goodsId);
    }
}

三、缓存击穿:单一热点key的“单点失效”问题

缓存击穿,和缓存雪崩很像,但区别在于:缓存雪崩是“大量key同时失效”,而缓存击穿是“单一热点key失效”——当某个热点key过期时,大量请求同时访问这个key,导致所有请求都穿透到数据库,造成数据库瞬间压力增大。

日常开发中,缓存击穿的场景非常常见,比如:某商品是热点商品(比如促销商品),缓存过期后,大量用户同时查询该商品,此时缓存未命中,所有请求都直接访问数据库,导致数据库查询压力骤增,甚至出现查询超时的情况——这种情况,在测试环境用Jmeter模拟多用户同时查询同一商品,就能复现。

需要注意的是,缓存击穿和缓存雪崩的核心区别的是“影响范围”:缓存击穿只影响一个热点key对应的请求,而缓存雪崩影响大量key对应的请求;但两者的本质都是“缓存未命中,请求穿透到数据库”,只是触发条件不同。

3.1 缓存击穿的产生原因(日常开发场景)

日常开发中,缓存击穿的产生,主要有以下2种原因:

原因1:热点key的过期时间设置不合理。比如,某热点商品的缓存过期时间设置为1小时,而该商品的访问量非常大,1小时后缓存过期,大量请求同时访问该商品,导致缓存击穿。

原因2:热点key被手动删除。比如,开发时,为了测试数据更新,手动删除了热点key的缓存,此时大量请求同时访问该key,导致缓存未命中,穿透到数据库。

3.2 日常开发中的解决方案(可直接落地)

针对缓存击穿,日常开发中主要有3种解决方案,重点是“避免大量请求同时穿透到数据库”,具体如下:

方案1:热点key永不过期。这是最简单、最直接的方案,对于热点key,不设置过期时间,这样就不会出现过期失效的情况,避免缓存击穿。但需要注意:如果数据发生变化,必须手动更新缓存,否则会出现缓存数据不一致的问题——日常开发中,我们可以在更新热点数据的接口中,同步更新Redis缓存,或者定期更新缓存(比如定时任务每天凌晨更新一次)。

方案2:互斥锁方案。当缓存未命中时,不是所有请求都去查询数据库,而是只有一个请求去查询数据库,其他请求等待,查询到数据后,写入缓存,其他请求再从缓存中获取数据——这样就能避免大量请求同时穿透到数据库。

结合前面讲的Redis分布式锁,互斥锁方案的核心逻辑如下(Java代码):

public GoodsDTO getHotGoodsById(Long goodsId) {
    String cacheKey = "cache:goods:hot:" + goodsId;
    // 1. 先查询缓存
    GoodsDTO goodsDTO = (GoodsDTO) redisTemplate.opsForValue().get(cacheKey);
    if (goodsDTO != null) {
        return goodsDTO;
    }
    // 2. 缓存未命中,获取互斥锁
    String lockKey = "lock:goods:hot:" + goodsId;
    String lockValue = UUID.randomUUID().toString() + ":" + Thread.currentThread().getId();
    try {
        // 3. 尝试获取锁,设置过期时间为5秒(避免死锁)
        Boolean lock = redisTemplate.opsForValue().setIfAbsent(lockKey, lockValue, 5, TimeUnit.SECONDS);
        if (Boolean.TRUE.equals(lock)) {
            // 4. 获取锁成功,查询数据库
            goodsDTO = goodsService.selectById(goodsId);
            if (goodsDTO != null) {
                // 5. 写入缓存,设置较长的过期时间(比如2小时)
                redisTemplate.opsForValue().set(cacheKey, goodsDTO, 7200, TimeUnit.SECONDS);
            } else {
                // 6. 数据库查询不到,写入空缓存
                redisTemplate.opsForValue().set(cacheKey, null, 60, TimeUnit.SECONDS);
            }
            return goodsDTO;
        } else {
            // 7. 获取锁失败,等待100毫秒后重试
            Thread.sleep(100);
            return getHotGoodsById(goodsId); // 递归重试
        }
    } catch (InterruptedException e) {
        log.error("获取互斥锁异常", e);
        return null;
    } finally {
        // 8. 释放锁
        String script = "if redis.call('get', KEYS[1]) == ARGV[1] then return redis.call('del', KEYS[1]) else return 0 end";
        redisTemplate.execute(new DefaultRedisScript<Integer>(script, Integer.class),
                Collections.singletonList(lockKey),
                lockValue);
    }
}

这个方案的核心是“通过互斥锁,控制只有一个请求去查询数据库”,避免大量请求同时穿透。需要注意的是,锁的过期时间要设置合理(比如5秒),避免锁过期前,数据库查询还没完成,导致其他请求获取到锁,再次查询数据库。

方案3:热点key预热 + 提前续期。对于已知的热点key(比如促销商品),在缓存过期前,提前主动更新缓存,避免缓存过期。日常开发中,我们可以用定时任务,每隔一段时间(比如50分钟),查询热点key的缓存,若缓存即将过期(比如剩余时间小于10分钟),则重新查询数据库,更新缓存的过期时间。

示例代码(Java,定时任务):

@Component
@EnableScheduling
public class HotKeyRenewalTask {

    @Autowired
    private RedisTemplate redisTemplate;

    @Autowired
    private GoodsService goodsService;

    // 每隔50分钟执行一次
    @Scheduled(cron = "0 0/50 * * * ?")
    public void renewHotKey() {
        // 1. 获取所有热点商品ID(可以从配置文件、数据库中获取)
        List<Long> hotGoodsIds = Arrays.asList(1001L, 1002L, 1003L);
        for (Long goodsId : hotGoodsIds) {
            String cacheKey = "cache:goods:hot:" + goodsId;
            // 2. 查询缓存的剩余过期时间
            Long expire = redisTemplate.getExpire(cacheKey, TimeUnit.SECONDS);
            // 3. 若剩余时间小于10分钟(600秒),则更新缓存
            if (expire != null && expire < 600) {
                GoodsDTO goodsDTO = goodsService.selectById(goodsId);
                if (goodsDTO != null) {
                    // 重新设置过期时间为2小时
                    redisTemplate.opsForValue().set(cacheKey, goodsDTO, 7200, TimeUnit.SECONDS);
                    log.info("热点商品{}缓存续期完成,新的过期时间为2小时", goodsId);
                }
            }
        }
    }
}

四、缓存穿透:“不存在的key”导致的持续穿透问题

缓存穿透,是指请求查询的key在Redis中不存在,同时在数据库中也不存在,导致每次请求都直接穿透到数据库,无法被缓存拦截。日常开发中,这种场景非常常见,比如:用户查询一个不存在的商品ID、不存在的订单ID,此时Redis中没有该key的缓存,请求会直接访问数据库,数据库查询不到数据后,返回空结果,而Redis也不会缓存这个空结果,导致后续的相同请求,依然会穿透到数据库。

缓存穿透和缓存击穿、雪崩的区别在于:缓存穿透的key是“不存在的key”,而缓存击穿、雪崩的key是“存在但过期的key”;而且缓存穿透的影响是“持续的”——只要有请求查询这个不存在的key,就会穿透到数据库,而缓存击穿、雪崩是“一次性”的(过期时触发一次,缓存更新后就恢复正常)。

日常开发中,缓存穿透的问题很容易被忽略,因为开发时,我们更多关注“存在的数据如何缓存”,而忽略了“不存在的数据如何处理”。比如,测试时,我们通常测试存在的商品ID,而不会测试不存在的商品ID,导致这个问题在上线后才暴露,但其实在日常开发中,只要多考虑一步,就能规避。

4.1 缓存穿透的产生原因(日常开发场景)

日常开发中,缓存穿透的产生,主要有以下2种原因:

原因1:业务逻辑未处理“不存在的key”。比如,用户查询一个不存在的商品ID,接口先查询Redis,Redis中没有该key,然后查询数据库,数据库也没有该商品,接口直接返回空结果,但没有将“该key不存在”这个信息缓存到Redis中,导致后续的相同请求,依然会重复查询Redis和数据库。

原因2:恶意请求攻击。比如,有人恶意构造大量不存在的商品ID、订单ID,频繁请求接口,导致大量请求穿透到数据库,造成数据库压力增大——这种情况,即使是测试环境,也可能被模拟攻击,导致数据库查询压力增大。

4.2 日常开发中的解决方案(可直接落地)

针对缓存穿透,日常开发中主要有3种解决方案,核心是“让不存在的key也能被缓存”,避免重复穿透到数据库:

方案1:缓存空值。这是最常用、最直接的方案——当数据库查询不到数据时,将空结果(或一个特殊的标识,比如“null”)缓存到Redis中,并设置一个较短的过期时间(比如1分钟、5分钟),这样后续的相同请求,就能从Redis中获取到空结果,不会再穿透到数据库。

示例代码(Java):

public GoodsDTO getGoodsById(Long goodsId) {
    String cacheKey = "cache:goods:" + goodsId;
    // 1. 先查询缓存
    GoodsDTO goodsDTO = (GoodsDTO) redisTemplate.opsForValue().get(cacheKey);
    if (goodsDTO != null) {
        // 2. 缓存命中,返回结果(如果是缓存的空值,直接返回null)
        return goodsDTO == "null" ? null : goodsDTO;
    }
    // 3. 缓存未命中,查询数据库
    goodsDTO = goodsService.selectById(goodsId);
    if (goodsDTO != null) {
        // 4. 数据库查询到数据,写入缓存,设置过期时间1小时
        redisTemplate.opsForValue().set(cacheKey, goodsDTO, 3600, TimeUnit.SECONDS);
    } else {
        // 5. 数据库查询不到数据,写入空缓存,设置过期时间1分钟
        redisTemplate.opsForValue().set(cacheKey, "null", 60, TimeUnit.SECONDS);
    }
    return goodsDTO;
}

需要注意的是,缓存空值时,要设置较短的过期时间——因为如果后续该key对应的商品被创建(比如用户新增了一个商品,ID就是之前查询的不存在的ID),如果缓存空值的过期时间太长,会导致新商品的数据无法被缓存,出现缓存数据不一致的问题。

方案2:使用布隆过滤器(Bloom Filter)。布隆过滤器是一种数据结构,它可以快速判断一个key是否存在于一个集合中,具有高效、占用空间小的特点。日常开发中,我们可以将所有存在的key(比如所有商品ID、所有订单ID)提前加载到布隆过滤器中,当有请求过来时,先通过布隆过滤器判断该key是否存在:如果不存在,直接返回空结果,不查询Redis和数据库;如果存在,再查询Redis和数据库。

布隆过滤器的核心优势是“拦截不存在的key”,避免其穿透到Redis和数据库,尤其适合处理大量恶意请求的场景。日常开发中,我们可以使用Guava框架的BloomFilter实现,也可以使用Redis的布隆过滤器插件(Redis 4.0+支持)。

示例代码(Guava布隆过滤器):

@Component
public class BloomFilterConfig {

    @Autowired
    private GoodsService goodsService;

    // 初始化布隆过滤器,加载所有商品ID
    @Bean
    public BloomFilter<Long> goodsIdBloomFilter() {
        // 1. 查询所有商品ID
        List<Long> allGoodsIds = goodsService.getAllGoodsIds();
        // 2. 初始化布隆过滤器:预计数据量100万,误判率0.01
        BloomFilter<Long> bloomFilter = BloomFilter.create(
                Funnels.longFunnel(),
                1000000,
                0.01
        );
        // 3. 将所有商品ID加载到布隆过滤器中
        for (Long goodsId : allGoodsIds) {
            bloomFilter.put(goodsId);
        }
        return bloomFilter;
    }
}

// 接口中使用布隆过滤器
@Autowired
private BloomFilter<Long> goodsIdBloomFilter;

public GoodsDTO getGoodsById(Long goodsId) {
    // 1. 先通过布隆过滤器判断商品ID是否存在
    if (!goodsIdBloomFilter.mightContain(goodsId)) {
        // 2. 不存在,直接返回null,不查询Redis和数据库
        return null;
    }
    // 3. 存在,再查询Redis和数据库(后续逻辑和方案1一致)
    String cacheKey = "cache:goods:" + goodsId;
    GoodsDTO goodsDTO = (GoodsDTO) redisTemplate.opsForValue().get(cacheKey);
    if (goodsDTO != null) {
        return goodsDTO == "null" ? null : goodsDTO;
    }
    goodsDTO = goodsService.selectById(goodsId);
    if (goodsDTO != null) {
        redisTemplate.opsForValue().set(cacheKey, goodsDTO, 3600, TimeUnit.SECONDS);
    } else {
        redisTemplate.opsForValue().set(cacheKey, "null", 60, TimeUnit.SECONDS);
    }
    return goodsDTO;
}

需要注意的是,布隆过滤器存在“误判率”——它只能判断“key一定不存在”,但不能判断“key一定存在”(可能会把不存在的key判断为存在),但误判率可以通过参数设置(预计数据量、误判率)来调整,日常开发中,0.01的误判率已经足够使用。

方案3:接口层参数校验。在接口层,对请求参数进行校验,过滤掉明显不存在的key——比如,商品ID的规则是6位数字,那么对于非6位数字的商品ID,直接返回参数错误,不查询Redis和数据库。这种方案可以拦截一部分恶意请求,减少缓存穿透的概率,是日常开发中“第一道防线”。

示例代码(Java,Spring Boot接口校验):

@RestController
@RequestMapping("/goods")
public class GoodsController {

    @Autowired
    private GoodsService goodsService;

    // 接口参数校验:商品ID必须是6位数字
    @GetMapping("/{goodsId}")
    public Result<GoodsDTO> getGoodsById(@PathVariable @Pattern(regexp = "^\\d{6}$", message = "商品ID必须是6位数字") String goodsId) {
        Long id;
        try {
            id = Long.parseLong(goodsId);
        } catch (NumberFormatException e) {
            return Result.fail("商品ID格式错误");
        }
        GoodsDTO goodsDTO = goodsService.getGoodsById(id);
        return Result.success(goodsDTO);
    }
}

五、总结:日常开发中的避坑核心要点

作为一名有7年Java开发经验的程序员,在使用Redis的过程中,深刻体会到“细节决定成败”——Redis的分布式锁、缓存雪崩、击穿、穿透,这些问题看似复杂,但只要在日常开发中多考虑一步,就能有效规避,不需要等到生产环境出现问题后再去排查。

最后,总结一下日常开发中的核心避坑要点,方便大家记忆和落地:

  1. Redis分布式锁:必须设置过期时间,释放锁时用Lua脚本保证原子性,避免死锁和误删锁;热点场景可以用Redisson简化开发。
  2. 缓存雪崩:给缓存key设置随机过期时间,避免批量失效;做好缓存预热和降级,减少数据库压力。
  3. 缓存击穿:热点key永不过期或提前续期;用互斥锁控制单一请求查询数据库,避免大量请求穿透。
  4. 缓存穿透:缓存空值,用布隆过滤器拦截不存在的key;接口层做好参数校验,减少恶意请求。

其实,Redis的这些问题,本质上都是“缓存设计不合理”导致的——只要我们在日常开发中,结合业务场景,合理设置缓存的过期时间、合理设计锁的粒度、合理处理不存在的key,就能最大限度地避免这些问题,让Redis真正成为提升接口性能的“利器”,而不是引发问题的“隐患”。

版权声明 ▶ 本网站名称:黄磊的博客
▶ 本文标题:Redis 分布式锁与缓存雪崩、击穿、穿透
▶ 本文链接:https://www.huangleicole.com/database/121.html
▶ 转载本站文章需要遵守:商业转载请联系站长,非商业转载请注明出处!!

如果觉得我的文章对你有用,请随意赞赏