Spring Boot 集成 Redis 企业级实践:连接池、序列化与缓存穿透雪崩的工程化防御

Spring Boot 集成 Redis 企业级实践:连接池、序列化与缓存穿透雪崩的工程化防御

1. 从一个真实故障说起

假设你负责一个商品详情接口。平时 QPS 两千,数据库稳稳当当。某天运营把一批已下架商品的 ID 批量刷进了 App 的推荐位,用户点进去全部查不到数据。接口逻辑是"先查 Redis,没有就查数据库,数据库没有就把空结果也缓存一下"------但这次代码里只缓存了数据库查到的真实对象,查不到时直接返回 null,什么也没写。于是每一万次点击里,就有成千上万次直接穿透到数据库,数据库连接池被瞬间打满,整个商品服务雪崩。

这个故事的每一环都和 Redis 集成有关:连接被谁占满?为什么查不到什么都不写?为什么空结果会持续打库?同样的问题也会发生在另一种形态------某个热点 Key 在同一秒集体到期,所有请求同时回源建缓存。这些问题的根因都不在 Redis 本身,而在我们怎么使用它。

这篇文章不会从 Redis 命令手册讲起,而是先把 Spring Boot 与 Redis 的协作方式搭成一个能复述的框架,再逐个机制拆开:连接怎么建立和复用、对象怎么变成字节、缓存穿透和雪崩怎么分层防御、布隆过滤器在哪里接入。读完之后,你应该能直接判断"我这条业务该选哪种策略"。

2. 先记住一个最小模型

先用一句普通话说清楚:Spring Boot 集成 Redis,本质上是一个应用进程通过一条或多条长连接,把对象序列化成字节写进一个远程字典,并依赖过期时间和淘汰策略控制这份副本的生命周期。

把整体拆成三部分:

  • 连接层:Spring Data Redis 通过 RedisConnectionFactory 创建连接,默认底层是 Lettuce,再包一层连接池。它决定"并发请求怎么共享有限的 TCP 连接"。
  • 编解码层:RedisTemplate 用 KeySerializer 和 ValueSerializer 把 Java 对象与字节数组互相转换。它决定"写进去的是什么、读出来能不能还原"。
  • 缓存策略层:业务代码决定什么时候读、什么时候写、写什么、活多久、失败怎么办。它决定"缓存是不是真的挡住了数据库"。

这三层的关系可以先记成一句话:连接层管通道,编解码层管格式,策略层管行为。下面这张图展示一次读请求的完整流向。

text 复制代码
[客户端请求]
      |
      v
[Controller / Service]
      |
      v
[RedisTemplate.opsForValue().get(key)]
      |  (1) 用 KeySerializer 把 key 转成字节
      v
[LettuceConnectionFactory]
      |  (2) 从连接池借一条连接(或复用共享连接)
      v
[Redis Server]
      |  (3) 命中 -> 返回字节
      |  (4) 未命中 -> 返回 nil
      v
[RedisTemplate] 用 ValueSerializer 反序列化
      |
      +-- 命中:直接返回对象
      +-- 未命中:回源数据库 -> 写缓存 -> 返回

先建立这个流转顺序很重要:后面讲连接池参数时,你知道它作用在第 (2) 步;讲序列化时,你知道它作用在第 (1) 和第 (4) 步;讲穿透雪崩时,你知道要改的是第 (4) 步之后的分支。

3. 连接层:Lettuce 连接池到底在池化什么

3.1 为什么需要连接池,而不是每次新建

Redis 客户端和服务器之间是一条 TCP 长连接。建立连接需要三次握手,认证、选库也有开销。如果每次读写都新建再关闭,高并发下光是握手和 TIME_WAIT 就能拖垮应用。连接池的作用是把"已建立的连接"当作可复用资源,用的时候借、用完还,避免重复创建。

Lettuce 和早期常用的 Jedis 有一个关键区别:Jedis 是"一个连接同一时刻只能被一个线程用",所以必须靠连接池保证线程安全;Lettuce 基于 Netty,单条连接可以多路复用,多个线程可以共享同一条连接发送命令并按序取回结果。那为什么还要配连接池?因为在 阻塞式命令(也就是代码里同步调用 get、set 那种)以及事务、订阅等场景下,一条连接上的排队会互相影响;配池可以把并发压力分散到多条连接,也能在连接异常时快速替换。

这里最容易误解的是:以为 Lettuce 配了池就等于每条命令独占一条连接。实际上默认是共享连接优先,只有开启 pooling 并借用连接时才会从池里取。

3.2 连接池的组成和关键参数

一个 Lettuce 连接池由连接工厂、池配置和底层 Netty 共享连接三部分组成。下面是最小可运行的 Spring Boot 配置示例,用 YAML 直接表达。

yaml 复制代码
# application.yml
spring:
  data:
    redis:
      host: 127.0.0.1
      port: 6379
      password: ""
      database: 0
      timeout: 2000ms
      lettuce:
        pool:
          enabled: true          # 显式开启池化
          max-active: 64         # 池中最大连接数
          max-idle: 16           # 最大空闲连接
          min-idle: 4            # 最小空闲连接,预热用
          max-wait: 1000ms       # 借不到连接时最多等多久,超时抛异常
        shutdown-timeout: 200ms

每个参数都对应一个具体后果:max-active 太小,高并发时线程会卡在 max-wait 上甚至抛 RedisCommandTimeoutException;max-idle 太小,突发流量一来要频繁新建连接;min-idle 设得太高,空闲时也占着服务器连接资源。max-wait 一定要设,否则借不到连接会无限等。

3.3 一个完整的可运行示例:观察池耗尽

目标 :在本地复现"连接池上限过小导致请求排队超时"的现象。前置环境 :本地启动 Redis 6.x 以上,JDK 17,用 Spring Boot 3.2。输入:50 个并发线程各执行 100 次 Redis 读写。

java 复制代码
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.context.ConfigurableApplicationContext;
import org.springframework.data.redis.core.StringRedisTemplate;

import java.util.concurrent.CountDownLatch;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.atomic.AtomicInteger;

@SpringBootApplication
public class PoolStressDemo {

    public static void main(String[] args) throws Exception {
        ConfigurableApplicationContext ctx = SpringApplication.run(PoolStressDemo.class, args);
        StringRedisTemplate redis = ctx.getBean(StringRedisTemplate.class);

        int threads = 50;
        int rounds = 100;
        ExecutorService pool = Executors.newFixedThreadPool(threads);
        CountDownLatch start = new CountDownLatch(1);
        CountDownLatch done = new CountDownLatch(threads);
        AtomicInteger ok = new AtomicInteger();
        AtomicInteger fail = new AtomicInteger();

        for (int i = 0; i < threads; i++) {
            final int id = i;
            pool.submit(() -> {
                try {
                    start.await();
                    for (int r = 0; r < rounds; r++) {
                        String key = "demo:pool:" + id + ":" + r;
                        try {
                            redis.opsForValue().set(key, "v" + r, 60, TimeUnit.SECONDS);
                            String v = redis.opsForValue().get(key);
                            if (v != null) {
                                ok.incrementAndGet();
                            }
                        } catch (Exception e) {
                            fail.incrementAndGet();
                        }
                    }
                } catch (InterruptedException ignored) {
                    Thread.currentThread().interrupt();
                } finally {
                    done.countDown();
                }
            });
        }

        start.countDown();
        done.await(60, TimeUnit.SECONDS);
        pool.shutdownNow();
        System.out.println("ok=" + ok.get() + ", fail=" + fail.get());
        ctx.close();
    }
}

关键步骤 :50 个线程同时起跑,每个线程做 100 次 set 加 get。预期结果 :把 max-active 设为 4、max-wait 设为 10ms 时,fail 会明显上升,打印出大量超时;把 max-active 设为 64、max-wait 设为 1000ms 后,fail 基本归零。适用场景 :压测时判断池大小是否够用。容易改错的地方 :一是忘记显式开启 pool.enabled,以为配了参数就生效;二是用 redis.opsForValue() 却忽略了它底层返回的是共享连接,压测结论和真实池化场景不一致。

4. 编解码层:序列化决定缓存能不能被正确还原

4.1 默认序列化为什么容易踩坑

Spring Data Redis 的 RedisTemplate 默认使用 JdkSerializationRedisSerializer。它把 Java 对象写成带类信息的二进制,读的时候要求类名、serialVersionUID 完全一致。你只要改了实体类的包名或加了一个字段,旧数据就反序列化失败。更麻烦的是,用 redis-cli 打开这些 key,看到的是乱码,排查问题时无法肉眼确认内容。

所以企业里更常见的做法是:Key 用 String 序列化保证可读,Value 用 JSON 序列化保证跨语言和可排查。

4.2 一个自定义 RedisTemplate 的完整示例

目标 :让对象的 key 可读、value 是 JSON,并正确保留泛型信息。前置环境 :Spring Boot 3.2 + Jackson + Redis。输入:一个商品对象,写入再读出。

java 复制代码
import com.fasterxml.jackson.annotation.JsonTypeInfo;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.fasterxml.jackson.databind.jsontype.impl.LaissezFaireSubTypeValidator;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.data.redis.connection.RedisConnectionFactory;
import org.springframework.data.redis.core.RedisTemplate;
import org.springframework.data.redis.serializer.GenericJackson2JsonRedisSerializer;
import org.springframework.data.redis.serializer.StringRedisSerializer;

@Configuration
public class RedisConfig {

    @Bean
    public RedisTemplate<String, Object> redisTemplate(RedisConnectionFactory factory) {
        RedisTemplate<String, Object> template = new RedisTemplate<>();
        template.setConnectionFactory(factory);

        ObjectMapper mapper = new ObjectMapper();
        mapper.activateDefaultTyping(
                LaissezFaireSubTypeValidator.instance,
                ObjectMapper.DefaultTyping.NON_FINAL,
                JsonTypeInfo.As.PROPERTY);
        GenericJackson2JsonRedisSerializer jsonSerializer =
                new GenericJackson2JsonRedisSerializer(mapper);

        StringRedisSerializer stringSerializer = new StringRedisSerializer();
        template.setKeySerializer(stringSerializer);
        template.setHashKeySerializer(stringSerializer);
        template.setValueSerializer(jsonSerializer);
        template.setHashValueSerializer(jsonSerializer);
        template.afterPropertiesSet();
        return template;
    }
}
java 复制代码
public class Product {
    private Long id;
    private String name;
    private Integer stock;

    public Product() {}

    public Product(Long id, String name, Integer stock) {
        this.id = id;
        this.name = name;
        this.stock = stock;
    }

    public Long getId() { return id; }
    public void setId(Long id) { this.id = id; }
    public String getName() { return name; }
    public void setName(String name) { this.name = name; }
    public Integer getStock() { return stock; }
    public void setStock(Integer stock) { this.stock = stock; }
}
java 复制代码
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.data.redis.core.RedisTemplate;
import org.springframework.stereotype.Service;

import java.time.Duration;

@Service
public class ProductCacheService {

    @Autowired
    private RedisTemplate<String, Object> redisTemplate;

    public void save(Product p) {
        redisTemplate.opsForValue().set("product:" + p.getId(), p, Duration.ofMinutes(10));
    }

    public Product get(Long id) {
        Object v = redisTemplate.opsForValue().get("product:" + id);
        return v instanceof Product ? (Product) v : null;
    }
}

关键步骤 :注册自定义 RedisTemplate,key 走字符串序列化,value 走带类型信息的 JSON。预期结果 :在执行 redis-cli get product:1 时能看到形如 {"@class":"...Product","id":1,...} 的 JSON,而不是乱码;读回时能还原成 Product。适用场景 :中大型后端、需要人工排查缓存内容的团队。容易改错的地方:开启 DefaultTyping 会带来反序列化安全风险,如果缓存内容可能被外部写入,应改用白名单校验,或对可信对象才开类型信息。

4.3 序列化方式的对比

方式 可读性 跨语言 体积 兼容性 适用场景
JDK 序列化 差 否 大 依赖类结构 本地临时缓存、短周期
JSON 好 是 中 字段增减较友好 大多数业务缓存
Protobuf 差 是 小 需 schema 管理 高吞吐、跨服务
String 好 是 小 无 计数器、简单标记

5. 缓存穿透:查不到的东西为什么一直打数据库

5.1 穿透的本质

回到第 1 节的故障。穿透的本质是:请求查询的数据在数据库里根本不存在,所以缓存永远无法命中,每次都落到数据库。 恶意攻击者可以用不存在的 ID 批量刷接口,把数据库当 Redis 用。

5.2 空值缓存

最简单的防御是:数据库查不到时,也在 Redis 写一个特殊标记,比如 __NULL__,并设置较短的过期时间。下一次同样请求就能命中这个标记,直接返回空。

要说清楚它为什么有效:攻击者反复查同一个不存在 ID,第一次落库,之后全部命中空值;但它的边界是,如果攻击者每次用不同 ID,空值缓存就挡不住,因为每个新 ID 都会落一次库。

5.3 布隆过滤器

布隆过滤器解决的问题是"不用查数据库就能判断某个 ID 一定不存在"。它的结构是一个位数组加多个哈希函数:写入时把 ID 用 k 个哈希映射到 k 个位置置 1;查询时看这 k 个位是否全为 1,只要有一个是 0,就说明这个 ID 一定不在集合里。

它能帮助理解什么:它是一个前置的快速否定过滤器。它不能替代哪部分真实机制:布隆过滤器返回"可能存在"时,数据仍可能不存在(假阳性),所以后面还是要走缓存加数据库查询。

5.4 分层防御的完整流程

text 复制代码
[请求 id]
    |
    v
[布隆过滤器 contains(id)?]
    |  false -> 直接返回不存在(一定不在库里)
    true
    |
    v
[查 Redis 缓存]
    |  命中 -> 返回
    |  未命中
    v
[查数据库]
    |  有数据 -> 写缓存 -> 返回
    |  无数据 -> 写空值缓存(短TTL) -> 返回空

5.5 三个完整示例之一:手写布隆过滤器并验证假阳性

目标 :用最小代码验证"没有的数据一定判不存在,假阳性反而可能误判存在"。前置环境 :纯 JDK,无第三方依赖。输入:插入 1000 个已有 ID,然后查 1000 个不存在的 ID。

java 复制代码
import java.nio.charset.StandardCharsets;
import java.util.BitSet;
import java.util.Random;

public class SimpleBloomFilter {
    private final BitSet bits;
    private final int size;
    private final int hashCount;

    public SimpleBloomFilter(int expected, double fpp) {
        int m = (int) Math.ceil(-(expected * Math.log(fpp)) / (Math.log(2) * Math.log(2)));
        this.size = Math.max(64, m);
        this.hashCount = Math.max(1, (int) Math.round((double) this.size / expected * Math.log(2)));
        this.bits = new BitSet(this.size);
    }

    private int[] hashes(String value) {
        int[] out = new int[hashCount];
        long h1 = 1125899906842597L;
        for (byte b : value.getBytes(StandardCharsets.UTF_8)) {
            h1 = h1 * 31 + b;
        }
        long h2 = 0;
        for (byte b : value.getBytes(StandardCharsets.UTF_8)) {
            h2 = h2 * 131 + b;
        }
        for (int i = 0; i < hashCount; i++) {
            long combined = h1 + i * h2;
            out[i] = (int) ((combined & Long.MAX_VALUE) % size);
        }
        return out;
    }

    public void add(String value) {
        for (int idx : hashes(value)) {
            bits.set(idx);
        }
    }

    public boolean mightContain(String value) {
        for (int idx : hashes(value)) {
            if (!bits.get(idx)) {
                return false;
            }
        }
        return true;
    }

    public static void main(String[] args) {
        SimpleBloomFilter filter = new SimpleBloomFilter(1000, 0.01);
        Random random = new Random(42);
        for (int i = 0; i < 1000; i++) {
            filter.add("exist:" + i);
        }
        int falsePositive = 0;
        for (int i = 0; i < 1000; i++) {
            if (filter.mightContain("miss:" + random.nextInt(100000))) {
                falsePositive++;
            }
        }
        System.out.println("size=" + filter.size + ", hashCount=" + filter.hashCount);
        System.out.println("falsePositive=" + falsePositive);
        System.out.println("exist:1=" + filter.mightContain("exist:1"));
        System.out.println("never-added=" + filter.mightContain("totally-unknown-id"));
    }
}

关键步骤 :先按预期元素数和误判率算出位数组长度与哈希个数,再插入、查询。预期结果 :exist:1 输出 true;falsePositive 远小于 1000,但通常大于 0,说明存在假阳性。never-added 可能为 true 也可能为 false,取决于哈希落点。适用场景 :预先知道要过滤的 ID 集合。容易改错的地方 :一是 hashCount 算错导致位密度过高,假阳性飙升;二是以为"返回 true 就一定存在",把布隆过滤器当真值来源。

6. 缓存雪崩:为什么大批 key 会同时失效

6.1 雪崩的两种形态

缓存雪崩不是单一现象。第一种是 大批 key 在同一时间点集体过期 ,比如凌晨批量预热时统一设了 10 分钟 TTL,到点后所有请求同时回源。第二种更严重:Redis 实例本身宕机或网络抖动,缓存层整体不可用,所有流量直接压到数据库。

6.2 随机过期时间

对第一种,最直接的缓解是给过期时间加随机抖动,让失效时间散开。原来固定 600 秒,改成 600 秒加 0 到 120 秒的随机值。这样即使同一批写入,也不会同一秒失效。

6.3 互斥重建

对热点 key 回源,可以用分布式锁保证同一时刻只有一个线程去查库重建缓存,其他线程短暂重试或返回旧值。这里要注意:锁本身要用 Redis 的 SET key value NX PX,并在 finally 中释放,且释放前校验 value 是否是自己持有的,避免误删别人的锁。

6.4 一个完整的带互斥重建的缓存示例

目标 :热点商品并发查询时,只有一个线程回源,其余命中。前置环境 :Spring Boot 3.2 + Redis + 数据库可用(示例用 Map 模拟数据库)。输入:20 个线程同时查同一个商品 ID。

java 复制代码
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.data.redis.core.StringRedisTemplate;
import org.springframework.stereotype.Service;

import java.time.Duration;
import java.util.Map;
import java.util.UUID;
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.ThreadLocalRandom;

@Service
public class HotProductService {

    @Autowired
    private StringRedisTemplate redis;

    // 模拟数据库
    private final Map<String, String> db = new ConcurrentHashMap<>();

    public HotProductService() {
        db.put("1001", "{\"id\":1001,\"name\":\"手机\",\"stock\":9}");
    }

    public String getProduct(String id) {
        String key = "product:" + id;
        String cached = redis.opsForValue().get(key);
        if (cached != null) {
            return "__NULL__".equals(cached) ? null : cached;
        }

        String lockKey = "lock:product:" + id;
        String token = UUID.randomUUID().toString();
        Boolean locked = redis.opsForValue().setIfAbsent(lockKey, token, Duration.ofSeconds(5));
        if (Boolean.TRUE.equals(locked)) {
            try {
                // 双重检查,避免上一个持锁者已经写好
                String again = redis.opsForValue().get(key);
                if (again != null) {
                    return "__NULL__".equals(again) ? null : again;
                }
                String fromDb = db.get(id);
                if (fromDb == null) {
                    redis.opsForValue().set(key, "__NULL__", Duration.ofSeconds(30));
                    return null;
                }
                long ttl = 600 + ThreadLocalRandom.current().nextLong(120);
                redis.opsForValue().set(key, fromDb, Duration.ofSeconds(ttl));
                return fromDb;
            } finally {
                // 只有自己持有锁才删除
                if (token.equals(redis.opsForValue().get(lockKey))) {
                    redis.delete(lockKey);
                }
            }
        }

        // 没拿到锁:短暂等待后重试一次缓存
        try {
            Thread.sleep(50);
        } catch (InterruptedException e) {
            Thread.currentThread().interrupt();
        }
        String retry = redis.opsForValue().get(key);
        if (retry != null) {
            return "__NULL__".equals(retry) ? null : retry;
        }
        return null;
    }
}

关键步骤 :先查缓存,未命中再抢锁,拿到锁的双重检查后回源,写缓存时用随机 TTL,最后校验 token 释放锁。预期结果 :20 个并发线程中,只有 1 个执行数据库查询,其余要么命中缓存,要么短暂重试后命中。适用场景 :热点 key 重建。容易改错的地方 :一是用 redis.delete(lockKey) 不加校验,可能删掉别人刚拿到的锁;二是锁的过期时间比业务执行时间短,导致业务没跑完锁就掉了。

6.5 常用防御手段对比

手段 针对问题 优点 代价 不适用场景
空值缓存 穿透(同一 ID 重复查) 实现简单 占用空间、需短 TTL 随机 ID 攻击
布隆过滤器 穿透(大量不存在 ID) 内存小、速度快 有假阳性、删除困难 数据频繁增删
随机 TTL 雪崩(集中过期) 零成本 无 Redis 整体宕机
互斥重建 热点回源 减少数据库压力 增加一次抢锁开销 极低延迟敏感场景
多级缓存/降级 雪崩(Redis 不可用) 提升可用性 架构复杂 一致性要求极高

7. 工程使用:一次请求的完整防御链路

把前面所有机制串起来看,一个成熟商品查询接口的顺序应该是:先过布隆过滤器做否定判断,再查本地缓存,再查 Redis,未命中时用互斥锁重建,重建时写随机 TTL,数据库查不到写空值。

这里最容易误解的是:以为加了布隆过滤器就不需要空值缓存。实际上两者互补------布隆过滤器挡的是"从来没写入过的 ID",空值缓存挡的是"曾经查过但不存在、且可能在布隆过滤器中已经写入或假阳性的 ID"。

生产上还要注意:布隆过滤器的数据要和数据库保持一致。新增商品时要同步 add;如果业务允许删除商品,用计数布隆过滤器或定期重建来规避"无法删除"的限制。

8. 常见误区

  • 以为配了 lettuce 参数池就生效 :不显式开 pool.enabled,参数不生效,这在前面的压测示例里会直接体现为没有池化行为。
  • 以为 JSON 序列化一定安全:开启 DefaultTyping 后,如果缓存可被不可信来源写入,存在反序列化风险。
  • 以为布隆过滤器可以删除元素:标准布隆过滤器的位是共享的,删一个可能影响其他元素,需要计数版本或重建。
  • 以为随机 TTL 能解决所有雪崩:它只解决集中过期,解决不了 Redis 宕机,后者需要多级缓存和降级。
  • 以为互斥锁越细越好:锁粒度过细会导致锁对象暴增,粒度太粗会让无关请求互相阻塞,一般按业务 key 维度加锁。

9. 生产实践建议

  • 连接池参数以压测结果为准,max-active 参考峰值并发连接需求,max-wait 必须设且不宜过大。
  • 区分大 key:一个 value 超过 10KB 或元素数超过 5000 就要考虑拆分,否则会阻塞单线程、拖慢网络传输。
  • 缓存 key 统一加业务前缀,避免不同业务撞 key,也方便按前缀清理。
  • 所有缓存写入都要有明确 TTL,禁止无过期时间的永久 key,除非是配置类数据且有人工维护流程。
  • 热点 key 可以用本地缓存做二级缓冲,但要接受短暂不一致,并明确失效策略。
  • 上线前对缓存命中率、Redis 慢查询、连接池等待时间做监控,命中率骤降往往先于故障发生。

10. 排障清单

当线上出现"接口变慢、数据库压力大"时,按下面顺序排查:

步骤 检查项 命令或观察点 典型结论
1 Redis 是否可达 redis-cli ping 返回非 PONG 说明网络或实例问题
2 是否有慢查询 redis-cli slowlog get 10 大 key 或复杂命令
3 连接池是否耗尽 应用指标 max-wait 超时次数 需要调大池或定位阻塞命令
4 缓存命中率 监控 miss 率 突然下降多为雪崩或穿透
5 key 是否集中过期 redis-cli ttl key 大量相近 TTL 需加随机抖动
6 是否存在不存在的 ID 攻击 日志中 miss 的 key 分布 集中同一 ID 查空值,分散则上布隆过滤器

11. 面试/复盘问题

  1. Lettuce 与 Jedis 在线程模型上的区别是什么?为什么 Lettuce 仍建议配连接池?
  2. RedisTemplate 默认序列化有什么问题?如何在不引入安全风险的前提下让 value 可读?
  3. 空值缓存和布隆过滤器分别解决穿透的哪一部分?能否只留一个?
  4. 缓存雪崩有哪两种形态?随机 TTL 和互斥重建分别解决哪一种?
  5. 用 Redis 实现分布式锁时,为什么释放锁要校验持有者?锁过期时间设短了会发生什么?
  6. 布隆过滤器为什么不能删除元素?计数布隆过滤器付出了什么代价?

12. 总结

把整篇文章收成一张决策图:连接层决定并发能不能撑住,编解码层决定数据能不能读回来,策略层决定缓存能不能真正挡住数据库。穿透用"空值缓存加布隆过滤器"分层挡,雪崩用"随机 TTL 加互斥重建加多级缓存"分层挡,热点和雪崩常常是同一条链路的不同阶段。

工程上最有效的做法不是堆技术,而是先画出你这条业务请求的完整链路,再逐段问三个问题:这一步失败会怎样?这一步慢会怎样?这一步的边界在哪里?答案会直接告诉你该配哪个参数、加哪层防御。

13. 参考资料

相关推荐
Wx-bishekaifayuan1 小时前
springboot陨石鉴收系统96265-计算机课程设计、毕业设计
java·vue.js·spring boot·后端·python·spring·课程设计
code_slave(码畜)2 小时前
微服务架构落地:基础服务 —— 报表服务(上篇:定位、边界与整体架构)
java·spring boot·spring cloud·微服务·架构
FYKJ_20102 小时前
springboot家政服务平台66766-计算机课程设计、毕业设计
java·vue.js·spring boot·后端·mysql·spark·课程设计
x-Achan2 小时前
Redis实现方案:更新策略、穿透、雪崩、击穿、工具封装
java·spring boot·redis·spring·bootstrap·mybatis
新思维软件6 小时前
非机动车管理系统设计 | STM32+RFID+MQTT | X504291项目编号 X504291
spring boot·stm32·单片机·嵌入式硬件
ym hyd 11111 小时前
试题库管理系统源码 Java+SpringBoot+Vue3 前后分离
java·vue.js·spring boot·毕设
小蒜学长19 小时前
校园社团招新网站的设计与实现(代码+数据库+LW)
java·数据库·spring boot·后端·校园社团招新网站
卓怡学长21 小时前
w192基于springboot“考研情报站”微信小程序设计与实现
java·spring boot·spring·微信小程序·intellij-idea
ShineWinsu1 天前
对于Redis:Hash类型的解析
c++·redis·分布式·缓存·面试·hash·哈希表