分布式缓存笔记

分布式缓存笔记

1. 缓存简介

1.1 缓存的基本思想

缓存定义

  • 缓存最初的含义,是指用于加速 CPU 数据交换的 RAM,即随机存取存储器,通常这种存储器使用更昂贵但快速的静态 RAM(SRAM)技术,用以对 DRAM进 行加速。这是一个狭义缓存的定义。
  • 广义缓存的定义则更宽泛,任何可以用于数据高速交换的存储介质都是缓存,可以是硬件也可以是软件。

缓存存在的意义就是通过开辟一个新的数据交换缓冲区,来解决原始数据获取代价太大的问题,让数据得到更快的访问。

基本思想:时间局限性原理,通过空间换时间来达到加速数据获取的目的。

  • 时间局限性原理:被获取过一次的数据在未来会被多次饮用

  • 空间换时间:原始数据获取太慢,因此开辟一块高速独立空间,提供高速访问,加快获取速度

  • 性能-成本 Tradeoff:性能越高,延迟越小,所带来的成本也会越高。

    相同成本的容量,SSD 硬盘容量会比内存大 10~30 倍以上,但读写延迟却高 50~100 倍。

1.2 缓存优势

  • 提升访问性能:基于内存
  • 降低网络拥堵:缓存数据比原始数据小很多
  • 减轻服务负载:解析与计算减少
  • 增强可扩展性:缓存读写性能高,预热快,遭遇突发流量与性能瓶颈时,可快速部署上线

1.3 缓存代价

  • 引入缓存,增加了系统的复杂度
  • 缓存相比于原始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 的读写方式进行适当设计,既要保证数据高效返回,又要尽量避免数据不一致等各种问题。

1.4 缓存读写模式

1.4.1 Cache Aside 旁路缓存

对于写:更新 DB 后,直接将 key 从 cache 中删除,然后由 DB 驱动缓存数据的更新

对于读:是先读 cache,如果 cache 没有,则读 DB,同时将从 DB 中读取的数据回写到 cache。

特点:由业务端处理数据访问细节,利用Lazy计算的思想,更新DB后,直接删除cache并通过DB更新,确保数据以DB结果为准。

适用情景:

  • 没有专门的存储服务,同时对数据一致性要求较高的业务
  • 缓存数据更新比较复杂的业务

微博发展初期,不少业务采用这种模式,这些缓存数据需要通过多个原始数据进行计算后设置。在部分数据变更后,直接删除缓存。同时,使用一个 Trigger 组件,实时读取 DB 的变更日志,然后重新计算并更新缓存。如果读缓存的时候,Trigger 还没写入 cache,则由调用方自行到 DB 加载计算并写入 cache。

1.4.2 Read/Write Through 读写穿透

对于Cache Aside模式,业务应用需要同时维护cache和DB两个数据存储方,过于繁琐。

对于Read/Write Through模式,业务应用只需关注一个存储服务,而读写cache和DB的操作由存储服务代理。

  • 存储服务收到写请求,先查cache:
    • 如果数据在cache中不存在,则更新DB
    • 如果数据在cache中存在,则先更新cache,再更新DB
  • 存储服务收到读请求:
    • 如果cache命中,则直接返回;否则先从DB加载,回种到cache后返回响应

特点:

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

1.4.3 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,则会存在丢失的风险。适合于对一致性要求不太高的业务。

1.5 缓存分类

1.5.1 按宿主层次分类

缓存一般可以分为本地 Cache、进程间 Cache 和远程 Cache

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

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

1.5.2 按存储介质分类

分为内存型缓存和持久化型缓存

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

2. 缓存架构设计

2.1 缓存组件选择

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

2.2 缓存数据结构设计

对于直接简单 KV 读写的业务,你可以将这些业务数据封装为 String、Json、Protocol Buffer 等格式,序列化成字节序列,然后直接写入缓存中。读取时,先从缓存组件获取到数据的字节序列,再进行反序列化操作即可。

对于只需要存取部分字段或需要在缓存端进行计算的业务,你可以把数据设计为 Hash、Set、List、Geo 等结构,存储到支持复杂集合数据类型的缓存中,如 Redis、Pika 等。

2.3 缓存分布设计

  1. 选择分布式算法:取模 or 一致性Hash

    取模分布的方案简单,每个 key 只会存在确定的缓存节点,一致性 Hash 分布的方案相对复杂,一个 key 对应的缓存节点不确定。但一致性 Hash 分布,可以在部分缓存节点异常时,将失效节点的数据访问均衡分散到其他正常存活的节点,从而更好地保证了缓存系统的稳定性。

  2. 分布读写访问:Client直接进行Hash分布定位读写 or 交由Proxy代理进行读写路由

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

2.4 缓存架构部署及运维

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

2.5 缓存设计架构的常见考量点

2.5.1 读写方式

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

2.5.2 KV size

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

2.5.3 Key数量

  • 如果 key 数量不大,可以在缓存中存下全量数据,把缓存当 DB 存储来用。
    • 如果缓存读取 miss,则表明数据不存在,不需要去 DB 查询。
  • 如果数据量巨大,则在缓存中尽可能只保留频繁访问的热数据,对于冷数据直接访问 DB。

2.5.4 读写峰值

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

2.5.5 命中率

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

2.6 过期策略

  • 可以设置较短的过期时间,让冷 key 自动过期;
  • 可以让 key 带上时间戳,同时设置较长的过期时间,比如很多业务系统内部有这样一些 key:key_20190801。

3. 缓存问题

img

3.1 缓存失效

业务访问时,如果大量的 key 同时过期,很多缓存数据访问都会 miss,进而穿透到 DB,DB 的压力就会明显上升,由于 DB 的性能较差,只在缓存的 1%~2% 以下,这样请求的慢查率会明显上升。这就是缓存失效的问题。

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

3.2 缓存穿透

有特殊访客在查询一个不存在的 key,导致每次查询都会穿透到 DB,如果这个特殊访客再控制一批肉鸡机器,持续访问你系统里不存在的 key,就会对 DB 产生很大的压力,从而影响正常服务。

解决方案如下:

  • 第一种方案就是,查询这些不存在的数据时,第一次查 DB,虽然没查到结果返回 NULL,仍然记录这个 key 到缓存,只是这个 key 对应的 value 是一个特殊设置的值。
    • 防止缓存的默认值占据大量缓存空间
      • 对不存在的key设置较短的过期时间
      • 将这些不存在的 key 存在一个独立的公共缓存,从缓存查找时,先查正常的缓存组件,如果 miss,则查一下公共的非法 key 的缓存,如果后者命中,直接返回,否则穿透 DB,如果查出来是空,则回种到非法 key 缓存,否则回种到正常缓存
  • 第二种方案是,构建一个 BloomFilter 缓存过滤器,记录全量数据,这样访问数据时,可以直接通过 BloomFilter 判断这个 key 是否存在,如果不存在直接返回即可,根本无需查缓存和 DB。

3.2.1 BloomFilter

BloomFilter 的目的是检测一个元素是否存在于一个集合内。

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

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

3.3 缓存雪崩

3.4 缓存数据不一致

两个问题:DB与缓存数据不一致、多个缓存副本中的数据不一致

不一致的问题大多跟缓存更新异常有关。

  • 更新DB后,写缓存失败
  • rehash自动漂移策略,在节点多次上下线之后,会产生脏数据
  • 缓存存在多个副本,更新某副本失败,导致数据不一致

业务场景

  • 在缓存机器的带宽被打满,或者机房网络出现波动时,缓存更新失败,新数据没有写入缓存,就会导致缓存和 DB 的数据不一致。
  • 缓存 rehash 时,某个缓存机器反复异常,多次上下线,更新请求多次 rehash。这样,一份数据存在多个节点,且每次 rehash 只更新某个节点,导致一些缓存节点产生脏数据。

解决方案

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

3.5 数据并发竞争

高并发访问场景下,一旦缓存访问没有找到数据,大量请求会涌入DB,导致DB压力增大。

一般是由于缓存中数据key正好过期,导致多个线程并发查询DB

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

解决方案

  • 使用全局锁:当缓存miss后,多个线程并发竞争锁。只有加锁成功的线程,才可以到DB去加载数据

    其他线程读取缓存miss时,且无法获取到锁,则等待

  • 对缓存数据保持多个备份,即便其中一个备份中的数据过期或被剔除了,还可以访问其他备份,从而减少数据并发竞争的情况

3.6 Hot Key问题

大量并发请求访问同一个key,流量集中打在同一个缓存节点机器,导致缓存访问变慢、卡顿

解决方案:

  1. 寻找热点key

    • 对于提前预知的Hot Key,如重要节假日、促销活动等,提前进行分散处理

    • 对于突发事件,可通过Spark,对于流任务进行实时分析,及时发现新发布的热点key

      对于逐步发酵成为的热点key,可以通过Hadoop对批处理任务进行离线计算,找出历史数据的热点key

  2. 对缓存节点进行分散

    采用多副本+多级结合的缓存架构设计

    业务端采用本地缓存

    通过监控体系对缓存的 SLA 实时监控,通过快速扩容来减少热 key 的冲击

3.7 Big Key问题

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

原因:

  1. Big Key数量多:

    如果业务中这种大 key 很多,而这种 key 被大量访问,缓存组件的网卡、带宽很容易被打满,也会导致较多的大 key 慢查询。

  2. Big Key中内容多:

    如果大 key 缓存的字段较多,每个字段的变更都会引发对这个缓存数据的变更,同时这些 key 也会被频繁地读取,读写相互影响,也会导致慢查现象。

  3. Big Key被淘汰或过期:

    大 key 一旦被缓存淘汰,DB 加载可能需要花费很多时间,这也会导致大 key 查询慢的问题。

业务场景:

互联网系统中需要保存用户最新 1万 个粉丝的业务,比如一个用户个人信息缓存,包括基本资料、关系图谱计数、发 feed 统计等。

微博的 feed 内容缓存也很容易出现,一般用户微博在 140 字以内,但很多用户也会发表 1千 字甚至更长的微博内容,这些长微博也就成了大 key。

解决方案:

  1. 如果数据存在 Mc 中,可以设计一个缓存阀值,当 value 的长度超过阀值,则对内容启用压缩,让 KV 尽量保持小的 size,其次评估大 key 所占的比例,在 Mc 启动之初,就立即预写足够数据的大 key,让 Mc 预先分配足够多的 trunk size 较大的 slab。确保后面系统运行时,大 key 有足够的空间来进行缓存。
  2. 如果数据存在 Redis 中,比如业务数据存 set 格式,大 key 对应的 set 结构有几千几万个元素,这种写入 Redis 时会消耗很长的时间,导致 Redis 卡顿。此时,可以扩展新的数据结构,同时让 client 在这些大 key 写缓存之前,进行序列化构建,然后通过 restore 一次性写入。
  3. 将大 key 分拆为多个 key,尽量减少大 key 的存在。同时由于大 key 一旦穿透到 DB,加载耗时很大,所以可以对这些大 key 进行特殊照顾,比如设置较长的过期时间,比如缓存内部在淘汰 key 时,同等条件下,尽量不淘汰这些大 key。

分布式缓存笔记
https://ltyzzzxxx.github.io/2023/01/07/分布式缓存笔记/
作者
周三不Coding
发布于
2023年1月7日
许可协议