在构建高并发应用时,Redis 作为缓存层是提升性能的关键。然而,如果对缓存机制理解不深,很容易遇到缓存穿透、缓存击穿和缓存雪崩这三大“拦路虎”。本文基于黑马点评项目实战,深入剖析这三种问题的成因、解决方案,并分享在 MySQL、PostgreSQL 等数据库优化中的最佳实践。无论你是初学者还是资深开发者,都能从中获得可落地的经验。
一、缓存穿透:当查询“不存在”时,如何保护数据库?
缓存穿透是指查询一个根本不存在的数据。由于缓存中也没有该数据,每次请求都会穿透缓存,直接打到数据库,导致数据库压力骤增。在 数据库优化 中,这是最常见的风险之一。
典型场景: 恶意攻击者故意请求不存在的ID(如负数ID),或者业务中频繁查询已删除的记录。
✅ 解决方案:
- 缓存空对象: 当数据库查询结果为空时,也缓存一个空值(如“null”),并设置较短的过期时间(比如5分钟)。这样后续相同请求会直接命中缓存,避免穿透。
- 布隆过滤器: 在缓存前加一层布隆过滤器,快速判断数据是否可能存在。如果过滤器认为不存在,直接返回,避免查询数据库。
⚠️ 注意事项: 缓存空对象会占用内存,需要配合过期策略;布隆过滤器存在误判率,需要根据业务调整参数。
在实战中,我们推荐优先使用缓存空对象,因为它实现简单,适合大多数场景。具体流程如下:

上图展示了查询商户缓存时的完整流程。当缓存未命中时,先查询数据库,如果数据库为空,则缓存空对象并返回;如果数据库有数据,则缓存数据并返回。
个人主页:北极的代码(欢迎来访)
作者简介:java后端学习者
❄️个人专栏:苍穹外卖日记,SSM框架深入,JavaWeb
✨命运的结局尽可永在,不屈的挑战却不可须臾或缺!

二、缓存击穿:热点Key“突然死亡”的应对策略
缓存击穿是指一个热点Key在失效的瞬间,大量并发请求同时涌入,全部穿透到数据库,导致数据库负载飙升。这与 Redis 的过期策略密切相关。
修改ShopController中的业务逻辑,满足下面的需求:
根据id查询店铺时,如果缓存未命中,则查询数据库,将数据库结果写入缓存,并设置超时时间根据id修改店铺时,先修改数据库,再删除缓存
在这里我们使用了事务,
因为店铺数据可能涉及多张表(shop、shop_config、shop_address、shop_audit_log 等)
事务回滚保证这些修改要么全成功,要么全失败
避免出现“店铺名称改了,但配置没改”的中间状态
典型场景: 某个商品详情页的缓存过期,瞬间有上千个用户同时访问该商品。
✅ 解决方案对比:
| 方案 | 实现难度 | 性能 | 数据一致性 | 适用场景 |
|---|---|---|---|---|
| 互斥锁 | ⭐ 简单 | 中(有等待) | 强一致 | 绝大多数场景 |
| 逻辑过期 | ⭐⭐ 中等 | 高(无等待) | 最终一致 | 可容忍短暂不一致 |
| 永不过期 | ⭐⭐ 中等 | 高 | 最终一致 | 真正的热点数据 |
| 分级缓存 | ⭐⭐⭐ 较复杂 | 极高 | 可能不一致 | 超高并发(秒杀) |
从对比表可以看出,不同的方案适用于不同的场景:
- 互斥锁(Mutex Lock): 利用 Redis 的 SETNX 命令实现分布式锁,只允许一个线程去查询数据库并重建缓存,其他线程等待。实现简单,99%的场景都适用。
- 逻辑过期: 在缓存中设置一个逻辑过期时间(而非物理过期),后台异步线程负责刷新缓存。适合对延迟极度敏感的业务。
- 永不过期 + 手动刷新: 针对超热点数据(如微博热搜),设置缓存永不过期,通过定时任务或事件驱动手动刷新。
- 分级缓存: 本地缓存(如 Caffeine) + Redis 的组合,减少对 Redis 的依赖。
在黑马点评项目中,我们采用互斥锁方案解决缓存击穿。流程图如下:

问:什么是缓存击穿?怎么解决?
答:缓存击穿是指热点 Key 在过期瞬间,大量并发请求同时打到数据库。我们项目采用互斥锁方案:
当缓存失效时,不立即查数据库,而是先获取分布式锁
只有拿到锁的线程才能查数据库,其他线程等待
拿到锁的线程查完数据库并写入缓存后,释放锁
其他线程拿到锁后发现缓存已有数据,直接返回
同时使用 Double Check 防止重复查询。对于超热点数据(如首页爆款商品),我们采用永不过期+定时刷新的策略,彻底避免击穿。在高并发场景下,互斥锁会增加少量延迟(约 10-20ms),但能有效保护数据库。
代码实现的核心逻辑如下(注意 Double Check 的重要性):
public Shop queryWithMutexCache(Long id) {
String key = RedisConstants.CACHE_SHOP_KEY+ id;
//从Redis中查询商品缓存信息
String shopJson = stringRedisTemplate.opsForValue().get(key);
//判断缓存是否存在
if(StrUtil.isNotBlank(shopJson)){
//存在,直接返回
//将json转为对象
Shop shop = JSONUtil.toBean(shopJson, Shop.class);
return shop;
}
//判断命中的是否为空
if (shopJson != null){
return null;
}
//缓存击穿利用互斥锁解决
//1.获取互斥锁
String lockKey=RedisConstants.LOCK_SHOP_KEY+id;
Shop shop = null;
try {
boolean isLock = tryLock(lockKey);
//3.判断锁是否获取成功
if (!isLock){
//获取锁失败,休眠并重试
Thread.sleep(50);
return queryWithMutexCache(id);
}
//4.获取锁成功,DoubleCheck双检测
String resultShopJson= stringRedisTemplate.opsForValue().get( key);
//5.存在,返回数据
if (StrUtil.isNotBlank(resultShopJson)){
return JSONUtil.toBean(resultShopJson, Shop.class);
}
// 4.5 DoubleCheck命中空值
if (resultShopJson != null) {
return null;
}
//不存在,查询数据库
shop = getById(id);
if (shop== null){
//将空值写入缓存(防止缓存穿透)
stringRedisTemplate.opsForValue().set(key,"",RedisConstants.CACHE_NULL_TTL,TimeUnit.MINUTES);
return null;}
//写入缓存
stringRedisTemplate.opsForValue().set(key,JSONUtil.toJsonStr(shop),RedisConstants.CACHE_SHOP_TTL, TimeUnit.MINUTES);
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
throw new RuntimeException("获取锁失败", e);
} finally {
//6.释放锁
unlock(lockKey);
}
return shop;
}
为什么需要 Double Check?
text
| 时间 | 有双重检查 | 无双重检查 |
|---|---|---|
| t=0ms | 3个线程发现缓存null | 3个线程发现缓存null |
| t=1ms | 线程A拿到锁 | 线程A拿到锁 |
| t=2ms | 线程A第2次检查→null | 线程A直接查DB(无第2次检查) |
| t=3ms | 线程A查DB | 线程A查DB |
| t=101ms | 线程B拿到锁 | 线程B拿到锁 |
| t=102ms | 线程B第2次检查→有数据✅ | 线程B直接查DB❌ |
| t=103ms | 线程B返回,不查DB | 线程B又查了一次DB |
| t=104ms | 线程C拿到锁 | 线程C拿到锁 |
| t=105ms | 线程C第2次检查→有数据✅ | 线程C直接查DB❌ |
从表格可见,无双重检查时,3个线程会执行3次DB查询;而有双重检查时,只有1次DB查询。这能显著降低数据库压力,是数据库优化的关键技巧。
❄️ 三、缓存雪崩:大规模缓存失效的“雪崩效应”
缓存雪崩是指大量缓存在同一时间过期,或者 Redis 服务宕机,导致所有请求直接打到数据库,造成数据库瞬间崩溃。这与 MySQL、PostgreSQL 等数据库的并发承载能力密切相关。
缓存穿透是指:查询一个根本不存在的数据,缓存层和数据库层都没有这个数据。
每次请求都会直接穿透缓存,打到数据库上。
请求流程:
text
危害
大量请求直接打到数据库
数据库连接被打满,CPU飙升
可能引发缓存穿透攻击(恶意请求大量不存在的ID)
方案1:缓存空对象(最常用)
核心思路:查询不到数据时,缓存一个 或特殊标记,设置较短的过期时间。
优点:简单、有效
缺点:占用少量内存(短TTL缓解)方案2:布隆过滤器(最彻底)
核心思路:将所有存在的 ID 存入布隆过滤器,快速判断一个 ID 是否一定不存在。
Maven 依赖
xml
实现代码
优点:内存占用极小(100万ID约1MB),绝对拦截不存在的key
缺点:有误判率(0.1%),需要定期重建方案3:Redis 布隆过滤器模块(生产级推荐)
Guava 的布隆过滤器是内存级的,多实例部署时每个 JVM 都要加载一遍。推荐使用 RedisBloom 模块。
安装 RedisBloom
bash
Java 代码
方案4:请求限流(防御恶意攻击)
使用 Sentinel 或 Guava RateLimiter 进行限流。
Guava 限流
Sentinel 限流(阿里开原,更强大)
xml
方案5:参数校验(基础防御)
三大原因:
- 大量 Key 同时过期: 比如所有缓存都设置了相同的 TTL(如 1 小时),导致同一时刻集体失效。
- Redis 实例宕机: 缓存层不可用,所有请求直接穿透到数据库。
- 缓存服务重启: 重启后缓存为空,瞬间涌入大量请求。
我们主要修改的是将空值写入缓存中,在数据库中查不到商铺的时候直接将空值写入Redis,
然后结束,还有一步逻辑,就是在判断缓存命中的时候,即便缓存是空值,也会命中的,所以我们要添加一个逻辑,也就判断命中的时候是否是空值。也就是:当缓存中存的是空字符串 时,直接返回失败,不再查数据库。
问题本质:空字符串 vs null 的区别
代码逻辑:
先判断 → 处理有数据的命中
再判断 → 处理空字符串命中
最后才是 → 查数据库
这是一个标准的缓存穿透防护模式。
四如果不处理空值,有什么后果?
后果1:数据库被打穿
数据库压力:假设QPS=1000,全部穿透到数据库,数据库瞬间崩溃。
后果2:缓存被无效数据占满
虽然写了空值缓存,但因为不判断空值,每次请求都会:
从缓存读到
不认为命中
查数据库
再次写入 (覆盖已有的 )
浪费网络IO和CPU。
后果3:日志爆炸
每次穿透都会打印SQL日志、慢查询日志,磁盘很快写满。
缓存雪崩:
缓存雪崩是指:大量的缓存 key 在同一时间集中过期,导致大量请求直接打到数据库,造成数据库压力骤增甚至崩溃。
典型场景
与缓存穿透、缓存击穿的区别
✅ 解决方案:
- 随机过期时间: 给每个 Key 的 TTL 增加一个随机偏移量(如 1 小时 ± 10 分钟),避免集体失效。
- 多级缓存: 在 Redis 前加一层本地缓存(如 Caffeine),即使 Redis 宕机,本地缓存仍能提供服务。
- 限流降级: 对数据库层做限流保护,超出阈值的请求直接返回错误或降级页面。
- Redis 高可用: 部署 Redis 主从 + 哨兵模式或 Redis Cluster,确保缓存层的高可用。
最佳实践: 在项目初期就规划好缓存的过期策略,避免后期因缓存雪崩导致线上事故。同时,建议对数据库(如 MySQL、PostgreSQL)做读写分离和连接池优化,提高抗压能力。
1. 大量key同时过期(最常见)
java
2. Redis 实例宕机
Redis 服务挂了
所有请求直接打到数据库
3. 网络故障
应用与 Redis 之间的网络断开
所有缓存操作超时,请求穿透
三、解决方案(6种)
方案1:设置随机过期时间(最常用,推荐)
核心思路:给过期时间加上一个随机偏移量,避免同时过期。
更精细的随机策略:
方案2:永不过期 + 异步更新(终极方案)
核心思路:缓存不设过期时间,由后台定时任务异步刷新。
优点:彻底避免雪崩
缺点:数据一致性稍差(有延迟)方案3:使用 Redis 集群(解决实例宕机)
Redis 集群是 Redis 提供的分布式存储方案,用于解决单机 Redis 的三大瓶颈:
容量瓶颈:单机内存有限(比如 64GB),存不下所有数据
性能瓶颈:单机 QPS 有限(比如 10w/s),扛不住高并发
可用性瓶颈:单机挂了,整个服务不可用
核心思想:将数据分片存储到多个 Redis 节点上,每个节点只存一部分数据。
Redis Cluster(官方集群)详解
4.1 架构图
text
4.2 核心概念
1. 槽位(Slot)
Redis Cluster 有 16384 个槽位(0 ~ 16383)
每个 key 通过 CRC16 算法计算属于哪个槽:
每个 Master 节点负责一部分槽位
java
2. 分片(Sharding)
text
3. 主从复制
每个 Master 至少有 1 个 Slave(从节点)
Master 挂了,Slave 自动升级为 Master
实现高可用
4.3 数据读写流程
text
如果连错节点:
text
形象理解:
流程:
所有写操作(set、del、incr)必须走 Master
Master 将数据异步同步给所有 Slave
所有读操作(get、mget)可以走 Slave
说明:我们这里只是简单的理解,后面我们还会更深入的学习
三种主流部署模式对比
yaml
或者使用 Redis Cluster:
java
方案4:多级缓存(本地缓存 + Redis)
核心思路:在应用内存中加一层 Caffeine 缓存,即使 Redis 挂了,本地缓存还能扛一阵。
xml
优点:Redis 挂了本地缓存还能用
缺点:占用 JVM 内存,多实例间数据不一致方案5:请求限流 + 熔断降级
使用 Sentinel 或 Hystrix 保护数据库。
方案6:缓存预热(提前加载)
核心思路:系统启动时或高峰期前,提前把热点数据加载到缓存。
定时预热:

四、实战总结与最佳实践
通过以上分析,我们可以总结出以下核心经验:
| 问题 | 答案 |
|---|---|
| 为什么双重检查时缓存还是null? | 因为当前线程是第一个拿到锁的线程,还没有任何线程写入过缓存 |
| 什么时候会出现这种情况? | 缓存真正为空时(从未查询过 或 缓存已过期) |
| 这种情况是好是坏? | ✅ 是好的!说明当前线程应该去查数据库 |
| 如果双重检查时不是null呢? | 说明其他线程已经查过了,当前线程直接返回,不查数据库 |
核心记忆: 缓存穿透查“不存在”,缓存击穿打“热点”,缓存雪崩是“集体失效”。
双重检查时缓存还是null → 说明我是第一个 → 我去查数据库
双重检查时缓存不是null → 说明别人查过了 → 我直接用
在实际项目中,建议结合 Redis 的监控工具(如 RedisInsight)和 数据库优化 工具(如 MySQL 的慢查询日志)持续观察缓存命中率和数据库负载,及时调整策略。
最后,推荐大家阅读《Redis 设计与实现》和《高性能 MySQL》这两本书,深入理解缓存与数据库的底层原理。
[AFFILIATE_SLOT_1]如果你正在构建高并发系统,不妨试试将本文的互斥锁方案集成到你的项目中。同时,关注 MongoDB 和 PostgreSQL 的缓存策略,它们与 Redis 的配合能进一步提升系统性能。
[AFFILIATE_SLOT_2]结语:如果本文对你有帮助,请点赞、关注、收藏,你的支持就是我最大的鼓励!
//不存在,查询数据库
Shop shop = getById(id);
if (shop== null){
return Result.fail("商铺信息不存在");}
//写入缓存
stringRedisTemplate.opsForValue().set(key,JSONUtil.toJsonStr(shop),RedisConstants.CACHE_SHOP_TTL, TimeUnit.MINUTES);
return Result.ok(shop);
@Transactional
public Result update(Shop shop) {
Long id = shop.getId();
if (id==null){
return Result.fail("店铺id不能为空");
}
//更新数据库
updateById( shop);
//删除缓存
stringRedisTemplate.delete(RedisConstants.CACHE_SHOP_KEY+id);
return Result.ok();
}
}
时间线:
[开启事务] → [修改 DB 表1] → [修改 DB 表2] → [提交事务] → [删除 Redis 缓存]
↑ ↑ ↑
事务保证原子性 提交成功才删缓存 可重试
如果 DB 修改失败 → 事务回滚 → 不删缓存(缓存数据依然正确)
如果 DB 成功但删缓存失败 → 记录日志 → 异步重试删除
场景:线程A和线程B同时发现缓存不存在
错误流程(没有Double Check):
t=0ms: 线程A: 获取锁成功 → 查数据库(耗时200ms)
t=1ms: 线程B: 获取锁失败 → 休眠50ms → 重试
t=51ms: 线程B: 重试时,缓存还是空的(线程A还没写完)
线程B: 再次获取锁 → 又查了一次数据库 ❌
正确流程(有Double Check):
t=0ms: 线程A: 获取锁成功 → 查数据库(耗时200ms)
t=1ms: 线程B: 获取锁失败 → 休眠50ms → 重试
t=51ms: 线程B: 重试 → 获取锁成功
线程B: Double Check → 发现缓存已有数据(线程A写入了)
线程B: 直接返回,不查数据库 ✅
请求 → 查缓存(没有) → 查数据库(也没有) → 返回空
↑ ↑
缓存未命中 每次都查DB
null
java
@Service
public class ShopService {
@Autowired
private StringRedisTemplate redisTemplate;
@Autowired
private ShopMapper shopMapper;
private static final String CACHE_KEY_PREFIX = "shop:";
private static final Long NORMAL_TTL = 3600L; // 正常数据1小时
private static final Long NULL_TTL = 60L; // 空对象1分钟
public Shop getShopById(Long id) {
String cacheKey = CACHE_KEY_PREFIX + id;
// 1. 查缓存
String cachedJson = redisTemplate.opsForValue().get(cacheKey);
if (cachedJson != null) {
// 判断是否是空对象标记
if ("NULL".equals(cachedJson)) {
return null;
}
// 反序列化返回
return JSON.parseObject(cachedJson, Shop.class);
}
// 2. 查数据库
Shop shop = shopMapper.selectById(id);
// 3. 缓存结果
if (shop != null) {
// 正常数据
redisTemplate.opsForValue().set(
cacheKey,
JSON.toJSONString(shop),
NORMAL_TTL,
TimeUnit.SECONDS
);
} else {
// 空对象缓存
redisTemplate.opsForValue().set(
cacheKey,
"NULL",
NULL_TTL,
TimeUnit.SECONDS
);
}
return shop;
}
}
com.google.guava
guava
33.0.0-jre
java
@Service
public class ShopService {
@Autowired
private StringRedisTemplate redisTemplate;
@Autowired
private ShopMapper shopMapper;
// 布隆过滤器:预计100万数据,误判率0.001
private BloomFilter bloomFilter = BloomFilter.create(
Funnels.longFunnel(),
1_000_000, // 预期插入数量
0.001 // 误判率
);
@PostConstruct
public void initBloomFilter() {
// 启动时加载所有存在的shop_id到布隆过滤器
List allIds = shopMapper.selectAllIds();
for (Long id : allIds) {
bloomFilter.put(id);
}
log.info("布隆过滤器初始化完成,加载了 {} 个ID", allIds.size());
}
public Shop getShopById(Long id) {
// 1. 布隆过滤器快速判断
if (!bloomFilter.mightContain(id)) {
// 一定不存在,直接返回
return null;
}
// 2. 查缓存
String cacheKey = "shop:" + id;
String cachedJson = redisTemplate.opsForValue().get(cacheKey);
if (cachedJson != null) {
return JSON.parseObject(cachedJson, Shop.class);
}
// 3. 查数据库(可能存在,也可能是误判)
Shop shop = shopMapper.selectById(id);
if (shop != null) {
// 缓存正常数据
redisTemplate.opsForValue().set(
cacheKey,
JSON.toJSONString(shop),
3600,
TimeUnit.SECONDS
);
}
return shop;
}
// 新增店铺时,同步更新布隆过滤器
public void addShop(Shop shop) {
shopMapper.insert(shop);
bloomFilter.put(shop.getId());
}
}
# Docker 方式
docker run -p 6379:6379 redislabs/rebloom:latest
java
@Service
public class ShopService {
@Autowired
private RedisTemplate redisTemplate;
@Autowired
private ShopMapper shopMapper;
private static final String BLOOM_KEY = "shop_bloom";
@PostConstruct
public void initBloom() {
// 初始化布隆过滤器(不存在则创建)
redisTemplate.execute((RedisCallback) connection -> {
connection.executeCommand(
"BF.RESERVE".getBytes(),
BLOOM_KEY.getBytes(),
"0.001".getBytes(), // 误判率
"1000000".getBytes() // 容量
);
return true;
});
// 加载所有ID到布隆过滤器
List allIds = shopMapper.selectAllIds();
for (Long id : allIds) {
redisTemplate.execute((RedisCallback) connection -> {
connection.executeCommand(
"BF.ADD".getBytes(),
BLOOM_KEY.getBytes(),
id.toString().getBytes()
);
return true;
});
}
}
public Shop getShopById(Long id) {
// 1. 布隆过滤器判断
boolean exists = redisTemplate.execute((RedisCallback) connection -> {
return connection.executeCommand(
"BF.EXISTS".getBytes(),
BLOOM_KEY.getBytes(),
id.toString().getBytes()
) == 1;
});
if (!exists) {
return null; // 一定不存在
}
// 2. 查缓存 + 查数据库(同方案1)
// ...
}
}
java
@Service
public class ShopService {
// 每秒最多10个请求(针对单个ID)
private final LoadingCache limiters = Caffeine.newBuilder()
.expireAfterWrite(1, TimeUnit.MINUTES)
.build(id -> RateLimiter.create(10.0)); // 每秒10个令牌
public Shop getShopById(Long id) {
// 限流检查
RateLimiter limiter = limiters.get(id);
if (!limiter.tryAcquire()) {
log.warn("ID {} 请求过于频繁,已被限流", id);
return null;
}
// 正常查询逻辑...
}
}
com.alibaba.csp
sentinel-core
1.8.6
java
@Service
public class ShopService {
@PostConstruct
public void init() {
// 配置限流规则
List rules = new ArrayList<>();
FlowRule rule = new FlowRule();
rule.setResource("getShopById");
rule.setGrade(RuleConstant.FLOW_GRADE_QPS);
rule.setCount(100); // 每秒100 QPS
rules.add(rule);
FlowRuleManager.loadRules(rules);
}
public Shop getShopById(Long id) {
try {
// 使用 Sentinel 保护
Entry entry = SphU.entry("getShopById");
try {
return doGetShop(id);
} finally {
entry.exit();
}
} catch (BlockException e) {
log.warn("被限流了");
return null;
}
}
private Shop doGetShop(Long id) {
// 正常查询逻辑...
}
}
java
public Shop getShopById(Long id) {
// 1. 基础校验
if (id == null || id <= 0) {
return null;
}
// 2. ID范围校验(如果是自增ID)
Long maxId = getMaxShopId(); // 缓存最大ID
if (id > maxId) {
return null;
}
// 3. 格式校验(如果是雪花算法ID)
if (String.valueOf(id).length() != 19) {
return null;
}
// 正常查询...
}
""
public Result queryById(Long id) {
String key = RedisConstants.CACHE_SHOP_KEY+ id;
//从Redis中查询商品缓存信息
String shopJson = stringRedisTemplate.opsForValue().get(key);
//判断缓存是否存在
if(StrUtil.isNotBlank(shopJson)){
//存在,直接返回
//将json转为对象
Shop shop = JSONUtil.toBean(shopJson, Shop.class);
}
//判断命中的是否为空
if (shopJson != null){
return Result.fail("商铺信息不存在");
}
//不存在,查询数据库
Shop shop = getById(id);
if (shop== null){
//将空值写入缓存
stringRedisTemplate.opsForValue().set(key,"",RedisConstants.CACHE_NULL_TTL,TimeUnit.MINUTES);
return Result.fail("商铺信息不存在");}
//写入缓存
stringRedisTemplate.opsForValue().set(key,JSONUtil.toJsonStr(shop),RedisConstants.CACHE_SHOP_TTL, TimeUnit.MINUTES);
return Result.ok(shop);
}
| 缓存值 | %%PROTECTED_INLINE_CODE_45%% | %%PROTECTED_INLINE_CODE_46%% | 应该怎么处理 |
|---|---|---|---|
| %%PROTECTED_INLINE_CODE_47%% | %%PROTECTED_INLINE_CODE_48%% | %%PROTECTED_INLINE_CODE_49%% | 命中,返回数据 |
| %%PROTECTED_INLINE_CODE_50%% (空字符串) | %%PROTECTED_INLINE_CODE_51%% | %%PROTECTED_INLINE_CODE_52%% | 命中空值,直接返回失败 |
| %%PROTECTED_INLINE_CODE_53%% (不存在) | %%PROTECTED_INLINE_CODE_54%% | %%PROTECTED_INLINE_CODE_55%% | 未命中,查数据库 |
isNotBlankshopJson != nullnull
java
// 恶意攻击:请求10万个不存在的ID
for (int i = 1000000; i < 1100000; i++) {
queryById(i);
}
// 第一次请求:查DB → 缓存空值(2分钟过期)
// 第二次请求(2分钟内):如果不处理空值 → 又查DB → 又缓存空值
// 结果:数据库被反复查询,缓存形同虚设
""""""
java
// 场景1:批量设置缓存,过期时间都一样
for (int i = 1; i <= 10000; i++) {
stringRedisTemplate.opsForValue().set(
"shop:" + i,
shopJson,
3600, // 都是1小时后过期
TimeUnit.SECONDS
);
}
// 1小时后,这10000个key同时过期
// 下一秒的请求全部穿透到数据库
| 概念 | 原因 | 特点 | 影响范围 |
|---|---|---|---|
| 缓存穿透 | 查询不存在的数据 | 缓存和DB都没有 | 单个key |
| 缓存击穿 | 热点key过期 | 缓存没有,DB有 | 单个热点key |
| 缓存雪崩 | 大量key同时过期 | 缓存没有,DB有 | 大量key |
// 错误示例:所有key都是同一个过期时间
redis.set("product:1", data, 3600);
redis.set("product:2", data, 3600);
redis.set("product:3", data, 3600);
// ... 10000个
java
@Service
public class ShopService {
private static final long BASE_TTL = 3600; // 基础1小时
private static final Random RANDOM = new Random();
public void saveShopToCache(Shop shop) {
String key = "shop:" + shop.getId();
// 1小时 + 随机0~300秒,避免同时过期
long randomOffset = RANDOM.nextInt(300); // 0-300秒
long ttl = BASE_TTL + randomOffset;
stringRedisTemplate.opsForValue().set(
key,
JSONUtil.toJsonStr(shop),
ttl,
TimeUnit.SECONDS
);
}
}
java
// 方案A:固定范围随机
long ttl = 3600 + ThreadLocalRandom.current().nextInt(600); // 1小时 ± 5分钟
// 方案B:按业务类型分组随机
long ttl = 3600 + (id % 300); // 根据ID取模,分散过期时间
// 方案C:按时间槽分散
int hour = LocalDateTime.now().getHour();
long ttl = 3600 + (hour * 60); // 不同时段不同TTL
java
@Service
public class ShopService {
// 缓存永不过期(或者设置很长的TTL,比如7天)
public void saveShopToCache(Shop shop) {
String key = "shop:" + shop.getId();
stringRedisTemplate.opsForValue().set(key, JSONUtil.toJsonStr(shop));
// 不设置过期时间
}
// 后台定时任务:每小时刷新一次热点数据
@Scheduled(cron = "0 0 * * * ?") // 每小时执行
public void refreshHotShopCache() {
// 获取所有热点店铺ID
List hotShopIds = getHotShopIds();
for (Long id : hotShopIds) {
Shop shop = getById(id);
if (shop != null) {
stringRedisTemplate.opsForValue().set(
"shop:" + id,
JSONUtil.toJsonStr(shop)
);
}
}
log.info("刷新了 {} 个热点店铺缓存", hotShopIds.size());
}
}
┌─────────────┐
│ 客户端 │
└──────┬──────┘
│
┌────────────┼────────────┐
│ │ │
┌────▼────┐ ┌────▼────┐ ┌────▼────┐
│ Node 1 │ │ Node 2 │ │ Node 3 │
│ 槽0-5460│ │槽5461- │ │槽10923- │
│ Master │ │10922 │ │16383 │
└────┬────┘ └────┬────┘ └────┬────┘
│ │ │
┌────▼────┐ ┌────▼────┐ ┌────▼────┐
│ Node 4 │ │ Node 5 │ │ Node 6 │
│ Slave │ │ Slave │ │ Slave │
└─────────┘ └─────────┘ └─────────┘
slot = CRC16(key) % 16384
// 计算 key 属于哪个槽
int slot = CRC16.getCRC16("user:1001") % 16384;
// 假设 slot = 12345,就去负责 12345 槽位的节点读取
节点1(Master):负责槽位 0-5460 → 约 1/3 的数据
节点2(Master):负责槽位 5461-10922 → 约 1/3 的数据
节点3(Master):负责槽位 10923-16383 → 约 1/3 的数据
客户端:set user:1001 "张三"
步骤1:计算槽位
CRC16("user:1001") % 16384 = 12345
步骤2:查询槽位映射(客户端缓存了)
槽位 12345 在节点2上
步骤3:直接连接节点2执行写入
步骤4:节点2写入成功后,异步同步给 Slave
客户端连了节点1,执行 set user:1001 "张三"
节点1计算槽位 = 12345,发现自己不负责
节点1返回:MOVED 12345 192.168.1.2:6379
客户端收到 MOVED,更新本地映射,重试连节点2
写操作 读操作
│ │
▼ ▼
┌─────────┐ ┌─────────┐
│ Master │ ──同步──→ │ Slave 1 │
│ (主节点) │ │ (从节点) │
└─────────┘ └─────────┘
│ │
│ ──同步──→ │
▼ ▼
┌─────────┐ ┌─────────┐
│ Slave 2 │ │ Client │
│ (从节点) │ │ 读请求 │
└─────────┘ └─────────┘
| 作用 | 说明 | 类比 |
|---|---|---|
| 读写分离 | Master 写,Slave 读,分担压力 | 老板签字,助理复印 |
| 数据备份 | Slave 实时同步 Master 数据 | 实时云备份 |
| 高可用 | Master 挂了,Slave 自动升级为 Master | 老板休假,助理顶班 |
| 模式 | 数据分片 | 高可用 | 水平扩展 | 复杂度 | 适用场景 |
|---|---|---|---|---|---|
| 主从复制 | ❌ 不分片 | ✅ 读写分离 | ❌ 只能扩读 | ⭐ 简单 | 读多写少 |
| 哨兵模式 | ❌ 不分片 | ✅ 自动故障转移 | ❌ 只能扩读 | ⭐⭐ 中等 | 需要自动切换 |
| Redis Cluster | ✅ 分片 | ✅ 自动故障转移 | ✅ 可扩写 | ⭐⭐⭐ 复杂 | 海量数据、高并发 |
# 主从复制 + 哨兵模式
spring:
redis:
sentinel:
master: mymaster
nodes:
- 192.168.1.10:26379
- 192.168.1.11:26379
- 192.168.1.12:26379
@Configuration
public class RedisConfig {
@Bean
public RedisConnectionFactory redisConnectionFactory() {
RedisClusterConfiguration clusterConfig = new RedisClusterConfiguration()
.clusterNode("192.168.1.10", 6379)
.clusterNode("192.168.1.11", 6379)
.clusterNode("192.168.1.12", 6379);
return new LettuceConnectionFactory(clusterConfig);
}
}
com.github.ben-manes.caffeine
caffeine
3.1.8
java
@Service
public class ShopService {
@Autowired
private StringRedisTemplate redisTemplate;
// 本地缓存:最大10000条,5分钟后过期
private final Cache localCache = Caffeine.newBuilder()
.maximumSize(10000)
.expireAfterWrite(5, TimeUnit.MINUTES)
.build();
public Shop getShopById(Long id) {
// 1. 查本地缓存
Shop shop = localCache.getIfPresent(id);
if (shop != null) {
return shop;
}
// 2. 查Redis
String key = "shop:" + id;
String shopJson = redisTemplate.opsForValue().get(key);
if (StrUtil.isNotBlank(shopJson)) {
shop = JSONUtil.toBean(shopJson, Shop.class);
localCache.put(id, shop); // 写入本地缓存
return shop;
}
// 3. 查数据库
shop = getById(id);
if (shop != null) {
// 写入Redis
redisTemplate.opsForValue().set(key, JSONUtil.toJsonStr(shop), 3600, TimeUnit.SECONDS);
localCache.put(id, shop);
}
return shop;
}
}
java
@Service
@Slf4j
public class ShopService {
@Autowired
private StringRedisTemplate redisTemplate;
public Shop getShopById(Long id) {
try {
// 限流:每秒最多100个请求
Entry entry = SphU.entry("getShop");
try {
return doGetShop(id);
} finally {
entry.exit();
}
} catch (BlockException e) {
// 被限流,返回降级数据
log.warn("请求被限流,id: {}", id);
return getFallbackShop(id);
}
}
private Shop doGetShop(Long id) {
// 正常查询逻辑...
String key = "shop:" + id;
String shopJson = redisTemplate.opsForValue().get(key);
if (StrUtil.isNotBlank(shopJson)) {
return JSONUtil.toBean(shopJson, Shop.class);
}
return getById(id);
}
// 降级方案:返回默认数据
private Shop getFallbackShop(Long id) {
Shop fallback = new Shop();
fallback.setId(id);
fallback.setName("系统繁忙,请稍后重试");
return fallback;
}
}
java
@Component
public class CachePreheatRunner implements CommandLineRunner {
@Autowired
private ShopService shopService;
@Override
public void run(String... args) throws Exception {
// 系统启动时,加载热门店铺到缓存
log.info("开始缓存预热...");
List hotShopIds = getHotShopIds(); // 比如销量前1000的店铺
for (Long id : hotShopIds) {
shopService.getShopById(id); // 触发缓存加载
}
log.info("缓存预热完成,共加载 {} 个店铺", hotShopIds.size());
}
private List getHotShopIds() {
// 可以从数据库查询热销店铺ID
return shopMapper.selectHotShopIds(1000);
}
}
java
@Component
public class CacheScheduler {
// 每天凌晨2点预热,早上高峰期缓存都在
@Scheduled(cron = "0 0 2 * * ?")
public void preheatBeforePeak() {
// 预热逻辑...
}
}
浙公网安备 33010602011771号