Redis缓存设计模式深度实战:击穿、穿透、雪崩及一致性终极方案

引言

在高并发系统中,缓存是提升性能的银弹,而Redis早已成为缓存层的事实标准。然而,缓存的使用并非无脑存取这么简单,设计不当极易引发缓存穿透缓存击穿缓存雪崩 等经典问题,同时缓存与数据库的双写一致性也是面试和实战中的高频难题。

本文将从真实业务场景出发,结合 Spring Boot + Redis 的完整可运行代码,系统地讲解上述问题的原理与解决方案,包括布隆过滤器分布式互斥锁随机过期时间 以及先更新数据库再删除缓存的一致性策略。无论你是准备面试还是落地生产环境,都能找到直接可用的参考。

1. 核心概念:缓存三大"灾星"

1.1 缓存穿透

现象 :查询一个数据库中根本不存在的 key,由于缓存中也没有,每次请求都会穿过缓存直接打到数据库上,造成数据库压力过大。

常见原因 :恶意攻击、业务误传的非法ID。

解决思路

  • 缓存空值:将不存在的结果也缓存起来,设置较短过期时间。

  • 布隆过滤器:在缓存之前加一层概率性判无的过滤器,直接拦截不存在的 key。

1.2 缓存击穿

现象 :一个热点 key 在过期的一瞬间,大量并发请求同时查询该 key,导致全部请求落到数据库,瞬间压垮 DB。

解决思路

  • 加互斥锁:仅让一个请求去加载数据库并回写缓存,其余请求等待或快速失败。

  • 逻辑过期(永不过期):热点 key 不设物理过期,而是利用逻辑过期时间,异步刷新缓存。

1.3 缓存雪崩

现象 :大量缓存在同一时刻过期,或 Redis 宕机,导致所有请求直接请求数据库,造成 DB 层瞬时压力过高。

解决思路

  • 添加随机过期时间,避免同时过期。

  • 高可用架构:Redis 哨兵或集群。

  • 限流与降级。

2. 实战准备:项目搭建

我们创建一个简单的商品查询服务,使用 Spring Boot 2.7 + Redis,采用 Lettuce 客户端。

依赖(pom.xml)

xml 复制代码
<dependencies>
    <dependency>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-web</artifactId>
    </dependency>
    <dependency>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-data-redis</artifactId>
    </dependency>
    <!-- 布隆过滤器实现 -->
    <dependency>
        <groupId>com.google.guava</groupId>
        <artifactId>guava</artifactId>
        <version>31.1-jre</version>
    </dependency>
    <!-- lombok -->
    <dependency>
        <groupId>org.projectlombok</groupId>
        <artifactId>lombok</artifactId>
        <optional>true</optional>
    </dependency>
</dependencies>

配置(application.yml)

yaml 复制代码
spring:
  redis:
    host: localhost
    port: 6379
    timeout: 3000ms
    lettuce:
      pool:
        max-active: 8
        max-idle: 8
        min-idle: 0

实体类

java 复制代码
@Data
@AllArgsConstructor
@NoArgsConstructor
public class Product implements Serializable {
    private Long id;
    private String name;
    private BigDecimal price;
    private Integer stock;
}

模拟数据库访问层

java 复制代码
@Component
public class ProductRepository {
    private final Map<Long, Product> db = new ConcurrentHashMap<>();

    @PostConstruct
    public void init() {
        db.put(1L, new Product(1L, "iPhone 15", new BigDecimal("7999"), 100));
        db.put(2L, new Product(2L, "MacBook Pro", new BigDecimal("14999"), 50));
        db.put(3L, new Product(3L, "AirPods Pro", new BigDecimal("1899"), 200));
    }

    public Product findById(Long id) {
        // 模拟数据库查询延迟
        try { Thread.sleep(200); } catch (InterruptedException ignored) {}
        return db.get(id);
    }

    public void update(Product product) {
        db.put(product.getId(), product);
    }
}

3. 缓存穿透解决方案:布隆过滤器 + 空值缓存

我们采用 Google Guava 的布隆过滤器,在 Redis 缓存前做一次快速判断。

布隆过滤器初始化与产品ID注册

java 复制代码
@Configuration
public class BloomFilterConfig {
    @Bean
    public BloomFilter<Long> productBloomFilter() {
        // 预计插入1000个元素,误判率0.01
        BloomFilter<Long> filter = BloomFilter.create(Funnels.longFunnel(), 1000, 0.01);
        // 初始化已知存在的ID
        filter.put(1L);
        filter.put(2L);
        filter.put(3L);
        return filter;
    }
}

完整Service实现

java 复制代码
@Service
@Slf4j
public class ProductService {
    private final RedisTemplate<String, Object> redisTemplate;
    private final ProductRepository productRepository;
    private final BloomFilter<Long> bloomFilter;

    // 空值缓存过期时间
    private static final long NULL_TTL = 60;        // 秒
    // 正常商品缓存过期时间
    private static final long PRODUCT_TTL = 600;

    public ProductService(RedisTemplate<String, Object> redisTemplate,
                          ProductRepository productRepository,
                          BloomFilter<Long> bloomFilter) {
        this.redisTemplate = redisTemplate;
        this.productRepository = productRepository;
        this.bloomFilter = bloomFilter;
    }

    public Product getProduct(Long id) {
        // 1. 布隆过滤器预判
        if (!bloomFilter.mightContain(id)) {
            log.warn("布隆过滤器拦截不存在的ID: {}", id);
            return null; // 直接返回空,不查缓存与DB
        }

        String cacheKey = "product:" + id;
        // 2. 查询缓存
        Object cached = redisTemplate.opsForValue().get(cacheKey);
        if (cached != null) {
            if (cached instanceof Product) {
                return (Product) cached;
            }
            // 如果缓存的空对象标识(如字符串"NULL"),表示之前查过不存在
            log.info("命中空值缓存,ID: {}", id);
            return null;
        }

        // 3. 缓存未命中,查询数据库(加分布式锁防止击穿,见下一节)
        Product product = productRepository.findById(id);
        if (product != null) {
            // 写入缓存
            redisTemplate.opsForValue().set(cacheKey, product, PRODUCT_TTL, TimeUnit.SECONDS);
        } else {
            // 数据库也没有,缓存空对象防止穿透
            redisTemplate.opsForValue().set(cacheKey, "NULL", NULL_TTL, TimeUnit.SECONDS);
        }
        return product;
    }
}

注意:布隆过滤器不支持删除元素,如果商品下架或新增,需要同步更新过滤器(可以定期全量重建)。空值缓存的 key 需同步清理。

4. 缓存击穿解决方案:分布式互斥锁

当热点 key 过期时,使用 Redis 的 SETNX 命令实现简单的分布式锁,保证只有一个线程去加载数据库。

分布式锁工具类

java 复制代码
@Component
public class RedisDistributedLock {
    private final StringRedisTemplate stringRedisTemplate;

    public RedisDistributedLock(StringRedisTemplate stringRedisTemplate) {
        this.stringRedisTemplate = stringRedisTemplate;
    }

    /**
     * 尝试获取锁,非阻塞
     * @param lockKey   锁的key
     * @param requestId 请求标识(用于释放锁时校验)
     * @param expireSec 锁的过期时间(秒)
     * @return 是否获取成功
     */
    public boolean tryLock(String lockKey, String requestId, long expireSec) {
        return Boolean.TRUE.equals(stringRedisTemplate.opsForValue()
                .setIfAbsent(lockKey, requestId, expireSec, TimeUnit.SECONDS));
    }

    /**
     * 释放锁(Lua脚本保证原子性)
     */
    public boolean releaseLock(String lockKey, String requestId) {
        String script = "if redis.call('get', KEYS[1]) == ARGV[1] then return redis.call('del', KEYS[1]) else return 0 end";
        Long result = stringRedisTemplate.execute(
                new DefaultRedisScript<>(script, Long.class),
                Collections.singletonList(lockKey),
                requestId);
        return result != null && result == 1L;
    }
}

结合击穿防护的商品查询方法

java 复制代码
public Product getProductWithLock(Long id) {
    // ... 布隆过滤器判断同上 ...

    String cacheKey = "product:" + id;
    String lockKey = "lock:product:" + id;
    String requestId = UUID.randomUUID().toString();
    Product product = null;

    try {
        // 1. 尝试从缓存获取
        Object cached = redisTemplate.opsForValue().get(cacheKey);
        if (cached != null) {
            if (cached instanceof Product) return (Product) cached;
            return null; // 空对象缓存
        }

        // 2. 缓存未命中,尝试获取锁
        if (distributedLock.tryLock(lockKey, requestId, 10)) {
            // 双重检查
            cached = redisTemplate.opsForValue().get(cacheKey);
            if (cached != null) {
                if (cached instanceof Product) return (Product) cached;
                return null;
            }
            // 3. 查询数据库
            product = productRepository.findById(id);
            if (product != null) {
                redisTemplate.opsForValue().set(cacheKey, product, PRODUCT_TTL + ThreadLocalRandom.current().nextInt(60), TimeUnit.SECONDS);
            } else {
                redisTemplate.opsForValue().set(cacheKey, "NULL", NULL_TTL, TimeUnit.SECONDS);
            }
        } else {
            // 未获取到锁,短暂等待后重试(实际可用自旋或返回降级数据)
            TimeUnit.MILLISECONDS.sleep(50);
            return getProductWithLock(id); // 递归重试,注意防止栈溢出
        }
    } catch (InterruptedException e) {
        Thread.currentThread().interrupt();
    } finally {
        // 释放锁
        if (product != null || ... ) // 确保是加锁的线程释放
        distributedLock.releaseLock(lockKey, requestId);
    }
    return product;
}

说明 :上述代码中对 PRODUCT_TTL 添加了 ThreadLocalRandom.current().nextInt(60) 秒的随机值,正是雪崩的随机过期时间方案。

5. 缓存雪崩的精细化防控

除了随机过期时间,生产环境还需:

  • 多级缓存:本地缓存(如Caffeine) + Redis,即使Redis故障,本地缓存还能扛一阵。
  • 熔断降级:使用Hystrix/Sentinel,在Redis不可用时快速返回兜底数据。
  • Redis高可用:采用Cluster或Sentinel模式,避免单点故障。

随机过期示例 (已在上方代码体现):

设置过期时间时加入一个随机偏移量,确保不会在同一秒大量过期。

java 复制代码
int baseTtl = 600; // 10分钟
int randomOffset = ThreadLocalRandom.current().nextInt(120); // 0~2分钟
redisTemplate.opsForValue().set(cacheKey, product, baseTtl + randomOffset, TimeUnit.SECONDS);

6. 缓存更新策略与数据库一致性

6.1 经典方案:Cache Aside(先更新DB,再删除缓存)

  • 为何不是先删缓存,再更新DB?
    并发下可能出现:A请求删了缓存,B请求查询发现缓存不存在,从DB读到旧值并写入缓存,然后A才更新DB。此时缓存中依然是旧值,造成脏数据。
  • 为何不是先更新DB,再更新缓存?
    两个并发写操作可能导致缓存与DB不一致,且更新缓存的成本可能较高。
  • Cache Aside 最稳妥 :更新数据库成功后,删除缓存;下次读请求会重建缓存。

6.2 延迟双删

在某些极端情况下,即使先更新DB再删除缓存,也可能因为主从复制延迟导致读到旧数据。可以在更新DB后,延迟(如1秒)再删一次缓存,称为"延迟双删"。

一致性代码实践(商品更新接口)

java 复制代码
@Transactional
public Product updateProduct(Product product) {
    // 1. 更新数据库
    productRepository.update(product);
    // 2. 删除缓存(立即)
    String cacheKey = "product:" + product.getId();
    redisTemplate.delete(cacheKey);
    // 3. 延迟双删(异步执行,确保最终一致性)
    executorService.schedule(() -> {
        redisTemplate.delete(cacheKey);
    }, 500, TimeUnit.MILLISECONDS);
    return product;
}

对于要求强一致性的场景,可以引入Canal监听binlog,由消息队列驱动缓存更新,实现最终一致性。

7. 完整Controller测试

java 复制代码
@RestController
@RequestMapping("/product")
public class ProductController {
    private final ProductService productService;

    public ProductController(ProductService productService) {
        this.productService = productService;
    }

    @GetMapping("/{id}")
    public ResponseEntity<?> getProduct(@PathVariable Long id) {
        Product product = productService.getProductWithLock(id);
        if (product == null) {
            return ResponseEntity.ok("商品不存在");
        }
        return ResponseEntity.ok(product);
    }

    @PutMapping
    public ResponseEntity<?> update(@RequestBody Product product) {
        Product updated = productService.updateProduct(product);
        return ResponseEntity.ok(updated);
    }
}

使用JMeter或并发请求工具,可以验证击穿、穿透、雪崩防护是否生效。

8. 常见问题与注意事项

  1. 布隆过滤器数据变更:商品新增时需将ID加入过滤器,下架则无法删除。可采用定期全量重建过滤器(从DB加载所有有效ID)。
  2. 分布式锁的可靠性:上文给出的锁实现简单,但生产建议使用Redisson的RLock,支持自动续期和可重入。
  3. 缓存序列化问题:RedisTemplate默认使用JDK序列化,可读性差,建议配置为Jackson2JsonRedisSerializer,方便调试。
  4. 大Key与热Key:避免单个key过大(如存储整个列表),可拆分;热key可通过多副本、本地缓存等分摊压力。
  5. 内存淘汰策略 :设置为allkeys-lruvolatile-lru,避免内存满后写入失败。

9. 总结

本文从实战出发,详细解析了Redis缓存设计中的三大类问题------穿透、击穿、雪崩的成因与解决方案,并给出了Spring Boot下可直接运行的代码示例。同时,对缓存与数据库的一致性策略进行了深入对比,推荐采用"先更新数据库再删除缓存"的Cache Aside模式,辅以延迟双删保障最终一致性。

核心要点回顾

  • 穿透:布隆过滤器 + 空值缓存

  • 击穿:分布式互斥锁 + 逻辑过期

  • 雪崩:随机过期时间 + 多级缓存 + 限流

  • 一致性:Cache Aside + 延迟双删 / Canal异步同步

缓存设计没有银弹,需要根据业务场景灵活取舍。希望本文能成为你应对高并发缓存难题的一块坚实拼图。

完整代码已托管至 GitHub示例仓库 (示例地址,实际请自行搭建)。

相关推荐
爱码少年1 小时前
Spring Boot 文件上传下载完整指南:从基础到高级实践
java·spring boot
Flittly2 小时前
【AgentScope Java新手村系列】(7)子Agent编排
java·spring boot·笔记·spring·ai
java1234_小锋2 小时前
Spring Boot 的核心注解 @SpringBootApplication 由哪三个注解组成?
java·spring boot·后端
Master_Azur2 小时前
Web后端基础-Spring分层解耦
spring boot·后端·spring
ExC1dNtqz2 小时前
Redis 分布式锁进阶第六篇讲解
数据库·redis·分布式
小胖xiaopangss3 小时前
Redis 基础入门与实践指南
数据库·redis·缓存
kishu_iOS&AI3 小时前
Python Redis客户端 AI应用开发完整指南
人工智能·redis·ai a
心之伊始3 小时前
Spring AI Structured Output 实战:把大模型返回稳定转成 Java DTO
java·spring boot·大模型·spring ai·structured output