Redis缓存设计
在高并发系统中,Redis 作为核心缓存组件,能显著降低数据库压力、提升响应速度。但不合理的缓存设计易引发 穿透、击穿、雪崩 三大典型问题,可能导致数据库宕机、系统雪崩。
# 一、核心问题定义与本质区别
三大问题均源于“缓存未命中”,但触发场景、影响范围截然不同,需精准区分:
| 问题类型 | 定义 | 本质 | 典型场景 | 影响程度 |
|---|---|---|---|---|
| 缓存穿透 | 频繁请求“不存在的数据”,缓存无数据,请求直达数据库 | 缓存对“无效key”无防护,形成“缓存真空” | 恶意攻击(遍历不存在的ID)、业务误操作(查询非法参数) | 中等(持续消耗DB资源) |
| 缓存击穿 | 热点key突然过期,瞬间大量并发请求未命中缓存,全部穿透到数据库 | 热点key“失效时间点”与“高并发请求”重叠,形成流量尖峰 | 秒杀商品缓存过期、热门文章缓存失效 | 高(瞬间压垮DB) |
| 缓存雪崩 | 大量key同时过期,或缓存服务宕机,导致所有请求穿透到数据库 | 缓存层整体失效,流量集中冲击数据库 | 整点批量设置缓存过期、Redis 单点故障宕机 | 致命(系统整体雪崩) |
# 二、缓存穿透:无效请求的防护方案
缓存穿透的核心是“无效key绕过缓存”,解决方案围绕“拦截无效请求”“填补缓存真空”展开,推荐三级防护体系,按需组合使用。
# 2.1 解决方案1:接口参数校验(第一道防线)
核心原理:在请求抵达缓存/数据库前,通过业务规则拦截非法参数,从源头阻断无效请求,成本最低、见效最快。
# 实现要点
- 校验参数合法性:如用户ID、商品ID需为正整数,避免
-1、0、字符串等非法值。 - 限制查询范围:如时间范围不能超过3个月,分页查询页码不超过1000页。
- 黑名单拦截:维护恶意请求黑名单(如频繁查询无效ID的IP),直接拒绝。
# 代码示例
/**
* 商品查询接口参数校验
*/
@RestController
@RequestMapping("/goods")
public class GoodsController {
@Autowired
private GoodsService goodsService;
@GetMapping("/{id}")
public Result<Goods> getGoods(@PathVariable Long id) {
// 1. 基础参数校验:拦截非法ID
if (id == null || id <= 0) {
return Result.fail("商品ID必须为正整数");
}
// 2. 范围校验:假设商品ID最大为100万
if (id > 1000000) {
return Result.fail("商品ID超出合法范围");
}
// 3. 后续执行缓存查询逻辑
return goodsService.getGoodsById(id);
}
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
# 2.2 解决方案2:缓存空值(简单有效,中小场景首选)
核心原理:当数据库返回空结果时,在Redis中缓存“空值标记”并设置短期过期时间。后续请求会命中空值缓存,不再访问数据库,快速拦截无效请求。
# 实现要点
- 空值需设置短期过期时间(5~10分钟):避免真实数据新增后,缓存空值导致查询不到(数据一致性兜底)。
- 自定义
NullValue标记类:避免与Redis默认不存储null的特性冲突,同时区分“真实空数据”和“缓存空标记”。 - 结合参数校验使用:减少无效空值缓存的存储压力。
# 代码示例(Java + RedisTemplate)
@Service
public class GoodsService {
@Autowired
private RedisTemplate<String, Object> redisTemplate;
@Autowired
private GoodsMapper goodsMapper;
// 空值缓存过期时间:5分钟
private static final long NULL_CACHE_EXPIRE = 5;
// 正常数据缓存过期时间:30分钟
private static final long NORMAL_CACHE_EXPIRE = 30;
public Goods getGoodsById(Long id) {
String cacheKey = "goods:id:" + id;
// 1. 先查缓存
Object cacheValue = redisTemplate.opsForValue().get(cacheKey);
if (cacheValue != null) {
// 命中空值标记,直接返回null
if (cacheValue instanceof NullValue) {
return null;
}
// 命中正常数据,直接返回
return (Goods) cacheValue;
}
// 2. 缓存未命中,查询数据库
Goods goods = goodsMapper.selectById(id);
if (goods == null) {
// 3. 数据库无数据:缓存空值标记
redisTemplate.opsForValue().set(
cacheKey,
new NullValue(),
NULL_CACHE_EXPIRE,
TimeUnit.MINUTES
);
return null;
}
// 4. 数据库有数据:缓存真实值
redisTemplate.opsForValue().set(
cacheKey,
goods,
NORMAL_CACHE_EXPIRE,
TimeUnit.MINUTES
);
return goods;
}
// 自定义空值标记类(需序列化)
public static class NullValue implements Serializable {
private static final long serialVersionUID = 1L;
}
}
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
47
48
49
50
51
52
53
54
55
# 2.3 解决方案3:布隆过滤器(大数据量/高并发场景首选)
核心原理:布隆过滤器是概率性数据结构,通过“位数组+多个哈希函数”快速判断“元素是否可能存在于集合中”。不存在则直接返回,存在则走正常缓存流程,从根源拦截无效key。
# 核心特性
| 优点 | 缺点 |
|---|---|
| 空间利用率极高(以bit为单位存储) | 存在误判率(无法完全避免穿透,需兜底) |
| 查询速度快(O(k),k为哈希函数数量) | 不支持删除数据(需定期重建) |
| 支持海量数据(百万级元素仅需几MB内存) | 需提前初始化合法数据集合 |
# 实现方式:RedisBloom模块(生产环境推荐)
RedisBloom是Redis的官方扩展模块,原生支持布隆过滤器,无需手动实现复杂逻辑。
# 步骤1:安装RedisBloom模块
# 1. 下载RedisBloom模块(GitHub:https://github.com/RedisBloom/RedisBloom)
# 2. 启动Redis时加载模块
redis-server --loadmodule /path/to/redisbloom.so
# 或在redis.conf中配置(永久生效)
loadmodule /path/to/redisbloom.so
2
3
4
5
# 步骤2:核心代码实现(Java)
@Service
public class BloomFilterService {
@Autowired
private StringRedisTemplate redisTemplate;
// 布隆过滤器名称(存储所有合法商品ID)
private static final String BLOOM_FILTER_KEY = "bloom:filter:goods_ids";
// 预期元素数量(100万)
private static final long EXPECTED_SIZE = 1000000;
// 误判率(1%,根据业务调整)
private static final double FALSE_POSITIVE_RATE = 0.01;
// 初始化布隆过滤器(系统启动时执行)
@PostConstruct
public void initBloomFilter() {
// 检查过滤器是否已存在,不存在则创建
Boolean exists = redisTemplate.hasKey(BLOOM_FILTER_KEY);
if (Boolean.FALSE.equals(exists)) {
// 执行bf.reserve命令:创建布隆过滤器
redisTemplate.execute(
new DefaultRedisScript<>(
"return bf.reserve(KEYS[1], ARGV[1], ARGV[2])",
String.class
),
Collections.singletonList(BLOOM_FILTER_KEY),
String.valueOf(FALSE_POSITIVE_RATE),
String.valueOf(EXPECTED_SIZE)
);
// 批量添加合法商品ID(从数据库查询所有有效ID)
List<Long> validGoodsIds = goodsMapper.selectAllValidIds();
for (Long id : validGoodsIds) {
addToBloomFilter(id.toString());
}
log.info("布隆过滤器初始化完成,加载合法商品ID {} 个", validGoodsIds.size());
}
}
// 新增商品时,同步添加到布隆过滤器
public void addToBloomFilter(String value) {
redisTemplate.execute(
new DefaultRedisScript<>("return bf.add(KEYS[1], ARGV[1])", Long.class),
Collections.singletonList(BLOOM_FILTER_KEY),
value
);
}
// 判断元素是否可能存在(存在返回true,不存在返回false)
public boolean mightContain(String value) {
Long result = redisTemplate.execute(
new DefaultRedisScript<>("return bf.exists(KEYS[1], ARGV[1])", Long.class),
Collections.singletonList(BLOOM_FILTER_KEY),
value
);
return result != null && result == 1;
}
}
// 业务层整合布隆过滤器
@Service
public class GoodsService {
@Autowired
private BloomFilterService bloomFilterService;
public Goods getGoodsById(Long id) {
String cacheKey = "goods:id:" + id;
String bloomValue = id.toString();
// 1. 布隆过滤器拦截:不存在则直接返回(第一道拦截)
if (!bloomFilterService.mightContain(bloomValue)) {
log.info("布隆过滤器拦截无效商品ID:{}", id);
return null;
}
// 2. 查询缓存(第二道拦截,处理布隆误判)
Object cacheValue = redisTemplate.opsForValue().get(cacheKey);
if (cacheValue != null) {
return cacheValue instanceof NullValue ? null : (Goods) cacheValue;
}
// 3. 查询数据库并缓存结果(省略,同缓存空值方案)
return queryDbAndCacheResult(id, cacheKey);
}
}
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
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
# 误判率与内存占用参考
| 预期元素数量 | 误判率 | 内存占用(约) |
|---|---|---|
| 10万 | 0.01% | 190KB |
| 100万 | 0.01% | 1.8MB |
| 1000万 | 0.01% | 18MB |
| 1000万 | 1% | 12MB |
# 2.4 缓存穿透方案组合建议
- 中小场景:接口参数校验 + 缓存空值(低成本、易实现)。
- 大数据量/高并发场景:接口参数校验 + 布隆过滤器 + 缓存空值(三重防护,零穿透)。
# 三、缓存击穿:热点key的防护方案
缓存击穿的核心是“热点key失效瞬间的并发冲击”,解决方案围绕“避免热点key失效”“控制并发查询”展开,优先保证高可用和用户体验。
# 3.1 解决方案1:互斥锁(通用场景首选)
核心原理:缓存失效时,仅允许一个线程获取锁查询数据库并更新缓存,其他线程等待重试,避免大量并发请求直接打数据库。
# 实现要点
- 锁粒度:按热点key维度加锁(如
lock:goods:1001),避免全局锁降低并发。 - 锁过期时间:需大于“数据库查询+缓存更新”的总耗时(推荐3~5秒),避免锁提前释放导致并发问题。
- 重试策略:非阻塞重试(最多5次,间隔100~200ms),避免线程长期阻塞。
- 锁释放:必须在
finally块中释放,且仅释放当前线程持有的锁。
# 代码示例(Java + Redisson)
@Service
public class GoodsService {
@Autowired
private RedisTemplate<String, Object> redisTemplate;
@Autowired
private GoodsMapper goodsMapper;
@Autowired
private RedissonClient redissonClient;
// 锁前缀(按key维度加锁)
private static final String LOCK_PREFIX = "lock:goods:";
// 缓存过期时间(30分钟)
private static final long CACHE_EXPIRE = 30;
// 锁过期时间(3秒,需大于DB查询+缓存更新耗时)
private static final long LOCK_EXPIRE = 3;
// 最大重试次数
private static final int MAX_RETRY = 5;
// 重试间隔(100ms)
private static final long RETRY_INTERVAL = 100;
public Goods getGoodsById(Long id) {
String cacheKey = "goods:id:" + id;
String lockKey = LOCK_PREFIX + id;
// 1. 先查缓存:命中直接返回
Goods goods = (Goods) redisTemplate.opsForValue().get(cacheKey);
if (goods != null) {
return goods;
}
// 2. 缓存失效,获取互斥锁
RLock lock = redissonClient.getLock(lockKey);
try {
// 尝试获取锁:非阻塞(最多等待0秒),锁3秒后自动释放
boolean locked = lock.tryLock(0, LOCK_EXPIRE, TimeUnit.SECONDS);
if (locked) {
// 3. 获锁成功:查询数据库(双重检查缓存,避免锁等待期间缓存已更新)
goods = (Goods) redisTemplate.opsForValue().get(cacheKey);
if (goods != null) {
return goods;
}
// 数据库查询
goods = goodsMapper.selectById(id);
if (goods == null) {
// 数据库无数据:缓存空值(5分钟)
redisTemplate.opsForValue().set(
cacheKey,
new NullValue(),
5,
TimeUnit.MINUTES
);
return null;
}
// 数据库有数据:更新缓存
redisTemplate.opsForValue().set(
cacheKey,
goods,
CACHE_EXPIRE,
TimeUnit.MINUTES
);
return goods;
} else {
// 4. 获锁失败:重试(最多5次)
int retryCount = 0;
while (retryCount < MAX_RETRY) {
Thread.sleep(RETRY_INTERVAL);
goods = (Goods) redisTemplate.opsForValue().get(cacheKey);
if (goods != null) {
return goods;
}
retryCount++;
}
// 重试失败:返回兜底数据(保证用户体验)
return buildFallbackGoods(id);
}
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
return buildFallbackGoods(id);
} finally {
// 5. 释放锁(仅释放当前线程持有的锁)
if (lock.isHeldByCurrentThread()) {
lock.unlock();
}
}
}
// 构建兜底数据
private Goods buildFallbackGoods(Long id) {
Goods fallback = new Goods();
fallback.setId(id);
fallback.setName("系统繁忙,请稍后再试");
fallback.setPrice(BigDecimal.ZERO);
return fallback;
}
}
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
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
# 3.2 解决方案2:热点key永不过期(超高并发场景首选)
核心原理:不让热点key自动过期,从根源上避免失效瞬间的并发冲击,分为“物理永不过期”和“逻辑永不过期”两种方式。
# 方式1:物理永不过期(更新频率极低场景)
- 实现:缓存不设置
EXPIRE时间,热点数据永久存储在Redis中。 - 数据更新:数据库更新后,手动调用
SET命令覆盖缓存(如通过消息队列异步更新)。 - 适用场景:首页Banner、固定配置、更新频率极低的热点数据(如年度爆款商品)。
# 方式2:逻辑永不过期(更新频率中等场景)
- 实现:缓存设置物理过期时间(如30分钟),同时启动后台异步线程,定期(如29分钟)更新过期时间,保证“逻辑上不过期”。
- 优势:兼顾可用性和数据一致性,即使后台线程故障,物理过期时间仍能兜底。
# 代码示例(逻辑永不过期)
@Service
public class HotKeyRefreshService {
@Autowired
private RedisTemplate<String, Object> redisTemplate;
// 热点key列表(可从配置中心/数据库动态获取)
private static final List<String> HOT_KEYS = Arrays.asList(
"goods:id:1001", "goods:id:1002", "goods:id:1003"
);
// 定时任务:每29分钟刷新一次过期时间(Spring Schedule)
@Scheduled(fixedRate = 29 * 60 * 1000)
@Async // 异步执行,避免阻塞主线程
public void refreshHotKeyExpire() {
log.info("开始刷新热点key过期时间");
for (String key : HOT_KEYS) {
// 检查key是否存在,存在则延长过期时间至30分钟
Boolean exists = redisTemplate.hasKey(key);
if (Boolean.TRUE.equals(exists)) {
redisTemplate.expire(key, 30, TimeUnit.MINUTES);
log.info("刷新热点key:{} 过期时间至30分钟", key);
}
}
}
}
// 热点key存储逻辑
public void cacheHotGoods(Goods goods) {
String cacheKey = "goods:id:" + goods.getId();
// 设置物理过期时间30分钟
redisTemplate.opsForValue().set(cacheKey, goods, 30, TimeUnit.MINUTES);
}
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
# 3.3 解决方案3:逻辑过期+异步刷新(用户体验优先)
核心原理:缓存存储“数据+逻辑过期时间”,即使物理过期,仍返回旧数据,同时异步线程后台更新缓存,兼顾可用性和数据新鲜度(无等待时间)。
# 实现要点
- 缓存封装:自定义
CacheWrapper类,包含真实数据和逻辑过期时间。 - 物理过期时间:设为逻辑过期时间的2倍(兜底,避免缓存永久无效)。
- 异步更新:逻辑过期后,返回旧数据,同时异步获取锁更新缓存,避免并发更新。
# 代码示例
// 1. 封装缓存对象(含逻辑过期时间)
@Data
public class CacheWrapper<T> implements Serializable {
private T data; // 真实数据
private long logicExpireTime; // 逻辑过期时间(毫秒)
// 判断是否逻辑过期
public boolean isExpired() {
return System.currentTimeMillis() > logicExpireTime;
}
}
// 2. 业务层实现
@Service
public class GoodsService {
@Autowired
private RedissonClient redissonClient;
public Goods getGoodsById(Long id) {
String cacheKey = "goods:id:" + id;
String lockKey = LOCK_PREFIX + id;
// 1. 查询缓存
CacheWrapper<Goods> cacheWrapper = (CacheWrapper<Goods>) redisTemplate.opsForValue().get(cacheKey);
if (cacheWrapper == null) {
// 缓存未命中:走互斥锁查询DB(同3.1方案,省略)
return queryDbWithLock(id, cacheKey);
}
Goods goods = cacheWrapper.getData();
// 2. 逻辑未过期:直接返回数据
if (!cacheWrapper.isExpired()) {
return goods;
}
// 3. 逻辑过期:返回旧数据,异步更新缓存(不阻塞用户)
CompletableFuture.runAsync(() -> {
RLock lock = redissonClient.getLock(lockKey);
try {
// 尝试获取锁:非阻塞,避免并发更新
if (lock.tryLock(0, LOCK_EXPIRE, TimeUnit.SECONDS)) {
log.info("异步更新热点key缓存:{}", cacheKey);
// 查询最新数据
Goods newGoods = goodsMapper.selectById(id);
// 构建新缓存对象
CacheWrapper<Goods> newWrapper = new CacheWrapper<>();
newWrapper.setData(newGoods);
// 逻辑过期时间:30分钟后
newWrapper.setLogicExpireTime(System.currentTimeMillis() + 30 * 60 * 1000);
// 物理过期时间:60分钟(兜底)
redisTemplate.opsForValue().set(cacheKey, newWrapper, 60, TimeUnit.MINUTES);
}
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
log.error("异步更新缓存失败,key:{}", cacheKey, e);
} finally {
if (lock.isHeldByCurrentThread()) {
lock.unlock();
}
}
});
// 直接返回旧数据,保证用户体验
return goods;
}
}
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
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
# 3.4 缓存击穿方案组合建议
- 通用场景:互斥锁(兼顾一致性和并发)。
- 超高并发场景:逻辑永不过期 + 异步刷新(优先保证可用性)。
- 用户体验优先场景:逻辑过期 + 异步刷新(无等待时间)。
# 四、缓存雪崩:大规模失效的防护方案
缓存雪崩的核心是“缓存层整体失效”,解决方案围绕“分散失效时间”“提升缓存可用性”“限制数据库压力”展开,构建多层防护体系。
# 4.1 解决方案1:过期时间随机化(基础方案,必选)
核心原理:为每个缓存key设置“基础过期时间+随机偏移量”,避免大量key在同一时间点过期,分散流量峰值。
# 实现要点
- 基础过期时间:根据数据热度设置(热数据24小时,温数据30分钟,冷数据10分钟)。
- 随机偏移量:热数据偏移量大(1~2小时),冷数据偏移量小(5~10分钟),避免偏移后仍集中过期。
- 代码封装:统一工具类生成随机过期时间,避免重复代码。
# 代码示例
@Component
public class CacheTtlUtil {
// 随机数生成器
private static final Random RANDOM = new Random();
/**
* 生成随机过期时间(秒)
* @param baseTtl 基础过期时间(秒)
* @param level 数据热度等级
* @return 随机过期时间(秒)
*/
public int getRandomTtl(int baseTtl, HotspotLevel level) {
int randomRange = switch (level) {
case HIGH -> 2 * 3600; // 热数据:0~2小时偏移
case MEDIUM -> 3600; // 温数据:0~1小时偏移
case LOW -> 600; // 冷数据:0~10分钟偏移
};
// 随机偏移量:0 ~ randomRange 秒
return baseTtl + RANDOM.nextInt(randomRange);
}
// 数据热度等级枚举
public enum HotspotLevel {
HIGH, MEDIUM, LOW
}
}
// 使用示例
@Service
public class GoodsService {
@Autowired
private CacheTtlUtil cacheTtlUtil;
public void cacheGoods(Goods goods, CacheTtlUtil.HotspotLevel level) {
String cacheKey = "goods:id:" + goods.getId();
// 基础过期时间:热数据24小时,温数据30分钟,冷数据10分钟
int baseTtl = switch (level) {
case HIGH -> 24 * 3600;
case MEDIUM -> 30 * 60;
case LOW -> 10 * 60;
};
// 生成随机过期时间
int randomTtl = cacheTtlUtil.getRandomTtl(baseTtl, level);
// 缓存数据
redisTemplate.opsForValue().set(cacheKey, goods, randomTtl, TimeUnit.SECONDS);
}
}
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
47
48
49
# 4.2 解决方案2:多级缓存架构(提升可用性,推荐)
核心原理:构建“本地缓存(JVM)+ Redis”的二级缓存,即使Redis宕机,本地缓存仍能承接部分流量,避免缓存层整体失效。
# 实现要点
- 本地缓存选型:推荐Caffeine(高性能、支持过期策略),避免使用HashMap(无过期和淘汰机制)。
- 本地缓存配置:最大容量1~10万(避免占用过多JVM内存),过期时间2~5分钟(短于Redis缓存,保证数据一致性)。
- 一致性保障:数据库更新时,先更新Redis,再删除本地缓存(或通过消息队列通知所有节点删除本地缓存)。
# 代码示例(Caffeine + Redis)
// 1. 配置本地缓存(Spring Boot)
@Configuration
public class CaffeineConfig {
@Bean
public Cache<String, Object> localCache() {
return Caffeine.newBuilder()
.maximumSize(10000) // 最大缓存数量(根据内存调整)
.expireAfterWrite(3, TimeUnit.MINUTES) // 本地缓存过期时间:3分钟
.recordStats() // 开启统计(可选)
.build();
}
}
// 2. 业务层实现二级缓存
@Service
public class GoodsService {
@Autowired
private Cache<String, Object> localCache;
@Autowired
private RedisTemplate<String, Object> redisTemplate;
public Goods getGoodsById(Long id) {
String cacheKey = "goods:id:" + id;
// 1. 先查本地缓存(最快)
Object localValue = localCache.getIfPresent(cacheKey);
if (localValue != null) {
return localValue instanceof NullValue ? null : (Goods) localValue;
}
// 2. 再查Redis缓存
Object redisValue = redisTemplate.opsForValue().get(cacheKey);
if (redisValue != null) {
// 更新本地缓存(同步数据)
localCache.put(cacheKey, redisValue);
return redisValue instanceof NullValue ? null : (Goods) redisValue;
}
// 3. 最后查数据库并更新缓存(省略,同前)
Goods goods = queryDbAndCacheResult(id, cacheKey);
localCache.put(cacheKey, goods == null ? new NullValue() : goods);
return goods;
}
}
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
# 4.3 解决方案3:熔断降级与限流(保护数据库,兜底)
核心原理:当Redis宕机或数据库压力过大时,通过熔断降级限制请求量,返回兜底数据,避免数据库被压垮。
# 实现示例(Sentinel限流 + 熔断)
# 步骤1:添加依赖
<dependency>
<groupId>com.alibaba.csp</groupId>
<artifactId>sentinel-core</artifactId>
<version>1.8.6</version>
</dependency>
<dependency>
<groupId>com.alibaba.csp</groupId>
<artifactId>sentinel-annotation-aspectj</artifactId>
<version>1.8.6</version>
</dependency>
2
3
4
5
6
7
8
9
10
# 步骤2:配置限流规则与降级逻辑
// 1. 初始化Sentinel限流规则(系统启动时)
@Configuration
public class SentinelConfig {
@PostConstruct
public void initFlowRule() {
// 数据库查询接口限流规则:QPS上限1000(根据DB承载能力调整)
FlowRule flowRule = new FlowRule();
flowRule.setResource("goodsDbQuery"); // 资源名(与@SentinelResource注解一致)
flowRule.setGrade(RuleConstant.FLOW_GRADE_QPS); // 按QPS限流
flowRule.setCount(1000); // QPS上限
FlowRuleManager.loadRules(Collections.singletonList(flowRule));
// 熔断规则:异常比例超过50%,熔断10秒
DegradeRule degradeRule = new DegradeRule();
degradeRule.setResource("goodsDbQuery");
degradeRule.setGrade(RuleConstant.DEGRADE_GRADE_EXCEPTION_RATIO);
degradeRule.setCount(0.5); // 异常比例阈值
degradeRule.setTimeWindow(10); // 熔断时间(秒)
DegradeRuleManager.loadRules(Collections.singletonList(degradeRule));
}
}
// 2. 业务层添加Sentinel注解
@Service
public class GoodsService {
@Autowired
private GoodsMapper goodsMapper;
// 数据库查询方法:添加限流和熔断注解
@SentinelResource(
value = "goodsDbQuery",
fallback = "dbQueryFallback", // 降级兜底方法
blockHandler = "dbQueryBlockHandler" // 限流阻塞处理方法
)
public Goods queryDb(Long id) {
return goodsMapper.selectById(id);
}
// 降级兜底方法(异常时触发)
public Goods dbQueryFallback(Long id, Throwable e) {
log.error("数据库查询异常,id:{}", id, e);
return buildFallbackGoods(id);
}
// 限流阻塞处理方法(QPS超限时触发)
public Goods dbQueryBlockHandler(Long id, BlockException e) {
log.error("数据库查询限流,id:{}", id, e);
return buildFallbackGoods(id);
}
}
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
47
48
49
50
51
52
# 4.4 解决方案4:缓存高可用部署(避免单点故障,必选)
核心方案:采用Redis Cluster或“主从+哨兵”架构,确保缓存服务无单点故障,即使部分节点宕机,仍能提供服务。
| 部署方案 | 架构说明 | 优势 | 适用场景 | 部署成本 |
|---|---|---|---|---|
| 主从+哨兵 | 1主N从,哨兵监控主节点,故障自动切换 | 部署简单、成本低、兼容性好 | 中小规模系统 | 低 |
| Redis Cluster | 3主3从,数据自动分片,故障自愈 | 高可用、可横向扩展、支持海量数据 | 大规模、高并发系统 | 中 |
# Redis Cluster 配置示例(Spring Boot)
spring:
redis:
cluster:
nodes: 192.168.1.101:6379,192.168.1.102:6379,192.168.1.103:6379,192.168.1.104:6379,192.168.1.105:6379,192.168.1.106:6379
max-redirects: 3 # 最大重定向次数(集群分片跳转)
lettuce:
pool:
max-active: 32 # 连接池最大活跃数
max-idle: 8 # 连接池最大空闲数
min-idle: 4 # 连接池最小空闲数
timeout: 3000 # 连接超时时间(毫秒)
2
3
4
5
6
7
8
9
10
11
# 4.5 解决方案5:缓存预热(避免冷启动,推荐)
核心原理:系统启动时,主动加载热点数据到缓存,避免冷启动期间大量请求穿透到数据库(如秒杀系统启动后,秒杀商品缓存为空)。
# 实现要点
- 预热数据:仅加载热点数据(如秒杀商品、首页热门商品),避免全量数据预热占用过多内存。
- 预热时机:系统启动后(通过
CommandLineRunner或ApplicationRunner),或流量低峰期(如凌晨2点)。 - 异步预热:批量数据预热采用异步线程池,避免阻塞系统启动。
# 代码示例(Spring Boot 启动预热)
@Component
public class CacheWarmer implements CommandLineRunner {
@Autowired
private RedisTemplate<String, Object> redisTemplate;
@Autowired
private GoodsMapper goodsMapper;
@Autowired
private CacheTtlUtil cacheTtlUtil;
// 热点数据:秒杀商品ID列表(可从配置中心动态获取)
private static final List<Long> HOT_SECKILL_GOODS_IDS = Arrays.asList(1001L, 1002L, 1003L);
@Override
public void run(String... args) throws Exception {
// 异步预热热点数据(避免阻塞系统启动)
CompletableFuture.runAsync(this::warmUpSeckillGoods);
}
// 预热秒杀商品缓存
private void warmUpSeckillGoods() {
log.info("开始预热秒杀商品缓存,共{}个商品", HOT_SECKILL_GOODS_IDS.size());
for (Long id : HOT_SECKILL_GOODS_IDS) {
try {
Goods goods = goodsMapper.selectById(id);
String cacheKey = "goods:id:" + id;
// 设置随机过期时间(24小时~26小时)
int ttl = cacheTtlUtil.getRandomTtl(24 * 3600, CacheTtlUtil.HotspotLevel.HIGH);
redisTemplate.opsForValue().set(cacheKey, goods, ttl, TimeUnit.SECONDS);
log.info("预热商品缓存成功,ID:{}", id);
} catch (Exception e) {
log.error("预热商品缓存失败,ID:{}", id, e);
}
}
log.info("秒杀商品缓存预热完成");
}
}
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
# 4.6 缓存雪崩方案组合建议
- 基础组合:过期时间随机化 + Redis高可用部署(避免大部分雪崩场景)。
- 高可用组合:过期时间随机化 + 多级缓存 + 熔断降级 + Redis Cluster(核心系统首选)。
- 冷启动防护:缓存预热(秒杀、大促场景必选)。
# 五、实战最佳实践与监控告警
# 5.1 核心参数配置参考
| 参数类型 | 推荐值 | 说明 |
|---|---|---|
| 缓存空值过期时间 | 5~10分钟 | 平衡内存占用与数据新鲜度 |
| 布隆过滤器误判率 | 0.01%~1% | 百万级数据推荐1%(内存占用低) |
| 互斥锁过期时间 | 3~5秒 | 大于DB查询+缓存更新总耗时(预留冗余) |
| 本地缓存过期时间 | 2~5分钟 | 短于Redis缓存,避免数据不一致 |
| 本地缓存最大容量 | 1~10万 | 根据JVM内存调整(避免OOM) |
| 热点key缓存时间 | 24小时(逻辑永不过期) | 结合异步续期机制 |
| 限流QPS阈值 | 数据库最大承载QPS的80% | 预留缓冲空间 |
# 5.2 数据一致性保障
- 缓存更新策略:推荐“更新数据库 + 删除缓存”(Cache-Aside模式),避免“更新缓存 + 更新数据库”的一致性问题。
- 延迟删除:删除缓存时可添加短暂延迟(如100ms),解决“数据库更新未完成,缓存已被读取”的竞态条件。
- 分布式场景:使用消息队列通知所有节点删除本地缓存,保证集群节点数据一致。
# 5.3 监控告警体系(提前预警)
# 核心监控指标
- 缓存层:缓存命中率(阈值≥90%)、Redis内存使用率(阈值≤85%)、Redis节点存活状态、缓存过期key数量。
- 数据库层:DB QPS(监控突发增长)、DB慢查询次数(阈值≤10次/分钟)、DB连接数(阈值≤最大连接数的80%)。
- 业务层:接口响应时间(阈值≤500ms)、降级兜底触发次数(阈值≤100次/分钟)。
# 监控工具组合
- 采集:Prometheus + Redis Exporter + Spring Boot Actuator。
- 可视化:Grafana(配置仪表盘,实时查看指标)。
- 告警:AlertManager(支持邮件、短信、钉钉告警)。
# 关键告警规则
- 缓存命中率低于90%(持续5分钟)。
- Redis节点宕机(持续1分钟)。
- DB QPS突增50%以上(持续3分钟)。
- 接口响应时间超过1秒(持续5分钟)。
# 六、总结
Redis缓存三大问题的本质是“缓存未命中后的流量失控”,解决方案的核心思路是:提前拦截无效请求、控制并发查询流量、提升缓存可用性、保护数据库底线。
| 问题类型 | 核心解决方案 | 推荐组合 |
|---|---|---|
| 缓存穿透 | 布隆过滤器、缓存空值 | 接口校验 + 布隆过滤器 + 缓存空值 |
| 缓存击穿 | 互斥锁、逻辑永不过期 | 互斥锁 + 逻辑过期异步刷新 |
| 缓存雪崩 | 随机TTL、多级缓存、高可用部署 | 随机TTL + 多级缓存 + Redis Cluster + 熔断降级 |
实际落地时,需根据业务场景灵活选择方案:中小规模系统可优先使用“缓存空值+随机TTL+互斥锁”的轻量方案;大规模高并发系统则需组合“布隆过滤器+多级缓存+Redis Cluster+熔断降级”的全方位防护体系。
优先使用Redisson、RedisBloom、Caffeine等成熟框架,避免重复造轮子,同时通过监控告警提前发现风险,确保缓存体系稳定可靠。