【缓存系列】几种缓存读写方案的优缺点和选择

3,360 阅读12分钟

前言

在日常开发中,缓存是提升系统瓶颈的最简单方法之一,如果缓存使用得当,缓存可以增加系统吞吐量,减少响应时间、减少数据库负载等。

但在不同的场景下,所适用缓存读写策略是不尽相同的,这篇文章将介绍不同缓存读写策略在不同场合下的使用与存在问题的分析,并给出解决方案~

Cache Aside Pattern

Cache Aside Pattern 意为旁路缓存模式,是我们平时最常用的一个缓存读写模式,应用程序需要一起操作 DB 和 Cache,并且以 DB 的数据为准。 下面我们来看一下这个模式下的缓存读写步骤。

方案

  1. 更新 DB

  2. 删除 Cache

  1. 从 Cache 中读取数据,有数据直接返回

  2. Cache中没数据的话,从 DB 中读取数据,数据更新到 Cache 中后返回。

代码实施

public class DataCacheManager {

    public Data query(Long id) {
        String key = "keyPrefix:" + id;
        //查询缓存
        Data data = cacheService.get(key);
        if(data == null) {
            //查询DB
            data = dataDao.get(id);
            //更新缓存
            cacheService.set(key, data);
        }
        return data;
    }

    public Data update(Data data) {
        //更新DB
        data = dataDao.update(data);
        //更新缓存
        String key = "keyPrefix:" + data.getId();
        cacheService.del(key);
    }

}

在工作中的时候,我的应用分层是按照《阿里巴巴Java开发手册》进行的,如下图所示。所以我一般会将缓存的这块逻辑单独抽离,介于Dao层和Service层之间的Manager中实现,这样多个Service通常可以复用这样的缓存代码。

image-20220718011247884.png

存在的问题

看完上面的方案,可能你心里会有一些疑惑,为什么是删除缓存,而不是更新缓存?为什么是先更新DB,再删除Cache,可以交换下顺序吗?按照Cache Aside Pattern的实现,先更新DB,后删除Cache就一定没有问题了吗?我们一个个来分析。

1.为什么是删除缓存,而不是更新缓存?

从直观的角度上来看,更新操作,直接把缓存一起更新了应该是个更容易理解的方案。但从性能和安全的角度上来看,直接更新缓存就不一定是合理的了。

性能

对于一些比较大的key,更新Cache比删除Cache更耗费性能。甚至当写操作比较多时,可能会存在刚更新的缓存还没有被读过,又再次被更新的情况(这常被称为缓存扰动),导致缓存利用率不高。所以,基于懒加载的思想,不用就没必要存在,所以Cache Aside更支持直接del。

实际上,一般key也不会太大,而且我在生产中使用缓存的场景读请求流量也不会低,所以对于性能的影响个人感觉还好。

安全

在并发场景下,多个写请求同时更新缓存可能会造成数据不一致的问题,看下面这个过程:

写请求1写请求2读请求3
更新数据A到DB
更新数据B到DB
更新数据B到Cache
更新数据A到Cache
查询Cache数据,读取到A

由于线程调度等原因,写请求 1 的更新Cache操作晚于写请求 2 中的更新Cache操作,这样会导致最终写入缓存中的是来自写请求 1 的数据A,从而使得后面的读操作读取到的都是旧值。

2.为什么是先更新DB,再删除Cache,可以交换下顺序吗?

同样地,先删除Cache,再更新DB,也可能会造成DB数据和Cache数据的不一致。为什么呢?看下面这个过程:

写请求1读请求2读请求3
删除Cache数据A
查询Cache数据A不存在
从DB读取数据A
更新数据A到Cache
更新数据B到DB
查询Cache数据,读取到A

当多个请求并发时,写请求1的两个操作之间穿插了请求2的所有操作(主要是写DB比较慢,需要分配更多的时间片才能执行完成),导致Cache的数据没有正确更新。只有等下次更新或者缓存自动过期后才会把最新的数据B存入缓存,如果是更新则有可能再次发生这样的问题,导致一直不一致...

3.按照Cache Aside Pattern的实现,先更新DB,后删除Cache就一定没有问题了吗?

理论上来说还是可能会出现数据不一致性的问题,看下面这个过程:

请求1请求2请求3
查询Cache,不存在
从DB读取数据A
更新数据B到DB
删除Cache数据A
更新数据A到Cache
查询Cache,读取到数据A

同样可能由于线程调度等原因,读请求 1 的更新Cache操作晚于写请求 2 中的删除Cache操作,这样会导致最终写入缓存中的是来自请求 1 的旧值A,而写入数据库中的是来自请求 2 的新值B,即缓存数据落后于数据库,此时再有读请求 3 命中缓存,读取到的便是旧值A。

但与之前不同的是,这种场景出现的概率要小许多,因为更新DB所需的线程调度时间要远大于更新Cache,所以一般情况下都是Cache先执行完成。

4.Cache Aside Pattern如何完全杜绝数据不一致问题?

两个字,加锁,单机加JVM锁,集群加分布式锁。

对于写操作,需要将更新DB和删除Cache锁住,

对于读操作,需要将查询Cache不存在之后的操作锁住。

并且需要注意读操作和写操作的锁需要使用一把!!!即读操作没有命中缓存的时候不能进行写操作,反之同理。

来优化下之前的代码,假设是集群环境,使用分布式锁。

public class DataCacheManager {

    public Data query(Long id) {
        String key = "keyPrefix:" + id;
        //查询缓存
        Data data = cacheService.get(key);
        if(data == null) {
              try {
                cacheService.lock(key, 2);
                //查询DB
                data = dataDao.get(id);
                //更新缓存
                cacheService.set(key, data); 
            } finally {
                cacheService.unLock(key);
            }
        }
        return data;
    }

    @RedisLock(keySuffix = "#data.id", keyPrefix = "keyPrefix:")
    public Data update(Data data) {
        //更新DB
        data = dataDao.update(data);
        //更新缓存
        String key = "keyPrefix:" + data.getId();
        cacheService.del(key);
    }
}

注:对@RedisLock不熟悉的推荐参考下巧用 分布式锁 🔥 - 掘金,能更简单的使用分布式锁~

不过,加锁势必会影响性能,导致系统吞吐量下降,并发高时可能还会造成堵塞线程过多从而OOM。

5.Cache Aside Pattern如何尽量降低数据不一致的影响?

既然加锁会降低性能,如果能接受短暂时间的数据不一致场景,应该怎么尽量降低其影响呢?

解决办法就是更新Cache的同时给Cache增加一个比较短的过期时间,这样可以保证即使数据不一致的话影响也比较小。

但如果在短时间内,有大量缓存同时过期,导致大量的请求直接查询数据库,从而对数据库造成了巨大的压力,严重情况下可能会导致数据库宕机,这种情况叫做缓存雪崩。缓存雪崩的解决方案稍后再讨论,接着往下看~

6.Cache Aside Pattern首次读请求问题

对于第一次读取的Cache,数据一定不在Cache中,如果服务发布后一下来很多个读请求,很可能同时绕过if(data == null)判断条件从而一起请求DB,造成压力过大。

如何解决这个问题呢?

可以将热点数据提前加载到Cache中,比如读取配置获取热点数据Key,然后使用@PostConstruct注解在服务启动之前加载数据。

public class DataCacheManager {

    @PostConstruct
    public void init() {
        //假设配置的热点id为520,666
        List<Long> hotIds = Lists.newArrayList(520L, 666L);
        for (Long hotId : hotIds) {
            Data data = dataDao.get(hotId);
            //更新缓存
            cacheService.set(key, data, 60);
        }
    }
    //...
}

适用场景

由上述分析,该方案适合读多写少的场景,并且尽量使用在对数据一致性要求没有那么高的场景,例如商品详情页的商品数据缓存,商品描述和价格等信息变化的频次一般都很低,即使价格有变化,在下单的时候订单系统会读取最新的商品价格,确保数据准确。

Read Through Pattern

Read-Through 意为读穿透模式,它的流程和 Cache-Aside 读操作基本类似,不同点在于 Read-Through 中多了一个访问控制层,应用读请求只和访问控制层进行交互,而背后缓存命中与否的逻辑则由访问控制层与数据源进行交互。

这样做可以使业务层的实现会更加简洁,并且对于缓存层及持久化层的交互封装做得更好,可以更轻松的扩展和迁移。

方案

  1. 应用程序读请求访问控制层

  2. 访问控制层从 Cache 中读取数据,读取到就直接返回。

  3. 读取不到的话,先从 DB 加载,写入到 Cache 后返回。

image-20220725135145017.png

举个例子,著名的本地缓存Guava Cache采用的就是该模式。

//初始化
LoadingCache<String, Data> loadingCache = CacheBuilder.newBuilder()
            .maximumSize(1000)
            .expireAfterAccess(1, TimeUnit.MINUTES)
            .build(
                    new CacheLoader<String, Data>() {
                        public Data load(String key) {
                            return dataDao.query(key);
                        }
                    }
            );

//读操作,里面包含了 获取缓存-如果没有-则计算"[get-if-absent-compute]的原子语义
loadingCache.get(key);

实际上,在Cache Aside Pattern 中的实现上我们通过将缓存的逻辑抽离到Manager层中,一定程度上也算是勉强达到了降低应用层复杂度的效果(主要是将Service层当作了应用层)。

适用场景

该方案适合读请求多的场景,并且对数据一致性要求没有那么高的场景。另外,该方案也同样存在首次读取问题,可以在初始化时模拟外部读请求使数据能提前加载。

Write Through Pattern

Write-Through 意为写穿透模式,它也增加了访问控制层来提供更高程度的封装。不同于 Cache-Aside 的是,Write-Through 直写模式在写请求更新Cache之后,更新DB,并且这两步操作需要控制层保证是一个原子操作。

方案

  1. 应用程序请求访问控制层
  2. 访问控制层 更新Cache
  3. 同步更新DB

image-20220725135413218.png

存在的问题

1.为什么先更新Cache,再更新DB?

这里顺序关系不大,不管是先更新Cache还是先更新DB,都可能存在之前Cache Aside问题一中提过的脏数据问题。解决办法就是问题四的方式,通过加锁解决并发问题。

2.如何保证两步操作的原子性?

俊峰之前也没尝试过这种方案,但可以提出一些自己的想法~

如果更新Cache失败了,由于是第一步,可以返回异常让客户端重试。

如果是更新DB失败了,需要看怎么设计,如果希望客户端重试的话,可以把更新Cache回滚。如果不希望客户端重试,可以把失败的请求发送到消息队列中,然后消费该消息补偿失败。

适用场景

Write Through通常会和Read Through一同使用,满足读写需求。该方案主要适用于写请求比较多的场景,并且对数据一致性要求较高的场景,比如银行系统。

俊峰平时在开发过程很少见到有项目使用Write Through方案,除了必须保证更新Cache和更新DB的原子性会造成一定性能方面的影响外,最主要的是缓存服务的封装是比较难实现的,或者可以接入一些现成的框架,比如Redis官网推荐的RedisGears

Write Behind Pattern(异步缓存写入)

Write Behind 又叫 Write Back,意为异步回写模式。它与Read-Through/Write-Through 一样,具有类似的访问控制层提供到应用程序。不同的是,Write behind 在处理写请求时,只更新Cache后就返回,对于数据库的更新,则是通过批量异步更新的方式进行的,批量写入的时间点可以选在数据库负载较低的时间进行。

方案

  1. 应用程序请求访问控制层

  2. 访问控制层 更新Cache

  3. 异步更新DB

image-20220725143210965.png

存在的问题

1.异步相比Write Through带来了哪些好处和问题?

在 Write-Behind 模式下,由于不用同步更新DB,写请求延迟大大降低,并减轻了数据库的压力,具有较好的吞吐性。

但数据库和缓存的一致性较弱,比如当更新的数据还未被写入数据库时,直接从数据库中查询数据是落后于缓存的。同时,缓存的负载较大,如果缓存宕机会导致数据丢失,所以需要做好缓存的高可用(以后会介绍~)。

适用场景

显然,根据上面的分析,Write behind 模式下非常适合大量写操作的场景,比如电商秒杀场景中库存的扣减。

在之前的文章——如何选择一个合适的库存扣减方案?中我们提到过Redis配合lua脚本的方案,实际上和Write Behind思想是一致的,都是先更新Cache,后异步更新DB,只是没有单独封装访问控制层。

总结

四种缓存方案各有优缺点,这也印证了那句老话,没有最完美的方案,只有最适合的方案。

俊峰在实际项目中使用的最多的就是Cache Aside(用于Redis),有时候会结合Read Through(用于本地缓存guava)构成二级缓存,后面会单独写一篇文章介绍~

最后

阅读完如果对您有帮助,记得点个赞和关注哦,有👍有动力

我正在参与掘金技术社区创作者签约计划招募活动,点击链接报名投稿