一、场景痛点:缓存是怎么把数据库打挂的
电商中台的高并发链路里,Redis 是挡在数据库前面的第一道闸门。商品详情、库存、购物车这些热点数据都走"先查缓存,未命中再查库"的经典模式。但线上跑久了会发现三类事故:
- 穿透:查询一个根本不存在的 key(比如恶意刷的 -1 商品 ID),缓存永远没有,请求直达数据库,一个攻击脚本就能把 DB 打满。
- 击穿:某个热点 key 恰好过期的瞬间,大量并发请求同时发现缓存失效,全部涌入数据库重建缓存——秒杀商品最典型。
- 雪崩:大量 key 在同一时间段集体过期,数据库瞬间被一波流量冲垮,进而拖垮整个服务。
三者的共同点是:缓存失效的瞬间,数据库承受了全部流量。下面逐个给出可落地的解法。
二、穿透:布隆过滤器 + 缓存空值,双层拦截
方案一:布隆过滤器前置拦截。把所有合法 ID 在启动时加载进布隆过滤器,查询前先判断 ID 是否可能存在,不存在直接返回空,根本不碰缓存和数据库:
@Component
public class BloomFilterGuard {
private static final int EXPECTED_SIZE = 1_000_000; // 预估商品总量
private static final double FPP = 0.01; // 允许的误判率
private final BloomFilter<Long> filter =
BloomFilter.create(Funnels.longFunnel(), EXPECTED_SIZE, FPP);
@Resource
private ProductMapper productMapper;
/** 启动时全量加载合法 ID(新增商品在写入时同步 put) */
@PostConstruct
public void init() {
// 分批流式加载,避免一次性 OOM
productMapper.streamAllIds().forEach(filter::put);
}
public boolean mightExist(Long productId) {
return filter.mightContain(productId);
}
}
方案二:缓存空值兜底。布隆过滤器有误判率,且删除商品后过滤器不会立即失效,所以对"查无此货"的结果也要写一个短 TTL 的空缓存,把穿透的代价从"打库"降为"写一个空 key":
public String getProduct(Long id) {
String key = "product:detail:" + id;
String json = stringRedisTemplate.opsForValue().get(key);
if (json != null) {
return json; // 命中缓存(含空值占位)
}
if (!bloomFilterGuard.mightExist(id)) {
return null; // 布隆过滤器直接拦截
}
String value = loadFromDb(id);
if (value == null) {
// 空值占位,TTL 给短一些(60s),防止恶意 key 长期占内存
stringRedisTemplate.opsForValue().set(key, "", Duration.ofSeconds(60));
return null;
}
stringRedisTemplate.opsForValue().set(key, value, Duration.ofMinutes(30));
return value;
}
三、击穿:互斥锁重建,只让一个线程回源
热点 key 过期瞬间的并发回源,用 SETNX 互斥锁解决:重建缓存的请求先抢锁,抢到锁的线程查库写缓存,其余线程短暂自旋等待后读缓存,避免重复打库:
private static final String LOCK_PREFIX = "lock:product:";
public String getProductWithLock(Long id) {
String key = "product:detail:" + id;
String json = stringRedisTemplate.opsForValue().get(key);
if (json != null) {
return json;
}
// 抢锁:setIfAbsent + 过期时间必须原子,防止持锁线程崩溃导致死锁
String lockKey = LOCK_PREFIX + id;
String token = UUID.randomUUID().toString();
Boolean locked = stringRedisTemplate.opsForValue()
.setIfAbsent(lockKey, token, Duration.ofSeconds(5));
if (Boolean.TRUE.equals(locked)) {
try {
// 双重检查:可能上一个持锁线程已经重建完缓存
json = stringRedisTemplate.opsForValue().get(key);
if (json == null) {
json = loadFromDb(id);
if (json == null) {
stringRedisTemplate.opsForValue()
.set(key, "", Duration.ofSeconds(60));
return null;
}
stringRedisTemplate.opsForValue()
.set(key, json, Duration.ofMinutes(30));
}
return json;
} finally {
// 用 Lua 保证"先比对 token 再删除",防止误删别人刚抢到的锁
releaseLock(lockKey, token);
}
}
// 没抢到锁:自旋等待,最多等 300ms
for (int i = 0; i < 30; i++) {
try { Thread.sleep(10); } catch (InterruptedException e) {
Thread.currentThread().interrupt();
break;
}
json = stringRedisTemplate.opsForValue().get(key);
if (json != null) {
return json;
}
}
return loadFromDb(id); // 极端兜底:直接回源,保证可用性
}
private void releaseLock(String lockKey, String token) {
// Lua 脚本:GET 比对 + DEL,原子执行
String lua = "if redis.call('get', KEYS[1]) == ARGV[1] " +
"then return redis.call('del', KEYS[1]) else return 0 end";
stringRedisTemplate.execute(new DefaultRedisScript<>(lua, Long.class),
List.of(lockKey), token);
}
三个细节最容易踩坑:
- 锁必须带过期时间:
setIfAbsent和expire分开调用在持锁线程宕机时会死锁,必须用setIfAbsent(key, value, timeout)原子完成。 - 释放锁必须比对 token:线程 A 的锁过期后线程 B 抢到锁,此时 A 的 finally 直接 DEL 会把 B 的锁误删,连锁引发多个线程同时回源。
- 锁粒度要小:锁到 id 而不是全局一把锁,否则一个热点 key 重建会阻塞所有商品的缓存回源。
四、雪崩:过期时间随机化 + 多级缓存兜底
雪崩的第一道防线是打散过期时间,避免大量 key 同一秒失效:
/** 基础 TTL 30 分钟 + 0~5 分钟随机抖动 */
long ttl = Duration.ofMinutes(30)
.plusSeconds(ThreadLocalRandom.current().nextLong(0, 300))
.getSeconds();
stringRedisTemplate.opsForValue().set(key, json, Duration.ofSeconds(ttl));
对于批量加载(比如首页推荐位一次性回源 50 个 key),尤其要注意:for 循环里给每个 key 都加随机抖动,而不是统一 TTL,否则这批 key 会整点集体过期。
第二道防线是本地缓存(Caffeine)做二级缓存:Redis 挂了或者大规模失效时,JVM 内还有一份短 TTL 的数据顶着,给数据库留出恢复时间。注意本地缓存要按业务区分——商品详情这类读多写少的数据适合,库存这类强一致数据不要放本地缓存。
第三道防线是限流降级:回源数据库的路径加一个信号量或令牌桶,超过阈值直接返回降级数据(比如"库存紧张"占位文案),宁可牺牲部分体验也不能打垮数据库:
@Component
public class DbFallbackGuard {
private final Semaphore semaphore = new Semaphore(50); // 最多 50 个并发回源
public <T> T withFallback(Supplier<T> supplier, T fallback) {
if (!semaphore.tryAcquire()) {
return fallback;
}
try {
return supplier.get();
} finally {
semaphore.release();
}
}
}
五、踩坑实录:三个真实线上事故
- 缓存与数据库不一致,根因是更新顺序反了。我们曾经"先更新数据库,再删缓存",但删除失败的请求会让旧缓存一直存活。后来统一为延迟双删:先删缓存 → 更新 DB → 再删一次缓存(延迟 500ms),并配合 MQ 异步兜底删除。不要用"更新缓存"而要用"删除缓存",因为更新缓存会引入并发写覆盖问题,删缓存让下次查询自然重建。
- BigKey 拖垮了 Redis 主从同步。一个商品详情 JSON 序列化后 800KB,热点时期单 key 读写都慢,主从复制积压缓冲区被打满触发全量重同步。解决:详情按字段拆分(基础信息 / 价格 / 库存分 key 存储),超过 100KB 的数据改走 Redis 分片或直接查库。
- 序列化不一致导致"缓存永不命中"。用 RedisTemplate<String, Object> 配 JDK 序列化器存进去的对象,和直接用 StringRedisTemplate 读出来的字节对不上,查一次就报错一次。生产环境统一用 StringRedisTemplate + JSON,别混用序列化器。
六、最佳实践清单
- 穿透:布隆过滤器拦截不存在的 ID + 空值短 TTL 占位,双保险。
- 击穿:SETNX 互斥锁 + token 比对释放 + 双重检查,锁粒度到 key。
- 雪崩:TTL 加随机抖动打散 + Caffeine 二级缓存 + 回源限流兜底。
- 一致性:延迟双删 + MQ 异步兜底删除缓存,禁止直接"更新缓存"。
- 监控:给缓存命中率、回源 QPS、锁等待超时三个指标都加告警,命中率跌破 90% 就要查。
缓存方案的取舍本质是用一致性换可用性:读多写少的场景大胆缓存,强一致场景(库存扣减、余额)老老实实走数据库事务 + 分布式锁,别为了缓存而缓存。