缓存击穿

在使用缓存时,我们往往是先根据key从缓存中取数据,如果拿不到就去数据源加载数据,写入缓存。但是在某些高并发的情况下,可能会出现缓存击穿的问题,比如一个存在的key,在缓存过期的一刻,同时有大量的请求,这些请求都会击穿到DB,造成瞬时DB请求量大、压力骤增。

一般解决方案

首先我们想到的解决方案就是加锁,一种办法是:拿到锁的请求,去加载数据,没有拿到锁的请求,就先等待。这种方法虽然避免了并发加载数据,但实际上是将并发的操作串行化,会增加系统延时。

singleflight

singleflight是groupcache这个项目的一部分,groupcache是memcache作者使用golang编写的分布式缓存。singleflight能够使多个并发请求的回源操作中,只有第一个请求会进行回源操作,其他的请求会阻塞等待第一个请求完成操作,直接取其结果,这样可以保证同一时刻只有一个请求在进行回源操作,从而达到防止缓存击穿的效果。下面是参考groupcache源码,使用Java实现的singleflight代码:

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
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
import java.util.concurrent.Callable;
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.ConcurrentMap;
import java.util.concurrent.CountDownLatch;

/**
* @author: layne.cai
* @create: 2021-06-17
*/
public class SingleFlight {

private final ConcurrentMap<Object, Call> calls = new ConcurrentHashMap<>();

@SuppressWarnings("unchecked")
public <V> V execute(Object key, Callable<V> callable) throws Exception {
Call<V> call = calls.get(key);
if (call == null) {
call = new Call<>();
Call<V> other = calls.putIfAbsent(key, call);
if (other == null) {
try {
return call.exec(callable);
} finally {
calls.remove(key);
}
} else {
call = other;
}
}
return call.await();
}

private static class Call<V> {

private V result;
private Exception exc;
private final CountDownLatch cdl = new CountDownLatch(1);

void finished(V result, Exception exc) {
this.result = result;
this.exc = exc;
cdl.countDown();
}

V await() throws Exception {
cdl.await();
if (exc != null) {
throw exc;
}
return result;
}

V exec(Callable<V> callable) throws Exception {
V result = null;
Exception exc = null;
try {
result = callable.call();
return result;
} catch (Exception e) {
exc = e;
throw e;
} finally {
finished(result, exc);
}
}
}


}

我们使用CountDownLatch来实现多个线程等待一个线程完成操作,CountDownLatch包含一个计数器,初始化时赋值,countDown()可使计数器减一,当count为0时唤醒所有等待的线程,await()可使线程阻塞。我们同样用CountDownLatch来模拟一个10次并发,测试代码如下:

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
public static void main(String[] args) {
int count = 10;
CountDownLatch cld = new CountDownLatch(count);
SingleFlight sf = new SingleFlight();
for (int i = 0; i < count; i++) {
new Thread(() -> {
try {
cld.await();
} catch (InterruptedException e) {
e.printStackTrace();
}
try {
String execute = sf.execute("key", () -> {
System.out.println("func");
return "bar";
});
System.out.println(execute);
} catch (Exception e) {
e.printStackTrace();
}

}).start();
cld.countDown();
}
}

测试结果如下:

1
2
3
4
5
6
7
8
9
10
11
func
bar
bar
bar
bar
bar
bar
bar
bar
bar
bar

可以看到回源操作只被执行了一次,其他9次直接取到了第一次操作的结果。

总结

可以看到singleflight可以有效解决高并发情况下的缓存击穿问题,singleflight这种控制机制不仅可以用在缓存击穿的问题上,理论上可以解决各种分层结构的高并发性能问题。