You cannot select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.

26 KiB

23 | 缓存设计:缓存可以锦上添花也可以落井下石

你好,我是朱晔。今天,我从设计的角度,与你聊聊缓存。

通常我们会使用更快的介质比如内存作为缓存来解决较慢介质比如磁盘读取数据慢的问题缓存是用空间换时间来解决性能问题的一种架构设计模式。更重要的是磁盘上存储的往往是原始数据而缓存中保存的可以是面向呈现的数据。这样一来缓存不仅仅是加快了IO还可以减少原始数据的计算工作。

此外缓存系统一般设计简单功能相对单一所以诸如Redis这种缓存系统的整体吞吐量能达到关系型数据库的几倍甚至几十倍因此缓存特别适用于互联网应用的高并发场景。

使用Redis做缓存虽然简单好用但使用和设计缓存并不是set一下这么简单需要注意缓存的同步、雪崩、并发、穿透等问题。今天我们就来详细聊聊。

不要把Redis当作数据库

通常我们会使用Redis等分布式缓存数据库来缓存数据但是**千万别把Redis当做数据库来使用。**我就见过许多案例因为Redis中数据消失导致业务逻辑错误并且因为没有保留原始数据业务都无法恢复。

Redis的确具有数据持久化功能可以实现服务重启后数据不丢失。这一点很容易让我们误认为Redis可以作为高性能的KV数据库。

其实从本质上来看Redis免费版是一个内存数据库所有数据保存在内存中并且直接从内存读写数据响应操作只不过具有数据持久化能力。所以Redis的特点是处理请求很快但无法保存超过内存大小的数据。

备注VM模式虽然可以保存超过内存大小的数据但是因为性能原因从2.6开始已经被废弃。此外Redis企业版提供了Redis on Flash可以实现Key+字典+热数据保存在内存中冷数据保存在SSD中。

因此把Redis用作缓存我们需要注意两点。

第一从客户端的角度来说缓存数据的特点一定是有原始数据来源且允许丢失即使设置的缓存时间是1分钟在30秒时缓存数据因为某种原因消失了我们也要能接受。当数据丢失后我们需要从原始数据重新加载数据不能认为缓存系统是绝对可靠的更不能认为缓存系统不会删除没有过期的数据。

第二从Redis服务端的角度来说缓存系统可以保存的数据量一定是小于原始数据的。首先我们应该限制Redis对内存的使用量也就是设置maxmemory参数其次我们应该根据数据特点明确Redis应该以怎样的算法来驱逐数据。

Redis的文档可以看到,常用的数据淘汰策略有:

  • allkeys-lru针对所有Key优先删除最近最少使用的Key
  • volatile-lru针对带有过期时间的Key优先删除最近最少使用的Key
  • volatile-ttl针对带有过期时间的Key优先删除即将过期的Key根据TTL的值
  • allkeys-lfuRedis 4.0以上针对所有Key优先删除最少使用的Key
  • volatile-lfuRedis 4.0以上针对带有过期时间的Key优先删除最少使用的Key。

其实这些算法是Key范围+Key选择算法的搭配组合其中范围有allkeys和volatile两种算法有LRU、TTL和LFU三种。接下来我就从Key范围和算法角度和你说说如何选择合适的驱逐算法。

首先从算法角度来说Redis 4.0以后推出的LFU比LRU更“实用”。试想一下如果一个Key访问频率是1天一次但正好在1秒前刚访问过那么LRU可能不会选择优先淘汰这个Key反而可能会淘汰一个5秒访问一次但最近2秒没有访问过的Key而LFU算法不会有这个问题。而TTL会比较“头脑简单”一点优先删除即将过期的Key但有可能这个Key正在被大量访问。

然后从Key范围角度来说allkeys可以确保即使Key没有TTL也能回收如果使用的时候客户端总是“忘记”设置缓存的过期时间那么可以考虑使用这个系列的算法。而volatile会更稳妥一些万一客户端把Redis当做了长效缓存使用只是启动时候初始化一次缓存那么一旦删除了此类没有TTL的数据可能就会导致客户端出错。

所以不管是使用者还是管理者都要考虑Redis的使用方式使用者需要考虑应该以缓存的姿势来使用Redis管理者应该为Redis设置内存限制和合适的驱逐策略避免出现OOM。

注意缓存雪崩问题

由于缓存系统的IOPS比数据库高很多因此要特别小心短时间内大量缓存失效的情况。这种情况一旦发生可能就会在瞬间有大量的数据需要回源到数据库查询对数据库造成极大的压力极限情况下甚至导致后端数据库直接崩溃。这就是我们常说的缓存失效,也叫作缓存雪崩

从广义上说,产生缓存雪崩的原因有两种:

  • 第一种是,缓存系统本身不可用,导致大量请求直接回源到数据库;
  • 第二种是应用设计层面大量的Key在同一时间过期导致大量的数据回源。

第一种原因主要涉及缓存系统本身高可用的配置不属于缓存设计层面的问题所以今天我主要和你说说如何确保大量Key不在同一时间被动过期。

程序初始化的时候放入1000条城市数据到Redis缓存中过期时间是30秒数据过期后从数据库获取数据然后写入缓存每次从数据库获取数据后计数器+1在程序启动的同时启动一个定时任务线程每隔一秒输出计数器的值并把计数器归零。

压测一个随机查询某城市信息的接口观察一下数据库的QPS

@Autowired
private StringRedisTemplate stringRedisTemplate;
private AtomicInteger atomicInteger = new AtomicInteger();

@PostConstruct
public void wrongInit() {
    //初始化1000个城市数据到Redis所有缓存数据有效期30秒
    IntStream.rangeClosed(1, 1000).forEach(i -> stringRedisTemplate.opsForValue().set("city" + i, getCityFromDb(i), 30, TimeUnit.SECONDS));
    log.info("Cache init finished");
    
    //每秒一次输出数据库访问的QPS
    
   Executors.newSingleThreadScheduledExecutor().scheduleAtFixedRate(() -> {
        log.info("DB QPS : {}", atomicInteger.getAndSet(0));
    }, 0, 1, TimeUnit.SECONDS);
}

@GetMapping("city")
public String city() {
    //随机查询一个城市
    int id = ThreadLocalRandom.current().nextInt(1000) + 1;
    String key = "city" + id;
    String data = stringRedisTemplate.opsForValue().get(key);
    if (data == null) {
        //回源到数据库查询
        data = getCityFromDb(id);
        if (!StringUtils.isEmpty(data))
            //缓存30秒过期
            stringRedisTemplate.opsForValue().set(key, data, 30, TimeUnit.SECONDS);
    }
    return data;
}

private String getCityFromDb(int cityId) {
    //模拟查询数据库,查一次增加计数器加一
    atomicInteger.incrementAndGet();
    return "citydata" + System.currentTimeMillis();
}

使用wrk工具设置10线程10连接压测city接口

wrk -c10 -t10 -d 100s http://localhost:45678/cacheinvalid/city

启动程序30秒后缓存过期回源的数据库QPS最高达到了700多

解决缓存Key同时大规模失效需要回源导致数据库压力激增问题的方式有两种。

方案一差异化缓存过期时间不要让大量的Key在同一时间过期。比如在初始化缓存的时候设置缓存的过期时间是30秒+10秒以内的随机延迟扰动值。这样这些Key不会集中在30秒这个时刻过期而是会分散在30~40秒之间过期

@PostConstruct
public void rightInit1() {
    //这次缓存的过期时间是30秒+10秒内的随机延迟
    IntStream.rangeClosed(1, 1000).forEach(i -> stringRedisTemplate.opsForValue().set("city" + i, getCityFromDb(i), 30 + ThreadLocalRandom.current().nextInt(10), TimeUnit.SECONDS));
    log.info("Cache init finished");
    //同样1秒一次输出数据库QPS
   Executors.newSingleThreadScheduledExecutor().scheduleAtFixedRate(() -> {
        log.info("DB QPS : {}", atomicInteger.getAndSet(0));
    }, 0, 1, TimeUnit.SECONDS);
}

修改后缓存过期时的回源不会集中在同一秒数据库的QPS从700多降到了最高100左右

方案二让缓存不主动过期。初始化缓存数据的时候设置缓存永不过期然后启动一个后台线程30秒一次定时把所有数据更新到缓存而且通过适当的休眠控制从数据库更新数据的频率降低数据库压力

@PostConstruct
public void rightInit2() throws InterruptedException {
    CountDownLatch countDownLatch = new CountDownLatch(1);
    //每隔30秒全量更新一次缓存 
    Executors.newSingleThreadScheduledExecutor().scheduleAtFixedRate(() -> {
        IntStream.rangeClosed(1, 1000).forEach(i -> {
            String data = getCityFromDb(i);
            //模拟更新缓存需要一定的时间
            try {
                TimeUnit.MILLISECONDS.sleep(20);
            } catch (InterruptedException e) { }
            if (!StringUtils.isEmpty(data)) {
                //缓存永不过期,被动更新
                stringRedisTemplate.opsForValue().set("city" + i, data);
            }
        });
        log.info("Cache update finished");
        //启动程序的时候需要等待首次更新缓存完成
        countDownLatch.countDown();
    }, 0, 30, TimeUnit.SECONDS);

    Executors.newSingleThreadScheduledExecutor().scheduleAtFixedRate(() -> {
        log.info("DB QPS : {}", atomicInteger.getAndSet(0));
    }, 0, 1, TimeUnit.SECONDS);

    countDownLatch.await();
}

这样修改后虽然缓存整体更新的耗时在21秒左右但数据库的压力会比较稳定

关于这两种解决方案,我们需要特别注意以下三点

  • 方案一和方案二是截然不同的两种缓存方式,如果无法全量缓存所有数据,那么只能使用方案一;
  • 即使使用了方案二,缓存永不过期,同样需要在查询的时候,确保有回源的逻辑。正如之前所说,我们无法确保缓存系统中的数据永不丢失。
  • 不管是方案一还是方案二,在把数据从数据库加入缓存的时候,都需要判断来自数据库的数据是否合法,比如进行最基本的判空检查。

之前我就遇到过这样一个重大事故某系统会在缓存中对基础数据进行长达半年的缓存在某个时间点DBA把数据库中的原始数据进行了归档可以认为是删除操作。因为缓存中的数据一直在所以一开始没什么问题但半年后的一天缓存中数据过期了就从数据库中查询到了空数据加入缓存爆发了大面积的事故。

这个案例说明,缓存会让我们更不容易发现原始数据的问题,所以在把数据加入缓存之前一定要校验数据,如果发现有明显异常要及时报警。

说到这里我们再仔细看一下回源QPS超过700的截图可以看到在并发情况下总共1000条数据回源达到了1002次说明有一些条目出现了并发回源。这就是我后面要讲到的缓存并发问题。

注意缓存击穿问题

在某些Key属于极端热点数据且并发量很大的情况下如果这个Key过期可能会在某个瞬间出现大量的并发请求同时回源相当于大量的并发请求直接打到了数据库。这种情况,就是我们常说的缓存击穿或缓存并发问题

我们来重现下这个问题。在程序启动的时候初始化一个热点数据到Redis中过期时间设置为5秒每隔1秒输出一下回源的QPS

@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;
}

可以看到每隔5秒数据库都有20左右的QPS

如果回源操作特别昂贵那么这种并发就不能忽略不计。这时我们可以考虑使用锁机制来限制回源的并发。比如如下代码示例使用Redisson来获取一个基于Redis的分布式锁在查询数据库之前先尝试获取锁

@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;
}

这样可以把回源到数据库的并发限制在1

在真实的业务场景下,不一定要这么严格地使用双重检查分布式锁进行全局的并发限制,因为这样虽然可以把数据库回源并发降到最低,但也限制了缓存失效时的并发。可以考虑的方式是:

  • 方案一,使用进程内的锁进行限制,这样每一个节点都可以以一个并发回源数据库;
  • 方案二不使用锁进行限制而是使用类似Semaphore的工具限制并发数比如限制为10这样既限制了回源并发数不至于太大又能使得一定量的线程可以同时回源。

注意缓存穿透问题

在之前的例子中,缓存回源的逻辑都是当缓存中查不到需要的数据时,回源到数据库查询。这里容易出现的一个漏洞是,缓存中没有数据不一定代表数据没有缓存,还有一种可能是原始数据压根就不存在。

比如下面的例子。数据库中只保存有ID介于0不含和10000包含之间的用户如果从数据库查询ID不在这个区间的用户会得到空字符串所以缓存中缓存的也是空字符串。如果使用ID=0去压接口的话从缓存中查出了空字符串认为是缓存中没有数据回源查询其实相当于每次都回源

@GetMapping("wrong")
public String wrong(@RequestParam("id") int id) {
    String key = "user" + id;
    String data = stringRedisTemplate.opsForValue().get(key);
    //无法区分是无效用户还是缓存失效
    if (StringUtils.isEmpty(data)) {
        data = getCityFromDb(id);
        stringRedisTemplate.opsForValue().set(key, data, 30, TimeUnit.SECONDS);
    }
    return data;
}

private String getCityFromDb(int id) {
    atomicInteger.incrementAndGet();
    //注意只有ID介于0不含和10000包含之间的用户才是有效用户可以查询到用户信息
    if (id > 0 && id <= 10000) return "userdata";
    //否则返回空字符串
    return "";
}

压测后数据库的QPS达到了几千

如果这种漏洞被恶意利用的话,就会对数据库造成很大的性能压力。这就是缓存穿透

这里需要注意,缓存穿透和缓存击穿的区别:

  • 缓存穿透是指,缓存没有起到压力缓冲的作用;
  • 而缓存击穿是指,缓存失效时瞬时的并发打到数据库。

解决缓存穿透有以下两种方案。

方案一对于不存在的数据同样设置一个特殊的Value到缓存中比如当数据库中查出的用户信息为空的时候设置NODATA这样具有特殊含义的字符串到缓存中。这样下次请求缓存的时候还是可以命中缓存即直接从缓存返回结果不查询数据库

@GetMapping("right")
public String right(@RequestParam("id") int id) {
    String key = "user" + id;
    String data = stringRedisTemplate.opsForValue().get(key);
    if (StringUtils.isEmpty(data)) {
        data = getCityFromDb(id);
        //校验从数据库返回的数据是否有效
        if (!StringUtils.isEmpty(data)) {
            stringRedisTemplate.opsForValue().set(key, data, 30, TimeUnit.SECONDS);
        }
        else {
            //如果无效直接在缓存中设置一个NODATA这样下次查询时即使是无效用户还是可以命中缓存
            stringRedisTemplate.opsForValue().set(key, "NODATA", 30, TimeUnit.SECONDS);
        }
    }
    return data;
}

但,这种方式可能会把大量无效的数据加入缓存中,如果担心大量无效数据占满缓存的话还可以考虑方案二,即使用布隆过滤器做前置过滤。

布隆过滤器是一种概率型数据库结构由一个很长的二进制向量和一系列随机映射函数组成。它的原理是当一个元素被加入集合时通过k个散列函数将这个元素映射成一个m位bit数组中的k个点并置为1。

检索时我们只要看看这些点是不是都是1就大概知道集合中有没有它了。如果这些点有任何一个0则被检元素一定不在如果都是1则被检元素很可能在。

原理如下图所示:

布隆过滤器不保存原始值空间效率很高平均每一个元素占用2.4字节就可以达到万分之一的误判率。这里的误判率是指,过滤器判断值存在而实际并不存在的概率。我们可以设置布隆过滤器使用更大的存储空间,来得到更小的误判率。

你可以把所有可能的值保存在布隆过滤器中,从缓存读取数据前先过滤一次:

  • 如果布隆过滤器认为值不存在,那么值一定是不存在的,无需查询缓存也无需查询数据库;
  • 对于极小概率的误判请求才会最终让非法Key的请求走到缓存或数据库。

要用上布隆过滤器我们可以使用Google的Guava工具包提供的BloomFilter类改造一下程序启动时初始化一个具有所有有效用户ID的、10000个元素的BloomFilter在从缓存查询数据之前调用其mightContain方法来检测用户ID是否可能存在如果布隆过滤器说值不存在那么一定是不存在的直接返回

private BloomFilter<Integer> bloomFilter;

@PostConstruct
public void init() {
    //创建布隆过滤器元素数量10000期望误判率1%
    bloomFilter = BloomFilter.create(Funnels.integerFunnel(), 10000, 0.01);
    //填充布隆过滤器
    IntStream.rangeClosed(1, 10000).forEach(bloomFilter::put);
}

@GetMapping("right2")
public String right2(@RequestParam("id") int id) {
    String data = "";
    //通过布隆过滤器先判断
    if (bloomFilter.mightContain(id)) {
        String key = "user" + id;
        //走缓存查询
        data = stringRedisTemplate.opsForValue().get(key);
        if (StringUtils.isEmpty(data)) {
            //走数据库查询
            data = getCityFromDb(id);
            stringRedisTemplate.opsForValue().set(key, data, 30, TimeUnit.SECONDS);
        }
    }
    return data;
}

对于方案二,我们需要同步所有可能存在的值并加入布隆过滤器,这是比较麻烦的地方。如果业务规则明确的话,你也可以考虑直接根据业务规则判断值是否存在。

其实,方案二可以和方案一同时使用,即将布隆过滤器前置,对于误判的情况再保存特殊值到缓存,双重保险避免无效数据查询请求打到数据库。

注意缓存数据同步策略

前面提到的3个案例其实都属于缓存数据过期后的被动删除。在实际情况下修改了原始数据后考虑到缓存数据更新的及时性我们可能会采用主动更新缓存的策略。这些策略可能是

  • 先更新缓存,再更新数据库;
  • 先更新数据库,再更新缓存;
  • 先删除缓存,再更新数据库,访问的时候按需加载数据到缓存;
  • 先更新数据库,再删除缓存,访问的时候按需加载数据到缓存。

那么我们应该选择哪种更新策略呢我来和你逐一分析下这4种策略

“先更新缓存再更新数据库”策略不可行。数据库设计复杂,压力集中,数据库因为超时等原因更新操作失败的可能性较大,此外还会涉及事务,很可能因为数据库更新失败,导致缓存和数据库的数据不一致。

“先更新数据库再更新缓存”策略不可行。一是如果线程A和B先后完成数据库更新但更新缓存时却是B和A的顺序那很可能会把旧数据更新到缓存中引起数据不一致二是我们不确定缓存中的数据是否会被访问不一定要把所有数据都更新到缓存中去。

“先删除缓存再更新数据库,访问的时候按需加载数据到缓存”策略也不可行。在并发的情况下,很可能删除缓存后还没来得及更新数据库,就有另一个线程先读取了旧值到缓存中,如果并发量很大的话这个概率也会很大。

“先更新数据库再删除缓存,访问的时候按需加载数据到缓存”策略是最好的。虽然在极端情况下这种策略也可能出现数据不一致的问题但概率非常低基本可以忽略。举一个“极端情况”的例子比如更新数据的时间节点恰好是缓存失效的瞬间这时A先读取到了旧值随后在B操作数据库完成更新并且删除了缓存之后A再把旧值加入缓存。

需要注意的是,更新数据库后删除缓存的操作可能失败,如果失败则考虑把任务加入延迟队列进行延迟重试,确保数据可以删除,缓存可以及时更新。因为删除操作是幂等的,所以即使重复删问题也不是太大,这又是删除比更新好的一个原因。

因此,针对缓存更新更推荐的方式是,缓存中的数据不由数据更新操作主动触发,统一在需要使用的时候按需加载,数据更新后及时删除缓存中的数据即可。

重点回顾

今天,我主要是从设计的角度,和你分享了数据缓存的三大问题。

第一我们不能把诸如Redis的缓存数据库完全当作数据库来使用。我们不能假设缓存始终可靠也不能假设没有过期的数据必然可以被读取到需要处理好缓存的回源逻辑而且要显式设置Redis的最大内存使用和数据淘汰策略避免出现OOM的问题。

第二缓存的性能比数据库好很多我们需要考虑大量请求绕过缓存直击数据库造成数据库瘫痪的各种情况。对于缓存瞬时大面积失效的缓存雪崩问题可以通过差异化缓存过期时间解决对于高并发的缓存Key回源问题可以使用锁来限制回源并发数对于不存在的数据穿透缓存的问题可以通过布隆过滤器进行数据存在性的预判或在缓存中也设置一个值来解决。

第三,当数据库中的数据有更新的时候,需要考虑如何确保缓存中数据的一致性。我们看到,“先更新数据库再删除缓存,访问的时候按需加载数据到缓存”的策略是最为妥当的,并且要尽量设置合适的缓存过期时间,这样即便真的发生不一致,也可以在缓存过期后数据得到及时同步。

最后,我要提醒你的是,在使用缓存系统的时候,要监控缓存系统的内存使用量、命中率、对象平均过期时间等重要指标,以便评估系统的有效性,并及时发现问题。

今天用到的代码我都放在了GitHub上你可以点击这个链接查看。

思考与讨论

  1. 在聊到缓存并发问题时我们说到热点Key回源会对数据库产生的压力问题如果Key特别热的话可能缓存系统也无法承受毕竟所有的访问都集中打到了一台缓存服务器。如果我们使用Redis来做缓存那可以把一个热点Key的缓存查询压力分散到多个Redis节点上吗
  2. 大Key也是数据缓存容易出现的一个问题。如果一个Key的Value特别大那么可能会对Redis产生巨大的性能影响因为Redis是单线程模型对大Key进行查询或删除等操作可能会引起Redis阻塞甚至是高可用切换。你知道怎么查询Redis中的大Key以及如何在设计上实现大Key的拆分吗

关于缓存设计,你还遇到过哪些坑呢?我是朱晔,欢迎在评论区与我留言分享你的想法,也欢迎你把今天的内容分享给你的朋友或同事,一起交流。