概述
如今许多互联网应用系统都重度依赖缓存来提高读操作的性能,对于这些系统来说如何正确地使用缓存至关重要。本文从缓存读取这个视角来讨论缓存架构设计上的一些思路。重点关注如何防止缓存雪崩。
1. 缓存读操作
引入缓存后,读数据的流程如下:
- (1)先读缓存,如果缓存中有数据(hit),则返回缓存中的结果;
- (2)如果缓存中没有数据(miss),则回源到database获取,然后把结果写入缓存再返回。
2. 缓存雪崩
在正常情况下,一旦miss就去查DB是没有问题的。但是如果大量缓存集中在某一时间段失效,将导致所有请求都去访问后端的DB,DB压力会很大,甚至被压垮,造成雪崩。
- 场景一
电商系统的某个大促活动的首页,首页有很多新上架的商品。活动开始前,技术团队对缓存做了预热,由于是脚本化预热,这些商品的Cache数据几乎都是同时创建好,并且过期时间都设置为5分钟。这就会导致这大量的商品数据在5分钟后集中失效。
- 场景二
cache系统刚上线(或者刚从崩溃中恢复过来),没有对cache进行预热。cache中什么也没有,这时瞬时大流量过来也会产生雪崩。
3. 解决思路
3.1 cache过期时间均匀分布
针对上面的场景一,可以对cache的过期时间做一个均匀分布的处理。比如1-5分钟内,随机分布。
3.2 排斥锁
针对场景二,可以考虑使用排斥锁(mutex)。即第一个线程过来读取cache,发现没有,就去访问DB。后续线程再过来就需要等待第一个线程读取DB成功,cache里的value变得可用,后续线程返回新的value。伪代码如下:
public Object getCacheValue(String key, int expiredTime) {
Object cacheValue = cache.get(key);
if (cacheValue != null) {
return cacheValue;
} else {
try {
if (DistributeLock.lock(key)) {
cacheValue = cache.get(key);
if (cacheValue != null) { // double check
return cacheValue;
} else {
cacheValue = GetValueFromDB(); // 读数据库
cache.set(key, cacheValue, expiredTime);
}
}
} finally {
DistributeLock.unlock(key);
}
return cacheValue;
}
}
方案细节:
-
使用了分布式锁,这当然是考虑到在分布式环境下,读请求会落到集群中的不同应用服务机器上。分布式锁可以选用zookeeper或基于redis的setnx这类原子性操作来实现。
-
加锁时需要用到经典的double-check lock。
-
本方案虽然能够减轻DB压力,防止雪崩。但由于用到了加锁排队,吞吐率是不高的。仅适用于并发量不大的场景。
3.3 缓存过期标记+异步刷新
排斥锁方案对缓存过期是零容忍的:cache一旦过期,后续所有读操作就必须返回新的value。如果我们稍微放宽点限制:在cache过期时间T到达后,允许短时间内部分读请求返回旧值,我们就能提出兼顾吞吐率的方案。实际上既然用了cache,系统就默许了容忍cache和DB的数据短时间的不一致。
限制放宽后,下面我们提出一个优化思路。时间T到达后,cache中的key和value不会被清掉,而只是被标记为过期(逻辑上过期,物理上不过期),然后程序异步去刷新cache。而后续部分读线程在前面的线程刷新cache成功之前,暂时获取cache中旧的value返回。一旦cache刷新成功,后续所有线程就能直接获取cache中新的value。可以看到,这个思路很大程度上减少了排斥锁的使用(虽然并没有完全消除排斥锁)。
下面先看下伪代码:
public Object getCacheValue(String key, int expiredTime) {
final String signKey = "sign:" + key;
Object cacheValue = cache.get(key);
if (!isExpired(signKey, false)) { // 缓存标记未过期
return cacheValue;
} else {
// 缓存标记signKey已过期,异步更新缓存key
THREAD_POOL.execute(() -> {
try {
if (DistributeLock.lock(key)) {
if (isExpired(signKey, true)) { // double-check
Object cacheValue = GetValueFromDB(); // 读数据库
if (cacheValue != null) {
cache.set(key, cacheValue); // 设置缓存
setSign(signKey, expiredTime); // 设置缓存标记
}
}
}
} catch (Exception ex) {
logger.error(ex.getMessage(), ex);
} finally {
DistributeLock.unlock(key);
}
});
return cacheValue;
}
}
// 判断缓存标记是否过期
private boolean isExpired(String signKey, boolean prolongTime) {
Object time = cache.get(signKey);
if (null == time || Long.valueOf(time) < System.currentTimeMillis()) {
if (prolongTime) {
// 将过期时间后延一分钟,防止同一时间过期多次而出现多次重载
this.setSign(signKey, 1 * 60);
}
return true;
}
return false;
}
// 设置signKey的过期时间
private void setSign(String key, int expiredSeconds) {
DateTime dateTime = new DateTime();
dateTime = dateTime.plusSeconds(expiredSeconds);// 当前时间延后expiredSeconds秒
cache.set(key, String.valueOf(dateTime.getMillis()));
}
方案细节
- signKey:既然存放数据的cache不会被清掉,那么就通过别的key也就是代码中的signKey来标记过期。signKey的过期时间一到,就代表实际key逻辑过期。
- 异步刷新cache时也用到了排斥锁,这是因为同一时间多个读线程进来都发现signKey已过期,就都要去异步刷新cache,所以这里有必要加上排斥锁。但注意到isExpired方法中(35-41行),signKey一旦过期,马上把过期时间延后1分钟,这是为了让后续进来的线程先返回旧的value。这样只有极少一部分读线程去刷新cache。因此需要加排斥锁的线程也并不多。
4.小结
本文讨论了防止缓存雪崩的三个方案:
- cache过期时间均匀分布
- 排斥锁
- 缓存过期标记+异步刷新