0%

缓存设计-注意缓存雪崩问题

由于缓存系统的 IOPS(即每秒进行读写操作的次数) 比数据库高很多,因此要特别小心短时间内大量缓存失效的情况。这种情况一旦发生,可能就会在瞬间有大量的数据需要回源到数据库查询,对数据库造成极大的压力,极限情况下甚至导致后端数据库直接崩溃。这就是我们常说的缓存失效,也叫作缓存雪崩。

从广义上说,产生缓存雪崩的原因有两种:

  • 第一种是,缓存系统本身不可用,导致大量请求直接回源到数据库;
  • 第二种是,应用设计层面大量的 Key 在同一时间过期,导致大量的数据回源。

第一种原因,主要涉及缓存系统本身高可用的配置,不属于缓存设计层面的问题,所以今天我主要和你说说如何确保大量 Key 不在同一时间被动过期。

程序初始化的时候放入 1000 条城市数据到 Redis 缓存中,过期时间是 30 秒;数据过期后从数据库获取数据然后写入缓存,每次从数据库获取数据后计数器 +1;在程序启动的同时,启动一个定时任务线程每隔一秒输出计数器的值,并把计数器归零。

压测一个随机查询某城市信息的接口,观察一下数据库的 QPS:

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
@Slf4j
@RestController
public class RedisXbDemo {

@Autowired
private StringRedisTemplate stringRedisTemplate;

private AtomicInteger atomicInteger = new AtomicInteger();

@PostConstruct
public void wrongInit() {
//初始化1000个城市数据到Redis,所有缓存数据有效期30秒
IntStream.rangeClosed(1, 1000).forEach(i -> stringRedisTemplate.opsForValue().set("city" + i, getCityFromDb(i), 30, TimeUnit.SECONDS));
log.info("Cache init finished");
//每秒一次,输出数据库访问的QPS
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 = stringRedisTemplate.opsForValue().get(key);
if (data == null) {
//回源到数据库查询
data = getCityFromDb(id);
if (!StringUtils.isEmpty(data)) {
//缓存30秒过期
stringRedisTemplate.opsForValue().set(key, data, 30, TimeUnit.SECONDS);
}
}
return data;
}

private String getCityFromDb(int cityId) {
//模拟查询数据库,查一次增加计数器加一
atomicInteger.incrementAndGet();
return "citydata" + System.currentTimeMillis();
}
}

使用压测工具,启动程序 30 秒后缓存过期,回源的数据库 QPS 最高达到了 700 多:

1
2
3
4
2022-09-11 21:33:17.604  INFO 18856 --- [pool-1-thread-1] c.s.e.d.interfaces.redis.RedisXbDemo     : DB QPS : 0
2022-09-11 21:33:18.603 INFO 18856 --- [pool-1-thread-1] c.s.e.d.interfaces.redis.RedisXbDemo : DB QPS : 734
2022-09-11 21:33:19.604 INFO 18856 --- [pool-1-thread-1] c.s.e.d.interfaces.redis.RedisXbDemo : DB QPS : 271
2022-09-11 21:33:20.603 INFO 18856 --- [pool-1-thread-1] c.s.e.d.interfaces.redis.RedisXbDemo : DB QPS : 0

解决缓存 Key 同时大规模失效需要回源,导致数据库压力激增问题的方式有两种。

方案一,差异化缓存过期时间,不要让大量的 Key 在同一时间过期。比如,在初始化缓存的时候,设置缓存的过期时间是 30 秒 +10 秒以内的随机延迟(扰动值)。这样,这些 Key 不会集中在 30 秒这个时刻过期,而是会分散在 30~40 秒之间过期:

1
2
3
4
5
6
7
8
9
10
@PostConstruct
public void rightInit1() {
//初始化1000个城市数据到Redis,所有缓存数据有效期30秒
IntStream.rangeClosed(1, 1000).forEach(i -> stringRedisTemplate.opsForValue().set("city" + i, getCityFromDb(i), 30 + ThreadLocalRandom.current().nextInt(10), TimeUnit.SECONDS));
log.info("Cache init finished");
//每秒一次,输出数据库访问的QPS
Executors.newSingleThreadScheduledExecutor().scheduleAtFixedRate(() -> {
log.info("DB QPS : {}", atomicInteger.getAndSet(0));
}, 0, 1, TimeUnit.SECONDS);
}

修改后,缓存过期时的回源不会集中在同一秒,数据库的 QPS 从 700 多降到了最高 100 左右:

1
2
3
4
5
6
7
8
9
10
11
12
13
2022-09-11 21:35:10.554  INFO 784 --- [pool-1-thread-1] c.s.e.d.interfaces.redis.RedisXbDemo     : DB QPS : 0
2022-09-11 21:35:11.555 INFO 784 --- [pool-1-thread-1] c.s.e.d.interfaces.redis.RedisXbDemo : DB QPS : 77
2022-09-11 21:35:12.555 INFO 784 --- [pool-1-thread-1] c.s.e.d.interfaces.redis.RedisXbDemo : DB QPS : 108
2022-09-11 21:35:13.553 INFO 784 --- [pool-1-thread-1] c.s.e.d.interfaces.redis.RedisXbDemo : DB QPS : 110
2022-09-11 21:35:14.554 INFO 784 --- [pool-1-thread-1] c.s.e.d.interfaces.redis.RedisXbDemo : DB QPS : 82
2022-09-11 21:35:15.555 INFO 784 --- [pool-1-thread-1] c.s.e.d.interfaces.redis.RedisXbDemo : DB QPS : 110
2022-09-11 21:35:16.554 INFO 784 --- [pool-1-thread-1] c.s.e.d.interfaces.redis.RedisXbDemo : DB QPS : 95
2022-09-11 21:35:17.555 INFO 784 --- [pool-1-thread-1] c.s.e.d.interfaces.redis.RedisXbDemo : DB QPS : 99
2022-09-11 21:35:18.554 INFO 784 --- [pool-1-thread-1] c.s.e.d.interfaces.redis.RedisXbDemo : DB QPS : 102
2022-09-11 21:35:19.553 INFO 784 --- [pool-1-thread-1] c.s.e.d.interfaces.redis.RedisXbDemo : DB QPS : 105
2022-09-11 21:35:20.553 INFO 784 --- [pool-1-thread-1] c.s.e.d.interfaces.redis.RedisXbDemo : DB QPS : 95
2022-09-11 21:35:21.553 INFO 784 --- [pool-1-thread-1] c.s.e.d.interfaces.redis.RedisXbDemo : DB QPS : 22
2022-09-11 21:35:22.555 INFO 784 --- [pool-1-thread-1] c.s.e.d.interfaces.redis.RedisXbDemo : DB QPS : 0

方案二,让缓存不主动过期。初始化缓存数据的时候设置缓存永不过期,然后启动一个后台线程 30 秒一次定时把所有数据更新到缓存,而且通过适当的休眠,控制从数据库更新数据的频率,降低数据库压力:

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
@PostConstruct
public void rightInit2() throws InterruptedException {
CountDownLatch countDownLatch = new CountDownLatch(1);
//每隔30秒全量更新一次缓存
Executors.newSingleThreadScheduledExecutor().scheduleAtFixedRate(() -> {
IntStream.rangeClosed(1, 1000).forEach(i -> {
String data = getCityFromDb(i);
//模拟更新缓存需要一定的时间
try {
TimeUnit.MILLISECONDS.sleep(20);
} catch (InterruptedException e) { }
if (!StringUtils.isEmpty(data)) {
//缓存永不过期,被动更新
stringRedisTemplate.opsForValue().set("city" + i, data);
}
});
log.info("Cache update finished");
//启动程序的时候需要等待首次更新缓存完成
countDownLatch.countDown();
}, 0, 30, TimeUnit.SECONDS);

//每秒一次,输出数据库访问的QPS
Executors.newSingleThreadScheduledExecutor().scheduleAtFixedRate(() -> {
log.info("DB QPS : {}", atomicInteger.getAndSet(0));
}, 0, 1, TimeUnit.SECONDS);
countDownLatch.await();
}

这样修改后,虽然缓存整体更新的耗时在 21 秒左右,但数据库的压力会比较稳定:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
2022-09-11 21:36:48.330  INFO 17304 --- [pool-2-thread-1] c.s.e.d.interfaces.redis.RedisXbDemo     : DB QPS : 46
2022-09-11 21:36:49.329 INFO 17304 --- [pool-2-thread-1] c.s.e.d.interfaces.redis.RedisXbDemo : DB QPS : 46
2022-09-11 21:36:50.329 INFO 17304 --- [pool-2-thread-1] c.s.e.d.interfaces.redis.RedisXbDemo : DB QPS : 46
2022-09-11 21:36:51.329 INFO 17304 --- [pool-2-thread-1] c.s.e.d.interfaces.redis.RedisXbDemo : DB QPS : 47
2022-09-11 21:36:52.328 INFO 17304 --- [pool-2-thread-1] c.s.e.d.interfaces.redis.RedisXbDemo : DB QPS : 46
2022-09-11 21:36:53.328 INFO 17304 --- [pool-2-thread-1] c.s.e.d.interfaces.redis.RedisXbDemo : DB QPS : 46
2022-09-11 21:36:54.330 INFO 17304 --- [pool-2-thread-1] c.s.e.d.interfaces.redis.RedisXbDemo : DB QPS : 46
2022-09-11 21:36:55.331 INFO 17304 --- [pool-2-thread-1] c.s.e.d.interfaces.redis.RedisXbDemo : DB QPS : 46
2022-09-11 21:36:56.329 INFO 17304 --- [pool-2-thread-1] c.s.e.d.interfaces.redis.RedisXbDemo : DB QPS : 47
2022-09-11 21:36:57.337 INFO 17304 --- [pool-2-thread-1] c.s.e.d.interfaces.redis.RedisXbDemo : DB QPS : 35
2022-09-11 21:36:58.329 INFO 17304 --- [pool-2-thread-1] c.s.e.d.interfaces.redis.RedisXbDemo : DB QPS : 44
2022-09-11 21:36:59.329 INFO 17304 --- [pool-2-thread-1] c.s.e.d.interfaces.redis.RedisXbDemo : DB QPS : 46
2022-09-11 21:37:00.331 INFO 17304 --- [pool-2-thread-1] c.s.e.d.interfaces.redis.RedisXbDemo : DB QPS : 46
2022-09-11 21:37:01.330 INFO 17304 --- [pool-2-thread-1] c.s.e.d.interfaces.redis.RedisXbDemo : DB QPS : 46
2022-09-11 21:37:02.331 INFO 17304 --- [pool-2-thread-1] c.s.e.d.interfaces.redis.RedisXbDemo : DB QPS : 47
2022-09-11 21:37:03.329 INFO 17304 --- [pool-2-thread-1] c.s.e.d.interfaces.redis.RedisXbDemo : DB QPS : 47

关于这两种解决方案,我们需要特别注意以下三点:

  • 方案一和方案二是截然不同的两种缓存方式,如果无法全量缓存所有数据,那么只能使用方案一;
  • 即使使用了方案二,缓存永不过期,同样需要在查询的时候,确保有回源的逻辑。因为我们无法确保缓存系统中的数据永不丢失。
  • 不管是方案一还是方案二,在把数据从数据库加入缓存的时候,都需要判断来自数据库的数据是否合法,比如进行最基本的判空检查。

之前我就遇到过这样一个重大事故,某系统会在缓存中对基础数据进行长达半年的缓存,在某个时间点 DBA 把数据库中的原始数据进行了归档(可以认为是删除)操作。因为缓存中的数据一直在所以一开始没什么问题,但半年后的一天缓存中数据过期了,就从数据库中查询到了空数据加入缓存,爆发了大面积的事故。

这个案例说明,缓存会让我们更不容易发现原始数据的问题,所以在把数据加入缓存之前一定要校验数据,如果发现有明显异常要及时报警。

------ 本文结束------