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. 面试/复盘问题
- Lettuce 与 Jedis 在线程模型上的区别是什么?为什么 Lettuce 仍建议配连接池?
- RedisTemplate 默认序列化有什么问题?如何在不引入安全风险的前提下让 value 可读?
- 空值缓存和布隆过滤器分别解决穿透的哪一部分?能否只留一个?
- 缓存雪崩有哪两种形态?随机 TTL 和互斥重建分别解决哪一种?
- 用 Redis 实现分布式锁时,为什么释放锁要校验持有者?锁过期时间设短了会发生什么?
- 布隆过滤器为什么不能删除元素?计数布隆过滤器付出了什么代价?
12. 总结
把整篇文章收成一张决策图:连接层决定并发能不能撑住,编解码层决定数据能不能读回来,策略层决定缓存能不能真正挡住数据库。穿透用"空值缓存加布隆过滤器"分层挡,雪崩用"随机 TTL 加互斥重建加多级缓存"分层挡,热点和雪崩常常是同一条链路的不同阶段。
工程上最有效的做法不是堆技术,而是先画出你这条业务请求的完整链路,再逐段问三个问题:这一步失败会怎样?这一步慢会怎样?这一步的边界在哪里?答案会直接告诉你该配哪个参数、加哪层防御。
13. 参考资料
- Spring Data Redis 官方文档:https://docs.spring.io/spring-data/redis/reference/
- Lettuce 官方文档:https://lettuce.io/core/release/reference/
- Redis 官方文档(含持久化、复制、Cluster、慢查询):https://redis.io/docs/latest/
- Redis 官方命令参考:https://redis.io/docs/latest/commands/
- Martin Kleppmann 关于 Redis 分布式锁的讨论文章:https://martin.kleppmann.com/2016/02/08/how-to-do-distributed-locking.html
- 《Redis 设计与实现》黄健宏著,机械工业出版社