Redis 高并发缓存架构:击穿、穿透、雪崩全解
缓存是提升系统吞吐量最简单的手段,但它引入的问题也比你想象的更多。击穿、穿透、雪崩——这三个听起来像地质灾害的名词,是每一个高并发系统设计者必须跨越的三道坎。
缓存的基本模型#
在深入三个核心问题之前,先建立一个标准的缓存读写模型:
标准缓存读取流程:
客户端 → 缓存层(Redis)→ [命中] → 直接返回
→ [未命中] → 数据库层 → 写入缓存 → 返回
标准缓存写入流程:
方案 A(Cache Aside):先更新数据库,再删除缓存
方案 B(Read/Write Through):通过缓存层代理读写
方案 C(Write Behind):只写缓存,异步刷入数据库
在绝大多数互联网场景中,Cache Aside(旁路缓存)是最常用的模式——代码直接操作 Redis 和数据库,简单透明。但正是这种简单性,隐藏着三大问题。
问题一:缓存穿透(Cache Penetration)#
定义:查询一个根本不存在的数据,缓存中没有,数据库中也没有。每次请求都会穿透缓存直接打到数据库。
正常请求:
查询 user_id=123 → 缓存命中 → 返回(快)
穿透请求:
查询 user_id=-1 → 缓存未命中 → 查数据库 → 数据库也没有 → 返回空
查询 user_id=-1 → 缓存未命中 → 查数据库 → 数据库也没有 → 返回空
查询 user_id=-1 → 缓存未命中 → 查数据库 → 数据库也没有 → 返回空
如果每秒 10000 次这样的请求,数据库直接被压垮
穿透通常由恶意攻击或业务 Bug 引起——攻击者构造大量不存在的 ID,绕过缓存直接攻击数据库。
解决方案#
方案 1:缓存空值
public User getUserById(Long userId) {
String key = "user:" + userId;
String cached = redis.get(key);
if (cached != null) {
if ("NULL_PLACEHOLDER".equals(cached)) {
return null; // 之前查过,数据库中不存在
}
return JSON.parseObject(cached, User.class);
}
// 缓存未命中,查数据库
User user = userMapper.selectById(userId);
if (user != null) {
redis.setex(key, 3600, JSON.toJSONString(user));
} else {
// 关键:把「不存在」也缓存起来,但过期时间设短一些
redis.setex(key, 300, "NULL_PLACEHOLDER"); // 5 分钟过期
}
return user;
}
优点:简单直接。缺点:如果攻击者用大量不同的随机 ID,会创建大量空值缓存,浪费内存。
方案 2:布隆过滤器(Bloom Filter)
布隆过滤器原理:
用一个 bit 数组 + 多个哈希函数来判断一个元素「可能存在」或「一定不存在」
初始化:把所有已存在的 user_id 加入布隆过滤器
user_id=1 → hash1, hash2, hash3 → 对应 bit 位置 1
user_id=2 → hash1, hash2, hash3 → 对应 bit 位置 1
user_id=3 → hash1, hash2, hash3 → 对应 bit 位置 1
...
查询时:
user_id=999 → hash1, hash2, hash3 → 如果有一个 bit 位是 0 → 一定不存在 → 直接返回
user_id=123 → hash1, hash2, hash3 → 所有 bit 位都是 1 → 可能存在 → 继续查缓存/数据库
关键特性:
- 判断「不存在」是 100% 准确的
- 判断「存在」有极小的误判率(可配置,通常 < 0.1%)
- 空间效率极高:100 万个 ID 只需要约 1.2MB 内存
public User getUserById(Long userId) {
// 第一道防线:布隆过滤器
if (!bloomFilter.mightContain(userId)) {
return null; // 100% 确定不存在
}
// 第二道防线:Redis 缓存
String key = "user:" + userId;
String cached = redis.get(key);
if (cached != null) {
return JSON.parseObject(cached, User.class);
}
// 第三道防线:数据库
User user = userMapper.selectById(userId);
if (user != null) {
redis.setex(key, 3600, JSON.toJSONString(user));
}
return user;
}
生产环境建议:布隆过滤器 + 缓存空值双重防护。布隆过滤器拦截绝大部分无效请求,缓存空值兜底布隆过滤器的误判。
问题二:缓存击穿(Cache Breakdown)#
定义:一个热点 key 在过期的瞬间,大量并发请求同时到达,全部穿透到数据库。
时间线:
T0: 热点 key "product:999" 在缓存中,QPS 50000,全部命中缓存
T1: key 过期了
T2: 50000 个并发请求同时发现缓存未命中
T3: 50000 个请求同时打到数据库
T4: 数据库扛不住,连接池耗尽,服务雪崩
这就是「击穿」——一个点的缓存失效,导致整个数据库被打穿
解决方案#
方案 1:互斥锁(Mutex Lock)
public Product getProductById(Long productId) {
String key = "product:" + productId;
String cached = redis.get(key);
if (cached != null) {
return JSON.parseObject(cached, Product.class);
}
// 缓存未命中,尝试获取分布式锁
String lockKey = "lock:" + key;
boolean locked = redis.setnx(lockKey, "1", 10); // 10秒过期
if (locked) {
try {
// 双重检查:获取锁后再查一次缓存
cached = redis.get(key);
if (cached != null) {
return JSON.parseObject(cached, Product.class);
}
// 只有获得锁的线程才查数据库
Product product = productMapper.selectById(productId);
redis.setex(key, 3600, JSON.toJSONString(product));
return product;
} finally {
redis.del(lockKey); // 释放锁
}
} else {
// 未获得锁的线程短暂等待后重试
Thread.sleep(50);
return getProductById(productId); // 重试
}
}
优点:保证只有一个线程回源数据库。缺点:其他线程需要等待,增加了响应时间。
方案 2:逻辑过期
@Data
public class CachedProduct {
private Product product;
private long expireTime; // 逻辑过期时间
}
public Product getProductById(Long productId) {
String key = "product:" + productId;
String cached = redis.get(key);
if (cached != null) {
CachedProduct cachedProduct = JSON.parseObject(cached, CachedProduct.class);
if (cachedProduct.getExpireTime() > System.currentTimeMillis()) {
// 未过期,直接返回
return cachedProduct.getProduct();
}
// 逻辑过期了,但物理上还在缓存中
// 异步更新,当前请求返回旧数据
if (redis.setnx("rebuild:" + key, "1", 10)) {
CompletableFuture.runAsync(() -> rebuildCache(productId));
}
return cachedProduct.getProduct(); // 返回旧数据(稍有过期但可接受)
}
// 首次加载
Product product = productMapper.selectById(productId);
CachedProduct cachedProduct = new CachedProduct();
cachedProduct.setProduct(product);
cachedProduct.setExpireTime(System.currentTimeMillis() + 3600_000);
redis.set(key, JSON.toJSONString(cachedProduct)); // 不设 Redis TTL!
return product;
}
private void rebuildCache(Long productId) {
Product product = productMapper.selectById(productId);
CachedProduct cachedProduct = new CachedProduct();
cachedProduct.setProduct(product);
cachedProduct.setExpireTime(System.currentTimeMillis() + 3600_000);
redis.set("product:" + productId, JSON.toJSONString(cachedProduct));
redis.del("rebuild:product:" + productId);
}
优点:不阻塞任何请求。缺点:在缓存刷新期间,部分请求会读到稍旧的数据。但对于商品详情这类场景,几秒钟的延迟完全可以接受。
方案 3:永不过期 + 后台主动刷新
对于超级热点数据(比如首页推荐位),直接设置永不过期,用一个后台线程定时刷新:
@Scheduled(fixedRate = 300_000) // 每 5 分钟刷新一次
public void refreshHotProducts() {
List<Long> hotProductIds = getTop100HotProducts();
for (Long productId : hotProductIds) {
Product product = productMapper.selectById(productId);
redis.set("product:" + productId, JSON.toJSONString(product));
}
}
问题三:缓存雪崩(Cache Avalanche)#
定义:大量缓存 key 在同一时间集中过期,导致大量请求同时打到数据库。
缓存雪崩 vs 缓存击穿:
击穿:一个热点 key 过期 → 数据库被打穿
雪崩:大量 key 同时过期 → 数据库被打崩
场景:
凌晨 2:00 批量导入 100 万个商品到缓存,统一设置 24 小时过期
次日凌晨 2:00,100 万个 key 同时过期
所有商品查询同时打到数据库 → 数据库宕机
解决方案#
方案 1:过期时间加随机值
// 在基础过期时间上加一个随机偏移
int baseExpire = 3600; // 1 小时
int randomOffset = ThreadLocalRandom.current().nextInt(0, 600); // 0-600 秒随机
redis.setex(key, baseExpire + randomOffset, value);
// 100 万个 key 的过期时间被分散到 1 小时 ~ 1 小时 10 分钟之间
// 避免了集中过期
方案 2:多级缓存
请求 → L1 本地缓存(Caffeine)→ L2 分布式缓存(Redis)→ 数据库
特点:
- L1 缓存容量小但速度极快(纳秒级)
- L2 缓存容量大但需要网络 IO(毫秒级)
- 即使 L2 全部失效,L1 仍然可以挡住一部分请求
- L1 和 L2 的过期时间错开,避免同时失效
// 多级缓存实现
private final Cache<String, Product> localCache = Caffeine.newBuilder()
.maximumSize(10_000)
.expireAfterWrite(5, TimeUnit.MINUTES)
.build();
public Product getProduct(Long productId) {
String key = "product:" + productId;
// L1: 本地缓存
Product product = localCache.getIfPresent(key);
if (product != null) return product;
// L2: Redis 缓存
String cached = redis.get(key);
if (cached != null) {
product = JSON.parseObject(cached, Product.class);
localCache.put(key, product); // 回填 L1
return product;
}
// 数据库
product = productMapper.selectById(productId);
if (product != null) {
redis.setex(key, 3600 + randomOffset(), JSON.toJSONString(product));
localCache.put(key, product);
}
return product;
}
方案 3:熔断降级
当数据库压力过大时,启动熔断机制,直接返回默认数据或缓存中的旧数据:
@HystrixCommand(fallbackMethod = "getProductFallback")
public Product getProduct(Long productId) {
// 正常流程...
}
public Product getProductFallback(Long productId) {
// 降级策略:
// 1. 尝试从 Redis 获取过期数据(即使已过期也比没有好)
// 2. 返回默认商品数据
// 3. 返回 "系统繁忙,请稍后重试"
return Product.defaultProduct();
}
缓存与数据库的一致性#
除了三大经典问题,缓存与数据库的一致性也是一个绕不开的话题。
双写一致性的几种策略#
策略 1:先删缓存,再更新数据库
问题:删缓存和写数据库之间的窗口期内,其他线程会读到旧数据
策略 2:先更新数据库,再删缓存(Cache Aside,推荐)
问题:极端场景下可能不一致(概率极低)
场景:
线程 A 读缓存未命中 → 读数据库得到旧值 v1
线程 B 更新数据库为 v2 → 删除缓存
线程 A 把 v1 写入缓存
→ 缓存中是旧值 v1
但这个场景发生的前提是「读数据库比写数据库还慢」,在实际中概率极低
策略 3:延迟双删
先删缓存 → 更新数据库 → 延迟 N 毫秒 → 再删一次缓存
可以覆盖策略 2 的极端场景
策略 4:基于 binlog 的异步同步(Canal / Debezium)
数据库变更 → binlog → Canal → 消息队列 → 缓存更新消费者
最终一致性方案,适合对一致性要求不高的场景
推荐组合:
强一致性场景(库存、余额):
不使用缓存,直接读写数据库 + 分布式锁
准实时一致性场景(商品信息、用户信息):
Cache Aside + 延迟双删 + TTL 兜底
最终一致性场景(文章、评论、推荐):
Canal 订阅 binlog + 异步更新缓存 + 合理 TTL
缓存架构的演进路线#
阶段 1:单体应用 + 本地缓存
HashMap / Guava Cache
问题:多实例间缓存不一致
阶段 2:分布式缓存
Redis / Memcached
问题:单点故障、容量受限
阶段 3:多级缓存
本地缓存 + Redis + CDN
问题:一致性维护复杂
阶段 4:智能缓存
根据访问模式动态调整 TTL
热点数据自动识别和预加载
冷数据自动淘汰
总结速查表#
| 问题 | 本质 | 方案 |
|---|---|---|
| 缓存穿透 | 查不存在的数据 | 布隆过滤器 + 缓存空值 |
| 缓存击穿 | 热点 key 过期 | 互斥锁 / 逻辑过期 / 永不过期 |
| 缓存雪崩 | 大量 key 同时过期 | 随机过期时间 + 多级缓存 + 熔断 |
| 数据一致性 | 缓存和数据库不同步 | Cache Aside + 延迟双删 / binlog 同步 |
缓存设计没有银弹。每一个方案都是在一致性、可用性、延迟、复杂度之间做权衡。理解每一个方案的适用场景和局限性,才能在具体的业务场景中做出正确的架构选择。