掘金 后端 ( ) • 2024-04-28 10:45

缓存的使用原则是:无它也可,有它更强。永远不要强依赖缓存,它会丢,也会被淘汰。

之所以使用缓存,是为了使服务能对外提供更高的性能,但性能一致性就像是天平的两端,没办法都达到最佳状态,所以需要平衡。数据库-缓存更新问题中核心点有3个:缓存利用率并发缓存+数据库一起成功

一、经典的三种缓存更新策略

常见的缓存更新策略有三种:

  1. Cache Aside(旁路缓存)策略
  2. Read/Write Through(读穿/写穿)策略
  3. Write Behind caching(写回)策略

1、Cache aside(旁路缓存)

Cache Aside(旁路缓存)是最常用的,应用程序直接与【数据库、缓存】交互,并负责对缓存的维护,可细分为【读策略】和【写策略】。

读策略:

读策略.png

写策略:

写策略.png

关于旁路缓存的几个问题

1. 【写策略】为什么不是写完数据库后更新缓存?

  1. 最主要的原因是防止数据不一致的问题:
如上图所示,若存在两个不同线程的写请求,首先线程1的写请求更新了数据库,接着线程2的写请求更新了数据库,但由于网络延迟,线程1可能会晚于线程2更新缓存,那最终的结果就是写入数据库的值是线程2的值,而写入缓存的结果是线程1的值,数据不一致。
  1. 另一个原因是删除缓存更符合懒加载的思路,需要时再更新。

2.【写策略】为什么不先删除缓存再写数据库?

f86fc954-1cab-4bdc-8e85-dcc071c3f553.png 如上图所示,读请求写请求并发,写请求先删除缓存,读请求来请求,发现没有缓存,于是去读数据库,接着更新了缓存(缓存为旧数据),然后写请求才更新数据库,最终数据库中为新数据,缓存中为旧数据。

针对“先删缓存后写数据库”的策略,有一种优化策略就是“延时双删”。

  1. 首先是“不延时”双删策略:首先删除缓存,然后更新数据库,此时可能存在读请求拿到旧的数据库数据写到缓存里,所以需要再次删掉缓存,让后续请求命中到数据库,从而把最新的数据填充到缓存。
  2. 为什么需要“延时”呢?
    1. 普通双删策略下,假设有读请求从数据库拿到了旧数据,准备填充到缓存,另一个写请求刚更新完数据库,立刻删除了缓存,在这之后读请求才把原来拿到的旧数据写到缓存里,就会出现不一致。

      这里可能会有疑问:读请求要完成的只是一个写缓存操作,而写请求完成的是写库+删缓存操作,前者大概率是比后者快的,所以延时是不是也没太有必要。但java中的线程调度是抢占式调度,还真就有可能在准备更新缓存的时候,该线程的时间片就用完了,切换到那个写库+删缓存的操作去了,所以为了保险,还是需要延时再删的。

    2. 延时就是等写请求更新完数据库后,延迟那么一会儿【读业务逻辑数据的耗时 + 几百毫秒】再去删除缓存,这样的目的在于,延的这段时长用于保证读请求已经拿到旧数据并且填充到缓存了,这个时候再去删除缓存,下一次读请求去填充缓存的时候就可以拿到最新的数据了。

在并发场景下,只要延时时间合适,延时后删是一定可以避免数据不一致的问题,但是在延时删除期间,是有可能读取到旧数据的,这在一些场景下是无法接受的。而延时双删的目的则是为了保证在数据库和缓存最终一致性的情况下,尽可能减少老数据出现的可能性。

那什么场景适合“延时双删”而不适合“Cache Aside”呢?

  1. 旁路缓存策略无法满足数据一致性要求的场景下(不是完全强一致)

3. Cache-Aside 存在数据不一致的可能吗?

可能!但可能性比较小。

如上图所示,读写并发场景下:线程1的读请求在未命中缓存的情况下查数据库,接着线程2的写请求更新数据库,但由于极端原因,线程1的读请求的更新缓存操作晚于线程2的写请求的删除缓存操作,最终结果是写入缓存的是线程1的旧值,而写入数据库的是线程2的新值。但这种场景出现的条件是:缓存失效且读写并发执行,而且读请求查数据库的执行早于写请求更新数据库,同时读请求的更新缓存操作完成晚于写请求,可能性较小。

此外,Cache-Aside模式虽然在两个线程并发下可以有效保证数据一致性,但一旦并发线程数超过3个,就仍有可能出现数据不一致了。 image.png 如上图所示,线程A修改数据后,删除缓存后;线程B读取数据库数据,放入缓存前,线程C修改了数据,然后线程C删除了缓存,最后线程B再写入缓存,最终导致数据库中保存的是线程C写入的数据,而缓存中保存的是线程B读到的线程A写入的数据。

4. 写操作时如果更新数据库成功,但删除缓存失败,会导致数据不一致,补偿机制有哪些?

一般情况下,数据库更新和缓存删除会都成功或失败的,如果数据非常重要,必须保证缓存删除成功,可以考虑以下措施:

  1. 删除重试机制

    1. 将重试数据写表,然后用定时任务进行重试
    2. 将重试的请求写入mq等消息中间件,在mq的consumer中处理【推荐】
    3. 订阅mysql的binlog,在订阅者中,如果发现了更新数据的请求,则删除相应的缓存

    大厂都是怎么做 MySQLtoRedis 同步的? | MRCODE-BOOK
    (订阅变更日志的本质,是把权威数据源(比如mysql)当作leader副本,让其他异质系统(比如Redis)成为它的follower副本,通过同步变更日志的方式,保证leader和follower之间保持一致)

  2. @Transactional注解

redis在执行删除时如果发生异常,这个异常就可以被@Transactional捕获,然后回滚掉刚才数据库的更新。但这种方式不适用于一条数据库更新对应多条缓存删除的情况,因为多条缓存删除可能存在部分成功部分失败的情况。

2、 Read/Write through(读/写穿透)

旁路缓存需要在应用程序中维护两个数据存储(数据库、缓存),这样应用程序比较啰嗦。而Read/Write Through Pattern(读/写穿透)将数据库和缓存合起来,即更新数据库的操作由缓存自己代理了。可以理解为只有一个单独的存储,而存储自己维护自己的cache。

image (1).png

读策略:

  1. 先从cache中读取数据,读取到直接返回
  2. 从cache中读取不到,则从DB加载写入到cache后返回响应。

写策略:

  1. 先查cache,cache中不存在,有两种策略:
    1. 写入cache,由cache同步更新到数据库
    2. 不写入cache,直接写入数据库。一般选择第二种。
  2. cache中存在,则先更新cache,然后cache服务自己更新DB

Read through 和 Write through一般都是配合使用的。

3、 Write behind caching(写回策略)

Write behind的思路同样是“应用程序只和缓存交互且只能通过缓存写数据”。不同点在于Write through会把数据立即写入数据库中,而Write behind会在一段时间之后异步写入数据库。

图中的读写请求中Read data from lower memory into cache block环节其实是没必要的

对于Write through/Write behind caching的写操作,都存在没有命中缓存的情况,这时候有两种处理方式:

  1. write allocate:写入缓存,由缓存去写库
  2. no-write allocate:不写入缓存,直接写库

通常来说,Write behind caching会选择write allocate,Write-through选择no-write allocate,因为Write behind caching面对的是写多的场景,write allocate可以帮助提升性能,而对于Write-through没明显帮助。

为什么缓存没命中时,还要定位Cache block?
这是因为Cache block是分配过来的,需要判断数据即将写入到cache block里的位置是否被其他数据占用了此位置,如果这个其他数据是脏的,那么就要帮忙把它写回到内存。【思想是“顺手帮别人忙”】

数据库写操作可以用不同的方式完成:
(由于此策略主要被用在CPU高速缓存中,所以以CPU高速缓存的例子来描述)

  1. “写回策略触发”:当缓存块最终被修改(被标记为脏)且需要被替换时,脏缓存块中的内容会被写回到数据库中
  2. 定期写回:一些系统可能会定期将修改过的数据写回数据库。
  3. 强制写回:在某些操作(如系统关闭、上下文切换或手动刷新缓存)的过程中,可能会强制执行将所有脏缓存写回到主内存的操作

4、 三种缓存读写策略如何选择

实际场景中,最常见的是Cache Aside,另外两种策略主要用在计算机系统里或开源框架源码中

1、Cache aside

优点
业务端处理所有数据访问细节,同时利用懒加载的思想,更新DB后,直接删除cache,以DB结果为准,可以大幅降低cache和DB中数据不一致的概率。

缺点

  1. 当写入比较频繁时,缓存中的数据会被频繁清理,会影响缓存命中率。可以考虑“更新数据库后更新缓存,但给缓存加一个较短的过期时间,这样即使出现缓存不一致的情况,缓存的数据也很快过期,业务能接受就行”。
  2. 冷启动问题,可以提前把热点数据放到cache中

适用场景:
读多写少的场景,不适合写多的场景。写多的场景,可以使用延时双删策略。

Cache aside策略如何解决缓存穿透缓存击穿缓存雪崩的问题?
对于缓存穿透的问题,可以采用布隆过滤器
对于缓存击穿的问题,可以采用冷数据预热或者热点数据防过期或者通过分布式锁限制查库写缓存的请求
对于缓存雪崩的问题,可以采用随机打散过期时间

2、Read/Write through

优点:

  1. 存储服务封装了所有的数据处理细节,业务端代码只用关注业务逻辑本身,系统的隔离性好。
  2. 读写穿透策略的数据一致性是最好的,所以在金融交易系统中,这种策略会有一定的应用。

缺点:

  1. 经常使用的分布式缓存Redis或Memcached并没有提供cache将数据写入DB的功能
  2. 写操作时,无论在不在cache中,都需要写回到数据库,性能有影响。

适用场景:
这种缓存策略在平时的开发过程中比较少见,大概率是因为我们经常使用的分布式缓存Redis或Memcached并没有提供cache将数据写入DB的功能。而在本地缓存中比如Guava或caffeine中就有读写穿透的影子。

为什么Redis或Memcached不提供这种功能?

微博 Feed 的 Outbox Vector(即用户最新微博列表)就采用这种模式。一些粉丝较少且不活跃的用户发表微博后,Vector 服务会首先查询 Vector Cache,如果 cache 中没有该用户的 Outbox 记录,则不写该用户的 cache 数据,直接更新 DB 后就返回,只有 cache 中存在才会通过 CAS 指令进行更新。

3、Write behind caching

优点:

  1. 写性能非常高,非常适合

Write behind caching(写回策略)适合的应用场景是写多的但一致性要求不是那么高的场景,比如消息队列中消息的异步写入磁盘、MySQL的Innodb Buffer Pool 机制(InnoDB 存储引擎用于缓存数据和索引的内存区域)、Linux文件系统中的PageCache算法、CPU的缓存都用到了这种策略。
缺点:

  1. 一致性差

适用场景:

  1. 适合变更特别频繁的业务,特别是可以合并写请求的业务,比如一些计数业务点赞次数业务
  2. 消息队列中消息的异步写入磁盘
  3. MySQL的Innodb Buffer Pool 机制(InnoDB 存储引擎用于缓存数据和索引的内存区域)都用到了这种策略。揭开 Buffer Pool 的面纱

二、 分布式事务协议

分布式事务的理论基础是CAP,由于P(分区容错)是必选项,所以只能在AP或CP中选择。
image.png

分布式系统的事务处理 | 酷 壳 - CoolShell

1、基于XA协议 ----刚性事务(强一致性事务)

XA协议是一个基于数据库层面的分布式事务协议,其分为两部分:事务管理器和本地资源管理器。事务管理器作为一个全局的调度者,负责对各个本地资源管理器统一号令提交或回滚。它可以用于管理跨多个资源(包括数据库和其他资源,如消息队列、缓存等)的分布式事务。

1、2PC/3PC协议

2PC(Two Phase Commit):第一阶段做投票,第二阶段做决定

第一阶段:

  1. 协调者会问所有的参与者结点,是否可以执行提交操作。
  2. 各个参与者开始事务执行的准备工作:如:为资源上锁,预留资源,写undo/redo log……
  3. 参与者响应协调者,如果事务的准备工作成功,则回应“可以提交”,否则回应“拒绝提交”。

第二阶段:

  1. 如果所有的参与者都回应“可以提交”,那么,协调者向所有的参与者发送“正式提交”的命令。参与者完成正式提交,并释放所有资源,然后回应“完成”,协调者收集各结点的“完成”回应后结束这个Global Transaction。
  2. 如果有一个参与者回应“拒绝提交”,那么,协调者向所有的参与者发送“回滚操作”,并释放所有资源,然后回应“回滚完成”,协调者收集各结点的“回滚”回应后,取消这个Global Transaction。

3PC(Three Phase Commit):在询问的时候并不锁定资源,除非所有人都同意了,才开始锁资源。

2、基于补偿(业务层) ---- 柔性事务(最终一致性)

1、补偿事务TCC

TCC的核心思想是:针对每个操作都要注册一个与其对应的确认Try和补偿Cancel。

2、Saga事务

Saga是由一系列的本地事务构成。每一个本地事务在更新完数据库之后,会发布一条消息或者一个事件来触发Saga中的下一个本地事务的执行。如果一个本地事务因为某些业务规则无法满足而失败,Saga会执行在这个失败的事务之前成功提交的所有事务的补偿操作。

3、基于最终一致性

本地消息表

通过在事务主动发起方额外新建事务消息表,事务发起方处理业务和记录事务消息在本地事务中完成,轮询事务消息表的数据发送事务消息,事务被动方基于消息中间件消费事务消息表中的事务。

MQ事务方案(可靠消息事务)

基于 MQ 的分布式事务方案其实是对本地消息表的封装,将本地消息表基于 MQ 内部,其他方面的协议基本与本地消息表一致。

最大努力通知

最大努力通知也称为定期校对,是对MQ事务方案的进一步优化。它在事务主动方增加了消息校对的接口,如果事务被动方没有接收到消息,此时可以调用事务主动方提供的消息校对的接口主动获取。

分布式系统 - 分布式事务及实现方案

三、 秒杀场景如何使用缓存

由于秒杀场景会存在瞬时高并发的情况,如果直接跟数据库交互,肯定是扛不住的,因此必须要使用缓存。

而且秒杀是很明显的“读多写少”的场景。

既然使用到缓存,就存在缓存一致性的问题。通常情况下,我们需要在redis中保存商品信息(商品Id、商品名称、规格属性、库存等信息),同时数据库中也要有相关信息,毕竟缓存并不完全可靠。

1、 缓存问题

秒杀过程如下:

但上面的秒杀过程存在几个问题:

  1. 缓存击穿: 比如商品A第一次秒杀,缓存是没有数据的,但数据库有。在高并发下,还是会有大量的请求打到数据库中的。解决办法首先是在项目启动之前,先把缓存进行预热,并设置合理的过期时间。其次,使用分布式锁,限制查库的请求量。

  1. 缓存穿透:如果有恶意攻击者,大量请求不存在的商品信息,这些请求就会穿过缓存而直接打到数据库中了,虽然由于前面加了分布式锁,能保证数据库不会直接挂掉,但一旦加锁就会影响性能。这个时候有一种办法是引入布隆过滤器。但这样相当于又引入了一层,需要保证布隆过滤器和缓存中的数据一致性问题,引入了复杂度【不推荐】,这种场景下,可以把用户请求的那些“不存在”的商品Id也缓存起来,而且缓存的失效时间尽量短一点,不要长时间占用缓存的资源。

到底选哪一种?分布式锁还是存不存在的缓存?看对性能的需求吧,没强需求就分布式锁。

缓存其实保证的也只是“非正常情况”下秒杀系统的可用性。在有预热的前提下,缓存中正常是会有秒杀商品数据的,(非正常情况1)如果没有就通过分布式锁来写回缓存;(非正常情况2)有用户恶意请求,就把请求的不存在的商品也都缓存起来。
但在“正常情况下”,用户的并发请求量还是很大,首当其冲的就是库存问题。

2、 库存问题

秒杀场景下的库存有“预扣库存”、“回退库存”、“库存不足”、“库存超卖”的问题。

最保险的方式是使用数据库扣减库存:
update product set stock=stock-1 where id=product and stock > 0;

通过这种方式来保证不会出现库存超卖的情况。但在高并发的场景下,数据库有可能扛不住这么多请求。
因此,库存扣减时,还需要依靠缓存来扛。

boolean exist = redisClient.query(productId,userId);
//1. 先判断该用户有没有秒杀过该商品,如果已经秒杀过,则直接返回-1。
if(exist) {
    return -1;
}
//2. 扣减库存,判断返回值是否小于0,如果小于0,则直接返回0,表示库存不足。
if(redisClient.incrby(productId, -1)<0) {
    return 0;
}

//3. 如果扣减库存后,返回值大于或等于0,则将本次秒杀记录保存起来。然后返回1,表示成功。
redisClient.add(productId,userId);
return 1;

但这种方式依然存在问题:在高并发情况下,大多数请求都会执行到incrby方法,会导致最后缓存中实际的数为一个绝对值很大的负数。后面如果有用户超时未支付或者退货的话,缓存中的库存就不准了!这时候可以使用lua脚本。

StringBuilder lua = new StringBuilder();
lua.append("if (redis.call('exists', KEYS[1]) == 1) then");
lua.append("    local stock = tonumber(redis.call('get', KEYS[1]));");
lua.append("    if (stock == -1) then");
lua.append("        return 1;");
lua.append("    end;");
lua.append("    if (stock > 0) then");
lua.append("        redis.call('incrby', KEYS[1], -1);");
lua.append("        return stock;");
lua.append("    end;");
lua.append("    return 0;");
lua.append("end;");
lua.append("return -1;");

该代码的主要流程如下:

  1. 先判断商品id是否存在,如果不存在则直接返回。【这一步其实不用放到lua脚本里】
  2. 获取该商品id的库存,判断库存如果是-1,则直接返回,表示不限制库存。
  3. 如果库存大于0,则扣减库存。
  4. 如果库存等于0,是直接返回,表示库存不足。

面试必备:秒杀场景九个细节-鸿蒙开发者社区-51CTO.COM

这里其实就是强依赖缓存了,所以必须保证缓存的可用性【不会挂掉、或key不会非正常过期】。
采用lua脚本,就会有缓存无法动态扩容,这点需要平衡。
这里每个请求都有多次缓存操作,需要保证缓存能支持翻倍的QPS。

库存信息还是需要异步同步到数据库中,可以如下操作:
Redis对于每个用户的秒杀,在获取锁之后使用 RPUSH key value插入秒杀请求,当插入的秒杀请求数达到上限时,停止所有后续插入。然后我们可以在台启动多个工作线程,使用 LPOP key 读取秒杀成功者的用户id,然后再操作数据库做最终的下订单减库存操作。