缓存:高并发读的救世主

实在不知道该编什么名字,总之先复习一下缓存吧。本文讲的重点是服务端缓存,尤其是 Redis 相关的设计。

概述

众所周知的是,我们的业务数据多数都会选择存储在 DB 里,但数据库本身是一个吞吐量有限的单点,在实际的高并发场景下,我们肯定不可能让所有的流量都流向 DB,因此在这种情况下,业务往往会涉及一些缓存来缓解 DB 的压力。

具体的来说,从客户端到服务端,链路的每一个节点都能具有缓存的能力。比如客户端的 HTTP Cache、边缘节点的 CDN 缓存,再到服务端缓存,包括内存缓存、Redis 缓存等等,在开头我们说过,重点是服务端缓存,因此我们会对客户端缓存暂且不表。(反正一言以蔽之也就是强缓存和协商缓存)。

CDN 缓存

CDN 在过去我们已经讲过很多遍了,这次重新掏出来只能说是无他,唯手熟尔。

使用 CDN 缓存你可以将数据缓存在边缘节点,从而降低端到端网络耗时。

值得一提的是,在近期的实际优化中,即使你并不是使用 DNS 缓存,而是使用了其动态加速的特性,我们也从中获得了收益:大致是因为如果不走 CDN,在全球网络时直接连接源站点,尽管相比 CDN 来说是一种直连,少了很多跳,但传输稳定性却不行;而通过 CDN 的动态加速来优化链路传输,从而可以降低响应延迟、提升接口成功率。

服务端缓存

上面我们用两个名词介绍了客户端缓存,在用几句话介绍了 CDN 缓存。接下来重点就来了:如何去设计缓存和解决我们缓存中遇到的问题。

首先我们先来考虑缓存可以解决哪些问题:

  1. CPU 计算问题:比如在之前做 SSR 时需要在服务端频繁的进行资源计算(render),如果能够对部分计算后的内容进行缓存,就能有效减少 CPU 的压力。
  2. IO 问题:对于标题中提到的高并发场景,可能会造成磁盘或者网络 IO 的压力,使用缓存能有效降低链路中的 IO 压力。

但是加了缓存也就意味着,这些数据都不是实时获取的了,需要对实时性有一定容忍度,且需要尽可能的保持一致性。

如何设计缓存

根据上述我们分析「解决问题」的场景,我们可以看出,缓存并不是一个十全十美的东西,因此设计一个无效的缓存还不如没有缓存,那么关于缓存,我们大致可以考虑以下指标来设计和选择缓存:

  • 命中率:缓存命中率是一个最重要的指标,如果你设计的缓存实际并没有被命中,那么即使系统再高效,也和你的缓存无关
  • 吞吐量:假设你的缓存命中率是 100%,但是你的缓存吞吐量却很低,导致整个服务的吞吐都被拉低了,那还不如没有缓存,直接加限流算了
  • 是否需要分布式支持:内存缓存也就是在程序内部的,那么必然是个单机缓存,而如果需要分布式缓存,我们则更多的使用 Redis 来实现分布式
  • 是否有扩展功能:这是《凤凰架构》中提到的,更多的像是「选择缓存框架」时的考虑,指的是是否会提供一些管理功能。譬如最大容量、失效时间、失效事件、命中率统计,等等。

命中率

大部分情况下,我们永远不可能把数据表照搬进缓存,也就是说,我们会对字段和缓存行进行筛选。就字段来说,我们肯定会选择热门的字段,毕竟大 key 会造成读写的性能下降,如果用的较少(QPS 较低)的部分就没有必要进 Redis 了。

而缓存行意味着我们不需要将表中的所有行都同步,比如我们缓存了用户的微博内容,但是大部分情况下,用户并不会查阅好多年前的内容,而热数据肯定是「近期的微博热搜」。

因此这里就涉及到了淘汰算法。淘汰算法相信大家学过操作系统的话其实也挺熟悉了,毕竟 CPU 也有淘汰算法,常见的淘汰算法有:

  • FIFO(First In First Out):先进先出类似于一个普通队列,大部分情况下 FIFO 是无意义的,尤其是在我们上述的例子中就更不合适了,热点数据直接被踢出。
  • LRU(Least Recent Used):LRU 会淘汰最久未被访问的资源,大部分情况下这已经够用了,但也可能会存在某个热点数据只是访问不连续,一段时间没人访问就被错误踢出的情况。使用双向链表来进行记录,而使用 HashMap 来进行访问,实现也较为简单。
  • LFU(Least Frequently Used):LFU 会淘汰最不经常用的数据,非常符合保留热数据的诉求,但也会存在问题,假设说存在一个网站爆点当时访问量很大,热点过后没有一个比他访问量更大的(他是历史最高),那么尽管话题过气了,仍然会长期存在缓存中。需要维护一个计数器,每次访问则 +1。

而基于 LFU,衍生出了 TinyLFU,W-TinyLFU,ARC 和 LIRS。这些进阶算法都值得单开一篇文章说明了,所以这里先按下不表。

缓存分类

  • 本地缓存:缓存存储在进程内,这种方式读的时候最快,因为根本不涉及网络 IO,问题是因为是本地缓存,所以各自是独立的。如果要实现一套同步复制和更新的机制,那么更新为了保证一致性就会变得很重。
  • 分布式缓存:目前如果提到缓存,大部分场景都会默认优先使用分布式缓存,他虽然相比本地缓存多了一层网络 IO,但是优点是与程序是完全解耦而独立的,目前也有很成熟的解决方案可以处理分布式缓存,而无需关心细节(没错说的就是 Redis)

当然,本地缓存和分布式缓存是可以同时使用的,两者同时使用,我们可以叫做「多级缓存」。

多级缓存中我们优先读取本地缓存,如果本地缓存不存在,再读取分布式缓存,如果分布式缓存也不存在,则会回源到 DB。

但是在更新缓存时,需要同时更新本地缓存,分布式缓存,相比使用单一缓存,一致性问题将会变得更加突出。简单说明就是发送通知,通知各级淘汰或者更新缓存。而关于怎么保证一致性,这个可以见上一期中「如何解决服务中的事务问题」中的 ACK 设计。

缓存遇到的挑战

一致性

缓存当然不是完全都是优点,在前面我们就一直提到缓存更新时的一致性问题。大部分情况下,当我们使用缓存时,我们基本上会选择追求最终一致性而不是强一致性,如果需要强一致性的场合不太适合添加缓存。

缓存一致性虽然说起来就这几个字,但其本质上也是一个很大的课题。

在上面我们说到,在读缓存时,我们先读缓存,在读数据库。但是在写时,因为缓存服务和数据库服务本质上是两个服务,同样是一个分布式事务的问题,此时先写什么后写什么,怎么避免一致性问题就变得尤为重要。

先写数据库,再写缓存?

先写数据库再写缓存看上去没什么大问题,毕竟数据库写入成功,缓存写入失败的情况下,最多就是直接访问数据库嘛。

但是实际上我们会发现如果有两个请求并发的情况下:

  1. 请求 1 先更新了数据库,将 value 从 1 改成 2
  2. 请求 2 希望 value 从 1 变成 3
  3. 数据库本身是会上行锁的,所以必然会存在先后顺序,则 value 可能为 1 或者 2,我们假设 value 变成了 3
  4. 2 和 3 更新完成后,更新缓存的请求刚发出,其到达的顺序可能是 2 先到达或者 3 先到达
  5. 如果是 2 先到达,那么最终会定格在 3。
  6. 但如果 2 后到达,那么缓存就被变更成了 2,与预期不符。

先写缓存,再写数据库?

同样不能解决问题,甚至更糟糕了,如果缓存都更新成功了,而数据库更新失败,那将是灾难性的。

先删除缓存,后写数据库?

删除缓存而不是更新缓存的策略叫做 Cache Aside

整体步骤是:

  1. 读取时不变,依旧是读缓存,没有则捞数据库,用数据库数据更新缓存
  2. 写时更新数据库+删除缓存

当然,同样也分成了两类:先删除缓存和后删除缓存。

先来说说先删后写,对于先删后写来说:

  1. 请求 1 希望更新数据库的 value,从 1 变成 2,所以删除了缓存
  2. 请求 2 希望获取 value,此时发现没有缓存,读取后更新缓存,此时 value 还是旧的值
  3. 请求 1 更新数据库,value 变成了 2

此时依旧会出现不一致的情况。似乎问题仍然没有解决。

先写数据库,后删除缓存?

如果先写数据库,后删除缓存,那么可能遇到的情况是:

  1. 请求 1 希望更新数据库的 value,从 1 变成 2
  2. 此时请求 2 请求 value,因为没有删除,所以读到了旧数据

此时如果 请求 1 删除缓存,那么下次访问时就能拿到新的值,在理想情况下,似乎并没有什么问题。

但是这里我们忽略了一种情况,在读写分离的情况下,有可能请求 1 更新完数据库后,从库并没有更新,此时可能请求 2 就可能更新了错误的数据,仍然拿到了旧的值。

尽管设置超时可以一定程度缓解这个情况,但不一定符合业务的需求,毕竟缓存过短的话就没有意义,如果长时间脏数据,这就成为了个 Bug。

如何修复边界 case

刚刚我们提到了几种边界 Case,其实并不是没有解决方案,「写+更新」的策略合并不是完全不能用。

因为我们知道,在高并发情况下,如果删除了缓存,缓存就很有可能被击穿(将在后面讲解),此时,我们希望缓存是长期存在的,这种情况就更适合「写+更新」的策略。

要解决「写+更新」中的不一致问题,最简单的方法就是使用分布式锁,简单的来说,就是控制同一时间只有一个请求进行「写+更新」的操作,那样问题就会小很多,但是我们依旧没有办法解决更新失败的问题。

对于更新失败的问题,在分布式事务的解决方案中我们其实也有提及,但是如果真要上「分布式事务」同时成功或者失败可能又太重了,我们引入一个消息队列,或者通过订阅 binlog 来更新(本质上还是消息队列),通过消息队列的可靠性来保证,是比较常见的做法。此时也不需要分布式锁了,毕竟更新被异步了。

因为消息队列本身有 ACK+重试机制来保证消费的可靠性,利用这一特性,我们就能尽可能保证 Redis 更新的可靠性了。

如果你的策略是删除,而前面遇到的读写不一致的问题,有一种解决方案叫做「延迟双删」,也就是过一段时间我再删一次,此时就能避免并发时遇到的删了却读了脏数据的问题。

但是对于延迟双删来说,延迟多久是一个比较麻烦的问题。

总结来说:

  1. 对于「更新+写」,建议别用,凉的太快
  2. 对于「写+更新」,利用 MQ(binlog)来进行保序+可靠更新
  3. 对于「删除+写」,延迟双删来解决,也可以使用分布式锁
  4. 对于「写+删除」,同样可以用延迟双删来解决

关于 Cache Aside 在读场景中使用了分布式锁,步骤大概是:

  1. 需要进行数据库写入,上锁,删除缓存,等更新完数据库后释放锁
  2. 读时有缓存读缓存,没有发现上锁状态,暂不处理,等待锁释放,抢锁,然后执行从数据库获取和更新 Redis

是否会存在锁过重的情况,我们留待后续讨论。

缓存穿透

缓存穿透意味着缓存不存在,而回源的情况。

结合我们上面对缓存设计的介绍,大部分场景下其实这是一个正常的现象,冷数据的 QPS 也不会太高,并不会有什么影响,最多咱们对于冷数据也进行一定时间的缓存。此外,如果发现一段时间内访问了不存在的数据造成了回源,也可以直接将空对象存入缓存中。

但是以上说的是正常情况,如果是异常情况,有恶意请求进行流量攻击,此时可以结合限流限频来防御,如果是 DDOS 类由于 IP 大量分散导致很难识别的,也可以通过布隆过滤器来快速判断数据是否存在。

缓存击穿

可以看到,撇除恶意攻击,缓存穿透在正常情况下的危害性并不大,而缓存击穿则比较严重。

缓存击穿,意味着热点数据在某一时间失效或者被删除,大量 QPS 涌入造成源负载过重。

这里的解决方案可以是:

  1. 永不过期:热点数据永远存在于 Redis 中,先前我们讲过一致性的解决方案,此时我们只能使用写+更新的策略。
  2. 逻辑过期:永不过期带来的问题是如果存在任何问题导致缓存不一致,我们将失去最后的修复手段,因此也可以在缓存物理过期前加上逻辑过期,逻辑过期时间再去更新缓存,此时逻辑过期时间需要小于缓存的物理过期时间。这样物理过期时间相当于最后的防御措施,安全系数高了很多。
  3. 加锁同步:即使被击穿,因为有锁的存在,同时只会有一条记录回源,而拿到锁后,在回源前重新检查是否有数据。与 Cache Aside 中分布式锁的情况类似。换言之, Cache Aside 如果是行锁,也不会存在太大问题。

缓存雪崩

缓存雪崩,意味着大量缓存在同一个时间点过期,可能是因为业务设置,也可能是因为缓存故障,此时分布式锁由于是个行锁,就不会产生多大效果。

针对性的策略有:

  1. 设置不同的过期时间,避免同时过期
  2. 多级缓存,此时两级缓存的过期时间可以不一样,此时击穿到数据源的可能性就大大降低了。

当然,同样的,缓存击穿的诸如逻辑过期、永不过期等手段依旧可以解决这个问题;对回源进行限流同样也可以一定程度的缓解。

缓存预热

缓存预热也就是在业务访问前,提前将数据准备好,这样可以有效避免新数据上线时找不到缓存的问题,可以结合实际情况进行。

总结

对于缓存设计来说,同样也没有银弹,需要结合自己的实际业务情况来选择适合自己的缓存方案。

关于 Redis 的其他问题,我们将在其他文章中另行说明。

参考资料

植入部分

如果您觉得文章不错,可以通过赞助支持我。

如果您不希望打赏,也可以通过关闭广告屏蔽插件的形式帮助网站运作。

标签: 知识

添加新评论