

























实在不知道该编什么名字,总之先复习一下缓存吧。本文讲的重点是服务端缓存,尤其是 Redis 相关的设计。
众所周知的是,我们的业务数据多数都会选择存储在 DB 里,但数据库本身是一个吞吐量有限的单点,在实际的高并发场景下,我们肯定不可能让所有的流量都流向 DB,因此在这种情况下,业务往往会涉及一些缓存来缓解 DB 的压力。
具体的来说,从客户端到服务端,链路的每一个节点都能具有缓存的能力。比如客户端的 HTTP Cache、边缘节点的 CDN 缓存,再到服务端缓存,包括内存缓存、Redis 缓存等等,在开头我们说过,重点是服务端缓存,因此我们会对客户端缓存暂且不表。(反正一言以蔽之也就是强缓存和协商缓存)。
CDN 在过去我们已经讲过很多遍了,这次重新掏出来只能说是无他,唯手熟尔。
使用 CDN 缓存你可以将数据缓存在边缘节点,从而降低端到端网络耗时。
值得一提的是,在近期的实际优化中,即使你并不是使用 DNS 缓存,而是使用了其动态加速的特性,我们也从中获得了收益:大致是因为如果不走 CDN,在全球网络时直接连接源站点,尽管相比 CDN 来说是一种直连,少了很多跳,但传输稳定性却不行;而通过 CDN 的动态加速来优化链路传输,从而可以降低响应延迟、提升接口成功率。
上面我们用两个名词介绍了客户端缓存,在用几句话介绍了 CDN 缓存。接下来重点就来了:如何去设计缓存和解决我们缓存中遇到的问题。
首先我们先来考虑缓存可以解决哪些问题:
但是加了缓存也就意味着,这些数据都不是实时获取的了,需要对实时性有一定容忍度,且需要尽可能的保持一致性。
根据上述我们分析「解决问题」的场景,我们可以看出,缓存并不是一个十全十美的东西,因此设计一个无效的缓存还不如没有缓存,那么关于缓存,我们大致可以考虑以下指标来设计和选择缓存:
大部分情况下,我们永远不可能把数据表照搬进缓存,也就是说,我们会对字段和缓存行进行筛选。就字段来说,我们肯定会选择热门的字段,毕竟大 key 会造成读写的性能下降,如果用的较少(QPS 较低)的部分就没有必要进 Redis 了。
而缓存行意味着我们不需要将表中的所有行都同步,比如我们缓存了用户的微博内容,但是大部分情况下,用户并不会查阅好多年前的内容,而热数据肯定是「近期的微博热搜」。
因此这里就涉及到了淘汰算法。淘汰算法相信大家学过操作系统的话其实也挺熟悉了,毕竟 CPU 也有淘汰算法,常见的淘汰算法有:
而基于 LFU,衍生出了 TinyLFU,W-TinyLFU,ARC 和 LIRS。这些进阶算法都值得单开一篇文章说明了,所以这里先按下不表。
当然,本地缓存和分布式缓存是可以同时使用的,两者同时使用,我们可以叫做「多级缓存」。
多级缓存中我们优先读取本地缓存,如果本地缓存不存在,再读取分布式缓存,如果分布式缓存也不存在,则会回源到 DB。
但是在更新缓存时,需要同时更新本地缓存,分布式缓存,相比使用单一缓存,一致性问题将会变得更加突出。简单说明就是发送通知,通知各级淘汰或者更新缓存。而关于怎么保证一致性,这个可以见上一期中「如何解决服务中的事务问题」中的 ACK 设计。
缓存当然不是完全都是优点,在前面我们就一直提到缓存更新时的一致性问题。大部分情况下,当我们使用缓存时,我们基本上会选择追求最终一致性而不是强一致性,如果需要强一致性的场合不太适合添加缓存。
缓存一致性虽然说起来就这几个字,但其本质上也是一个很大的课题。
在上面我们说到,在读缓存时,我们先读缓存,在读数据库。但是在写时,因为缓存服务和数据库服务本质上是两个服务,同样是一个分布式事务的问题,此时先写什么后写什么,怎么避免一致性问题就变得尤为重要。
先写数据库再写缓存看上去没什么大问题,毕竟数据库写入成功,缓存写入失败的情况下,最多就是直接访问数据库嘛。
但是实际上我们会发现如果有两个请求并发的情况下:
同样不能解决问题,甚至更糟糕了,如果缓存都更新成功了,而数据库更新失败,那将是灾难性的。
删除缓存而不是更新缓存的策略叫做 Cache Aside。
整体步骤是:
当然,同样也分成了两类:先删除缓存和后删除缓存。
先来说说先删后写,对于先删后写来说:
此时依旧会出现不一致的情况。似乎问题仍然没有解决。
如果先写数据库,后删除缓存,那么可能遇到的情况是:
此时如果 请求 1 删除缓存,那么下次访问时就能拿到新的值,在理想情况下,似乎并没有什么问题。
但是这里我们忽略了一种情况,在读写分离的情况下,有可能请求 1 更新完数据库后,从库并没有更新,此时可能请求 2 就可能更新了错误的数据,仍然拿到了旧的值。
尽管设置超时可以一定程度缓解这个情况,但不一定符合业务的需求,毕竟缓存过短的话就没有意义,如果长时间脏数据,这就成为了个 Bug。
刚刚我们提到了几种边界 Case,其实并不是没有解决方案,「写+更新」的策略合并不是完全不能用。
因为我们知道,在高并发情况下,如果删除了缓存,缓存就很有可能被击穿(将在后面讲解),此时,我们希望缓存是长期存在的,这种情况就更适合「写+更新」的策略。
要解决「写+更新」中的不一致问题,最简单的方法就是使用分布式锁,简单的来说,就是控制同一时间只有一个请求进行「写+更新」的操作,那样问题就会小很多,但是我们依旧没有办法解决更新失败的问题。
对于更新失败的问题,在分布式事务的解决方案中我们其实也有提及,但是如果真要上「分布式事务」同时成功或者失败可能又太重了,我们引入一个消息队列,或者通过订阅 binlog 来更新(本质上还是消息队列),通过消息队列的可靠性来保证,是比较常见的做法。此时也不需要分布式锁了,毕竟更新被异步了。
因为消息队列本身有 ACK+重试机制来保证消费的可靠性,利用这一特性,我们就能尽可能保证 Redis 更新的可靠性了。
如果你的策略是删除,而前面遇到的读写不一致的问题,有一种解决方案叫做「延迟双删」,也就是过一段时间我再删一次,此时就能避免并发时遇到的删了却读了脏数据的问题。
但是对于延迟双删来说,延迟多久是一个比较麻烦的问题。
总结来说:
关于 Cache Aside 在读场景中使用了分布式锁,步骤大概是:
是否会存在锁过重的情况,我们留待后续讨论。
缓存穿透意味着缓存不存在,而回源的情况。
结合我们上面对缓存设计的介绍,大部分场景下其实这是一个正常的现象,冷数据的 QPS 也不会太高,并不会有什么影响,最多咱们对于冷数据也进行一定时间的缓存。此外,如果发现一段时间内访问了不存在的数据造成了回源,也可以直接将空对象存入缓存中。
但是以上说的是正常情况,如果是异常情况,有恶意请求进行流量攻击,此时可以结合限流限频来防御,如果是 DDOS 类由于 IP 大量分散导致很难识别的,也可以通过布隆过滤器来快速判断数据是否存在。
可以看到,撇除恶意攻击,缓存穿透在正常情况下的危害性并不大,而缓存击穿则比较严重。
缓存击穿,意味着热点数据在某一时间失效或者被删除,大量 QPS 涌入造成源负载过重。
这里的解决方案可以是:
缓存雪崩,意味着大量缓存在同一个时间点过期,可能是因为业务设置,也可能是因为缓存故障,此时分布式锁由于是个行锁,就不会产生多大效果。
针对性的策略有:
当然,同样的,缓存击穿的诸如逻辑过期、永不过期等手段依旧可以解决这个问题;对回源进行限流同样也可以一定程度的缓解。
缓存预热也就是在业务访问前,提前将数据准备好,这样可以有效避免新数据上线时找不到缓存的问题,可以结合实际情况进行。
对于缓存设计来说,同样也没有银弹,需要结合自己的实际业务情况来选择适合自己的缓存方案。
关于 Redis 的其他问题,我们将在其他文章中另行说明。
此内容由惯性聚合(RSS阅读器)自动聚合整理,仅供阅读参考。 原文来自 — 版权归原作者所有。