分布式缓存:缓存原理和经典问题

1. 缓存知识点全景;

在这里插入图片描述

2. 缓存的原理、引入及设计;

2.1 缓存的原理;

缓存的定义:

  • 缓存最初的含义,是指用于加速 CPU 数据交换的 RAM,即随机存取存储器,通常这种存储器使用更昂贵但快速的静态 RAM(SRAM)技术,用以对 DRAM 进行加速。这是一个狭义缓存的定义。
  • 而广义缓存的定义则更宽泛,任何可以用于数据高速交换的存储介质都是缓存,可以是硬件也可以是软件。
  • 缓存存在的意义就是通过开辟一个新的数据交换缓冲区,来解决原始数据获取代价太大的问题,让数据得到更快的访问。本次主要讨论广义缓存,特别是互联网产品大量使用的各种缓存组件和技术。
    在这里插入图片描述

缓存的基本思想:缓存构建的基本思想是利用时间局限性原理,通过空间换时间来达到加速数据获取的目的,同时由于缓存空间的成本较高,在实际设计架构中还要考虑访问延迟和成本的权衡问题。这里面有 3 个关键点。

在这里插入图片描述

  • 一是时间局限性原理,即被获取过一次的数据在未来会被多次引用,比如一条微博被一个人感兴趣并阅读后,它大概率还会被更多人阅读,当然如果变成热门微博后,会被数以百万/千万计算的更多用户查看。
  • 二是以空间换时间,因为原始数据获取太慢,所以我们开辟一块高速独立空间,提供高效访问,来达到数据获取加速的目的。
  • 三是性能成本 Tradeoff,构建系统时希望系统的访问性能越高越好,访问延迟越低小越好。但维持相同数据规模的存储及访问,性能越高延迟越小,成本也会越高,所以在系统架构设计时,你需要在系统性能和开发运行成本之间做取舍。比如左边这张图,相同成本的容量,SSD 硬盘容量会比内存大 10~30 倍以上,但读写延迟却高 50~100 倍。

缓存的优势:

  • 提升访问性能
  • 降低网络拥堵
  • 减轻服务负载
  • 增强可扩展性

缓存存储原始数据,可以大幅提升访问性能。不过在实际业务场景中,缓存中存储的往往是需要频繁访问的中间数据甚至最终结果,这些数据相比 DB 中的原始数据小很多,这样就可以减少网络流量,降低网络拥堵,同时由于减少了解析和计算,调用方和存储服务的负载也可以大幅降低。缓存的读写性能很高,预热快,在数据访问存在性能瓶颈或遇到突发流量,系统读写压力大增时,可以快速部署上线,同时在流量稳定后,也可以随时下线,从而使系统的可扩展性大大增强。

缓存的代价:任何事情都有两面性,缓存也不例外,我们在享受缓存带来一系列好处的同时,也注定需要付出一定的代价。

  • 首先,服务系统中引入缓存,会增加系统的复杂度。
  • 其次,由于缓存相比原始 DB 存储的成本更高,所以系统部署及运行的费用也会更高。
  • 最后,由于一份数据同时存在缓存和 DB 中,甚至缓存内部也会有多个数据副本,多份数据就会存在一致性问题,同时缓存体系本身也会存在可用性问题和分区的问题。这就需要我们加强对缓存原理、缓存组件以及优秀缓存体系实践的理解,从系统架构之初就对缓存进行良好设计,降低缓存引入的副作用,让缓存体系成为服务系统高效稳定运行的强力基石。

一般来讲,服务系统的全量原始数据存储在 DB 中(如 MySQL、HBase 等),所有数据的读写都可以通过 DB 操作来获取。但 DB 读写性能低、延迟高,如 MySQL 单实例的读写 QPS 通常只有千级别(线上可以倒 3000~6000),读写平均耗时 10~100ms 级别,如果一个用户请求需要查 20 个不同的数据来聚合,仅仅 DB 请求就需要数百毫秒甚至数秒。而 cache 的读写性能正好可以弥补 DB 的不足,比如 Memcached 的读写 QPS 可以达到 10~100 万级别,读写平均耗时在 1ms 以下,结合并发访问技术,单个请求即便查上百条数据,也可以轻松应对。

但 cache 容量小,只能存储部分访问频繁的热数据,同时,同一份数据可能同时存在 cache 和 DB,如果处理不当,就会出现数据不一致的问题。所以服务系统在处理业务请求时,需要对 cache 的读写方式进行适当设计,既要保证数据高效返回,又要尽量避免数据不一致等各种问题。

2.2 缓存读写模式;

如下图,业务系统读写缓存有 3 种模式:

  • Cache Aside(旁路缓存)
  • Read / Write Through(读写穿透)
  • Write Behind Caching(异步缓存写入)
    在这里插入图片描述

Cache Aside
在这里插入图片描述
如上图所示,Cache Aside 模式中,业务应用方对于写,是更新 DB 后,直接将 key 从 cache 中删除,然后由 DB 驱动缓存数据的更新;而对于读,是先读 cache,如果 cache 没有,则读 DB,同时将从 DB 中读取的数据回写到 cache。

这种模式的特点是,业务端处理所有数据访问细节,同时利用 Lazy 计算的思想,更新 DB 后,直接删除 cache 并通过 DB 更新,确保数据以 DB 结果为准,则可以大幅降低 cache 和 DB 中数据不一致的概率。

如果没有专门的存储服务,同时是对数据一致性要求比较高的业务,或者是缓存数据更新比较复杂的业务,这些情况都比较适合使用 Cache Aside 模式。如微博发展初期,不少业务采用这种模式,这些缓存数据需要通过多个原始数据进行计算后设置。在部分数据变更后,直接删除缓存。同时,使用一个 Trigger 组件,实时读取 DB 的变更日志,然后重新计算并更新缓存。如果读缓存的时候,Trigger 还没写入 cache,则由调用方自行到 DB 加载计算并写入 cache。

Read / Write Through
在这里插入图片描述
如上图,对于 Cache Aside 模式,业务应用需要同时维护 cache 和 DB 两个数据存储方,过于繁琐,于是就有了 Read / Write Through 模式。在这种模式下,业务应用只关注一个存储服务即可,业务方的读写 cache 和 DB 的操作,都由存储服务代理。存储服务收到业务应用的写请求时,会首先查 cache,如果数据在 cache 中不存在,则只更新 DB,如果数据在 cache 中存在,则先更新 cache,然后更新 DB。而存储服务收到读请求时,如果命中 cache 直接返回,否则先从 DB 加载,回种到 cache 后返回响应。

这种模式的特点是,存储服务封装了所有的数据处理细节,业务应用端代码只用关注业务逻辑本身,系统的隔离性更佳。另外,进行写操作时,如果 cache 中没有数据则不更新,有缓存数据才更新,内存效率更高。

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

Write Behind Caching
在这里插入图片描述

Write Behind Caching 模式与 Read / Write Through 模式类似,也由数据存储服务来管理 cache 和 DB 的读写。不同点是,数据更新时,Read / write Through 是同步更新 cache 和 DB,而 Write Behind Caching 则是只更新缓存,不直接更新 DB,而是改为异步批量的方式来更新 DB。该模式的特点是,数据存储的写性能最高,非常适合一些变更特别频繁的业务,特别是可以合并写请求的业务,比如对一些计数业务,一条 Feed 被点赞 1万次,如果更新 1万次 DB 代价很大,而合并成一次请求直接加 1万,则是一个非常轻量的操作。但这种模型有个显著的缺点,即数据的一致性变差,甚至在一些极端场景下可能会丢失数据。比如系统 Crash、机器宕机时,如果有数据还没保存到 DB,则会存在丢失的风险。所以这种读写模式适合变更频率特别高,但对一致性要求不太高的业务,这样写操作可以异步批量写入 DB,减小 DB 压力。

总结:三种模式各有优劣,不存在最佳模式。实际上,我们也不可能设计出一个最佳的完美模式出来,如同前面讲到的空间换时间、访问延迟换低成本一样,高性能和强一致性从来都是有冲突的,系统设计从来就是取舍,随处需要 trade-off。即如何根据业务场景,更好的做 trade-off,从而设计出更好的服务系统。

2.3 缓存分类及常用缓存介绍;

按宿主层次分类

宿主层次分类的话,缓存一般可以分为本地 Cache、进程间 Cache 和远程 Cache。

  • 本地 Cache 是指业务进程内的缓存,这类缓存由于在业务系统进程内,所以读写性能超高且无任何网络开销,但不足是会随着业务系统重启而丢失。
  • 进程间 Cache 是本机独立运行的缓存,这类缓存读写性能较高,不会随着业务系统重启丢数据,并且可以大幅减少网络开销,但不足是业务系统和缓存都在相同宿主机,运维复杂,且存在资源竞争。
  • 远程 Cache 是指跨机器部署的缓存,这类缓存因为独立设备部署,容量大且易扩展,在互联网企业使用最广泛。不过远程缓存需要跨机访问,在高读写压力下,带宽容易成为瓶颈。

本地 Cache 的缓存组件有 Ehcache、Guava Cache 等,开发者自己也可以用 Map、Set 等轻松构建一个自己专用的本地 Cache。进程间 Cache 和远程 Cache 的缓存组件相同,只是部署位置的差异罢了,这类缓存组件有 Memcached、Redis、Pika 等。

按存储介质分类:这样可以分为内存型缓存和持久化型缓存。

  • 内存型缓存将数据存储在内存,读写性能很高,但缓存系统重启或 Crash 后,内存数据会丢失。
  • 持久化型缓存将数据存储到 SSD/Fusion-IO 硬盘中,相同成本下,这种缓存的容量会比内存型缓存大 1 个数量级以上,而且数据会持久化落地,重启不丢失,但读写性能相对低 1~2 个数量级。Memcached 是典型的内存型缓存,而 Pika 以及其他基于 RocksDB 开发的缓存组件等则属于持久化型缓存。

2.4 缓存的引入及设计架构;

缓存组件选择:在设计架构缓存时,首先要选定缓存组件,比如要用 Local-Cache,还是 Redis、Memcached、Pika 等开源缓存组件,如果业务缓存需求比较特殊,你还要考虑是直接定制开发一个新的缓存组件,还是对开源缓存进行二次开发,来满足业务需要。

缓存数据结构设计:确定好缓存组件后,还要根据业务访问的特点,进行缓存数据结构的设计。对于直接简单 KV 读写的业务,你可以将这些业务数据封装为 String、Json、Protocol Buffer 等格式,序列化成字节序列,然后直接写入缓存中。读取时,先从缓存组件获取到数据的字节序列,再进行反序列化操作即可。对于只需要存取部分字段或需要在缓存端进行计算的业务,你可以把数据设计为 Hash、Set、List、Geo 等结构,存储到支持复杂集合数据类型的缓存中,如 Redis、Pika 等。

缓存分布设计:确定了缓存组件,设计好了缓存数据结构,接下来就要设计缓存的分布。可以从 3 个维度来进行缓存分布设计。

  • 首先,要选择分布式算法,是采用取模还是一致性 Hash 进行分布。取模分布的方案简单,每个 key 只会存在确定的缓存节点,一致性 Hash 分布的方案相对复杂,一个 key 对应的缓存节点不确定。但一致性 Hash 分布,可以在部分缓存节点异常时,将失效节点的数据访问均衡分散到其他正常存活的节点,从而更好地保证了缓存系统的稳定性。
  • 其次,分布读写访问如何进行实施,是由缓存 Client 直接进行 Hash 分布定位读写,还是通过 Proxy 代理来进行读写路由?Client 直接读写,读写性能最佳,但需要 Client 感知分布策略。在缓存部署发生在线变化时,也需要及时通知所有缓存 Client,避免读写异常,另外,Client 实现也较复杂。而通过 Proxy 路由,Client 只需直接访问 Proxy,分布逻辑及部署变更都由 Proxy 来处理,对业务应用开发最友好,但业务访问多一跳,访问性能会有一定的损失。
  • 最后,缓存系统运行过程中,如果待缓存的数据量增长过快,会导致大量缓存数据被剔除,缓存命中率会下降,数据访问性能会随之降低,这样就需要将数据从缓存节点进行动态拆分,把部分数据水平迁移到其他缓存节点。这个迁移过程需要考虑,是由 Proxy 进行迁移还是缓存 Server 自身进行迁移,甚至根本就不支持迁移。对于 Memcached,一般不支持迁移,对 Redis,社区版本是依靠缓存 Server 进行迁移,而对 Codis 则是通过 Admin、Proxy 配合后端缓存组件进行迁移。

缓存架构部署及运维管理:设计完毕缓存的分布策略后,接下来就要考虑缓存的架构部署及运维管理了。架构部署主要考虑如何对缓存进行分池、分层、分 IDC,以及是否需要进行异构处理。

  • 核心的、高并发访问的不同数据,需要分别分拆到独立的缓存池中,进行分别访问,避免相互影响;访问量较小、非核心的业务数据,则可以混存。
  • 对海量数据、访问超过 10~100万 级的业务数据,要考虑分层访问,并且要分摊访问量,避免缓存过载。
  • 如果业务系统需要多 IDC 部署甚至异地多活,则需要对缓存体系也进行多 IDC 部署,要考虑如何跨 IDC 对缓存数据进行更新,可以采用直接跨 IDC 读写,也可以采用 DataBus 配合队列机进行不同 IDC 的消息同步,然后由消息处理机进行缓存更新,还可以由各个 IDC 的 DB Trigger 进行缓存更新。
  • 某些极端场景下,还需要把多种缓存组件进行组合使用,通过缓存异构达到最佳读写性能。
  • 站在系统层面,要想更好得管理缓存,还要考虑缓存的服务化,考虑缓存体系如何更好得进行集群管理、监控运维等。

2.5 缓存设计架构过程中的常见考量点;

在这里插入图片描述
读写方式:首先是 value 的读写方式。是全部整体读写,还是只部分读写及变更?是否需要内部计算?比如,用户粉丝数,很多普通用户的粉丝有几千到几万,而大 V 的粉丝更是高达几千万甚至过亿,因此,获取粉丝列表肯定不能采用整体读写的方式,只能部分获取。另外在判断某用户是否关注了另外一个用户时,也不需要拉取该用户的全部关注列表,直接在关注列表上进行检查判断,然后返回 True / False 或 0 / 1 的方式更为高效。在关注列表上,Redis 是比 Memcahced 更优的选择。

KV size:然后是不同业务数据缓存 KV 的 size。如果单个业务的 KV size 过大,需要分拆成多个 KV 来缓存。但是,不同缓存数据的 KV size 如果差异过大,也不能缓存在一起,避免缓存效率的低下和相互影响。

key 的数量:key 的数量也是一个重要考虑因素。如果 key 数量不大,可以在缓存中存下全量数据,把缓存当 DB 存储来用,如果缓存读取 miss,则表明数据不存在,根本不需要再去 DB 查询。如果数据量巨大,则在缓存中尽可能只保留频繁访问的热数据,对于冷数据直接访问 DB。

读写峰值:另外,对缓存数据的读写峰值,如果小于 10万级别,简单分拆到独立 Cache 池即可。而一旦数据的读写峰值超过 10 万甚至到达 100 万级的QPS,则需要对 Cache 进行分层处理,可以同时使用 Local-Cache 配合远程 cache,甚至远程缓存内部继续分层叠加分池进行处理。微博业务中,大多数核心业务的 Memcached 访问都采用的这种处理方式。

命中率:缓存的命中率对整个服务体系的性能影响甚大。对于核心高并发访问的业务,需要预留足够的容量,确保核心业务缓存维持较高的命中率。比如微博中的 Feed Vector Cache,常年的命中率高达 99.5% 以上。为了持续保持缓存的命中率,缓存体系需要持续监控,及时进行故障处理或故障转移。同时在部分缓存节点异常、命中率下降时,故障转移方案,需要考虑是采用一致性 Hash 分布的访问漂移策略,还是采用数据多层备份策略。

过期策略

  • 可以设置较短的过期时间,让冷 key 自动过期;
  • 也可以让 key 带上时间戳,同时设置较长的过期时间,比如很多业务系统内部有这样一些 key:key_20191019,过了这个时间就可以把缓存删掉。

平均缓存穿透加载时间:平均缓存穿透加载时间在某些业务场景下也很重要,对于一些缓存穿透后,加载时间特别长或者需要复杂计算的数据,而且访问量还比较大的业务数据,要配置更多容量,维持更高的命中率,从而减少穿透到 DB 的概率,来确保整个系统的访问性能。

缓存可运维性:对于缓存的可运维性考虑,则需要考虑缓存体系的集群管理,如何进行一键扩缩容,如何进行缓存组件的升级和变更,如何快速发现并定位问题,如何持续监控报警,最好有一个完善的运维平台,将各种运维工具进行集成。

缓存安全性:对于缓存的安全性考虑,一方面可以限制来源 IP,只允许内网访问,同时对于一些关键性指令,需要增加访问权限,避免被攻击或误操作时,导致重大后果。

3. 七大缓存经典问题;

在缓存系统的设计架构中,还有很多坑,很多的明枪暗箭,如果设计不当会导致很多严重的后果。设计不当,轻则请求变慢、性能降低,重则会数据不一致、系统可用性降低,甚至会导致缓存雪崩,整个系统无法对外提供服务。

3.1 缓存失效;

问题描述:缓存第一个经典问题是缓存失效。上一课时讲到,服务系统查数据,首先会查缓存,如果缓存数据不存在,就进一步查 DB,最后查到数据后回种到缓存并返回。缓存的性能比 DB 高 50 到100 倍以上,所以我们希望数据查询尽可能命中缓存,这样系统负荷最小,性能最佳。缓存里的数据存储基本上都是以 key 为索引进行存储和获取的。业务访问时,如果大量的 key 同时过期,很多缓存数据访问都会 miss,进而穿透到 DB,DB 的压力就会明显上升,由于 DB 的性能较差,只在缓存的 1% 到 2% 以下,这样请求的慢查率会明显上升。这就是缓存失效的问题。

原因分析

导致缓存失效,特别是很多 key 一起失效的原因,跟我们日常写缓存的过期时间息息相关。

在写缓存时,我们一般会根据业务的访问特点,给每种业务数据预置一个过期时间,在写缓存时把这个过期时间带上,让缓存数据在这个固定的过期时间后被淘汰。一般情况下,因为缓存数据是逐步写入的,所以也是逐步过期被淘汰的。但在某些场景,一大批数据会被系统主动或被动从 DB 批量加载,然后写入缓存。这些数据写入缓存时,由于使用相同的过期时间,在经历这个过期时间之后,这批数据就会一起到期,从而被缓存淘汰。此时,对这批数据的所有请求,都会出现缓存失效,从而都穿透到 DB,DB 由于查询量太大,就很容易压力大增,请求变慢。

业务场景:很多业务场景,稍不注意,就出现大量的缓存失效,进而导致系统 DB 压力大、请求变慢的情况。比如同一批火车票、飞机票,当可以售卖时,系统会一次性加载到缓存,如果缓存写入时,过期时间按照预先设置的过期值,那过期时间到期后,系统就会因缓存失效出现变慢的问题。类似的业务场景还有很多,比如微博业务,会有后台离线系统,持续计算热门微博,每当计算结束,会将这批热门微博批量写入对应的缓存。还比如,很多业务,在部署新 IDC 或新业务上线时,会进行缓存预热,也会一次性加载大批热数据。
在这里插入图片描述

解决方案:对于批量 key 缓存失效的问题,原因既然是预置的固定过期时间,那解决方案也从这里入手。设计缓存的过期时间时,使用公式:过期时间 = base 时间 + random 随机时间。即相同业务数据写缓存时,在基础过期时间之上,再加一个随机的过期时间,让数据在未来一段时间内慢慢过期,避免瞬时全部过期,对 DB 造成过大压力,如下图所示。
在这里插入图片描述

3.2 缓存穿透;

问题描述:缓存穿透是一个很有意思的问题。因为缓存穿透发生的概率很低,所以一般很难被发现。但是,一旦你发现了,而且量还不小,你可能立即就会经历一个忙碌的夜晚。因为对于正常访问,访问的数据即便不在缓存,也可以通过 DB 加载回种到缓存。而缓存穿透,则意味着有特殊访客在查询一个不存在的 key,导致每次查询都会穿透到 DB,如果这个特殊访客再控制一批肉鸡机器,持续访问你系统里不存在的 key,就会对 DB 产生很大的压力,从而影响正常服务。

原因分析

缓存穿透存在的原因,就是因为我们在系统设计时,更多考虑的是正常访问路径,对特殊访问路径、异常访问路径考虑相对欠缺。

缓存访问设计的正常路径,是先访问 cache,cache miss 后查 DB,DB 查询到结果后,回种缓存返回。这对于正常的 key 访问是没有问题的,但是如果用户访问的是一个不存在的 key,查 DB 返回空(即一个 NULL),那就不会把这个空写回 cache。那以后不管查询多少次这个不存在的 key,都会 cache miss,都会查询 DB。整个系统就会退化成一个“前端 + DB“的系统,由于 DB 的吞吐只在 cache 的 1%~2% 以下,如果有特殊访客,大量访问这些不存在的 key,就会导致系统的性能严重退化,影响正常用户的访问。

业务场景:缓存穿透的业务场景很多,比如通过不存在的 UID 访问用户,通过不存在的车次 ID 查看购票信息。用户输入错误,偶尔几个这种请求问题不大,但如果是大量这种请求,就会对系统影响非常大。

解决方案

  • 第一种方案就是,查询这些不存在的数据时,第一次查 DB,虽然没查到结果返回 NULL,仍然记录这个 key 到缓存,只是这个 key 对应的 value 是一个特殊设置的值。
  • 二种方案是,构建一个 BloomFilter 缓存过滤器,记录全量数据,这样访问数据时,可以直接通过 BloomFilter 判断这个 key 是否存在,如果不存在直接返回即可,根本无需查缓存和 DB。
    在这里插入图片描述

不过这两种方案在设计时仍然有一些要注意的坑。

  • 对于方案一,如果特殊访客持续访问大量的不存在的 key,这些 key 即便只存一个简单的默认值,也会占用大量的缓存空间,导致正常 key 的命中率下降。所以进一步的改进措施是,对这些不存在的 key 只存较短的时间,让它们尽快过期;或者将这些不存在的 key 存在一个独立的公共缓存,从缓存查找时,先查正常的缓存组件,如果 miss,则查一下公共的非法 key 的缓存,如果后者命中,直接返回,否则穿透 DB,如果查出来是空,则回种到非法 key 缓存,否则回种到正常缓存。
  • 对于方案二,BloomFilter 要缓存全量的 key,这就要求全量的 key 数量不大,10 亿条数据以内最佳,因为 10 亿条数据大概要占用 1.2 GB 的内存。也可以用 BloomFilter 缓存非法 key,每次发现一个 key 是不存在的非法 key,就记录到 BloomFilter 中,这种记录方案,会导致 BloomFilter 存储的 key 持续高速增长,为了避免记录 key 太多而导致误判率增大,需要定期清零处理。

BloomFilter

BloomFilter 是一个非常有意思的数据结构,不仅仅可以挡住非法 key 攻击,还可以低成本、高性能地对海量数据进行判断,比如一个系统有数亿用户和百亿级新闻 feed,就可以用 BloomFilter 来判断某个用户是否阅读某条新闻 feed。下面来对 BloomFilter 数据结构做一个分析,如下图所示。

在这里插入图片描述

BloomFilter 的目的是检测一个元素是否存在于一个集合内。它的原理,是用 bit 数据组来表示一个集合,对一个 key 进行多次不同的 Hash 检测,如果所有 Hash 对应的 bit 位都是 1,则表明 key 非常大概率存在,平均单记录占用 1.2 字节即可达到 99%,只要有一次 Hash 对应的 bit 位是 0,就说明这个 key 肯定不存在于这个集合内。

BloomFilter 的算法是,首先分配一块内存空间做 bit 数组,数组的 bit 位初始值全部设为 0,加入元素时,采用 k 个相互独立的 Hash 函数计算,然后将元素 Hash 映射的 K 个位置全部设置为 1。检测 key 时,仍然用这 k 个 Hash 函数计算出 k 个位置,如果位置全部为 1,则表明 key 存在,否则不存在。

BloomFilter 的优势是,全内存操作,性能很高。另外空间效率非常高,要达到 1% 的误判率,平均单条记录占用 1.2 字节即可。而且,平均单条记录每增加 0.6 字节,还可让误判率继续变为之前的 1/10,即平均单条记录占用 1.8 字节,误判率可以达到 1/1000;平均单条记录占用 2.4 字节,误判率可以到 1/10000,以此类推。这里的误判率是指,BloomFilter 判断某个 key 存在,但它实际不存在的概率,因为它存的是 key 的 Hash 值,而非 key 的值,所以有概率存在这样的 key,它们内容不同,但多次 Hash 后的 Hash 值都相同。这个概率非常低。对于 BloomFilter 判断不存在的 key ,则是 100% 不存在的,反证法,如果这个 key 存在,那它每次 Hash 后对应的 Hash 值位置肯定是 1,而不会是 0。

3.3 缓存雪崩;

问题描述:系统运行过程中,缓存雪崩是一个非常严重的问题。缓存雪崩是指部分缓存节点不可用,导致整个缓存体系甚至甚至服务系统不可用的情况。缓存雪崩按照缓存是否 rehash(即是否漂移)分两种情况:

  • 缓存不支持 rehash 导致的系统雪崩不可用
  • 缓存支持 rehash 导致的缓存雪崩不可用

原因分析

在上述两种情况中,缓存不进行 rehash 时产生的雪崩,一般是由于较多缓存节点不可用,请求穿透导致 DB 也过载不可用,最终整个系统雪崩不可用的。而缓存支持 rehash 时产生的雪崩,则大多跟流量洪峰有关,流量洪峰到达,引发部分缓存节点过载 Crash,然后因 rehash 扩散到其他缓存节点,最终整个缓存体系异常。

第一种情况比较容易理解,如下图所示。缓存节点不支持 rehash,较多缓存节点不可用时,大量 Cache 访问会失败,根据缓存读写模型,这些请求会进一步访问 DB,而且 DB 可承载的访问量要远比缓存小的多,请求量过大,就很容易造成 DB 过载,大量慢查询,最终阻塞甚至 Crash,从而导致服务异常。

第二种情况是是因为缓存分布设计时,会选择一致性 Hash 分布方式,同时在部分节点异常时,采用 rehash 策略,即把异常节点请求平均分散到其他缓存节点。在一般情况下,一致性 Hash 分布 + rehash 策略可以很好得运行,但在较大的流量洪峰到临之时,如果大流量 key 比较集中,正好在某 1~2 个缓存节点,很容易将这些缓存节点的内存、网卡过载,缓存节点异常 Crash,然后这些异常节点下线,这些大流量 key 请求又被 rehash 到其他缓存节点,进而导致其他缓存节点也被过载 Crash,缓存异常持续扩散,最终导致整个缓存体系异常,无法对外提供服务。

业务场景:缓存雪崩的业务场景并不少见,微博、Twitter 等系统在运行的最初若干年都遇到过很多次。比如,微博最初很多业务缓存采用一致性 Hash + rehash 策略,在突发洪水流量来临时,部分缓存节点过载 Crash 甚至宕机,然后这些异常节点的请求转到其他缓存节点,又导致其他缓存节点过载异常,最终整个缓存池过载。另外,机架断电,导致业务缓存多个节点宕机,大量请求直接打到 DB,也导致 DB 过载而阻塞,整个系统异常。最后缓存机器复电后,DB 重启,数据逐步加热后,系统才逐步恢复正常。

解决方案

方案一,对业务 DB 的访问增加读写开关,当发现 DB 请求变慢、阻塞,慢请求超过阀值时,就会关闭读开关,部分或所有读 DB 的请求进行 failfast 立即返回,待 DB 恢复后再打开读开关,如下图。
在这里插入图片描述

方案二,对缓存增加多个副本,缓存异常或请求 miss 后,再读取其他缓存副本,而且多个缓存副本尽量部署在不同机架,从而确保在任何情况下,缓存系统都会正常对外提供服务。

方案三,对缓存体系进行实时监控,当请求访问的慢速比超过阀值时,及时报警,通过机器替换、服务替换进行及时恢复;也可以通过各种自动故障转移策略,自动关闭异常接口、停止边缘服务、停止部分非核心功能措施,确保在极端场景下,核心功能的正常运行。

实际上,微博平台系统,这三种方案都采用了,通过三管齐下,规避缓存雪崩的发生。

3.4 数据不一致;

问题描述:同一份数据,可能会同时存在 DB 和缓存之中。那就有可能发生,DB 和缓存的数据不一致。如果缓存有多个副本,多个缓存副本里的数据也可能会发生不一致现象。

原因分析:不一致的问题大多跟缓存更新异常有关。比如更新 DB 后,写缓存失败,从而导致缓存中存的是老数据。另外,如果系统采用一致性 Hash 分布,同时采用 rehash 自动漂移策略,在节点多次上下线之后,也会产生脏数据。缓存有多个副本时,更新某个副本失败,也会导致这个副本的数据是老数据。

业务场景:导致数据不一致的场景也不少。如下图所示,在缓存机器的带宽被打满,或者机房网络出现波动时,缓存更新失败,新数据没有写入缓存,就会导致缓存和 DB 的数据不一致。缓存 rehash 时,某个缓存机器反复异常,多次上下线,更新请求多次 rehash。这样,一份数据存在多个节点,且每次 rehash 只更新某个节点,导致一些缓存节点产生脏数据。
在这里插入图片描述

解决方案

  • 第一个方案,cache 更新失败后,可以进行重试,如果重试失败,则将失败的 key 写入队列机服务,待缓存访问恢复后,将这些 key 从缓存删除。这些 key 在再次被查询时,重新从 DB 加载,从而保证数据的一致性。
  • 第二个方案,缓存时间适当调短,让缓存数据及早过期后,然后从 DB 重新加载,确保数据的最终一致性。
  • 第三个方案,不采用 rehash 漂移策略,而采用缓存分层策略,尽量避免脏数据产生。
    在这里插入图片描述

3.5 缓存并发竞争;

问题描述

互联网系统,线上流量较大,缓存访问中很容易出现数据并发竞争的现象。数据并发竞争,是指在高并发访问场景,一旦缓存访问没有找到数据,大量请求就会并发查询 DB,导致 DB 压力大增的现象。

数据并发竞争,主要是由于多个进程/线程中,有大量并发请求获取相同的数据,而这个数据 key 因为正好过期、被剔除等各种原因在缓存中不存在,这些进程/线程之间没有任何协调,然后一起并发查询 DB,请求那个相同的 key,最终导致 DB 压力大增,如下图。
在这里插入图片描述

业务场景:数据并发竞争在大流量系统也比较常见,比如车票系统,如果某个火车车次缓存信息过期,但仍然有大量用户在查询该车次信息。又比如微博系统中,如果某条微博正好被缓存淘汰,但这条微博仍然有大量的转发、评论、赞。上述情况都会造成该车次信息、该条微博存在并发竞争读取的问题。

解决方案
方案一:使用全局锁。如下图所示,即当缓存请求 miss 后,先尝试加全局锁,只有加全局锁成功的线程,才可以到 DB 去加载数据。其他进程/线程在读取缓存数据 miss 时,如果发现这个 key 有全局锁,就进行等待,待之前的线程将数据从 DB 回种到缓存后,再从缓存获取。
在这里插入图片描述

方案二,对缓存数据保持多个备份,即便其中一个备份中的数据过期或被剔除了,还可以访问其他备份,从而减少数据并发竞争的情况,如下图。
在这里插入图片描述

3.6 Hot key;

问题描述:第六个经典问题是 Hot key。对于大多数互联网系统,数据是分冷热的。比如最近的新闻、新发表的微博被访问的频率最高,而比较久远的之前的新闻、微博被访问的频率就会小很多。而在突发事件发生时,大量用户同时去访问这个突发热点信息,访问这个 Hot key,这个突发热点信息所在的缓存节点就很容易出现过载和卡顿现象,甚至会被 Crash。

原因分析:Hot key 引发缓存系统异常,主要是因为突发热门事件发生时,超大量的请求访问热点事件对应的 key,比如微博中数十万、数百万的用户同时去吃一个新瓜。数十万的访问请求同一个 key,流量集中打在一个缓存节点机器,这个缓存机器很容易被打到物理网卡、带宽、CPU 的极限,从而导致缓存访问变慢、卡顿。

业务场景:引发 Hot key 的业务场景很多,比如明星结婚、离婚、出轨这种特殊突发事件,比如奥运、春节这些重大活动或节日,还比如秒杀、双 12、618 等线上促销活动,都很容易出现 Hot key 的情况。

解决方案:要解决这种极热 key 的问题,首先要找出这些 Hot key 来。对于重要节假日、线上促销活动、集中推送这些提前已知的事情,可以提前评估出可能的热 key 来。而对于突发事件,无法提前评估,可以通过 Spark,对应流任务进行实时分析,及时发现新发布的热点 key。而对于之前已发出的事情,逐步发酵成为热 key 的,则可以通过 Hadoop 对批处理任务离线计算,找出最近历史数据中的高频热 key。

找到热 key 后,就有很多解决办法了。首先可以将这些热 key 进行分散处理,比如一个热 key 名字叫 hotkey,可以被分散为 hotkey#1、hotkey#2、hotkey#3,……hotkey#n,这 n 个 key 分散存在多个缓存节点,然后 client 端请求时,随机访问其中某个后缀的 hotkey,这样就可以把热 key 的请求打散,避免一个缓存节点过载,如下图所示。
在这里插入图片描述
其次,也可以 key 的名字不变,对缓存提前进行多副本 + 多级结合的缓存架构设计。

再次,如果热 key 较多,还可以通过监控体系对缓存的 SLA 实时监控,通过快速扩容来减少热 key 的冲击。

最后,业务端还可以使用本地缓存,将这些热 key 记录在本地缓存,来减少对远程缓存的冲击。

3.7 Big key。

问题描述:大 key,是指在缓存访问时,部分 Key 的 Value 过大,读写、加载易超时的现象。

原因分析:造成这些大 key 慢查询的原因很多。如果这些大 key 占总体数据的比例很小,存 Mc,对应的 slab 较少,导致很容易被频繁剔除,DB 反复加载,从而导致查询较慢。如果业务中这种大 key 很多,而这种 key 被大量访问,缓存组件的网卡、带宽很容易被打满,也会导致较多的大 key 慢查询。另外,如果大 key 缓存的字段较多,每个字段的变更都会引发对这个缓存数据的变更,同时这些 key 也会被频繁地读取,读写相互影响,也会导致慢查现象。最后,大 key 一旦被缓存淘汰,DB 加载可能需要花费很多时间,这也会导致大 key 查询慢的问题。

业务场景
大 key 的业务场景也比较常见。比如互联网系统中需要保存用户最新 1万 个粉丝的业务,比如一个用户个人信息缓存,包括基本资料、关系图谱计数、发 feed 统计等。微博的 feed 内容缓存也很容易出现,一般用户微博在 140 字以内,但很多用户也会发表 1 千字甚至更长的微博内容,这些长微博也就成了大 key,如下图。
在这里插入图片描述

解决方案
第一种方案,如果数据存在 Mc 中,可以设计一个缓存阀值,当 value 的长度超过阀值,则对内容启用压缩,让 KV 尽量保持小的 size,其次评估大 key 所占的比例,在 Mc 启动之初,就立即预写足够数据的大 key,让 Mc 预先分配足够多的 trunk size 较大的 slab。确保后面系统运行时,大 key 有足够的空间来进行缓存。
在这里插入图片描述
第二种方案,如果数据存在 Redis 中,比如业务数据存 set 格式,大 key 对应的 set 结构有几千几万个元素,这种写入 Redis 时会消耗很长的时间,导致 Redis 卡顿。此时,可以扩展新的数据结构,同时让 client 在这些大 key 写缓存之前,进行序列化构建,然后通过 restore 一次性写入,如下图所示。
在这里插入图片描述

第三种方案时,如下图所示,将大 key 分拆为多个 key,尽量减少大 key 的存在。同时由于大 key 一旦穿透到 DB,加载耗时很大,所以可以对这些大 key 进行特殊照顾,比如设置较长的过期时间,比如缓存内部在淘汰 key 时,同等条件下,尽量不淘汰这些大 key。
在这里插入图片描述

发布了119 篇原创文章 · 获赞 12 · 访问量 2万+

猜你喜欢

转载自blog.csdn.net/hualaoshuan/article/details/102638188