在使用 Redis 缓存时,经常会遇到一些异常问题。
概括来说有 4 个方面:
- 缓存中的数据和数据库中的不一致;
- 缓存雪崩;
- 缓存击穿;
- 缓存穿透。
关于第一个问题【缓存中的数据和数据库中的不一致】,在之前的文章中,已经深入分析过了,可以参考: 深入解析缓存模式下的数据一致性问题
今天重点看下后面三个问题。
缓存雪崩
缓存雪崩是指大量的应用请求无法在 Redis 缓存中进行处理,紧接着,应用将大量请求发送到数据库层,导致数据库层的压力激增。
产生原因
产生缓存雪崩的原因有两种:
- 缓存系统本身不可用,导致大量请求直接回源到数据库;
- 应用设计层面大量的 Key 在同一时间过期,导致大量的数据回源。
针对第一个原因,可以通过主从节点的方式构建 Redis缓存高可用集群,如果主节点故障宕机了,从节点还可以切换成为主节点,继续提供缓存服务,避免了由于缓存实例宕机而导致的缓存雪崩问题。
重点看下第二种原因,在缓存数据同时过期的情况下,对系统性能的影响。
假如我们有一个查询城市数据的需求。
- 在系统初始化时,将 1000 条城市数据放入 Redis 缓存中,过期时间为 30s;
- Redis 中数据过期后,从数据库中获取数据,然后写入缓存,每查询一次数据库,计数器加 1;
- 程序启动的同时,启动一个定时任务线程每隔一秒输出计数器的值,并把计数器归零。
package com.redis.demo.controller;
@Slf4j
@RestController
public class RedisController {
@Autowired
private RedisUtil redisUtil;
private AtomicInteger atomicInteger = new AtomicInteger(); // 全局 QPS 计数器
@PostConstruct
public void init() {
//初始化1000个城市数据到Redis,所有缓存数据有效期30秒
IntStream.rangeClosed(1, 1000).forEach(i -> redisUtil.set("city" + i, getCityFromDb(i), 30));
//每秒一次,输出数据库访问的QPS,同时把计数器置0
Executors.newSingleThreadScheduledExecutor().scheduleAtFixedRate(() -> {
log.info("DB QPS : {}", atomicInteger.getAndSet(0));
}, 0, 1, TimeUnit.SECONDS);
}
@GetMapping("/city")
public String city() {
//随机查询一个城市
int id = ThreadLocalRandom.current().nextInt(1000) + 1;
String key = "city" + id;
String data = redisUtil.get(key);
if (data == null) {
//回源到数据库查询
data = getCityFromDb(id);
if (!StringUtils.isEmpty(data))
//缓存30秒过期
redisUtil.set(key, data, 30);
}
return data;
}
/**
* 从数据库中查询数据
* @param cityId 城市ID
* @return
*/
private String getCityFromDb(int cityId) {
try {
TimeUnit.MICROSECONDS.sleep(100); //模拟数据库查询操作
} catch (InterruptedException e) {
e.printStackTrace();
}
//模拟查询数据库,查一次计数器加一
atomicInteger.incrementAndGet();
return "citydata" + System.currentTimeMillis();
}
}
使用 wrk 工具,设置 4 线程 4 连接压测 city 接口:
wrk -c4 -t4 -d100s http://localhost:8080/city
缓存数据 30s 后过期,回源的数据库 QPS 最高达到了635.
2024-11-14 14:48:21.525 INFO 11546 --- [pool-1-thread-1] c.redis.demo.controller.RedisController : DB QPS : 307
2024-11-14 14:48:22.525 INFO 11546 --- [pool-1-thread-1] c.redis.demo.controller.RedisController : DB QPS : 635
2024-11-14 14:48:23.525 INFO 11546 --- [pool-1-thread-1] c.redis.demo.controller.RedisController : DB QPS : 74
解决方案
由于缓存 Key 同时大规模过期导致数据库压力激增的解决方式有两种:
随机化过期时间
随机化过期时间,不要让大量的 Key 在同一时间过期。
在系统初始化时,将过期时间设置为 30s + 10s 以内的随机值,这样的话,Key就会被分散到 30~40s 之间过期了。
@PostConstruct
public void init() {
//初始化1000个城市数据到Redis,所有缓存数据有效期30~40秒
IntStream.rangeClosed(1, 1000).forEach(i -> redisUtil.set("city" + i, getCityFromDb(i), 30 + ThreadLocalRandom.current().nextInt(10)));
}
缓存过期后,回源数据库不会集中在同一秒,数据库的 QPS 从 600 多降到了 100 左右。
2024-11-14 15:01:31.556 INFO 11845 --- [pool-1-thread-1] c.redis.demo.controller.RedisController : DB QPS : 93
2024-11-14 15:01:32.555 INFO 11845 --- [pool-1-thread-1] c.redis.demo.controller.RedisController : DB QPS : 95
2024-11-14 15:01:33.555 INFO 11845 --- [pool-1-thread-1] c.redis.demo.controller.RedisController : DB QPS : 107
2024-11-14 15:01:34.555 INFO 11845 --- [pool-1-thread-1] c.redis.demo.controller.RedisController : DB QPS : 91
2024-11-14 15:01:35.555 INFO 11845 --- [pool-1-thread-1] c.redis.demo.controller.RedisController : DB QPS : 104
2024-11-14 15:01:36.557 INFO 11845 --- [pool-1-thread-1] c.redis.demo.controller.RedisController : DB QPS : 120
2024-11-14 15:01:37.555 INFO 11845 --- [pool-1-thread-1] c.redis.demo.controller.RedisController : DB QPS : 89
2024-11-14 15:01:38.558 INFO 11845 --- [pool-1-thread-1] c.redis.demo.controller.RedisController : DB QPS : 114
2024-11-14 15:01:39.555 INFO 11845 --- [pool-1-thread-1] c.redis.demo.controller.RedisController : DB QPS : 99
2024-11-14 15:01:40.558 INFO 11845 --- [pool-1-thread-1] c.redis.demo.controller.RedisController : DB QPS : 54
2024-11-14 15:01:41.557 INFO 11845 --- [pool-1-thread-1] c.redis.demo.controller.RedisController : DB QPS : 13
缓存永不过期
让缓存永不过期,启动一个后台线程 60 秒一次定时把所有数据更新到缓存,而且通过适当的休眠,控制从数据库更新数据的频率,降低数据库压力。
@PostConstruct
public void rightInit2() throws InterruptedException {
CountDownLatch countDownLatch = new CountDownLatch(1);
//每隔60秒全量更新一次缓存
Executors.newSingleThreadScheduledExecutor().scheduleAtFixedRate(() -> {
log.info("定时去更新缓存");
IntStream.rangeClosed(1, 1000).forEach(i -> {
String data = getCityFromDb(i);
// 通过适当的休眠,来控制访问数据库的频率
try {
TimeUnit.MILLISECONDS.sleep(20);
} catch (InterruptedException e) {
}
if (!StringUtils.isEmpty(data)) {
//缓存永不过期,被动更新
redisUtil.set("city" + i, data);
}
});
log.info("定时更新缓存完成");
//启动程序的时候需要等待首次更新缓存完成
countDownLatch.countDown();
}, 0