Redis 作为缓存层,需要解决缓存穿透、击穿、雪崩三大经典问题,同时保证缓存与数据库的数据一致性。本文深入解析各种缓存策略与工程实践。
一、缓存失效三连问
1.1 缓存穿透 (Cache Penetration)
定义
查询一个根本不存在的数据,缓存和数据库都没有,导致每次请求都打到数据库。
场景
- 恶意攻击: 用随机 ID(如
id=-1 或超长字符串)发起大量请求。
- 业务漏洞: 前端未校验参数,直接穿透到后端。
危害
- 数据库承受大量无效查询,可能崩溃。
- 缓存层形同虚设。
解决方案
方案 1:缓存空对象
原理: 即使数据库查不到,也将空结果(null)写入缓存,设置较短的过期时间。
代码示例(Java + RedisTemplate):
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22
| public User getUserById(Long id) { String cacheKey = "user:" + id; User user = redisTemplate.opsForValue().get(cacheKey); if (user != null) { return user; } user = userMapper.selectById(id); if (user != null) { redisTemplate.opsForValue().set(cacheKey, user, 30, TimeUnit.MINUTES); } else { redisTemplate.opsForValue().set(cacheKey, new User(), 5, TimeUnit.MINUTES); } return user; }
|
优点:
缺点:
- 占用缓存空间(大量不存在的 key)。
- 需设置合理的过期时间(太长浪费内存,太短可能仍打到 DB)。
方案 2:布隆过滤器(推荐)
原理: 在请求到达缓存前,先用布隆过滤器判断数据是否可能存在。
布隆过滤器特性:
- 判断存在: 可能误判(实际不存在但判断存在)。
- 判断不存在: 100% 准确(一定不存在)。
架构流程:
1 2 3 4 5
| 客户端请求 ↓ 布隆过滤器(判断 key 是否存在) ├─ 不存在 → 直接返回 null(拦截恶意请求) └─ 可能存在 → 查询缓存 → 查询数据库
|
代码示例(Java + Redisson):
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38
| @Autowired private RedissonClient redisson;
@PostConstruct public void initBloomFilter() { RBloomFilter<Long> bloomFilter = redisson.getBloomFilter("user:bloom"); bloomFilter.tryInit(1000000L, 0.01); List<Long> userIds = userMapper.selectAllIds(); userIds.forEach(bloomFilter::add); }
public User getUserById(Long id) { RBloomFilter<Long> bloomFilter = redisson.getBloomFilter("user:bloom"); if (!bloomFilter.contains(id)) { return null; } String cacheKey = "user:" + id; User user = redisTemplate.opsForValue().get(cacheKey); if (user != null) { return user; } user = userMapper.selectById(id); if (user != null) { redisTemplate.opsForValue().set(cacheKey, user, 30, TimeUnit.MINUTES); } return user; }
|
优点:
- 高效拦截恶意请求(内存占用极小,100 万元素仅需 1.2MB)。
缺点:
- 需预热(将所有有效 key 加入过滤器)。
- 新增数据时需同步更新布隆过滤器。
方案 3:接口限流与参数校验
原理: 在网关层拦截恶意请求。
措施:
- 参数校验: 前端 + 后端双重校验(如 ID 范围、格式)。
- 限流: 使用令牌桶或漏桶算法(如 Guava RateLimiter、Sentinel)。
- 黑名单: 将恶意 IP 加入黑名单。
1.2 缓存击穿 (Cache Breakdown)
定义
一个热点 key 突然过期,瞬间大量并发请求同时打到数据库。
场景
危害
- 数据库瞬时压力飙升。
- 可能引发连锁反应(数据库慢查询 → 连接池耗尽)。
解决方案
方案 1:互斥锁(Mutex Lock)
原理: 当缓存失效时,只允许一个线程去查数据库并回写缓存,其他线程等待或重试。
代码示例(Java + RedisTemplate):
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45
| public User getUserById(Long id) { String cacheKey = "user:" + id; String lockKey = "lock:user:" + id; User user = redisTemplate.opsForValue().get(cacheKey); if (user != null) { return user; } Boolean locked = redisTemplate.opsForValue() .setIfAbsent(lockKey, "1", 10, TimeUnit.SECONDS); if (Boolean.TRUE.equals(locked)) { try { user = redisTemplate.opsForValue().get(cacheKey); if (user != null) { return user; } user = userMapper.selectById(id); if (user != null) { redisTemplate.opsForValue().set(cacheKey, user, 30, TimeUnit.MINUTES); } return user; } finally { redisTemplate.delete(lockKey); } } else { try { Thread.sleep(50); } catch (InterruptedException e) { Thread.currentThread().interrupt(); } return getUserById(id); } }
|
优点:
缺点:
- 其他线程需等待或重试(体验稍差)。
- 需保证锁的超时时间 > 数据库查询时间。
方案 2:逻辑过期(推荐)
原理: 热点数据永不过期,但在 value 内部记录逻辑过期时间。后台异步线程检测并更新数据。
数据结构:
1 2 3 4 5
| @Data public class CacheData<T> { private T data; private LocalDateTime expireTime; }
|
代码示例:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46
| private static final ExecutorService CACHE_REBUILD_EXECUTOR = Executors.newFixedThreadPool(10);
public User getUserById(Long id) { String cacheKey = "user:" + id; CacheData<User> cacheData = redisTemplate.opsForValue().get(cacheKey); if (cacheData == null) { return rebuildCache(id); } if (LocalDateTime.now().isAfter(cacheData.getExpireTime())) { String lockKey = "lock:user:" + id; Boolean locked = redisTemplate.opsForValue() .setIfAbsent(lockKey, "1", 10, TimeUnit.SECONDS); if (Boolean.TRUE.equals(locked)) { CACHE_REBUILD_EXECUTOR.submit(() -> { try { rebuildCache(id); } finally { redisTemplate.delete(lockKey); } }); } } return cacheData.getData(); }
private User rebuildCache(Long id) { User user = userMapper.selectById(id); if (user != null) { CacheData<User> cacheData = new CacheData<>(); cacheData.setData(user); cacheData.setExpireTime(LocalDateTime.now().plusMinutes(30)); redisTemplate.opsForValue().set("user:" + id, cacheData); } return user; }
|
优点:
- 用户无感知(始终返回数据,即使是旧数据)。
- 无阻塞(异步更新)。
缺点:
1.3 缓存雪崩 (Cache Avalanche)
定义
大量 key 同时过期 或 缓存服务宕机,导致请求全部打到数据库。
场景
- 系统重启后批量加载缓存,设置了相同的过期时间。
- Redis 服务器宕机。
危害
解决方案
方案 1:随机过期时间
原理: 在设置过期时间时,加上一个随机值(如 1-5 分钟)。
代码示例:
1 2 3
| int expireTime = 30 * 60 + ThreadLocalRandom.current().nextInt(0, 300); redisTemplate.opsForValue().set(cacheKey, user, expireTime, TimeUnit.SECONDS);
|
方案 2:高可用架构
措施:
- 主从 + 哨兵: 自动故障转移。
- Redis Cluster: 数据分片,单节点宕机影响有限。
- 多级缓存: 本地缓存(Caffeine)+ Redis。
方案 3:限流降级
原理: 当流量过大时,直接返回默认值或提示”系统繁忙”。
代码示例(Sentinel):
1 2 3 4 5 6 7 8 9 10 11
| @SentinelResource(value = "getUserById", blockHandler = "handleBlock", fallback = "handleFallback") public User getUserById(Long id) { }
public User handleBlock(Long id, BlockException ex) { return new User(); }
|
1.4 三者对比总结
| 问题 |
本质 |
核心对策 |
| 缓存穿透 |
数据根本不存在(恶意攻击) |
布隆过滤器、缓存空对象 |
| 缓存击穿 |
热点 key 过期(点的突破) |
互斥锁、逻辑过期 |
| 缓存雪崩 |
大量 key 同时过期(面的崩塌) |
随机过期时间、高可用架构、限流降级 |
二、缓存更新策略
2.1 三种经典模式
Cache Aside(旁路缓存,最常用)
读流程:
1 2 3
| 1. 查询缓存 ├─ 命中 → 返回 └─ 未命中 → 查数据库 → 写入缓存 → 返回
|
写流程:
为什么先删缓存?
- 避免数据不一致: 如果先更新 DB,再删缓存,删缓存失败会导致脏数据。
代码示例:
1 2 3 4 5 6 7 8 9
| public void updateUser(User user) { String cacheKey = "user:" + user.getId(); redisTemplate.delete(cacheKey); userMapper.updateById(user); }
|
Read/Write Through(读写穿透)
原理: 缓存层自动同步数据库,应用层只与缓存交互。
适用场景: 需要缓存中间件支持(如 Guava Cache + CacheLoader)。
Write Behind(异步刷新)
原理: 先写缓存,异步批量写数据库。
优点: 写性能极高(如游戏排行榜)。
缺点: 可能丢失数据(缓存宕机)。
2.2 缓存一致性问题
问题:先删缓存 vs 先更新数据库?
| 策略 |
流程 |
问题 |
推荐 |
| 先删缓存,再更新 DB |
1. 删缓存 → 2. 更新 DB |
线程 A 删缓存,线程 B 读到旧数据并写入缓存 |
✅ 推荐 |
| 先更新 DB,再删缓存 |
1. 更新 DB → 2. 删缓存 |
删缓存失败,缓存一直是旧数据(脏数据) |
❌ 不推荐 |
最佳实践:延迟双删
原理: 先删缓存 → 更新 DB → 延迟 N 秒 → 再删一次缓存。
代码示例:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19
| public void updateUser(User user) { String cacheKey = "user:" + user.getId(); redisTemplate.delete(cacheKey); userMapper.updateById(user); new Thread(() -> { try { Thread.sleep(1000); redisTemplate.delete(cacheKey); } catch (InterruptedException e) { Thread.currentThread().interrupt(); } }).start(); }
|
2.3 强一致性方案:分布式锁 + 版本号
原理: 使用分布式锁保证读写操作的原子性。
代码示例(Redisson):
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25
| public User getUserById(Long id) { String cacheKey = "user:" + id; String lockKey = "lock:user:" + id; RLock lock = redisson.getLock(lockKey); lock.lock(); try { User user = redisTemplate.opsForValue().get(cacheKey); if (user != null) { return user; } user = userMapper.selectById(id); if (user != null) { redisTemplate.opsForValue().set(cacheKey, user, 30, TimeUnit.MINUTES); } return user; } finally { lock.unlock(); } }
|
三、缓存预热与淘汰
3.1 缓存预热
场景: 系统启动时,提前将热点数据加载到缓存。
实现方式:
- 启动时查询数据库,批量写入 Redis。
- 使用定时任务(如每天凌晨)刷新热点数据。
代码示例:
1 2 3 4 5 6 7 8
| @PostConstruct public void warmUpCache() { List<User> hotUsers = userMapper.selectHotUsers(); hotUsers.forEach(user -> { String cacheKey = "user:" + user.getId(); redisTemplate.opsForValue().set(cacheKey, user, 30, TimeUnit.MINUTES); }); }
|
3.2 内存淘汰策略
配置:
1 2 3
| maxmemory 2gb maxmemory-policy allkeys-lru
|
常用策略:
| 策略 |
淘汰范围 |
推荐场景 |
| allkeys-lru |
所有 key |
通用场景(推荐) |
| volatile-lru |
设置过期时间的 key |
部分数据需永久保留 |
| allkeys-random |
所有 key |
测试环境 |
| volatile-ttl |
优先淘汰 TTL 短的 key |
Session、验证码等短期数据 |
四、面试高频问题
缓存穿透、击穿、雪崩的区别?
- 穿透:数据不存在(恶意);击穿:热点 key 过期(点);雪崩:大量 key 过期(面)。
布隆过滤器的误判率如何权衡?
- 误判率越低,占用内存越大。通常设置 0.01(1%)即可。
为什么先删缓存,再更新数据库?
如何保证缓存与数据库的强一致性?
- 使用分布式锁 + 延迟双删,或放弃缓存(直接查 DB)。
Redis 内存淘汰策略如何选择?
缓存击穿的逻辑过期方案为什么优于互斥锁?
- 逻辑过期无阻塞,用户体验更好(返回旧数据 vs 等待)。
通过合理的缓存策略,可以在高并发场景下保证系统的稳定性与一致性!🚀