0%

缓存设计-注意缓存击穿问题

在某些 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() {
// 初始化一个热点数据到 Redis 中,过期时间设置为 5 秒
stringRedisTemplate.opsForValue().set("hotsopt", getExpensiveData(), 5, TimeUnit.SECONDS);
// 每隔1秒输出一下回源的 QPS
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();
// 重新加入缓存,过期时间还是5秒
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");
//双重检查,因为可能已经有一个B线程过了第一次判断,在等锁,然后A线程已经把数据写入了Redis中
if (StringUtils.isEmpty(data)) {
//回源到数据库查询
data = getExpensiveData();
stringRedisTemplate.opsForValue().set("hotsopt", data, 5, TimeUnit.SECONDS);
}
} finally {
//别忘记释放,另外注意写法,获取锁后整段代码try+finally,确保unlock万无一失
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 {
//1、创建配置
//Redis url should start with redis:// or rediss://
Config config = new Config();
config.useSingleServer().setAddress("redis://" + url + ":" + port);

//2、根据 Config 创建出 RedissonClient 示例
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,这样既限制了回源并发数不至于太大,又能使得一定量的线程可以同时回源。
------ 本文结束------