开篇寄语:缓存,你真的用对了吗?
我们为什么要学习缓存呢?有必要学习缓存吗?
缓存的使用,是提升系统性能、改善用户体验的唯一解决之道。
其实,作为互联网公司,只要有直接面对用户的业务,要想持续确保系统的访问性能和可用性,都需要使用缓存。因此,缓存也是后端工程师面试中一个非常重要的考察点,面试官通常会通过应聘者对缓存相关知识的理解深入程度,来判断其开发经验和学习能力。可以说,对缓存的掌握程度,在某种意义上决定了后端开发者的职业高度。
想学好缓存,需要掌握哪些知识呢?
缓存知识点全景图:
- 首先,要熟练掌握缓存的基础知识,了解缓存常见的分类、读写模式,熟悉缓存的七大经典问题及解决应对之策,同时要从缓存组件的访问协议、Client入手,熟练掌握如何访问各种缓存组件,如Memcached、Redis、Pika等。
- 其次,要尽可能深入理解缓存组件的实现方案、设计原理,了解缓存的各种特性、优势和不足,这样在缓存数据与预期不一致时,能够快速定位并解决问题。
- 再次,还要多了解线上大中型系统是如何对缓存进行架构设计的。线上系统,业务功能丰富多变,跨域部署环境复杂,而且热点频发,用户习惯迥异。因此,缓存系统在设计之初就要尽量进行良好设计,规划好如何进行Hash及分布、如何保障数据的一致性、如何进行扩容和缩容。当然,缓存体系也需要伴随业务发展持续演进,这就需要对缓存体系进行持续的状态监控、异常报警、故障演练,以确保在故障发生时能及时进行人肉或自动化运维处理,并根据线上状态不断进行优化和改进。
- 最后,了解存在各种场景下的最佳实践,理解这些最佳实践背后的Tradeoff,做到知其然知其所以然,以便在实际工作中能举一反三,把知识和经验更好的应用到工作实践中来。
如何高效学习缓存呢?你能学到什么?
用 10 课时来分享:
- 如何更好地引入和使用缓存,自系统设计之初,就把缓存设计的关键点对号入座。
- 如何规避并解决缓存设计中的七大经典问题。
- 从协议、使用技巧、网络模型、核心数据结构、存储架构、数据处理模型、优化及改进方案等,多角度全方位深入剖析互联网企业大量使用的Memcached、Redis等开源缓存组件。
- 教你如何利用它们构建一个分布式缓存服务体系。
- 最后,我将结合诸如秒杀、海量计数、微博 Feed 聚合等经典业务场景,分析如何构建相应的高可用、高性能、易扩展的缓存架构体系。
通过本课程,你可以:
系统地学习缓存之设计架构的关键知识点;
- 学会如何更好地使用 Memcached、Redis 等缓存组件;
- 对这些缓存组件的内部架构、设计原理有一个较为深入的了解,真正做到知其然更知其所以然;
- 学会如何根据业务需要对缓存组件进行二次开发;
搞懂如何构建一个大型的分布式缓存服务系统; - 了解在当前多种热门场景下缓存服务的最佳实践;
- 现学现用,针对互联网大中型系统,构建出一个更好的缓存架构体系,在大幅提升系统吞吐和响应性能的同时,达到高可用、高扩展,从而可以更从容地应对海量并发请求和极端热点事件。
业务数据访问性能太低怎么办?
缓存的定义
广义缓存时任何可以用于数据高速交换的存储介质都是缓存,可以是硬件也可以是软件。
缓存存在的意义就是通过开辟一个新的数据交换缓冲区,来解决原始数据获取代价太大的问题,让数据得到更快的访问。
缓存的原理
缓存构建的基本思想是利用时间局限性原理,通过空间换时间
来达到加速数据获取的目的,同时由于缓存空间的成本较高,在实际设计架构中还要考虑访问延迟和成本的权衡问题。这里面有 3 个关键点。
- 一是时间局限性原理,即被获取过一次的数据在未来会被多次引用,比如一条微博被一个人感兴趣并阅读后,它大概率还会被更多人阅读,当然如果变成热门微博后,会被数以百万/千万计算的更多用户查看。
- 二是以空间换时间,因为原始数据获取太慢,所以我们开辟一块高速独立空间,提供高效访问,来达到数据获取加速的目的。
- 三是性能成本 Tradeoff,构建系统时希望系统的访问性能越高越好,访问延迟越低小越好。但维持相同数据规模的存储及访问,性能越高延迟越小,成本也会越高,所以在系统架构设计时,你需要在系统性能和开发运行成本之间做取舍。比如左边这张图,相同成本的容量,SSD 硬盘容量会比内存大 10~30 倍以上,但读写延迟却高 50~100 倍。
缓存的优势
缓存的优势:
- 提升访问性能
- 降低网络拥堵
- 减轻服务负载
- 增强可扩展性
缓存的代价
- 首先,服务系统重引入缓存,会增加系统的复杂度。
- 其次,由于缓存相比原始 DB 存储的成本更高,所以系统部署及运行的费用也会更高。
- 最后,由于一份数据同时存在缓存和 DB 中,甚至缓存内部也会有多个数据副本,多份数据就会存在一致性问题,同时缓存体系本身也会存在可用性问题和分区的问题。
如何根据业务来选择缓存模式和组件?
缓存读写模式
业务系统读写缓存有 3 种模式:
- Cache Aside(旁路缓存)
- Read/Write Through(读写穿透)
- Write Behind Caching(异步缓存写入)
Cache Aside
Cache Aside模式中,业务应用方对于写,是更新DB后,直接将可以从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,而是改为异步批量的方式来更新DB。该模式的特点是,数据存储的写性能最高,非常适合一些变更特别频繁的业务,特别是可以合并写请求的业务,比如对一些计数业务,一条 Feed 被点赞 1万 次,如果更新 1万 次 DB 代价很大,而合并成一次请求直接加 1万,则是一个非常轻量的操作。但这种模型有个显著的缺点,即数据的一致性变差,甚至在一些极端场景下可能会丢失数据。比如系统 Crash、机器宕机时,如果有数据还没保存到 DB,则会存在丢失的风险。所以这种读写模式适合变更频率特别高,但对一致性要求不太高的业务,这样写操作可以异步批量写入 DB,减小 DB 压力。
缓存的三种读写模式讲完了,你可以看到三种模式各有优劣,不存在最佳模式。实际上,我们也不可能设计出一个最佳的完美模式出来,如同前面讲到的空间换时间、访问延迟换低成本一样,高性能和强一致性从来都是有冲突的,系统设计从来就是取舍,随处需要 trade-off。这个思想会贯穿整个 cache 课程,这也许是我们学习这个课程的另外一个收获,即如何根据业务场景,更好的做 trade-off,从而设计出更好的服务系统。
缓存分类及常用缓存介绍
前面介绍了缓存的基本思想、优势、代价以及读写模式,接下来一起看下互联网企业常用的缓存有哪些分类。
按宿主层次分类
按宿主层次分类的话,缓存一般可以分为本地Cache、进程间Cache和远程Cache。
- 本地Cache是指业务进程内的缓存,这类缓存由于在业务系统进程内,所以读写性能超高且无任何网络开销,但不足是会随着业务系统重启而丢失。
- 进程间Cache是本机独立运行的缓存,这类缓存读写性能较高,不会随着业务系统重启丢数据,并且可以大幅减少网络开销,但不足是业务系统和缓存都在相同宿主机,运维复杂,且存在资源竞争。
- 远程Cache是指跨机器部署的缓存,这类缓存因为独立设备部署,容量大且易扩展,在互联网企业使用最广泛。不过远程缓存需要跨机访问,在高读写压力下,带宽容易成为瓶颈。
本地Cache的缓存组件有Ehache、Guava Cache等,开发者自己也可以用Map、Set等轻松构建一个自己专用的本地Cache。进程间Cache和远程Cache的缓存组件相同,只是部署位置的差异罢了,这类缓存组件有Memcached、Redis、Pika等。
按存储介质分类
按存储介质来分,这样可以分为内存型缓存和持久化型缓存。
- 内存型缓存将数据存储在内存,读写性能很高,但缓存系统重启或 Crash 后,内存数据会丢失。
- 持久化型缓存将数据存储到 SSD/Fusion-IO 硬盘中,相同成本下,这种缓存的容量会比内存型缓存大 1 个数量级以上,而且数据会持久化落地,重启不丢失,但读写性能相对低 1~2 个数量级。Memcached 是典型的内存型缓存,而 Pika 以及其他基于 RocksDB 开发的缓存组件等则属于持久化型缓存。
设计缓存架构时需要考量哪些因素?
缓存的引入及架构设计
缓存组件选择
在设计架构缓存时,你首先要选定缓存组件,比如要用 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 进行缓存更新。
- 某些极端场景下,还需要把多种缓存组件进行组合使用,通过缓存异构达到最佳读写性能。
- 站在系统层面,要想更好得管理缓存,还要考虑缓存的服务化,考虑缓存体系如何更好得进行集群管理、监控运维等。
IDC全称为Internet Data Center,中文译为“互联网数据中心”,是提供服务器托管、网络接入、电力供应、安全防护等服务的专业化机房设施。
缓存设计架构的常见考量点
在缓存设计架构的过程中,有一些非常重要的考量点,如下图所示,只有分析清楚了这些考量点,才能设计架构出更佳的缓存体系。
读写方式
首先是 value 的读写方式。是全部整体读写,还是只部分读写及变更?是否需要内部计算?比如,用户粉丝数,很多普通用户的粉丝有几千到几万,而大 V 的粉丝更是高达几千万甚至过亿,因此,获取粉丝列表肯定不能采用整体读写的方式,只能部分获取。另外在判断某用户是否关注了另外一个用户时,也不需要拉取该用户的全部关注列表,直接在关注列表上进行检查判断,然后返回 True/False 或 0/1 的方式更为高效。
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_20190801。
平均缓存穿透加载时间
平均缓存穿透加载时间在某些业务场景下也很重要,对于一些缓存穿透后,加载时间特别长或者需要复杂计算的数据,而且访问量还比较大的业务数据,要配置更多容量,维持更高的命中率,从而减少穿透到 DB 的概率,来确保整个系统的访问性能。
缓存可运维性
对于缓存的可运维性考虑,则需要考虑缓存体系的集群管理,如何进行一键扩缩容,如何进行缓存组件的升级和变更,如何快速发现并定位问题,如何持续监控报警,最好有一个完善的运维平台,将各种运维工具进行集成。
缓存安全性
对于缓存的安全性考虑,一方面可以限制来源 IP,只允许内网访问,同时对于一些关键性指令,需要增加访问权限,避免被攻击或误操作
时,导致重大后果。