1. 四个概念先分清
缓存异常治理里有几个高频词:预热、雪崩、穿透、击穿。先放一张表。
| 问题 | 含义 | 典型后果 |
|---|---|---|
| 缓存预热 | 系统启动或活动前提前加载热点数据 | 避免冷启动大量打 DB |
| 缓存雪崩 | 大量 key 同时失效,或 Redis 整体不可用 | 请求集中打到 DB |
| 缓存穿透 | 请求不存在的数据,Redis 和 DB 都没有 | 恶意请求绕过缓存 |
| 缓存击穿 | 单个热点 key 失效,大量并发同时回源 | 热点请求打爆 DB |
上述问题均发生在 cache-aside 访问链路中,也就是“请求先查缓存,缓存没有再查数据库”的链路,但触发原因不同。
预热解决的是冷启动问题。比如 MySQL 新增了 100 条活动商品记录,Redis 里还没有对应缓存。如果什么都不做,就要等真实用户请求逐步回填缓存;更稳妥的做法是在发布、活动开始或数据导入后,通过脚本、定时任务或运营任务提前把热点数据写入 Redis。
穿透是“查不存在”,击穿是“热点 key 过期”,雪崩是“大面积失效或 Redis 不可用”。
可以先用一条链路理解它们的关系:
请求进入
-> 参数校验 / 布隆过滤器拦截不存在数据(防穿透)
-> 查询 Redis
-> 命中直接返回
-> 未命中时控制回源并发(防击穿)
-> DB 查询后回填 Redis
-> TTL 随机、本地缓存、限流熔断兜底(防雪崩)
-> 热点数据提前加载(预热)
2. 缓存预热
缓存预热就是在真实流量进来之前,把热点数据提前写入 Redis。
适合预热的数据:
- 首页配置。
- 活动商品。
- 排行榜 topN。
- 字典配置。
- 访问量确定很高的详情页。
简单写法:
@Component
public class CacheWarmUpRunner implements ApplicationRunner {
private final ProductMapper productMapper;
private final StringRedisTemplate redisTemplate;
public CacheWarmUpRunner(ProductMapper productMapper, StringRedisTemplate redisTemplate) {
this.productMapper = productMapper;
this.redisTemplate = redisTemplate;
}
@Override
public void run(ApplicationArguments args) {
List<ProductDTO> products = productMapper.selectHotProducts();
for (ProductDTO product : products) {
String key = "product:" + product.getId();
redisTemplate.opsForValue().set(key, JsonUtils.toJson(product), 30, TimeUnit.MINUTES);
}
}
}
如果数据量大,不建议所有应用实例启动都预热一遍。多实例重复预热会在发布瞬间制造 DB 和 Redis 压力峰值。更好的方式是用定时任务、发布流程或单独的运营任务做预热。
ApplicationRunner 适合演示,不适合承载大规模生产预热。生产上通常改成异步任务、调度任务或发布流水线步骤,并且要支持分批、限速和失败重试。
3. 缓存雪崩
缓存雪崩常见原因:
| 原因 | 说明 |
|---|---|
| 大量 key 同时过期 | 比如批量导入时统一设置 30 分钟 TTL |
| Redis 故障 | Redis 不可用,所有请求绕到 DB |
| 热点活动开始 | 冷缓存瞬间被大量请求击中 |
治理方式:
- TTL 加随机值,避免同时过期。
- 热点数据提前预热。
- Redis 做主从、Sentinel 或 Cluster。
- 应用侧加限流、降级、熔断。
- 重要接口加本地缓存兜底。
TTL 加随机值:
int baseSeconds = 1800;
int randomSeconds = ThreadLocalRandom.current().nextInt(0, 300);
redisTemplate.opsForValue().set(key, value, baseSeconds + randomSeconds, TimeUnit.SECONDS);
不要把所有 key 都设置成同一个过期时间。这个问题在活动页、商品详情页、配置缓存里特别常见。
TTL 随机化主要适用于同批写入、同批过期的 key。它不能解决 Redis 整体故障,也不能代替限流、降级和多级缓存。
Redis 做主从、Sentinel 或 Cluster 能降低单点故障概率,但不能完全避免雪崩。如果客户端连接池、网络、机房或整组 Redis 同时异常,请求仍然可能绕到 DB。
4. Redis 故障时不能无限打 DB
如果 Redis 整体不可用,应用不能无脑查数据库,否则缓存故障会扩散成数据库故障。
常见保护:
| 手段 | 说明 |
|---|---|
| 限流 | 控制进入 DB 的请求量 |
| 熔断 | Redis 连续异常时快速失败或走降级 |
| 本地缓存 | 热点配置保留短时本地副本 |
| 降级值 | 非核心字段返回默认值 |
伪代码:
public ProductDTO getProduct(Long id) {
try {
ProductDTO cache = redisCache.get("product:" + id);
if (cache != null) {
return cache;
}
} catch (RedisConnectionFailureException ex) {
ProductDTO local = localCache.getIfPresent(id);
if (local != null) {
return local;
}
rateLimiter.acquire();
}
return productMapper.selectById(id);
}
这里不是说 Redis 异常后永远不查 DB,而是要控制 DB 的流量入口。
限流要分层:接口级限制保护 DB 总量,用户级或 IP 级限制压制异常来源,热点 key 级限制保护单点资源。熔断和 fallback 也不能只依赖某个框架的默认行为,核心接口要明确“失败时返回什么、是否允许查 DB、查 DB 的最大并发是多少”。
本地缓存可以作为 Redis 故障时的短时兜底,但它有 stale local cache 风险。多实例本地缓存之间不会自动一致,适合配置、热点只读数据和允许短时间旧值的场景。
5. 缓存穿透
缓存穿透是请求的数据根本不存在,比如不断请求 userId=-1、userId=999999999999。
解决方式:
| 方案 | 说明 |
|---|---|
| 参数校验 | 非法 ID 直接拒绝 |
| 缓存空对象 | DB 查不到时,短 TTL 缓存空值 |
| 布隆过滤器 | 判断 ID 是否可能存在 |
| 风控限流 | 对异常 IP、账号、UA 限制 |
缓存空对象示例:
public UserDTO getUser(Long userId) {
String key = "user:" + userId;
String json = redisTemplate.opsForValue().get(key);
if ("__NULL__".equals(json)) {
return null;
}
if (json != null) {
return JsonUtils.fromJson(json, UserDTO.class);
}
UserDO user = userMapper.selectById(userId);
if (user == null) {
redisTemplate.opsForValue().set(key, "__NULL__", 60, TimeUnit.SECONDS);
return null;
}
UserDTO dto = convert(user);
redisTemplate.opsForValue().set(key, JsonUtils.toJson(dto), 10, TimeUnit.MINUTES);
return dto;
}
空对象 TTL 要短,避免后来数据创建了,缓存里仍然长时间保留空值。
空对象也可能成为攻击放大点。如果攻击者持续构造随机 ID,即使每个空对象 TTL 很短,也会造成大量空 key,也就是 key explosion。因此参数校验必须放在最前置层,比如 gateway、controller 或 RPC 入口。

布隆过滤器可以当作“可能存在 ID”的白名单:判断一定不存在就直接返回,判断可能存在再继续查 Redis 和 MySQL。
需要注意两点:
- 布隆过滤器存在误判,少量不存在的 ID 仍可能打到 MySQL,所以还要配合空对象短 TTL、限流和风控。
- 所有合法 ID 都要写入布隆过滤器;热点数据是否提前写入 Redis,则取决于是否需要预热,不能把“写入布隆过滤器”和“写入缓存值”混为一件事。
6. 缓存击穿
缓存击穿是单个热点 key 失效,大量并发同时发现 Redis 没有,于是一起查 DB。
常用解决方案:
| 方案 | 说明 |
|---|---|
| 互斥锁 | 只有一个请求回源重建缓存,其他请求等待或快速失败 |
| 逻辑过期 | key 不设置物理过期,value 里存逻辑过期时间 |
| 热点永不过期 | 核心热点由后台任务刷新 |
互斥锁示例:
public ProductDTO getHotProduct(Long id) {
String key = "product:" + id;
ProductDTO cache = redisCache.get(key);
if (cache != null) {
return cache;
}
String lockKey = "lock:product:" + id;
String token = UUID.randomUUID().toString();
Boolean locked = redisTemplate.opsForValue()
.setIfAbsent(lockKey, token, 10, TimeUnit.SECONDS);
if (Boolean.TRUE.equals(locked)) {
try {
ProductDTO db = productMapper.selectById(id);
redisCache.set(key, db, 10, TimeUnit.MINUTES);
return db;
} finally {
releaseLock(lockKey, token);
}
}
LockSupport.parkNanos(TimeUnit.MILLISECONDS.toNanos(50));
return redisCache.get(key);
}
真实代码要处理等待超时、二次查询仍为空、空对象、异常重试等情况。不要让所有等待线程无限阻塞,也不要在抢锁失败后直接把 null 当成最终结果长期缓存。
互斥锁还有两个关键细节:
- 锁过期时间太短时,持锁线程还没回源完成,锁已经过期,其他线程会重复回源。
- LockSupport.parkNanos 只是短暂等待,流量很大时仍可能造成线程堆积,最好配合最大等待次数、快速失败或降级返回。
释放锁必须使用 compare-and-delete 语义,避免误删别人的锁。通常用 Lua 脚本保证“判断 token 相等”和“删除锁”两个动作原子执行:
if redis.call("get", KEYS[1]) == ARGV[1] then
return redis.call("del", KEYS[1])
else
return 0
end
7. 逻辑过期
逻辑过期是把过期时间存在 value 里,Redis key 本身不设置 TTL 或设置很长 TTL。
{
"expireAt": "2024-12-04T10:00:00",
"data": {
"id": 1001,
"name": "商品A"
}
}
读取时:
- 命中缓存后先返回旧值。
- 如果逻辑过期,尝试抢锁。
- 抢锁成功的线程异步重建缓存。
- 抢锁失败的线程继续返回旧值。
它牺牲短时间的数据新鲜度,换取热点接口的稳定性。这个短时间旧值窗口就是 stale data window。逻辑过期适合首页、活动页、排行榜,不适合价格强一致、库存强一致这类场景。
8. 一套组合拳
实际项目里不是只选一个方案。
| 问题 | 推荐组合 |
|---|---|
| 冷启动 | 预热 + 本地缓存 |
| 雪崩 | TTL 随机 + 限流 + Redis 高可用 |
| 穿透 | 参数校验 + 布隆过滤器 + 空对象短 TTL |
| 击穿 | 互斥锁或逻辑过期 |
| Redis 故障 | 熔断 + 降级 + DB 限流 |
可以把缓存治理理解成多层防护体系:
| 层级 | 作用 |
|---|---|
| L1 | 参数校验、风控、限流,减少无效请求进入系统 |
| L2 | 本地缓存、Redis、布隆过滤器,降低 DB 访问 |
| L3 | 熔断、降级、DB 限流,保证故障时系统可控 |
缓存的目标是减轻数据库压力,而非替代数据库。缓存异常治理的目标是让 Redis 出问题时系统可控,而不是假设 Redis 永远可用。
最后还要补监控。至少要持续看这些指标:
- cache hit ratio:命中率是否突然下降。
- DB QPS spike:缓存失效是否打穿到数据库。
- Redis latency:是否出现慢命令、网络抖动或连接池等待。
- hot key:是否存在单个 key 形成流量瓶颈。
- memory pressure:是否有内存逼近上限、淘汰抖动或缓存污染。
常见缓存写入模式也要分清:Cache Aside 是应用自己读写缓存;Write Through 是写请求同步写缓存和存储;Write Behind 是先写缓存再异步落库。本文主要讨论的是最常见的 Cache Aside 链路。
如果系统读流量很大,可以引入 multi-level cache,例如 Caffeine + Redis。但本地缓存会带来一致性和容量治理问题,必须控制 TTL、最大容量和失效策略,避免 bad key pollution 把无价值数据长期留在缓存里。