缓存的定义:
缓存的基本思想:缓存构建的基本思想是利用时间局限性原理,通过空间换时间来达到加速数据获取的目的,同时由于缓存空间的成本较高,在实际设计架构中还要考虑访问延迟和成本的权衡问题。这里面有 3 个关键点。
缓存的优势:
缓存存储原始数据,可以大幅提升访问性能。不过在实际业务场景中,缓存中存储的往往是需要频繁访问的中间数据甚至最终结果,这些数据相比 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 的读写方式进行适当设计,既要保证数据高效返回,又要尽量避免数据不一致等各种问题。
如下图,业务系统读写缓存有 3 种模式:
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 模式与 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,从而设计出更好的服务系统。
按宿主层次分类
宿主层次分类的话,缓存一般可以分为本地 Cache、进程间 Cache 和远程 Cache。
本地 Cache 的缓存组件有 Ehcache、Guava Cache 等,开发者自己也可以用 Map、Set 等轻松构建一个自己专用的本地 Cache。进程间 Cache 和远程 Cache 的缓存组件相同,只是部署位置的差异罢了,这类缓存组件有 Memcached、Redis、Pika 等。
按存储介质分类:这样可以分为内存型缓存和持久化型缓存。
缓存组件选择:在设计架构缓存时,首先要选定缓存组件,比如要用 Local-Cache,还是 Redis、Memcached、Pika 等开源缓存组件,如果业务缓存需求比较特殊,你还要考虑是直接定制开发一个新的缓存组件,还是对开源缓存进行二次开发,来满足业务需要。
缓存数据结构设计:确定好缓存组件后,还要根据业务访问的特点,进行缓存数据结构的设计。对于直接简单 KV 读写的业务,你可以将这些业务数据封装为 String、Json、Protocol Buffer 等格式,序列化成字节序列,然后直接写入缓存中。读取时,先从缓存组件获取到数据的字节序列,再进行反序列化操作即可。对于只需要存取部分字段或需要在缓存端进行计算的业务,你可以把数据设计为 Hash、Set、List、Geo 等结构,存储到支持复杂集合数据类型的缓存中,如 Redis、Pika 等。
缓存分布设计:确定了缓存组件,设计好了缓存数据结构,接下来就要设计缓存的分布。可以从 3 个维度来进行缓存分布设计。
缓存架构部署及运维管理:设计完毕缓存的分布策略后,接下来就要考虑缓存的架构部署及运维管理了。架构部署主要考虑如何对缓存进行分池、分层、分 IDC,以及是否需要进行异构处理。
读写方式:首先是 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 分布的访问漂移策略,还是采用数据多层备份策略。
过期策略
平均缓存穿透加载时间:平均缓存穿透加载时间在某些业务场景下也很重要,对于一些缓存穿透后,加载时间特别长或者需要复杂计算的数据,而且访问量还比较大的业务数据,要配置更多容量,维持更高的命中率,从而减少穿透到 DB 的概率,来确保整个系统的访问性能。
缓存可运维性:对于缓存的可运维性考虑,则需要考虑缓存体系的集群管理,如何进行一键扩缩容,如何进行缓存组件的升级和变更,如何快速发现并定位问题,如何持续监控报警,最好有一个完善的运维平台,将各种运维工具进行集成。
缓存安全性:对于缓存的安全性考虑,一方面可以限制来源 IP,只允许内网访问,同时对于一些关键性指令,需要增加访问权限,避免被攻击或误操作时,导致重大后果。
在缓存系统的设计架构中,还有很多坑,很多的明枪暗箭,如果设计不当会导致很多严重的后果。设计不当,轻则请求变慢、性能降低,重则会数据不一致、系统可用性降低,甚至会导致缓存雪崩,整个系统无法对外提供服务。
问题描述:缓存第一个经典问题是缓存失效。上一课时讲到,服务系统查数据,首先会查缓存,如果缓存数据不存在,就进一步查 DB,最后查到数据后回种到缓存并返回。缓存的性能比 DB 高 50 到100 倍以上,所以我们希望数据查询尽可能命中缓存,这样系统负荷最小,性能最佳。缓存里的数据存储基本上都是以 key 为索引进行存储和获取的。业务访问时,如果大量的 key 同时过期,很多缓存数据访问都会 miss,进而穿透到 DB,DB 的压力就会明显上升,由于 DB 的性能较差,只在缓存的 1% 到 2% 以下,这样请求的慢查率会明显上升。这就是缓存失效的问题。
原因分析:
导致缓存失效,特别是很多 key 一起失效的原因,跟我们日常写缓存的过期时间息息相关。
在写缓存时,我们一般会根据业务的访问特点,给每种业务数据预置一个过期时间,在写缓存时把这个过期时间带上,让缓存数据在这个固定的过期时间后被淘汰。一般情况下,因为缓存数据是逐步写入的,所以也是逐步过期被淘汰的。但在某些场景,一大批数据会被系统主动或被动从 DB 批量加载,然后写入缓存。这些数据写入缓存时,由于使用相同的过期时间,在经历这个过期时间之后,这批数据就会一起到期,从而被缓存淘汰。此时,对这批数据的所有请求,都会出现缓存失效,从而都穿透到 DB,DB 由于查询量太大,就很容易压力大增,请求变慢。
业务场景:很多业务场景,稍不注意,就出现大量的缓存失效,进而导致系统 DB 压力大、请求变慢的情况。比如同一批火车票、飞机票,当可以售卖时,系统会一次性加载到缓存,如果缓存写入时,过期时间按照预先设置的过期值,那过期时间到期后,系统就会因缓存失效出现变慢的问题。类似的业务场景还有很多,比如微博业务,会有后台离线系统,持续计算热门微博,每当计算结束,会将这批热门微博批量写入对应的缓存。还比如,很多业务,在部署新 IDC 或新业务上线时,会进行缓存预热,也会一次性加载大批热数据。
解决方案:对于批量 key 缓存失效的问题,原因既然是预置的固定过期时间,那解决方案也从这里入手。设计缓存的过期时间时,使用公式:过期时间 = base 时间 + random 随机时间。即相同业务数据写缓存时,在基础过期时间之上,再加一个随机的过期时间,让数据在未来一段时间内慢慢过期,避免瞬时全部过期,对 DB 造成过大压力,如下图所示。
问题描述:缓存穿透是一个很有意思的问题。因为缓存穿透发生的概率很低,所以一般很难被发现。但是,一旦你发现了,而且量还不小,你可能立即就会经历一个忙碌的夜晚。因为对于正常访问,访问的数据即便不在缓存,也可以通过 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 查看购票信息。用户输入错误,偶尔几个这种请求问题不大,但如果是大量这种请求,就会对系统影响非常大。
解决方案:
不过这两种方案在设计时仍然有一些要注意的坑。
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。
问题描述:系统运行过程中,缓存雪崩是一个非常严重的问题。缓存雪崩是指部分缓存节点不可用,导致整个缓存体系甚至甚至服务系统不可用的情况。缓存雪崩按照缓存是否 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 后,再读取其他缓存副本,而且多个缓存副本尽量部署在不同机架,从而确保在任何情况下,缓存系统都会正常对外提供服务。
方案三,对缓存体系进行实时监控,当请求访问的慢速比超过阀值时,及时报警,通过机器替换、服务替换进行及时恢复;也可以通过各种自动故障转移策略,自动关闭异常接口、停止边缘服务、停止部分非核心功能措施,确保在极端场景下,核心功能的正常运行。
实际上,微博平台系统,这三种方案都采用了,通过三管齐下,规避缓存雪崩的发生。
问题描述:同一份数据,可能会同时存在 DB 和缓存之中。那就有可能发生,DB 和缓存的数据不一致。如果缓存有多个副本,多个缓存副本里的数据也可能会发生不一致现象。
原因分析:不一致的问题大多跟缓存更新异常有关。比如更新 DB 后,写缓存失败,从而导致缓存中存的是老数据。另外,如果系统采用一致性 Hash 分布,同时采用 rehash 自动漂移策略,在节点多次上下线之后,也会产生脏数据。缓存有多个副本时,更新某个副本失败,也会导致这个副本的数据是老数据。
业务场景:导致数据不一致的场景也不少。如下图所示,在缓存机器的带宽被打满,或者机房网络出现波动时,缓存更新失败,新数据没有写入缓存,就会导致缓存和 DB 的数据不一致。缓存 rehash 时,某个缓存机器反复异常,多次上下线,更新请求多次 rehash。这样,一份数据存在多个节点,且每次 rehash 只更新某个节点,导致一些缓存节点产生脏数据。
解决方案:
问题描述:
互联网系统,线上流量较大,缓存访问中很容易出现数据并发竞争的现象。数据并发竞争,是指在高并发访问场景,一旦缓存访问没有找到数据,大量请求就会并发查询 DB,导致 DB 压力大增的现象。
数据并发竞争,主要是由于多个进程/线程中,有大量并发请求获取相同的数据,而这个数据 key 因为正好过期、被剔除等各种原因在缓存中不存在,这些进程/线程之间没有任何协调,然后一起并发查询 DB,请求那个相同的 key,最终导致 DB 压力大增,如下图。
业务场景:数据并发竞争在大流量系统也比较常见,比如车票系统,如果某个火车车次缓存信息过期,但仍然有大量用户在查询该车次信息。又比如微博系统中,如果某条微博正好被缓存淘汰,但这条微博仍然有大量的转发、评论、赞。上述情况都会造成该车次信息、该条微博存在并发竞争读取的问题。
解决方案:
方案一:使用全局锁。如下图所示,即当缓存请求 miss 后,先尝试加全局锁,只有加全局锁成功的线程,才可以到 DB 去加载数据。其他进程/线程在读取缓存数据 miss 时,如果发现这个 key 有全局锁,就进行等待,待之前的线程将数据从 DB 回种到缓存后,再从缓存获取。
方案二,对缓存数据保持多个备份,即便其中一个备份中的数据过期或被剔除了,还可以访问其他备份,从而减少数据并发竞争的情况,如下图。
问题描述:第六个经典问题是 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 记录在本地缓存,来减少对远程缓存的冲击。
问题描述:大 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。