有道无术,术尚可求,有术无道,止于术。

本系列Redis 版本 7.2.5

源码地址:https://gitee.com/pearl-organization/study-redis-demo

1. 缓存

缓存技术(Cache)通过将热点数据临时存储在查询速度更快的位置,从而提高了系统的响应速度和性能。‌

计算机硬件、软件都大量使用了缓存技术,例如 CPU 中三级缓存,作为 CPU 与内存之间临时数据交换区, CPU 会将热点、相同的重复执行指令等数据放入到高速缓存中, CPU 直接从缓存中获取指令、数据,从而提高了整机的响应速度。

Web 领域,可以对静态资源(图片、CSSJS等)进行缓存,例如:

  • 浏览器
  • CDN
  • Nginx

对动态资源(根据请求动态生成)进行缓存,例如:

  • 本地缓存:将数据缓存到本地内存中,例如使用EhCacheGuavaCaffeine
  • 分布式缓存:将数据缓存到多个分布式节点上,例如使用 Redis

1.1 数据库缓存

对于开发人员来说,接触最多的就是数据库缓存了。传统的关系型数据库,在高并发场景下,往往存在性能瓶颈,例如:

  • 数据存储量:一般达到千万级别,性能会明显下降
  • IO 瓶颈:使用硬盘作为存储介质
  • 并发瓶颈:单机模式并发能力并不高

为了减轻数据库的负载,一般都会优先考虑引入读写分离、缓存技术,数据库一般都自带缓存功能,但是往往都需要在业务层面实现自定义缓存。根据二八定律( 80% 的业务访问集中在 20% 的数据上),将小部分热点数据放入到缓存中,减少数据库的访问压力,进而提高网站访问速度。

数据库缓存适用于查询多更改少的数据场景,例如,项目中的系统配置、参数配置、字典等信息都会选择放入到缓存中。在实际开发中,大多都选择使用 Redis 作为分布式缓存数据库,因为它具有数据类型丰富、高性能、支持持久化、支持分布式、支持高可用等优点。

1.2 数据一致性

这里的一致性(Consistency),并不是数据库事务 ACID 中的 C事务一致性是指事务只能将数据库从一个一致状态转换到另一个一致状态,确保数据库中存储的数据是准确和可靠的。需要原子性、隔离性、持久性来确保数据的一致性,除此以外,还需要注意数据操作符合现实中的逻辑。

例如,典型的 AB 用户之间的转账场景中, A 转了多少, B 必须就加多少,而不能转了 200 只给 B100。此外,原子性确保要转账操作要么全部成功,要么全部失败,失败时进行回滚操作。隔离性保证并发操作时,防止更新丢失、脏读、幻读。持久性保证一旦事务提交后,数据的改变是永久的,即使遇到故障也不会丢失操作。以上措施,保证 AB 用户的账户金额,永远是完整可靠的。

这里的一致性,指的是 CAP 定理中的 CConsistency)。 CAP 定理是指在一个分布式系统中,不可能同时满足一致性、可用性、和分区容错性。在分布式系统中,数据分布在多个节点中,某条数据可能也是存在多个副本, CAP 中的一致性则是指,当数据写操作完成后,同一时刻所有读请求(可能落在不同的存储节点)获取的数据都是一致的(一样)。

简单来说,要实现 CAP 中的一致性,要求数据更新后,需要同步更新到多个数据副本,然后再一起提交操作,这样才能保证任意时刻,数据都是一致的。

数据库缓存方案中,源数据存储在数据库,副本数据存储在缓存。在只读缓存(没有写操作)的场景下,缓存和数据库中的数据都不会更新,肯定都是一致的。如果数据需要更新,则会引发一致性问题。但是在读写缓存(有读写操作)的场景下,会存在 CAP 中的一致性问题,即数据库中和缓存中的数据不一致。

例如,最简单的,当查询并缓存的数据后,调用了修改操作,但是没有更新缓存,导致下次查询还是返回缓存的的旧数据。这时,会引出写操作时,如何使用正确的方式处理缓存中的数据,解决数据一致性的问题。
在这里插入图片描述

2. 旁路缓存模式

引入数据库缓存,减少数据库的负载,提高了系统的性能。但是,缓存的引入势必会导致系统的复杂性上升,如何有效的管理缓存,如何防止数据不一致,这些问题成为了需要解决的痛点。

模式(Pattern)是解决某一类问题的方法论,对于数据库缓存,软件领域的大佬们也总结并提出了多种缓存模式,根据不同的业务需求和场景,我们可以直接选择已经设计好的缓存模式即可。

常用的缓存模式有:

  • Cache-Aside Pattern(旁路缓存)
  • Read-Through Pattern(透读缓存)
  • Write-Through Pattern(透写缓存)
  • Write-Behind/Write-Back Pattern(写后缓存)

2.1 工作原理

Cache-Aside Pattern 是最经典且应用广泛的一种模式,适用于读多写少的场景,在 Facebook 的一篇技术文档中,详细描述了 Facebook 基于 Memcache 搭建的分布式键值对存储平台,为全球超过十亿用户提供丰富的体验,其中就使用了这种模式。

旁路缓存基于业务程序进行缓存管理,读、写流程如下:

在这里插入图片描述
读取数据流程:

  1. Web 服务器需要数据时,首先通过提供一个字符串键来从 Memcache 请求该值。
  2. 如果缓存命中,则直接返回数据。
  3. 如果该键没有被缓存,Web 服务器将从数据库或其他后端服务中检索数据,并将键值对填充到缓存中。

写数据流程:

  1. Web 服务器向数据库发出 SQL 语句,写入到数据库。
  2. 然后向 Memcache 发送删除请求,让缓存中的过时数据失效。

这里 Cache-Aside Pattern 会引出以下几个问题:

  • 为什么是直接删除缓存而不是更新缓存?
  • 为什么是先更新数据库而不是先删除缓存?
  • 这种模式会存在一致性问题吗?

2.2 四种更新策略

使用数据库缓存,避免不了既要写入数据库,又要写入缓存,在双写场景中,数据库中的数据肯定只能写入,而缓存中的数据,可以进行更新,或者全部删除(下次读取会重新加载),按照排列组合,有以下几种数据写入策略:

  • 先写数据库,再删除缓存(旁路缓存)
  • 先写数据库,再更新缓存
  • 先更新缓存,再写数据库
  • 先删除缓存,再写数据库
2.2.1 先写数据库,再更新缓存

伪代码如下:

        // 1. 先写数据库
        dbStockCount = dbStockCount - 1;
        // 2. 再写缓存
        redisTemplate.opsForValue().set("stockCount", dbStockCount);

这种方式,存在严重的一致性问题,主要是以下场景:

  • 更新缓存失败
  • 并发场景

例如,A 线程执行更新操作时,数据库事务已提交,但是更新缓存时,由于网络原因或其他原因,实际没有更新成功,其他线程会直接从缓存中拿到旧数据,直到到下一次写请求成功:
在这里插入图片描述

例如,在多线程并发时,A 线程先更新数据库并提交事务,此时 B 线程也进来并更新数据库、写入缓存,此时 A 才执行到更新缓存,会将 B 的操作进行覆盖,导致数据不一致:

在这里插入图片描述

针对多线程问题,可能会有人说加个分布式锁不就行了,引入缓存,本就是为了提交性能,如果加锁,会导致串行化的执行,效率会很低

2.2.2 先更新缓存,再写数据库

这种方式和第一种差不多,由于数据库和缓存不是原子操作,一旦一个失败,另一个正常执行,都会导致数据不一致:
在这里插入图片描述
多线程并发时,也是一样的问题:
在这里插入图片描述
在了解更新缓存相关的策略后,总结一下为啥不能选择更新缓存:

  1. 大部分缓存服务器,都是不支持直接修改数据,只支持覆盖。想更新需要先查询出数据,然后再进行覆盖。如果数据量非常大,会很消耗性能,相比于直接删除更加麻烦
  2. 多线程环境下,更容易出现数据不一致问题,并且这时不一致的时间窗口很长,一旦不一致,除非下次更新到来并修正数据,否则缓存中的数据永远都是旧的
2.2.3 先删除缓存,再写入数据库

正常来说,先删除缓存,再写入数据库后,后续的查询线程,会获取最新数据,再装载到缓存中。然后,如果删除失败,则会导致缓存中还是旧数据,

如果删除缓存没有问题,在并发场景也可能会存在问题,例如,A 线程先删除缓存,然后去更新数据库,由于执行逻辑较为耗时,或者时间片问题,导致 B 线程在 A 线程更新据库之前,进行了查询并缓存操作,当 A 执行完成后,删除缓存的操作会被 B 覆盖,并且数据不一致:

在这里插入图片描述

2.2.4 先更新数据库,再删除缓存(旁路缓存)

和上面的先删除缓存后更新数据库一样,删除失败会导致数据不一致。在多线程环境下,例如,当 C 线程更新并删除缓存后, C 线程查询数据库,在写入缓存之前, A 线程执行更新数据库并删除缓存结束后, B 才将之前读取到的数据写入到缓存中,此时因为 B 读取到的是旧数据,导致数据不一致:

在这里插入图片描述
在了解删除缓存相关的策略后,总结一下为什么是先更新数据库而不是先删除缓存?

首先,在删除一定成功的情况下, 如果当前线程先删除缓存,那么在它执行更新数据库之前,其他线程进入并重新填充了缓存后,都会导致数据不一致,这种情况很容易发生。

如果当前线程先更新数据库,再删除缓存,导致不一致的条件相对来说概率更低,需要满足以下条件:

  • 缓存被清空
  • 在写请求更新数据库之前,读请求需要查询数据
  • 读请求需要在写请求删除缓存之后,再填充缓存

2.3 保证数据一致性

从上面的分析,可以看出 Cache-Aside Pattern 是最优方案,但是也会导致数据不一致,需要进行相关优化。如果想要实现强一致性,可以使用分布式锁,或者强一致性协议,但是使用缓存就是为了提高性能,并且想要实现分布式,根据 CAP 理论一般都会舍弃强一致性,所以一般追求最终一致性。

2.3.1 保证成功删除缓存

先更新数据库,再删除缓存伪代码如下:

        // 1. 执行业务逻辑.........
        
        // 2. 先更新数据库
        dbStockCount = dbStockCount - 1;
       
        // 3. 再删除缓存
        Boolean result = redisTemplate.delete("stockCount");

首先,删除缓存失败的可能性是比较小的,如果是 Redis 连接不上,会抛出异常,而且一般更新操作都会加上事务,就算是执行删除时整个程序宕机,事务也会回滚,不会执行更新数据库。如果是返回失败结果,并未抛出异常,也可以根据结果自己抛出异常触发事务回滚。

有很多文章会说删除执行失败时,可以进行先重试,重试一定次数,还失败时则发送到消息队列。或者使用中间件去监听数据库 Binlog 日志,发现更新数据时,再由监听服务去删除缓存。本人觉得,这些方案看着可行,实际开发中千万别这么做,为了删除一个缓存,引入额外组件,还要写很多代码去实现,完全是得不偿失,一万个没必要。

这里,推荐以下方式保证成功删除缓存:

  • 事务:删除异常,回滚整个数据库更新操作
  • 过期时间:缓存设置过期时间,自动失效
2.3.2 延迟双删

多线程环境下, Cache-Aside Pattern 主要是因为查询请求在读请求更新数据库之前,查询到了数据,并在读请求删除缓存之后,才回写缓存,导致不一致问题。如果写请求的执行时间大于读请求的执行时间,那么就可以写请求总会删除掉缓存,只会在很短一个时间窗口发生不一致问题:

在这里插入图片描述
这种方式,进行了两次删除操作,并在更新数据库后,延迟了一定的时间再执行删除缓存,所以一般成为延迟双删,伪代码如下:

        // 1. 删除缓存
        // 2. 执行业务逻辑.........

        // 3. 先更新数据库
        dbStockCount = dbStockCount - 1;
        
        // 4. 延迟一段时间
        TimeUnit.MILLISECONDS.sleep(600);
        
        // 5. 再删除缓存
        Boolean result = redisTemplate.delete("stockCount");

当然,延迟双删也存在问题:

  • 数据还是不一致:数据还是不一致这里没办法解决,但是保证只在一个极小的一个时间窗口内存在不一致,且最终都会删除缓存,重新加载缓存后,数据就一致了,还是实现了最终一致性。
  • 延迟时间如何设定:延迟时间时间需要大于读请求的执行时间,一般只能自行统计估算,但是读请求的耗时会随着数据量的递增越来越大,实际中也不是很好评估。
  • 同步延迟导致吞吐量下降:如果是同步延迟,那么会增大响应时间,这里可以使用异步线程进行延迟,然后再删除缓存。
  • 缓存击穿:如果删除了缓存,大量读请求并发过来时,同时判断到缓存为空,会导致同时查询数据库,可能会瞬间压垮数据库。可以使用双检加锁策略,首先检查是否有缓存,没有缓存会执行到查询请求,这里加一个互斥锁,再次检查是否有缓存,两次检查都没有时,则查询数据库,并回写缓存中。

3. 总结

旁路缓存模式适用于读多写少,对一致性要求不是特别严格的场景。

此外,在实际开发应用时,不需要自己再去实现一套缓存管理代码,有很多框架已经提供了基于声明式注解的缓存管理器抽象,只需要添加几个注解,就可以实现数据库缓存,例如:

Logo

开放原子开发者工作坊旨在鼓励更多人参与开源活动,与志同道合的开发者们相互交流开发经验、分享开发心得、获取前沿技术趋势。工作坊有多种形式的开发者活动,如meetup、训练营等,主打技术交流,干货满满,真诚地邀请各位开发者共同参与!

更多推荐