在某些 Key 属于极端热点数据,且并发量很大的情况下,如果这个 Key 过期,可能会在某个瞬间出现大量的并发请求同时回源,相当于大量的并发请求直接打到了数据库。这种情况,就是我们常说的缓存击穿或缓存并发问题。
我们来重现下这个问题。在程序启动的时候,初始化一个热点数据到 Redis 中,过期时间设置为 5 秒,每隔 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
| @Slf4j @RestController @RequestMapping("redis/jc") public class RedisJcDemo {
@Autowired private StringRedisTemplate stringRedisTemplate;
private AtomicInteger atomicInteger = new AtomicInteger();
@PostConstruct public void init() { stringRedisTemplate.opsForValue().set("hotsopt", getExpensiveData(), 5, TimeUnit.SECONDS); Executors.newSingleThreadScheduledExecutor().scheduleAtFixedRate(() -> { log.info("DB QPS : {}", atomicInteger.getAndSet(0)); }, 0, 1, TimeUnit.SECONDS); }
@GetMapping("wrong") public String wrong() { String data = stringRedisTemplate.opsForValue().get("hotsopt"); if (StringUtils.isEmpty(data)) { data = getExpensiveData(); stringRedisTemplate.opsForValue().set("hotsopt", data, 5, TimeUnit.SECONDS); } return data; }
private String getExpensiveData() { atomicInteger.incrementAndGet(); return "citydata" + System.currentTimeMillis(); } }
|
可以看到,每隔 5 秒数据库都有 10 左右的 QPS:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18
| 2022-09-11 22:19:40.191 INFO 34500 --- [pool-1-thread-1] c.s.e.d.interfaces.redis.RedisJcDemo : DB QPS : 0 2022-09-11 22:19:41.189 INFO 34500 --- [pool-1-thread-1] c.s.e.d.interfaces.redis.RedisJcDemo : DB QPS : 10 2022-09-11 22:19:42.189 INFO 34500 --- [pool-1-thread-1] c.s.e.d.interfaces.redis.RedisJcDemo : DB QPS : 0 2022-09-11 22:19:43.189 INFO 34500 --- [pool-1-thread-1] c.s.e.d.interfaces.redis.RedisJcDemo : DB QPS : 0 2022-09-11 22:19:44.189 INFO 34500 --- [pool-1-thread-1] c.s.e.d.interfaces.redis.RedisJcDemo : DB QPS : 0 2022-09-11 22:19:45.189 INFO 34500 --- [pool-1-thread-1] c.s.e.d.interfaces.redis.RedisJcDemo : DB QPS : 0 2022-09-11 22:19:46.190 INFO 34500 --- [pool-1-thread-1] c.s.e.d.interfaces.redis.RedisJcDemo : DB QPS : 10 2022-09-11 22:19:47.193 INFO 34500 --- [pool-1-thread-1] c.s.e.d.interfaces.redis.RedisJcDemo : DB QPS : 0 2022-09-11 22:19:48.189 INFO 34500 --- [pool-1-thread-1] c.s.e.d.interfaces.redis.RedisJcDemo : DB QPS : 0 2022-09-11 22:19:49.190 INFO 34500 --- [pool-1-thread-1] c.s.e.d.interfaces.redis.RedisJcDemo : DB QPS : 0 2022-09-11 22:19:50.190 INFO 34500 --- [pool-1-thread-1] c.s.e.d.interfaces.redis.RedisJcDemo : DB QPS : 0 2022-09-11 22:19:51.190 INFO 34500 --- [pool-1-thread-1] c.s.e.d.interfaces.redis.RedisJcDemo : DB QPS : 0 2022-09-11 22:19:52.190 INFO 34500 --- [pool-1-thread-1] c.s.e.d.interfaces.redis.RedisJcDemo : DB QPS : 2 2022-09-11 22:19:53.189 INFO 34500 --- [pool-1-thread-1] c.s.e.d.interfaces.redis.RedisJcDemo : DB QPS : 0 2022-09-11 22:19:54.190 INFO 34500 --- [pool-1-thread-1] c.s.e.d.interfaces.redis.RedisJcDemo : DB QPS : 0 2022-09-11 22:19:55.189 INFO 34500 --- [pool-1-thread-1] c.s.e.d.interfaces.redis.RedisJcDemo : DB QPS : 0 2022-09-11 22:19:56.188 INFO 34500 --- [pool-1-thread-1] c.s.e.d.interfaces.redis.RedisJcDemo : DB QPS : 0 2022-09-11 22:19:57.190 INFO 34500 --- [pool-1-thread-1] c.s.e.d.interfaces.redis.RedisJcDemo : DB QPS : 10
|
如果回源操作特别昂贵,那么这种并发就不能忽略不计。这时,我们可以考虑使用锁机制来限制回源的并发。比如如下代码示例,使用 Redisson 来获取一个基于 Redis 的分布式锁,在查询数据库之前先尝试获取锁:
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
| @Autowired private RedissonClient redissonClient;
@GetMapping("right") public String right() { String data = stringRedisTemplate.opsForValue().get("hotsopt"); if (StringUtils.isEmpty(data)) { RLock locker = redissonClient.getLock("locker"); if (locker.tryLock()) { try { data = stringRedisTemplate.opsForValue().get("hotsopt"); if (StringUtils.isEmpty(data)) { data = getExpensiveData(); stringRedisTemplate.opsForValue().set("hotsopt", data, 5, TimeUnit.SECONDS); } } finally { locker.unlock(); } } } return data; }
|
别忘了记得将 RedissonClient 加入到 Ioc 容器之中:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15
| @Configuration public class RedisConfig { @Bean(destroyMethod = "shutdown") public RedissonClient redisson(@Value("${spring.redis.host}") String url, @Value("${spring.redis.port}") String port) throws IOException { Config config = new Config(); config.useSingleServer().setAddress("redis://" + url + ":" + port);
RedissonClient redissonClient = Redisson.create(config); return redissonClient; } }
|
这样,可以把回源到数据库的并发限制在 1:
1 2 3 4 5 6 7 8 9 10 11
| 2022-09-11 22:50:17.890 INFO 41936 --- [pool-1-thread-1] c.s.e.d.interfaces.redis.RedisJcDemo : DB QPS : 1 2022-09-11 22:50:18.879 INFO 41936 --- [pool-1-thread-1] c.s.e.d.interfaces.redis.RedisJcDemo : DB QPS : 0 2022-09-11 22:50:19.877 INFO 41936 --- [pool-1-thread-1] c.s.e.d.interfaces.redis.RedisJcDemo : DB QPS : 0 2022-09-11 22:50:20.878 INFO 41936 --- [pool-1-thread-1] c.s.e.d.interfaces.redis.RedisJcDemo : DB QPS : 0 2022-09-11 22:50:21.877 INFO 41936 --- [pool-1-thread-1] c.s.e.d.interfaces.redis.RedisJcDemo : DB QPS : 0 2022-09-11 22:50:22.878 INFO 41936 --- [pool-1-thread-1] c.s.e.d.interfaces.redis.RedisJcDemo : DB QPS : 1 2022-09-11 22:50:23.877 INFO 41936 --- [pool-1-thread-1] c.s.e.d.interfaces.redis.RedisJcDemo : DB QPS : 0 2022-09-11 22:50:24.876 INFO 41936 --- [pool-1-thread-1] c.s.e.d.interfaces.redis.RedisJcDemo : DB QPS : 0 2022-09-11 22:50:25.876 INFO 41936 --- [pool-1-thread-1] c.s.e.d.interfaces.redis.RedisJcDemo : DB QPS : 0 2022-09-11 22:50:26.877 INFO 41936 --- [pool-1-thread-1] c.s.e.d.interfaces.redis.RedisJcDemo : DB QPS : 0 2022-09-11 22:50:27.878 INFO 41936 --- [pool-1-thread-1] c.s.e.d.interfaces.redis.RedisJcDemo : DB QPS : 1
|
在真实的业务场景下,不一定要这么严格地使用双重检查分布式锁进行全局的并发限制,因为这样虽然可以把数据库回源并发降到最低,但也限制了缓存失效时的并发。可以考虑的方式是:
- 方案一,使用进程内的锁进行限制,这样每一个节点都可以以一个并发回源数据库;
- 方案二,不使用锁进行限制,而是使用类似 Semaphore 的工具限制并发数,比如限制为 10,这样既限制了回源并发数不至于太大,又能使得一定量的线程可以同时回源。