缓存雪崩

概述

如今许多互联网应用系统都重度依赖缓存来提高读操作的性能,对于这些系统来说如何正确地使用缓存至关重要。本文从缓存读取这个视角来讨论缓存架构设计上的一些思路。重点关注如何防止缓存雪崩。

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过期时间均匀分布
  • 排斥锁
  • 缓存过期标记+异步刷新
發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章