「深入理解」缓存更新策略及缓存不一致问题解决方案

「深入理解」缓存更新策略及缓存不一致问题解决方案

一、缓存更新策略

缓存更新是redis为了节约内存而设计出来的一个东西,主要是因为内存数据宝贵,当我们向redis插入太多数据,此时就可能会导致缓存中的数据过多,所以redis会对部分数据进行更新,或者把他叫为淘汰更合适。

内存淘汰

Redis 缓存数据存储在内存中,当内存不足时,Redis 会根据设置的内存淘汰策略(如 LRU、LFU、随机等)自动清理数据。其中 LRU(Least Recently Used,最近最少使用)策略会优先清理掉最近最少被访问的数据,而 LFU(Least Frequently Used,最不常用)策略会优先清理掉最不常被访问的数据。

超时剔除

超时剔除策略通常适用于 Redis 缓存数据对实时性要求不是非常高,数据更新周期不太频繁的场景。当 Redis 缓存中的数据达到设置的过期时间时,会在下一次访问该数据时主动清除数据。由于超时剔除是被动更新策略,它不需要开发人员去主动更新缓存数据,适用于缓存数据的维护成本较高的应用情景。

主动更新

主动更新策略通常适用于 Redis 缓存和数据库之间要求数据保持一致的场景。当数据在数据库中发生变化时,应立即将变化记录到 Redis 缓存中,以避免缓存数据和数据库数据不一致。主动更新需要开发人员定期检查数据库数据更新情况,并将更新的数据插入到 Redis 缓存中。主动更新可以提高系统性能和用户体验,但需要注意主动更新频率,过于频繁的更新可能导致 Redis 缓存的性能下降。

内存淘汰超时剔除主动更新
说明内存淘汰是Redis内部的一种淘汰机制,当内存不足的时候自动淘汰部分数据。这种机制不需要自己维护,被淘汰的数据在下次查询的时候更新缓存。给缓存数据添加TT了时间,到期后Redis自动删除缓存数据。被淘汰的数据在下次查询时候更新缓存通过业务逻辑代码,在修改数据库的数据的同时,更新缓存
一致性一般
维护成本

业务场景:

内存淘汰

内存淘汰策略通常适用于 Redis 缓存数据量较大,但内存有限的情况。 在内存不足的情况下,Redis 会根据设置的内存淘汰策略自动清理数据,保证可用内存空间足够。常见的内存淘汰策略有 LRU 策略和 LFU 策略。LRU 策略用于清除最近最少使用的数据,而 LFU 策略用于清除最不常使用的数据。内存淘汰可以帮助避免 Redis 缓存因数据量过大导致的性能下降。

超时剔除

超时剔除策略通常适用于 Redis 缓存数据对实时性要求不是非常高,数据更新周期不太频繁的场景。当 Redis 缓存中的数据达到设置的过期时间时,会在下一次访问该数据时主动清除数据。由于超时剔除是被动更新策略,它不需要开发人员去主动更新缓存数据,适用于缓存数据的维护成本较高的应用情景。

主动更新

主动更新策略通常适用于 Redis 缓存和数据库之间要求数据保持一致的场景。当数据在数据库中发生变化时,应立即将变化记录到 Redis 缓存中,以避免缓存数据和数据库数据不一致。主动更新需要开发人员定期检查数据库数据更新情况,并将更新的数据插入到 Redis 缓存中。主动更新可以提高系统性能和用户体验,但需要注意主动更新频率,过于频繁的更新可能导致 Redis 缓存的性能下降。


二、三种数据库缓存不一致解决方案

由于我们的缓存的数据源来自于数据库,而数据库的数据是会发生变化的,因此,如果当数据库中数据发生变化,而缓存却没有同步,此时就会有一致性问题存在

其后果是:用户使用缓存中的过时数据,就会产生类似多线程数据安全问题,从而影响业务,产品口碑等;

怎么解决呢?有如下几种方案

Cache Aside Pattern :缓存调用者在更新完数据库后再去更新缓存,也称之为双写方案

Read/Write Through Pattern : 由系统本身完成,数据库与缓存的问题交由系统本身去处理

Write Behind Caching Pattern :调用者只操作缓存,其他线程去异步处理数据库,实现最终一致

image-20221102221822218

Cache Aside Pattern(缓存旁路模式)

在 Cache Aside Pattern 中,应用程序首先在缓存中查找所需数据,如果在缓存中找到,将数据返回给应用程序。如果在缓存中未找到,应用程序则从数据库中读取数据,并将数据同时放到缓存中,以便下次访问时可以直接从缓存中获取数据,这样可以大大提高访问速度。当从数据库中读取或更新数据时,需要将对应的缓存项进行清除或更新。这种模式比较简单并且易于实现。但是,由于没有处理并发更新,应用程序需要在更新数据库后负责使缓存项过期或清除缓存项,否则会导致缓存与数据库的数据不一致。

适用场景:对于读多写少的场景,数据变化频率较低的场景,该模式适用性较好。缓存中的数据过期后不主动更新,需要用户主动去数据库中进行读取,如果不存在则从数据库中读取并保存到缓存中。

优点:适用于读密集型场景,可以减轻数据库压力,并且能够保证缓存数据的一致性。

缺点:需要开发人员自己处理缓存和数据库数据的同步更新,可能会增加代码复杂度。


Read/Write Through Pattern(读写模式)

在 Read/Write Through Pattern 中,缓存与数据库之间的读写操作都由缓存管理器来处理。当应用程序请求一条数据时,缓存先尝试从缓存中读取此数据。如果缓存中不存在此数据,缓存将从数据库中读取并返回给应用程序。当一个数据被更新时,缓存管理器先将数据存储到缓存中,然后再保存到数据库中。这种模式保证了缓存与数据库数据的一致性,但同时也增加了缓存管理器维护缓存与数据库数据一致性的复杂性。

适用场景:该模式适用于读写频率较高且数据变化频率较低的场景。写入数据时,缓存管理器先将数据存储到缓存中,然后再保存到数据库中,这样可以保证缓存与数据库数据的同步更新。

优点:保证缓存和数据库数据的一致性。

缺点:写入操作会涉及到数据库操作,可能会影响系统的性能。


Write Behind Caching Pattern(回写缓存模式)

在 Write Behind Caching Pattern 中,数据的更新首先将被写入缓存,然后异步地保存到数据库中。这种模式可以提高写入性能,并减少数据库的写操作,提高系统的吞吐量,但也会加重程序的复杂度。如果在应用程序关闭或缓存服务器崩溃时,仍有未写入的数据,则可能导致数据丢失。因此,在实现该模式时需要谨慎处理异常,并在适当的时候强制将缓存中的脏数据写入数据库中,以避免数据损失。

适用场景:该模式适用于写入频率较高的场景,写入操作会先将数据写入到缓存中,然后异步地写入到数据库中。适用于大量写入操作的高并发场景。

优点:提高写入性能,增强系统的吞吐量。

缺点:存在数据丢失的风险,需要增加额外的错误处理机制,同时可能会导致缓存与数据库数据的不一致。


具体实现

Cache-Aside Pattern

  • 读取数据时:先从缓存中读取,如果缓存没有命中,再从数据库中读取数据并加入缓存;

  • 更新数据时:先更新数据库,再从缓存中删除对应的数据,以保证下次读取时能够直接从数据库中获取最新数据。

public Object readData(String key) {
    Object data = cache.get(key);
    if (data == null) {
        data = database.get(key);
        cache.put(key, data);
    }
    return data;
}

public void updateData(String key, Object data) {
    database.update(key, data);
    cache.remove(key);
}

Read/Write Through Pattern

  • 读取数据时:先从缓存中读取,缓存命中则返回数据,缓存未命中则从数据库中获取数据返回,并将数据存储到缓存中;

  • 更新数据时:先将数据存储到缓存中,再将数据写入到数据库中。

public Object readData(String key) {
    Object data = cache.get(key);
    if (data == null) {
        data = database.get(key);
        cache.put(key, data);
    }
    return data;
}

public void updateData(String key, Object data) {
    cache.put(key, data);
    database.update(key, data);
}

Write-Behind Caching Pattern

  • 更新数据时:将更新操作加入到缓存更新队列中,并在一段时间内合并多个更新操作一起写入数据库,从而减少对数据库的写操作。
private List<Pair<String, Object>> cacheQueue = new ArrayList<>();

public void updateData(String key, Object data) {
    cacheQueue.add(new Pair<>(key, data));
}

public void writeQueueToDatabase() {
    while (!cacheQueue.isEmpty()) {
        List<Pair<String, Object>> updates = new ArrayList<>();

        // 获取一批更新操作
        for (int i = 0; i < cacheQueue.size(); i++) {
            updates.add(cacheQueue.get(i));

            // 如果更新操作批次达到阈值,或者缓存更新队列已空,则开始执行写入操作
            if (updates.size() >= UPDATE_BATCH_SIZE || i == cacheQueue.size() - 1) {
                executeUpdates(updates);
                updates.clear();
            }
        }
    }
}

private void executeUpdates(List<Pair<String, Object>> updates) {
    // 批量写入到数据库中
    for (Pair<String, Object> update : updates) {
        database.update(update.first(), update.second());
    }

    // 从缓存更新队列中删除已处理的更新操作
    cacheQueue.removeAll(updates);
}

以上是三种常用缓存实现策略在Java语言中的示例代码。在实际应用中,我们需要在缓存和数据库之间进行平衡,选择合适的缓存策略以提高系统性能。


三、数据库和缓存不一致采用什么方案

先谈问题,再谈理论,最后说如何去实现。

需要一点点耐心阅读,为了减轻大家的理解和记忆负担,图文并茂

咱就说,别慌!!!

一切设计都是基于业务的,所以不同的场景会产出不同的最佳实践

暂无共有的最佳实践,下面的讨论也是如此。

希望大家友善踊跃交流,谢谢


数据库和缓存的数据不一致问题,大都是产生在更新数据时。

在更新的时候,操作缓存和数据库无疑就是以下四种可能之一:

  • 先更新缓存,再更新数据库
  • 先更新数据库,再更新缓存
  • 删除缓存,再更新数据库
  • 更新数据库,再删除缓存

一个一个分析,为什么会产生数据不一致问题?


先更新缓存,再更新数据库(Read/Write Through)

操作流程大致如下:问题出现在第四个操作上

image-20230327212807281

如果我成功更新了缓存,但是在执行更新数据库的那一步,服务器突然宕机了,那么此时,我的缓存中是最新的数据,而数据库中是旧的数据

脏数据就因此诞生了,并且如果我缓存的信息(是单独某张表的),而且这张表也在其他表的关联查询中,那么其他表关联查询出来的数据也是脏数据,结果就是直接会产生一系列的问题。


先更新数据库,再更新缓存(Cache-Aside)

先更新数据库,再更新缓存,其实还是存在类似的问题。

image-20230327212947514

只有等到缓存过期之后,才能访问到正确的信息。那么在缓存没过期的时间段内,所看到的都是脏数据。

从上面两张图中,大家也能看出,无论咋样,只要执行第二步时失败了,就必然会产生脏数据。

思考:如果如果如果两步都能执行成功?能保证数据一致性吗?

其实也不能,因为还有Java常考的并发


并发情况下的思考

如果上面的两小节,两步操作都能成功,在并发情况下是怎么样的呢?

image-20230327213039561

换成是先更新数据库,再更新缓存,也是一样的。

image-20230327213055151

在这里可以看到当执行时序被改变,那么就必然会产生脏数据

看到这里,也许学过 Java 锁知识的小伙伴可能会说,咱们可以加锁啊,这样就不会产生这样的问题啦~

在这里确实可以加锁,以保证用户的请求顺序,来达到数据一致性。


虽然加锁确实可以通过牺牲一些性能来保证一定数据一致性,但我还是不推荐更新缓存的方式。

原因如下:

  1. 首先加入缓存的主要作用是提高系统性能。
  2. 其次更新缓存的代价并不低。
    • 复杂场景下:比如可能更新了某个表的一个字段,然后其对应的缓存,是需要查询另外两个表的数据并进行运算,才能计算出缓存最新的值的。
    • 可能一些场景是需要这样的。
  3. 缓存利用率问题。一个频繁更新的缓存,它是否会被频繁的访问呢?
    • 一个缓存在很短的时间内,更新10次,20次或者更多,但是实际访问次数只有1、2次,这其实也是一种浪费。
    • 如果采用删除缓存就不会这样,删除了缓存,那么就只会等到有人要使用缓存的时候,才会重新查询数据,放入缓存中。这其实也是懒加载的思想,等到要使用了,再加载。

当然业务场景确实有这样的场景,这么使用也未免不可, 一切都要实事求是,而并非空谈。

接着我们再思考思考:难道先删除缓存,再更新数据库,或者是先更新数据库,再删除缓存就没有问题了吗?


先删除缓存,再更新数据库(Write-Through)

这种方式在没有高并发的情况下,是可能保持数据一致性的。

image-20230327213125487

如果只有第一步执行成功,而第二步失败,那么只有缓存中的数据被删除了,但是数据库没有更新,那么在下一次进行查询的时候,查不到缓存,只能重新查询数据库,构建缓存,这样其实也是相对做到了数据一致性。

但如果是处于读写并发的情况下,还是会出现数据不一致的情况:

image-20230327213141212

执行完成后,明显可以看出,1号用户所构建的缓存,并不是最新的数据,还是存在问题的~


先更新数据库,再删除缓存(Cache-Aside+Write-Behind)

如果更新数据库成功了,而删除缓存失败了,那么数据库中就会是新数据,而缓存中是旧数据,数据就出现了不一致情况。

image-20230327213158926

和之前一样,如果两段代码都执行成功,在并发情况下会是什么样呢

image-20230327213220436

还是会造成数据的不一致性。

但是此处达成这个数据不一致性的条件明显会比起其他的方式更为困难

  • 时刻1:读请求的时候,缓存正好过期
  • 时刻2:读请求在写请求更新数据库之前查询数据库,
  • 时刻3:写请求,在更新数据库之后,要在读请求成功写入缓存前,先执行删除缓存操作。

这通常是很难做到的,因为在真正的并发开发中,更新数据库是需要加锁的,不然没一点安全性~

一定程度上来讲,这种方式还是解决了一定程度上的数据不一致性问题的。


小结

无论选择下列那种方式

  • 先更新缓存,再更新数据库
  • 先更新数据库,再更新缓存
  • 先删除缓存,再更新数据库
  • 先更新数据库,再删除缓存

如果是在多服务或是并发情况下,其实都有可能产生数据不一致性。

不过在这四种选择中,平常都会优先考虑后两种方式。并且市面上对于这后两种选择,也已经有一些解决方案。

在谈解决方案之前,我们先看看需要解决的问题:

我们要如何保证这两段代码一起执行成功?

【先删除缓存,再更新数据库】在读写并发时,会产生一个缓存旧数据,而数据库是新数据的问题,这该如何解决呢?

image-20230327213239253

加锁可以解决并发情况下出现的不一致问题吗?

关于第三点讲解,在下一篇关于本地锁到Redis分布式锁中讲解。

关于数据一致性的补充

简单说,只要使用缓存,那么必然就会产生缓存和数据库数据不一致的问题。

在这首先我们要明确一个问题,就是我们的系统是否一定要做到“缓存+数据库”完全一致性?是否能够接受偶尔的数据不一致性问题?能够接受最长时间的数据不一致性?

强一致性

如果缓存和数据库要达到数据的完全一致,那么就只能读写都加锁,变成串行化执行,系统吞吐量也就大大降低了,一般不是必须达到强一致性,不采用这样的方式。

并且实在过于要求强一致性,会采用限流+降级,直接走MySQL,而不是特意加一层 Redis 来处理。

弱一致性(最终一致性)

一般而言,大都数项目中,都只是要求最终一致性,而非强一致性。

最终一致性是能忍受一定时间内的数据不一致性的,只要求最后的数据是一致的即可。

例如缓存一般是设有失效时间的,失效之后数据也会保证一致性,或者是下次修改时,没有并发,也会让数据回到一致性等等。

五、数据一致性解决方案

所谓的解决方案,其实大都也就是解决之前我们提出来的几个问题~

如何保证这两段代码一起执行成功

要想第二段代码成功执行,那么重试是必不可少的啦

重试的思想,在学习Java的道路会遇到很多次的哈,

1)引子

像如果学习过Java中锁相关知识的朋友,应该会记得自旋锁和互斥锁~

自旋锁:一种是没有获取到锁的线程就一直循环等待判断该资源是否已经释放锁,它不用将线程阻塞起来(NON-BLOCKING);

互斥锁:把自己阻塞起来,等待重新调度请求。

自旋锁的思想其实也就是一个while(true)一直重试罢了。

还有使用过openfegin的朋友会知道,它在发送请求时,也包含有一个重试机制,很多高可用的场景,都会加上重试~

2)重试

但是重试存在的问题,也有很多,需要重试几次呢?重试的间隔时间是多少呢?重试再失败该如何补偿呢?在重试的过程中,如果程序宕机,重试也就丢失啦

看到这些你有没有头大,有的话,就对了,认真思考每一个点,你都会发现很多其他的知识,这往往比老老实实的学习更有效。

我们如果仍然像锁机制或者是openfeign的机制一样,采取同步重试的方式的话,是解决不了问题的,如同步重试是可能会失败的,如果一直失败,则会一直占用线程资源,导致其他用户的请求无法正常被执行。

应该很容易想到,同步的对立面就是异步,异步重试,交由别人来做这件事情,自己不用去管这件事情即可。

谈到异步,并且是第三方来做的,最快想到的无疑就是消息队列啦~

3)消息队列-异步

如果学习过消息队列的朋友,应该很快就能get到,或者自己思考到这一点;

如果没有学习过的话,我觉得学习消息队列还是非常有必要的一件事情。

我们可以把第二步操作交由消息队列去做,达到一个异步重试的效果。并且引入消息队列来实现,代价并非想象中的那么大。

当然大家也会说,如果发送消息也失败呢?

有这种可能,但真的不算高,另外消息队列自身是很好的支持高可用的。

  1. 首先消息队列在高并发的场景下,可以毋庸置疑的说是一个非常重要的组件啦,所以引入消息队列以及维护消息队列,其实都不能算是额外的负担。
  2. 其次消息队列具有持久化,即使项目重启也不会丢失。
  3. 最后消息队列自身可以实现可靠性
    • 保证消息成功发送,发送到交换机;
    • 保证消息成功从交换机发送至队列;
    • 消费者端接收到消息,采用手动ACK确认机制,成功消费后才会删除消息,消费失败则重新投递~

图:

image-20230327213302080

(说明:消息队列的内部可靠机制就没有再详细画了)

4)Canal 订阅日志实现

消息队列虽然已经比较简单,但是仍然要手动的进行代码的编写,以及写一个消费者来进行监听,可以说还是比较麻烦,每个地方都还要引入消息队列,发送一个消息~,有没有办法省去这一步呢?有的勒,偷懒的人大有人在勒

现有的解决方案中,可以使用 alibaba 的开源组件 Canal,订阅数据库变更日志,当数据库发生变更时,我们可以拿到具体操作的数据,然后再去根据具体的数据,去删除对应的缓存。

当然Canal 也是要配合消息队列一起来使用的,因为其Canal本身是没有数据处理能力的。

相应的流程图大致变成下列这样:

image-20230327213314269

优点:

  • 算的上彻底解耦了,应用程序代码无需再管消息队列方面发送失败问题,全交由 Canal来发送。

缺点:

  • 引入了Canal中间件,需要一定的维护成本,需要实现高可用的话,也需考虑集群等,架构也会进一步变得复杂。

具体的代码实现,还是需要各位朋友去进行一番搜索啦。

在本文中,我更多的是针对Redis方面的学习,关于这部分的内容以及实现,我也只是通过八股文和一些文章观摩,并没有进行深入的研究,说来实在惭愧,还请各位见谅。


延时双删策略(Write-Behind)

问题:【先删除缓存,再更新数据库】在读写并发时,会产生缓存是旧数据,而数据库是新数据的问题,这该如何解决呢?

image-20230327213341229

(图片说明:上图为产生数据不一致性的情况)

延时双删流程图

image-20230327213405321

解决这样的问题,其实最好的方式就是在执行完更新数据库的操作后,先休眠一会儿,再进行一次缓存的删除,以确保数据一致性,这也就是市面上给出的主流解决方案–延时双删

相信大家在诸多面试八股文中,也常常会看到这个吧~

但是更加深入的思考“延时”两字,这个延时到底延时多久合适呢?有什么评判依据吗?

首先延迟删除的时间需要大于 1号用户执行流程的总时间

即:【1号用户从数据库读取数据+写入缓存】时间

但是要说具体是多长,这无法给出一个准确答复,只能经过不断的压测和实验,预估一个大概的时间,尽可能的去降低发生数据不一致的概率罢了。

补充:并发问题的解决,最常用的方式无疑就是加锁,那到底是加什么锁呢?在分布式系统中,对于并发,加的无疑就是分布式锁。


四、如何选择

前面介绍了几种情况的具体问题和解决方案,那么实际工作中应该如何选择呢?

我觉得主要还是根据实际的业务情况来分析。

比如,如果业务量不大,并发不高的情况,可以选择先删除缓存,后更新数据库的方式,因为这种方案更加简单。

但是,如果是业务量比较大,并发度很高的话,那么建议选择先更新数据库,后删除缓存的方式,因为这种方式并发问题更少一些。但是可能会引入加锁、延迟双删等更多机制,使得整个方案会更加复杂。

其实,先操作数据库,后操作缓存,是一种比较典型的设计模式——Cache Aside Pattern

这种模式的主要方案就是先写数据库,后删缓存,而且缓存的删除是可以在旁路异步执行的。

这种模式的优点就是我们说的,他可以解决”写写并发”导致的数据不一致问题,并且可以大大降低”读写并发”的问题,所以这也是Facebook比较推崇的一种模式。


五、优化方案

Cache Aside Pattern 这种模式中,我们可以异步的在旁路处理缓存。其实这种方案在大厂中确实有的还蛮多的。

主要的方式就是借助数据库的binlog或者基于异步消息订阅的方式。

也就是说,在代码的主要逻辑中,先操作数据库就行了,然后数据库操作完,可以发一个异步消息出来。

然后再由一个监听者在接到消息之后,异步的把缓存中的数据删除掉。

或者干脆借助数据库的binlog,订阅到数据库变更之后,异步的清除缓存。

这两种方式都会有一定的延时,通常在毫秒级别,一般用于在可接受秒级延迟的业务场景中。


六、总结

《人月神话》的作者Fred Brooks在早年有一篇很著名文章《No Silver Bullet》 ,他提到:

在软件开发过程里是没有万能的终杀性武器的,只有各种方法综合运用,才是解决之道。而各种声称如何如何神奇的理论或方法,都不是能杀死“软件危机”这头人狼的银弹。

也就是说,没有哪种技术手段或者方案,是放之四海皆准的。如果有的话,我们这些工程师也就没有存在的必要了。

所以,任何的技术方案,都是一个权衡的过程,要权衡的问题有很多,业务的具体情况,实现的复杂度、实现的成本,团队成员的接受度、可维护性、容易理解的程度等等。

所以,没有一个”完美”的方案,只有”适合”的方案。

但是,如何能选出一个适合的方案,这里面就需要有很多的输入来做支撑了。希望本文的内容可以为你日后的决策提供一点参考!


参考
Hollis
黑马程序员

  • 2
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 打赏
    打赏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

FrozenPenguin

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值