由于缓存系统的 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() { IntStream.rangeClosed(1, 1000).forEach(i -> stringRedisTemplate.opsForValue().set("city" + i, getCityFromDb(i), 30, TimeUnit.SECONDS)); log.info("Cache init finished"); 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)) { 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() { 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"); 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); 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);
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 把数据库中的原始数据进行了归档(可以认为是删除)操作。因为缓存中的数据一直在所以一开始没什么问题,但半年后的一天缓存中数据过期了,就从数据库中查询到了空数据加入缓存,爆发了大面积的事故。
这个案例说明,缓存会让我们更不容易发现原始数据的问题,所以在把数据加入缓存之前一定要校验数据,如果发现有明显异常要及时报警。