本文为《深入浅出大型网站架构设计 作者: 李力非》的笔记, 内容比较基础,适合入门阅读。
第1章 网站架构概述
网站业务规模增长带来的问题
(1)当网站的业务变得复杂时,网站应用会有更复杂的计算需求。
(2)一个事务的每个阶段,它的资源占用未必是一致的。
(3)网站和服务器就像平时使用的软件和个人计算机一样,它在代码本身没有漏洞的情况下,依然有出错的概率。
大型网站架构设计的目标和原则
- 1 高性能
衡量一个网站性能的主要指标可以分为以下两个角度。·从单个用户或客户端的角度来看,就是单个请求(在保证正确的情况下)的响应时间(即延时),一般来说响应时间越短,性能越高。从网站建设和维护者的角度来看,除了每个请求的响应时间,还有每秒事务次数(Transaction Per Second,TPS),以及服务器的性能指标,包括 CPU使用率、内存使用率、IOPS(Input Output Per Second)等。TPS 越高,性能越高。而服务器性能的衡量指标则是总体来说资源占用越低,网站性能越高。
高并发通俗地说,就是希望一个网站或者一个网站组件能够同时处理尽可能多的事务。衡量这一目标的指标,就是上面所说的:响应时间和 TPS。高并发作为一个独立的设计目标,它未必适用于所有类型的网站。 - 2 高可用(availability)
当一个网站的可用性很高时,则意味着它在绝大多数的时候可以被用户访问,并且一些小规模的故障和意外不会影响它的可用性。比较广义的指标来衡量:网站关键功能或者API的调用成功率。 - 3 伸缩性(Scalability)
“伸缩”指网站的服务器集群规模及一些其他的必需资源的增加与减少。伸缩性好的网站,在业务规模变化时,可以通过直接添加或者减少服务器来适应变化,并且能够最小化成本的浪费。 - 4 扩展性
扩展性(Extensibility)这个词和伸缩性似乎很像,但它们所指的对象完全不同。这里的扩展特指业务和功能的扩展。第2章 大型网站架构设计的流程
1 需求分析
需求驱动是指将需求作为设计的原动力来决定设计的特点,以及应该侧重哪些方面、舍弃哪些方面,当需求变动时,优先根据需求的变动量决定进行什么样的更新。需求驱动不是“软件优美驱动”和“团队偏好驱动”,也不是“成本节约驱动”。在设计网站架构时,读者一定要牢记的是,要设计的不是为了满足自己爱好或者设计偏好的架构,而是为了满足公司或者组织的业务需要的架构。 - 如何根据需求制定系统目标
(1)列举出这个架构是为了谁而设计的,即用户是谁。
(2)列举出每个类型的用户的所有用例。用例(Use Case)是一个描述用户如何使用这个系统的例子。
(3)根据这些用例,对用户进行优先级的分类和排序。必须有的功能,一般称为P0。P 代表英文单词Priority(优先级)。初始发布可以没有,但是未来版本马上需要有的功能,一般称为P1。有固然好,没有也没关系的功能,一般称为P2。
(4)根据P0制定当前架构必须具有的功能,根据P1制定当前架构必须没有的瓶颈。2 方案设计
一个完美的网站架构应该具备高性能、高并发、高可用等特征,但是同时也应该牢记“奥卡姆剃刀定律”:如无必要,勿增实体。
对访问模式比较频繁的网站,我们需要尽量追求高性能、高并发。对业务实时性和可靠性要求高、访问人群对服务质量敏感挑剔的网站,我们需要尽量追求高可用。对业务根据不同时间流量变化较大的网站,我们需要尽量追求伸缩性。对业务变动频繁、需要经常支持新用例或者淘汰老用例的系统,我们需要尽量追求扩展性。
对于满足一类需求的架构设计而言,除非只有一个选择,否则尽量设计多套方案以供选择,越复杂的架构,使用的依赖越多,越是要如此。
当面对一个复杂且潜在种种变动的需求时,设计者可以按照以下步骤来清除隐患。
(1)列举出所有不确定的部分。
(2)做出假设。
(3)根据做出的假设,列出备选方案。
备选方案不必像主方案那样深入设计,只需要有一个预备思想,以防意外出现时措手不及。
3 方案评估
- (1)需要开一个设计的研讨会议或者评估会议。
在这个过程中,务必确保做到:整个团队了解你的设计思路;有团队中水平与你相当或高于你的人来参与评估;有需求发起者参与评估。 - (2)团队在技术角度上认可了你的设计之后,需要对成本进行评估。
- (3)在进行技术评估和成本评估,了解了资源的使用率之后,再次回顾设计,查看有没有可以取舍的部分。
第3章 数据库的选择
关系数据库
关系数据库的一个最重要特征是由“表”构成。在关系数据库中,每张表都有一个独立且唯一的名字,而表中的每一行代表用户保存的值的联系,这样的无数行的值的组合或者说联系组成了一张表。关系数据库中的“关系”就是指表。所有的关系数据库都有一个模式(Schema),模式是指数据库的逻辑设计,通俗地说,就是数据库表的定义。
关系数据库的优势:
(1)使用者在对 SOL 掌握熟练的前提下,可在数据层直接对数据进行极为复杂的操作。
(2)关系数据库在完成数据操作时始终保持一致,而不会因为一些操作的错误或者先后顺序问题让某些请求读到一些过时或者不正确的数据。这一般也被简称为关系数据库的数据一致性。
(3)对于数据记录和写入记录的过程,关系数据库可以进行数据的值验证。
关系数据库合适使用的应用场景:经常要对数据进行非常复杂或者烦琐的操作的业务;对数据操作的安全性和可靠性要求极高的业务。
非关系数据库
非关系数据库,即不强调关系的数据库。简称NoSQL。需要注意的是,NoSQL 当然可以理解为No+SQL,即“不是SQL”的意思,但更应该理解为Not Only SQL,即“不仅仅是SQL”。
非关系数据库的核心在于键值对。
非关系数据库的优势:
(1)非关系数据库容易扩展和更新,在变动时所需要进行的配置和代码变化是最少的。
(2)非关系数据库在所做事务相同、所使用的产品技术水平相当的情况下,一般比关系数据库速度快。
(3)非关系数据库几乎不需要开发者学习额外的内容,易上手。
应用场景:需要快速开始开发并迭代的产品;产品所用到的数据定义不确定、未来变动很大;产品规模变动频繁,数据层需要经常扩展; 数据规模较大,处理速度要求较高。
常见的关系数据库产品
- MySQL
MySQL 对于想要使用关系数据库搭建网站的中小型企业、组织和个人来说是一个绝佳的选择,因为它的功能虽然不如一些成熟的纯商用、企业级数据库产品强大(如MS SQL Server),但完全能够满足业务规模较小的公司的需求,并且在建设之初,网站开发者可以使用完全免费的社区版,在成熟之后可以使用商业版并获取技术支持。在现在新兴的网站中,使用 MySQL 的网站已经逐渐变少了,因为同样的使用场景,更多的公司愿意尝试使用非关系数据库,因为同样简便、轻量的需求下,非关系数据库比 MySQL所需要的配置、学习成本都要低,而且性能更好。 - MS SQL Server
MS SQL Server 由微软公司出品。MS SQL Server 是典型的商用、企业级关系数据库。
它的主要优势有以下几个方面:拥有强大的可视化界面;与微软技术栈的结合非常自然且紧密。
主要的缺点:使用MS SQL Server一般就要使用微软的技术栈,需要一定的学习成本,经验无法迁移。由于使用全套的微软商用产品价格较贵。 - Oracle
它由甲骨文(Oracle)公司在1979年开发出第一个版本,至今仍是大型公司的关系数据库的第一选择。
Oracle 的优势,即可靠性和安全性。
Oracle的另一个特点是,它的优势同时也是劣势,即需要一定的学习成本,领域知识特征极强。
常见的非关系数据库产品
- MongoDB
第一版由10gen团队于2009年开发完成,也有社区免费版。它极为轻量,容易部署和使用,并且具有一定程度的类型定义,以JSON 形式储存数据。由于各个程序设计语言对JSON 的支持都非常好,所以MongoDB与绝大多数框架都能无缝整合。 - DynamoDB
DynamoDB 是由亚马逊(Amazon)公司旗下的亚马逊云服务(AWS)开发的产品。它是一款完全的云服务,甚至没有本地版。
云数据库
如果成本允许,网站的开发者应当尽可能地使用云服务,尤其是数据库,理由如下。
第一,云数据库可以省去专门针对数据库的部署和配置。
第二,云数据库的维护和伸缩都不需要用户手动操作,这一点极为重要,对于很多用户来说,这一点也是使用云数据库最重要的原因。
第三,大多数云服务商都对云数据库提供了良好的配套监视系统。
第4章 数据库优化:分库分表
当数据存量或者数据的吞吐量增大时,服务器的负担就会逐渐增加以至于影响读/写的性能和成功率。这时,就需要将数据库进行切分。如果将数据分散到多台数据库服务器,则称为分库;如果将一台数据库服务器上的数据用多张表表示,则称为分表。
当数据的吞吐量很大时,主要会影响数据库读/写的延时,因为每次经过网络传输的数据都很多,当数据库是关系数据库时,由于其在一般情况下操作比非关系数据库更为复杂、涉及的验证和安全操作更多,所以消耗的时间和资源会更多。
具体是进行分库还是分表并无定则,还需要网站开发者结合其他方面来决定,但是在很多情况下进行分表即可解决。
当数据库本身的数据量很大时,读/写操作的性能也会下降,具体下降程度取决于数据库的实现。同时,无论是关系数据库还是非关系数据库,都会有“索引”的概念。索引的本意就是为了加快查询,如果索引的速度都受到了影响,那么数据库的性能就更加不堪了。
数据备份是指每隔一段时间对生产环境中的数据进行复制。如果经过了分库或者分表,就可以对不同的表采取不同的备份策略。
数据量越大、数据越集中,发生其他问题导致数据损坏或丢失的风险越大,而发生问题时造成的问题也越大。当我们把数据过于集中地存放在一台服务器、一个集群,以及一张表或者库中时,如果该服务器、集群、表或者库发生问题,那么所有数据的存取都会出现问题,对网站造成的损失也会非常巨大。如果经过了分库分表,就可以在一定程度上降低风险以及减少最终造成的损失。
实现分库分表
垂直分库分表
垂直分库分表中的“垂直”,就好比数据库或表是一个实物,我们用刀从上往下(即垂直)切下,分成两个或更多部分,这样的切分方式就是垂直。
垂直分库分表适用于如下场景:表的列数过多;表中的信息明显属于多个业务逻辑模块;表中的信息重要程度差异明显;表中的字段大小差异明显;表中的字段访问频率差异明显。
注意:分表之后,所有分出来的表都依然和原来的表共享一个唯一的ID。
水平分库分表
水平分库分表就是横向切分一个库或表,切分之后,分出来的库或表依然保持原表的结构,但是存储的是另一部分的数据。
水平分库分表适用于以下场景:当前单库或单表的数据行数过大,已经导致单库或者单表的读/写出现了性能下降;尚未出现性能瓶颈,但业务特征导致数据库规模(行数)会持续增长或大幅增长。
比较常见的有以下几种。
- 固定范围
即规定每张表的大小,一旦给出的数据ID超出该表的大小,则将其存到下一张表中。优点:易于理解,易于实现;随着业务和数据规模的增长,数据表均匀增长,理论上可以无限扩展。缺点:范围的大小不易掌握,太大则重现了分库分表前的问题,太小则造成了太多的子库或子表,从而造成过重的维护负担。 - 使用配置表
使用配置表,即再创建一张表,该表专门负责存储从数据ID映射到子表的信息。优点:易于理解,易于实现;变动灵活,随着业务和数据规模的增长,数据表的增长可以自由控制。缺点:配置表本身也是表,也面临着需要分库分表的问题。 - 基于算法的映射
优点在于:可以通过调制映射算法使数据在子表中的分布达到相对均匀,并且无论数据如何增长,每个子表承担的新数据都是相对均匀的。缺点:实现手段毕竟比前两种要复杂一些,而且需要设计者挑选一个适合当前用例的算法,不至于在数据增长时出现不均匀的子表增长。当业务变动,扩充新表时需要重新设计映射算法,而映射算法的重新设计有时会造成所有数据都需要重新分布,维护代价将大大增高。
分库分表带来的问题
- 全局唯一ID
数据的 ID 需要额外的机制保护其唯一性。一个常见的手段是使用各个编程语言框架提供的 ID 生成逻辑,例如,Java 中的UUID 类。但是UUID 类的主要缺陷是占用过多的空间。 - 关系数据库的部分操作
分库分表之后会影响关系数据库的部分操作的有效性和效率。join 操作可以将某个简单的查询结果与另一个简单的查询结果组合,完成复杂的查询逻辑。但是一旦分库之后,仅仅依靠数据层的操作,就无法达到join 操作的效果,而是需要分成两次独立的查询,通过应用层的逻辑将它们连接起来,那么在某种程度上,就失去了使用关系数据库的意义。 - 事务支持
数据库,尤其是关系数据库,有原生的事务支持,即所谓的一致性、原子性等(最流行的说法是ACID,A=Atomicity(原子性),C-Consistency(一致性),I=Isolation(独立性),D=Durability(持久性)。
但是分库之后,事务就不能再被原生支持。第5章 数据库优化:读写分离
经过读写分离之后,在应用层对数据库进行操作时,写操作和读操作的请求将会被发送至不同的数据库服务器。
读写分离之后,一般会有一主一从两台服务器(库),或者一主多从多台服务器(库),而控制读写分离的实际操作,在应用层或者数据访问的封装层中完成,在需要进行读/写操作时,实时决定要将请求发送给哪台服务器。
读写分原动力是因为数据读写的频率不均衡;分开的方式一般是将同样的数据复制到多台数据库服务器中。
何时需要使用读写分离:1) 数据库的读/写频率极高,已经造成数据库的性能明显下降;2) 数据库的信息根据业务逻辑和生产数据可知,读和写频率有明显差距,一般来说读操作次数远远大于写操作次数;3) 数据库的性能下降原因不在于读操作本身。
读写分离的好处:首先,使用读写分离之后读操作和写操作对相应服务器的压力都将大大减轻,从而提升性能。其次,在分离读/写操作之后,我们可以进一步地为主/从数据库进行有区别的、针对性的优化。再次,“高可用性”的重要性和实现方式。最后,针对某些数据库,使用读写分离还可以带来更多额外的好处。
如MySQL中有X锁和S锁的概念,X锁是指排它锁(X是Exclusive,即排斥),S锁是指共享锁(S是Shared,即共享),读操作往往需要加S锁,而写操作往往需要加X锁。读操作往往需要加S锁,而写操作往往需要加X锁。
实现读写分离
- 中间件实现
中间件(Middleware)是一个处于硬件(Hardware)和软件(Software)之间的组件。尽量寻找数据库厂商或者第三方提供的封装。 - 应用层实现
引入中间件固然可以较为简单地实现读写分离,但是也为系统引入了大量的复杂性。
应用层实现需要将访问数据库的代码抽象到一个层中,称其为数据访问层(Dataaccesslayer),但它不是一个独立的组件或者服务器。
推荐优先寻求符合业务需求的业内流行的组件。
读写分离带来的问题
- 副本的实时性
副本实时性的解决方案:(1)根据业务的实时敏感度决定是否采用读写分离。(2)一旦对某信息发生了写操作,下一个读操作在主数据库上进行。(3)对主数据库进行二次读取。 - 成本问题
读写分离最大的问题在于,从数据库也是数据库(服务器),它也需要成本,当从数据库及其中间件、数据访问层消耗的建设和维护成本以及导致的故障频率增加时,还有很多其他选项值得考虑,如缓存。第6章 缓存
缓存有以下作用:
● 节省对数据原存储地点的查询次数。
● 节省对数据原存储地点的查询时间。
总的来说,实时性要求越低的数据,越适合缓存,反之则不适合缓存。缓存策略
缓存策略,是指系统决定什么时候缓存什么数据,以及决定什么时候删除什么缓存数据的标准。
系统的资源是有限的,系统不可能缓存所有被请求的数据。所有缓存功能都会有一个上限,系统会根据缓存的策略,在缓存达到上限时,删除多余的缓存。什么是“多余的”,就是由缓存策略决定的。
缓存策略大致可以分为以下两种。
● 基于访问频率的缓存策略。
● 基于访问时间的缓存策略。
注意:目前软件行业中使用的缓存策略极为复杂,在实际应用中,网站开发者往往会对两者进行结合和优化,争取根据系统的特点两者兼顾。
- LFU缓存策略
优先保存被请求频率最高的数据,优先删除被请求频率最低的数 据。此类策略最典型的算法叫作最近最不常用算法,英文为Least Frequently Used,简称LFU。
在该算法中,系统按照所有数据的被请求频率进行排序,在缓存空间达到上限时,删除缓存中被请求频率最低的数据。 - LRU缓存策略
优先保存最近被请求的数据,优先删除最后一次被请求时间距离现在最久的数据。此类策略最典型的算法叫作最近最少使用算法,英文为 LeastRecently Used,简称LRU。
在该算法中,系统按照所有数据最后一次被请求的时间进行排序,在缓存空间达到上限时,删除缓存中最后一次被请求时间距离现在最远的数据。 - 缓存策略的优劣
基于访问频率的缓存策略(LFU)比较适用于大量重复请求的缓存数据,并且请求一般来说对时间不敏感。当请求数量达到一定程度时,按照频率对数据进行缓存可以极大缓解系统压力,因为在这样的系统中,占多数的请求往往请求的是占少数的数据,这一点很像经济学上的“二八定律”。基础版LFU的缺点在于,如果某一类数据在过去的访问频率极高,而最近的访问频率不高,它不会被及时删除,从而影响缓存的效率。基于访问时间的缓存策略(LRU)属于比较广泛适用的算法,很多系统在用例没有特殊需求时,都可以使用。如果系统中有大量数据,已经造成了系统负担,而且根据系统的应用场景,旧的数据确定不会再次需要,LRU 就可以发挥作用,安全删除旧数据。基础版LRU 的缺点很明显,它不考虑访问频率,而在某些数据请求频率有明显模式的系统中(比如新闻网站,某些新闻的点击率会远远高于冷门新闻),它的缓存效率就不会很高。 - 缓存命中率
如果一个客户端发起请求,而缓存系统根据请求特征,在缓存中找到了对应的资源,而没有去后端系统发起请求,那么我们称为一次缓存命中。例如,客户端发起了100 次请求,其中有60次是由缓存返回的结果,那么缓存命中率则是60%。缓存的命中率一般由系统本身的特质决定,我们不用对其绝对值进行定义,只需要对其进行纵向的比较,收集数据,不断提升自身系统的缓存命中率即可。一般来说,静态数据越多的网站,应该越追求高缓存命中率。所谓静态数据,是指不会因为时间或者用户活动而变动的数据。缓存的类型
根据缓存的保存位置,我们可以大致将缓存分为以下几种类型。按照以下方式分类,可以帮助我们在设计大型网站的缓存系统时,更有条理地设计缓存的层次。
1 客户端缓存
客户端缓存是指所有发起请求的位置所保存的缓存,有时候我们也将其称为本地缓存。客户端缓存对系统压力的缓解作用是最大的,因为实际发起的请求连网站系统的 最外围都没有达到,同时,因为客户端多种多样且往往有着自己的缓存配置,网站系统对其的控制力也是最弱的。
2 CDN 缓存
CDN (Content Delivery Network,内容分发网络)是一类服务器或者虚拟服务器,它所处的位置在客户端和网站服务器之间,CDN会缓存大量的网站数据,并将它们发布到许许多多的服务器节点上,在客户端实际访问时,CDN 会根据客户端的位置、响应速度、访问频率等(由具体的缓存策略决定)就近获取数据并返回给客户端。
3 应用缓存
此处的应用,特指网站的应用层。在应用中的缓存,往往是指在内存中或者在JVM(假如是Java应用)中的缓存,也包括应用代码中程序员显式声明及实现的各类缓存。
这类的缓存数据往往不大,不包括巨量的图片、视频等,而以一些短数据、配置数据为主。基于分布式集群的缓存
集群缓存,我们可以将它视为数据库层之上的数据库,与普通的 数据库相比,它针对性能,使用各类算法和架构设计对访问速度进行了特别的优化。业内常用的MemCached、Redis等缓存系统,都是基于分布式集群的缓存。
这一类缓存系统需要网站开发者专门配置,也需要编写一定的中间层代码实现,但同时,它的潜力最大。
分布式缓存有两个主要的应用场景。
● 读操作次数远远大于写操作次数的业务。
● 读操作时需要进行复杂运算的业务。
分布式缓存的作用是将第一次请求数据库及计算的结果保存下来,以后可以多次重复使用。缓存的问题
缓存是网站架构师在应用层和数据层额外添加的一层,因此,它自然会给系统带来额外的复杂度和错误可能性
- 缓存过热
缓存毕竟是由服务器和代码组成的,所以数据库会有的问题它依然会有,只是问题会小一些,如果对缓存中的某些数据访问次数过高,缓存依然会有崩溃的可能,我们称这种现象为缓存过热。解决缓存过热最有效的方法就是制造缓存副本,将压力分散到多个缓存服务器或者缓存服务器集群上。 - 缓存穿透
即如果在客户端缓存中没有找到数据,那么会经过CDN;如果CDN中没有,那么会来到应用层;等等。这个请求一路翻山越岭“穿透”了各个层次,最终到达数据库,就称为缓存穿透。
发生缓存穿透的可能性有很多,因此对应的解决方案也多种多样。首先,很常见的缓存穿透原因,就是网站架构的设计者没有设计好资源与URL/URI 的请求关系,即对于相差不多的资源,网站使用的请求完全不同,因此缓存系统无法识别它是否已经缓存过这一类数据。其次,有时候缓存数据非常复杂或者巨大,而其过期时间又较短,那么会出现请求经常“穿透”数据库的情况。在这种情况下,网站架构师可以选择调整过期时间,增加缓存预热层,例如,运行后台线程,保持缓存的有效性,或者针对缓存进行进一步的细化,将复杂的缓存切分成多个部分,缩短缓存的时间和降低资源消耗。最后,就是缓存中确实没有对应数据,其实这种情况对于已经正确配置缓存的网站来说非常少见,但是在有爬虫程序或者黑客攻击时,就会出现这种情况。
这时候需要网站程序员针对这类情况设置保护层,例如,设置每个 IP 地址/客户端/用户可以发起的最大请求数、最大请求频率,或配置专门针对爬虫的限流系统 - 缓存雪崩
如果所有的缓存都在同一时间一起失效,会发生什么?首先,会发生大量的缓存穿透,然后,新的缓存需要被添加,而如果不巧这个缓存又是一个过热缓存,那么会有很多台应用层服务器一起更新缓存,可谓雪上加霜,这种情况往往会导致大规模的系统性能降低甚至崩溃,我们称之为缓存雪崩。针对缓存雪崩,我们有三种解决方案。
第一,如果一个缓存是过热缓存,而我们对其进行了复制的话,则要确保复制的缓存的失效时间不同,否则肯定会造成雪崩。失效时间一般来说使用随机数就可以满足要求。
第二,我们可以引入与上一节中提到的预热机制类似的机制:由一个后台进程负责模拟请求,访问一部分缓存数据,保持缓存的活跃性。
第三,针对多台服务器一起更新缓存的问题,我们需要引入缓存的锁机制。
简单来说,就是如果一台服务器正在更新某个缓存,那么其他服务器就不能更新它。
常见的缓存系统
- MemCachedMemCached 是一个开源的缓存系统。MemCached 的接口非常简单。它提供键值对关系的数据库表,在使用时,客户端如果需要复杂的关系或类型,则需要自己对缓存进行进一步的包装,这个过程可能会降低性能,但 MemCached 是多线程、非阻塞I/O 的网络模型,在处理数据结构简单但数据量大的情况时,具有很高的性能。但是,MemCached 的一个重要特点是不支持持久化,是一个真正的“缓存”,在没有进行特殊包装或配置的前提下,一旦缓存服务器重启或崩溃,那么所有缓存都会失效。
- RedisRedis 也是一个开源的缓存系统。Redis 与MemCached相比,它支持更复杂的数据操作,例如,列表、集合、散列等,原生的数据结构支持远远好于 MemCached。同时,它具有更复杂的储存逻辑,它会将一些高频的数据放在内存中进行缓存,而其他数据则有可能会被保存在磁盘系统中。
第7章 动静分离
笔者尝试对动态数据和静态数据做一个直接的定义:通过从开发者的角度看,是否是动态数据,由响应中的数据是否由当前请求的特征决定。如果响应的数据会被当前请求的任何信息决定,那么这些数据就是动态数据,反之,则是静态数据。
动静分离是一种性能提高手段,它是指通过架构、服务端业务逻辑代码和前端代码等显式地区分动态数据和静态数据的读取,对动态数据,不做特殊处理;对静态数据,利用上一章中的缓存架构和技术对这一类数据进行缓存,使得客户端在一个兼有动态数据和静态数据的网站上,对某个网页发起多次请求时,第一次之后的请求可以从缓存中获取静态数据,从而请求只需要从服务器端获取动态数据。
分离是指区别它们的访问方式,使服务器端或者客户端根据数据的本身特征来决定其访问方式,而不是单纯通过业务逻辑来决定动静分离是一种复合了设计和技术手段的优化手段,不是一个独立的技术动静分离在真正实现之前,还需要经过一些特殊的预处理,主要是针对数据在业务角度上的分类和在技术上从服务器端-客户端交互的角度进行的分离。
- 动静分离的作用
第一,也是最重要的,可以提升网站的响应速度,改善网站的性能。
有一个常见的对于性能提升的小误区在于,如果静态数据的数据量很小,则动静分离没有什么作用,因为能够被节省下来的数据下载量并不大,从而带来的节省的下载时间也不多,事实并不一定如此,因为如果缓存是基于本地的,那么减少 HTTP 请求次数的意义也很大。
如果发送的HTTP请求数量变少,即大多数情况下所需的TCP连接数量能够被减少,对于总延时来说就是减少了很大一部分,那么对客户端延时也就是用户的体验而言就已经是很大的提升了。
第二,网站的开发者可以利用此机会提升用户的使用体验。
第三,网站的开发者也可以在实现动静分离的过程中,识别出各个组件的流量特征,从而做好各个组件的解耦,在未来的重构或者扩展过程中,可以更容易地在不进行巨大改动的情况下实现变化。
出现以下两种场景时,网站开发者应该首先考虑动静分离是否可以是一个能快速提升网站性能的手段。● 网站面临伸缩瓶颈,尤其是与网页数据和后端服务的读取相关的瓶颈。网站用例属于经常要刷新,甚至是需要在特定时间快速刷新网页的情况,如抢票、特卖、新闻热点跟进等情况。 出现以上两种场景,设计并实现动静分离可以非常有效且快速地提升网站的性能。
- 拆分动态数据和静态数据
识别出页面上的动态数据和静态数据之后,就可以对它们进行必要的预处理,为架构改造做准备了。首先,以动态数据和静态数据为分界,分离所有被耦合在一起的请求。
实现动静分离逻辑,一方面需要服务器端分离各个请求的路径终端;另一方面则需要客户端发起额外请求,并组装到已经加载的页面中。这种实现方式需要服务器端和客户端双方的努力。 - 改造数据要注意的问题
首先,以上改造数据的手段都不是互相排斥的。这也是笔者在本书始终传达并会一直强调的思想:面临任何技术选择,都要一直保持对多个选项的开放度,不要对任何一个选项过于执着,从而忽视了其他选项的优点。要尽可能将不同的选项利用在最合适的场景中并灵活组合达到最佳效果。其次,在进行动静分离的数据改造时,开发者必须确保至少静态数据是被映射到唯一的URI上的。
最后,网站开发者需注意,在完成数据改造后,所有被识别为静态数据的API或者路径终端,若非必要,尽量不要在响应中留下带有根据请求特征变化的信息。如果一定需要这些信息,那么应该将这些信息通过动态数据的响应返回。 - 动静分离的架构改造
- 浏览器缓存
在HTTP 响应头中,开发者可以利用一个叫作Cache-Control的头部定义缓存策略。
开发者只要在服务器端返回的响应中指定对应的字段,就可以让自己希望被缓存的内容缓存了。一般情况下,在动静分离中,开发者只需要不指定no-store,然后指定合理的缓存时间,就可以让静态数据被缓存在服务器中。 - CDN缓存
CDN 缓存和浏览器缓存的一个重要区别在于,对于网站开发者来说,CDN 缓存是在自己掌握中的,而浏览器缓存是完完全全在用户的机器上的,这就造成了一旦数据在特殊情况下需要做出调整,客户端缓存远没有CDN缓存值得信赖。 - Web服务器缓存
有的Web服务器本身就可以被当作缓存使用,如Nginx服务器。Nginx服务器非常擅长处理静态数据,尤其是网页文件,处理效率很高,它既可以当作代理服务器使用,又可以当作缓存服务器使用。因此,很多人选择在网站应用层之前搭建一个Nginx实例,专门负责处理一些只有静态数据的网页文件,不仅可以极大缓解应用层的压力,而且Nginx服务器本身返回这些网页的速度也非常快。
这种缓存拥有易搭建、易设置,且效率高、性能好的特点,可以说是事半功倍。但这种缓存也有其局限:其一,大多数通过这种方式搭建的缓存服务器只能处理完全的静态文件,即整个网页都是静态数据的文件,如果希望实现一个服务器端整合的页面,那么还需要交给下面几层;其二,这种缓存服务器大多架设在各台服务器上,不是分布式的缓存,没有分布式缓存的各种优势(详情见第 6 章),可以支持的数据量也不大,它可以被用于支持少数静态页面,但是伸缩性不强。 - 分布式缓存
如第6章介绍的那样,开发者可以选择任何适合自己的缓存技术,如 Redis,然后将这些数据访问请求发给缓存层,看有没有可以使用的缓存。 - 页面组装
假如用户请求的页面是一个在服务器端组建静态数据和动态数据的页面,那么在从缓存中获取数据之后,应用层还需要调用能够生成动态数据的代码,生成动态数据之后,与静态数据组装成页面。这一步与通过客户端来获取动态数据有很大的区别。它也是一个性能瓶颈,因为为了使网页在发送给用户之前组装完毕,服务器端需要渲染该网页,这一操作并不是一个轻松的操作,它会同时占用 CPU 和 GPU,从而对服务器造成一定的性能负担。除此之外,缓存中的数据有时候为了节省空间,是经过一定压缩处理的,在进行服务器端处理时,解压操作也会消耗一定资源。第8章 负载均衡
负载均衡(Load balance)是指当服务请求或系统任务很多时,后端搭建有多台服务器或多个集群可以处理该任务,然后由一个系统总体负责这些请求或任务的分发,这个任务分配和转发的过程就叫负载均衡。注意:此处的均衡不是绝对平均的意思。其实,“均衡”作为一个英文单词“balance”的翻译,它是一个动词,在这个上下文中更接近于“协调”的意思。
负载均衡器需要根据当时的实际情况和它本身配置的算法来决定谁最适合处理当前任务。负载均衡的类型
从客户端的角度出发,由近至远,将负载均衡分为如下几种类型。
● DNS负载均衡。DNS 负载均衡,即在DNS层实现均衡,按照之前模型分类的角度,应被归类为全局的负载均衡。DNS 负载均衡是指在客户端向DNS服务器发送请求寻找服务器IP地址时,DNS服务器会因为客户端请求来自不同的地理位置而返回不同的网站数据中心IP地址,从而实现流量的分配。
● 硬件负载均衡。纯硬件层面也可以实现负载均衡,一般来说都在网络设备中实现,有专业人员负责实现和维护。
● 软件负载均衡。软件负载均衡有很多种类型,是当前业内实践的主要侧重点。这一类负载均衡按照业内流行的软件类型,大致可以分为在 Linux层面的负载均衡,业内流行的Linux Virtual Server(一般简称为LVS)和在软件层面基于HTTP协议的负载均衡,业内较为流行的是直接使用或基于Nginx 的负载均衡器。反向代理
反向代理(Reverse Proxy),是指一类负责接收客户端请求,转发给后端服务器,然后当后端服务器处理完请求之后,再把后端服务器的响应转发给用户的系统。“正向代理”或“代理”为客户端服务,目的是隐藏真正的客户端。“反向代理”为服务器端服务,目的是隐藏真正的服务器端。“反向代理”的侧重点在于,后端服务器将它作为与客户端交流的代理,而对于客户端而言,它也是和后端服务器沟通的媒介–只是很多情况下客户端并不知情。而“负载均衡”的侧重点集中在一个相比之下更加明确的任务:面对进入的用户流量,用某种方式分发给后端服务器,而达到一种分发算法所认为的“平衡”状态。“反向代理”的概念大于“负载均衡”,且“反向代理”的职责之一是负载均衡。
“反向代理”这个概念时,除了期望它具备负载均衡功能,还应该期望它具备哪些功能呢?
(1)“反向代理”具备缓存功能。
(2)“反向代理”为后端服务器提供安全性。
(3)“反向代理”为后端服务器统一提供 SSL 加速。
SSL 是指传输层的加密协议,通俗地说,它通过加密请求的方式来保障客户端和服务器端之间的通信安全。一个网站一旦使用了 SSL,它就必须解决一个很基本的问题:SSL加密的请求总要在一个地方被解密,从而让业务逻辑层可以正常处理该请求。“反向代理”的一个重要任务就是在反向代理服务器上进行 SSL 解密,通常被称作终结SSL 会话(SSLTermination),后面的处理过程就不需要SSL了。
DNS负载均衡
DNS (Domain Name System,域名系统)。DNS 有很多种表达从域名到IP 地址映射关系的手段,下文主要介绍当前流行的两种手段:A记录和CName。
A 记录(A Record),A是英文 Address,即地址的简写,是指网站主机名或者域名对应到IP地址的域名记录方式。
CName (Canonical Name Record,真实名称记录),一般业内称其为 CName,本书也如此称呼。与A记录相对的是,CName保存的是域名到域名的映射关系,而非 IP 地址。
在DNS服务器通过CName查找到另一个域名之后,还需要进行进一步解析,直到找到该域名实际对应的IP地址为止。事实上,CName的作用就好像我们在编程语言中使用一个常量来表示一个数字、一个字符、一个字符串……一样,在实际情况中,一台服务器可能会被部署到多个网站,对于云运营商提供的服务更是如此。
CName 更大的作用在于,如果网站开发者并不是自己在维护一整套服务器,而是对应的网络系统使用某个云服务商提供的CDN 服务(多数情况如此),那么云服务商很显然应该提供CName而非A记录给网站开发者,让网站开发者可以将自己注册的域名对应到CName 上,这样云服务商就对外隐藏了自己维护、更新和替换服务器,以及服务器 IP 地址的过程,不会每次变动服务器时都需要使用者来更新DNS 记录。
DNS负载均衡,就是通过配置DNS 服务器,让自己网站的域名被映射到A 记录和 CName(或者是其他类型的记录,此处不一一列举)中,然后让DNS 服务器负责导流
- DNS服务器常见的策略如下。
● 加权,即允许使用者对每条记录设置权重。
● 地理位置,即让 DNS服务商根据用户请求的地理位置选择负载均衡。
● 延时和故障转移。更广义地说,这种服务基于各条记录的当前状态。延时策略,即根据当前各个配置记录的响应延时返回最优的记录,通俗地说就是谁处理得快,就把请求发给谁。而故障转移策略则是基于错误率的,即谁的错误率最低就优先发给谁,或者谁的错误率高过某个值就不发给谁。 - DNS负载均衡的优缺点
DNS 的优点在于配置和使用简单。
但是DNS负载均衡不是万能的,它具有如下缺点:更新慢。配置DNS负载均衡无论使用的是A记录、CName 还是其他的记录形式,只要用户想更新服务器记录,系统都存在一个真空期,在更新期间新的请求会被发送到被更新的服务器实例上。与上一点还有一个相关且类似的问题:因为更新慢,负载均衡策略在实际操作中未必如配置一般有效。最后,从配置DNS负载均衡的步骤读者也可以看到:即使是最先进的 DNS 服务商,他们所支持的策略或算法也是很简单的,而且还不允许使用者自定义。这一点才是DNS 负载均衡的致命缺点,也是开发者需要使用其他负载均衡手段的最大原因。硬件负载均衡
硬件负载均衡所使用的硬件设备是专门负责负载均衡的,由于这类硬件设备是由专业公司负责开发并作为专业用途的,其易用度一般都很高,且性能极佳。业内常见且著名的负载均衡设备是F5公司开发的BIG-IP 设备。它支持四层负载均衡,且可以起到反向代理的作用,执行反向代理的各种任务,包括检测并拒绝恶意攻击,例如DDoS、SSL 加速、内容缓存、防火墙等,并且还支持 IPv6,算法也相当智能。
硬件负载均衡的优点如下。
● 功能全面。
● 性能佳。
其缺点也很明显。
● 价格昂贵。
● 扩展和维护困难。软件负载均衡:LVS
LVS(Linux Virtual Server,Linux虚拟服务器),它基于Linux 内核,在IP层实现了基于内容分发的负载平衡,将一组服务器构建成一台总体的虚拟服务器,所以叫作Linux 虚拟服务器。有的地方称其为“四层调度”(L4Switch)、“四层负载均衡”或者类似的名字。这个四层不是代表有四层转发的意思,而是代表OSI协议的第四层,即传输层、TCP/IP协议层。LVS在TCP/IP层对网络请求进行转发,因此被称为“四层负载均衡”。出于同样的原因,有的地方也称 LVS 为“IP 层负载均衡”。 - LVS的优缺点
LVS 的主要优势在于,它的负载均衡策略是基于第四层也就是 TCP/IP 协议层的,它不像我们接下来要介绍的 Nginx 主要基于第七层,因此能够支持的网站应用范围更广,并且由于 LVS 拥有较久的历史,以及作为开源软件,业内实践经验丰富,其配置也灵活且方便,即使对此不熟悉的读者,如果发现网站需求很适合使用 LVS 负载均衡,也可以很轻松地找到相应资料完成配置。当然它也存在劣势,由于它不是在第七层,即应用层的,所以不支持应用层,即HTTP 层的一些特有功能,比如针对域名、目录的分流。并且 LVS 是不支持本地重试的,只能让客户端发起重试,也就是说某请求如果在转发过程中由于某种原因断开了,LVS 只能直接丢弃该请求,等待客户端重试。软件负载均衡:Nginx
Nginx是一个由俄罗斯软件工程师IgorSysoev开发的Web服务器,现在也是一个开源软件。Nginx 也常常叫作七层负载均衡,因为其处于 OSI 七层协议的顶层–应用层,一般使用HTTP 协议,因此有时候也叫作HTTP网关(Gateway),Gateway的意思其实就是大门或门口,代表隐藏了内部细节作为请求总出入口的组件,或者有的地方也把它叫作应用层负载均衡,与 LVS 的“IP 层负载均衡”相呼应。
Nginx采用了一套全新的基于事件驱动的模型,以异步处理、单线程、非阻塞模型处理任务,并成为它的特色和性能优异的基础。
Nginx原生支持四种负载均衡策略。
● 轮询。
Nginx将轮询和带权重的轮询看作同一种策略,网站开发者既可以配置权重,使它成为带权重轮询,又可以将权重留空,使用普通轮询,此时流量将被均匀发布到各台后端服务器上。
● 最少链接。与 LVS 相似,Nginx也提供了基于服务器最少链接算法的负载均衡策略,即优先将请求发送给当前活跃链接数最少的服务器。
● IP地址散列(IP Hash),又称为源地址散列,这也与 LVS 的源地址散列算法类似,即基于客户端的IP地址做一个散列,并且Nginx还会保证同样的IP 地址一定会被散列到同样的服务器上,除非服务器不可用。
● 自定义散列(GenericHash),除了IP地址散列,Nginx还提供了一种散列手段:自定义散列。开发者可以选择配置一个自定义的散列键,例如,IP 地址+端口的组合,或者请求的URI等。Nginx会确保请求依然被平均发往各台服务器。
Nginx的错误重试七层负载均衡一般都支持请求重试。为了能够重试,当前的这个HTTP 请求需要被保存下来,根据请求的大小,Nginx会自动选择保存方式,较大的使用临时文件保存,较小的则直接保存在内存中,等待执行结果返回,如果执行失败,则直接转发给下一台服务器。
- Nginx的调整升级
七层负载均衡系统的服务器调整步骤大致如下。(1)通过升级,系统首先通知后端服务器正常关闭,即完成当前所有请求,然后根据实际情况进行关闭。(2)后端服务器标记自身为关闭之后,如果有新的请求依然被转发到该服务器上,只需要直接拒绝即可,拒绝返回的响应必须能够由负载均衡器识别,例如,使用一个特殊响应状态码表示。(3)对于需要更新系统的服务器,进行系统更新,对于需要更换硬件的服务器,更换硬件。(4)将该后端服务器重启。负载均衡的实践流程
流量基本概念:
● DAU:DAU 是英文 Daily ActiveUser 的简写,即日活跃用户数量,也就是俗称的“日活”,一般用某网站每天登录的用户数量来表示。
● QPS:QPS 是英文 QueriesPer Second的简写,即每秒查询率,也就是一秒一台服务器被请求访问的次数,一般用来衡量一台特定服务器能够处理的访问流量。
● TPS:TPS 是英文 Transaction Per Second的缩写,即每秒事务率,也就是一秒一台服务器处理事务的次数。TPS 比 QPS 表示的范围稍微广一些,可以用来代表一台服务器可以处理的几乎所有类型的业务的频率。
● IOPS:IOPS 是英文Input Output Per Second 的缩写,即每秒输入输出率,该指标在衡量网站性能时也有使用但用得不多。
● 连接数:连接数是指一台服务器在一个特定时间点与其客户端建立起的连接数量,有时特指 HTTP 连接数。该指标非常重要,原因在于,几乎所有市面上的服务器软硬件在各个环节有硬性或软性的连接数量限制,如果一个网站忽视了连接数的瓶颈,即使其处理事务很快,但瞬时连接数达到了最大值,也会造成事务阻塞。
● 流量:流量是由请求数量与请求大小或响应数量与响应大小决定的,即12每秒输入流量=每秒请求数量*每个请求的平均大小每秒输出流量=每秒响应数量x每个响应的平均大小
在一般情况下,若无特殊需求,笔者推荐使用DAU+连接数,或者 TPS+连接数来进行初步估算。
总的来说,对网站设置负载均衡需要经过两个阶段:第一个阶段,估算流量;第二个阶段,四级导流。
从客户端到服务器端,开发者可以使用的负载均衡手段依次为DNS、硬件、LVS、Nginx。我们可以简称为四级导流,即四个层次的负载均衡。在实践中,如果不是超大型网站,笔者建议开始时按照 Nginx、LVS、DNS和硬件的顺序进行架设。其中,硬件仅在网站流量较为确定且网站开发者对稳定性要求极高的情况下采用,否则为了扩展性的考虑,尽量优先使用LVS。
实际操作中还会有其他的情况需要考虑。此处列举两个方面。高峰时段TPS可能是一个不准确的流量估计。这时候可能需要开发者采取某种方式使系统实现自动伸缩或使用一些支持自动伸缩(Auto-scaling)的系统。“异地多活”。对于很多业务关键的大型网站来说,还需要遵守一个原则:保持冗余。通俗地说,就是准备多一些服务器预防突发情况。并且,冗余从负载均衡的角度看格外重要。在业内我们一般概括这种思想为“异地多活”。异地多活是指理想状态下一个网站的服务器应该处于物理上的不同地点,多个数据中心都能提供服务,保持网站活跃。
第9章 异步和非阻塞
同步,英文是synchronous,是指某个调用者调用了某个函数、方法或者模块之后,会要求该函数、方法或者模块执行完毕并给出一个结果,如果没有出现结果,则调用过程不算结束。从网站的角度看,同步是指客户端调用网站的某个API并且期待一个结果,如果该结果没有返回,则此客户端会一直询问或等待结果;或者是指网站内部的模块 A 调用模块B并且期待一个结果,如果模块B一直在执行,没有返回结果,那么模块A会一直等到结果出现为止。
异步,英文是asynchronous,是指某个调用者调用了某个函数、方法或者模块,然后直接结束调用。当函数执行完毕的时候,被调用者会通过其他的渠道通知调用者调用的结果。从网站的角度看,异步是指客户端调用了网站的某个 API,然后就直接返回了,无论该API有没有返回结果,客户端当场是看不到该结果的,然后网站会以其他形式通知客户端调用的结果;而对于网站内部而言,就是指模块A调用模块B之后就直接返回了。同样地,模块B必须以其他方式通知模块A执行的结果。
同步和异步强调的是调用者和被调用者之间的通信方式的区别,而阻塞和非阻塞强调的是调用者在调用过程中的状态不同。
阻塞,英文是blocking,是指调用者在等待被调用者返回调用结果之前,等待结果的状态。因为其他的任务在结果返回之前都被“阻塞”了,所以这种状态被称为阻塞。相反地,非阻塞,英文是non-blocking,是指调用者不用等待被调用者返回结果的状态,此时其他的任务不会因为这个调用结果而被“阻塞”,所以这种状态被称为非阻塞。一个需要从概念上注意的误区就是,阻塞和非阻塞不代表当前的调用是不是失败或者是不是以某种形式卡住了,而仅仅是指是否等待的这个状态。一个调用虽然只花费1毫秒,远远快于其他任务的执行速度,但只要调用者保持等待的时候不处理其他的任务,那么它就是阻塞的。
在UNIX网络编程中,这两组概念比笔者在此处定义得更明确。同步是指调用者主动请求结果。异步是指调用者被动知道结果。阻塞和非阻塞虽然和笔者此处的定义接近,但它们同时还特指线程的状态,即阻塞是指线程被挂起,而非阻塞是指线程不被挂起。
多线程的概念很简单,即当前应用使用了多个线程来完成一个或多个任务。多线程是异步的一种实现手段,但异步不一定需要多个线程,同时多线程也可以处理其他的工作,从某种程度上达到非阻塞的目的。最后,从广义的角度看,异步或者非阻塞,其实都存在一个隐含前提,即系统有别的线程、别的远程服务可以依赖,让它们去独立地执行任务。
- 异步和非阻塞的作用
异步主要适用于满足以下条件的应用系统。系统消息的时效性要求不高,或系统消息可以接受一定时间的延时。
系统对于消息的接收和处理顺序要求不高,或者换一种说法,究其本质,整个系统中的系统时钟没有同步要求。非阻塞则适用于满足以下条件的应用系统。非阻塞处理事务相比阻塞处理事务更能提升系统性能尤其是吞吐量。
系统的调用者不立即期望事务完成。非阻塞的核心在于,系统被要求办一件事时,它对客户端,即接口的调用者表示“开始办了”,然后让办事本身不阻塞系统运行,再等待下一个客户端的调用。这样做的前提就是客户端并不立即期望事务完成。 - 异步和非阻塞的架构
当网站的客户端调用网站的API 时,出于某些原因:网站的业务逻辑处理需要花费较长时间;客户端不要求立即得到结果。
根据实际情况,我们有两种改造方案。方案一,在接收请求后,原本的业务逻辑模块将剩余的业务逻辑转交给另一个线程池。主线程直接返回客户端一个响应,剩余的业务逻辑将由该线程池中的线程接手处理,该线程池中的线程会调用结果处理模块进行下一步逻辑处理。方案二,在微服务中创建一个新接口,在接收请求后结束当前线程,关闭连接,然后当任务正式执行完成后,完成者会通过该接口通知当前服务,当前服务在处理完该通知之后,调用结果处理模块进行下一步逻辑处理。
注意:对下游服务的远程调用不是将一个同步且阻塞的逻辑改造成异步且非阻塞或者非阻塞的必要条件。初始改造的目的完全可能仅仅是网站本地处理业务逻辑时消耗的时间太长,从而需要从同步且阻塞变成非阻塞或异步且非阻塞。
- 异步的优势
(1)通过异步处理事务最大的作用就是提高网站的响应速度。
(2)异步可以降低某些接口的流量压力,达到技术错峰的目的。
错峰可以通过非技术手段,也可以通过非纯技术手段(包括往工作流中添加二次验证步骤等)使得用户的操作被引入原先的一系列业务逻辑的执行中,而用户的人为操作则会将原本挤在一起的流量完全分散。而将一个同步执行过程变成异步,事实上是将一个组件拆分成两个组件,将一个发往该服务的请求变成多个发往一个或多个组件的请求。因此,原本被一个组件的一个接口接收的流量,现在被多个组件处理,或者从由该组件在同一时间一起处理变成由该组件依次处理,也能很好地达到错峰的目的。
(3)异步可以提高系统解耦的能力。实战:以Java为例
- Runnable
Runnable接口的作用是提供给Java开发者一个使用线程类的入口,开发者只需要用一个类去实现这个接口和它的方法,并将这个类交给相关的线程类,写在这个方法中的逻辑就将被相关的线程类使用并在定义的时间内执行。
在业内实践中,已经很少直接在Thread层面上操作业务逻辑,往往都用Executor和ExecutorService提供的更方便的线程管理功能。
简单地说:Runnable接口中的run()方法就是将不希望阻塞当前线程的业务逻辑放进去的地方。 - Callable
Callable接口和Runnable接口非常相似,我们可以理解为Callable接口是Runnable接口的功能升级版本。
Callable接口也只有一个方法V call() throws Exception;
,其中,V是在 Callable 接口上声明的泛型。它代表call()方法可以返回一个由用户实现该接口时定义的类型。Callable接口和Runnable接口的作用几乎一模一样,此处不再赘述。但与 Runnable 接口相比,Callable 接口多了两个灵活的地方。它允许开发者抛出一个 checked 异常。。它允许开发者返回一个自定义的结果类型。
Callable接口中的call方法就是将不希望阻塞当前线程的业务逻辑放进去的地方,而V则是返回结果的通道。 - Future
Future 接口代表了一个执行过程的“未来”结果,因此它叫作Future(“未来”的英文单词)。这个类的实例并不代表这个执行过程已经结束,它只是一条抽象的“线”,牵着那个也许已经执行完、也许还没有执行完的结果,开发者可以根据需要,将它保存在内存中,在必要的时候,要求它执行完并返回结果。
Future 接口有好几个方法,我们在此处需要关注的只有一个,对其他方法感兴趣的读者可以自行查询JDK 的文档:V get() throws InterruptedException,ExecutionException;
其中,V是在Future 接口上声明的泛型,它代表Future所代表的那个执行过程的执行结果。开发者调用get()方法时,相当于“催促”这个执行过程执行完毕并返回结果。
注意:get方法是阻塞调用!
使用Future接口实现异步且非阻塞的逻辑,一定要注意,当你关心结果并调用get()方法时,它会一直等到结果出现才执行下一步。换言之,如果该执行过程本身耗时很久,那么调用get方法有可能需要等待很长时间。
除此之外,我们还会有另一个常见的需求,就是希望为这个get()方法设置一个时间上限。因为既然我们使用了Future 接口,那么说明我们有将逻辑变成非阻塞的需求,说明原来的阻塞调用会花费很长时间。需要注意的是,使用 Future 接口并不代表系统在执行了其他逻辑之后,get()方法就能迅速返回执行结果,它依然可能需要等待很长时间。在某些情况下,我们希望等待一定时间,而超出这个时间以后,则抛出异常。
Future接口还有一个简单的方法可以帮助我们检查执行过程是否完成:boolean isDone()
;调用isDone()
方法将得到执行过程是否完成的信息,如果返回true,则表明执行完成,结果可以直接使用;如果返回false,则说明执行尚未完成,然后调用get()方法等待直到结果完成或超时。 - Executor和ExecutorService
Executor接口和ExecutorService接口就是我们平时所说的线程池,从JDK1.5版本开始支持。它们封装了一系列针对线程的操作,让开发者不用关心线程级别的操作,专心于编写业务逻辑。它们是Java线程池类共同实现的接口,提供了线程执行任务和终止线程的几个基本方法。
Executor接口和ExecutorService接口就是我们平时所说的线程池,从JDK1.5版本开始支持。它们封装了一系列针对线程的操作,让开发者不用关心线程级别的操作,专心于编写业务逻辑。它们是Java线程池类共同实现的接口,提供了线程执行任务和终止线程的几个基本方法。
Executor接口只提供了一个方法:void execute(Runnable command);
execute()方法接收的参数是一个Runnable,没有返回值。该方法的实现流程是开发者通过实现一个Runnable,定义了一个希望线程池去执行的任务,然后将该任务交给线程池,让线程池以其定义的方式去执行。
执行任务的时间是不确定的:在开发者调用execute()方法之后的某个时间点。显而易见,Executor 接口尽管简洁,但缺少了很多我们需要的功能,例如,终止线程、了解任务是否完成、获取任务执行结果、强制任务立即执行并完成等。因此就有了ExecutorService接口,它是Executor接口的一个扩展接口。
ExecutorService接口的方法如下:<T> Future<T>submit(Callable<T> task);
其中,T 是声明在该方法上的泛型,它代表 Callable 返回的结果类型。submit()方法接收一个Callable 作为它要执行的任务定义,并返回一个Future,也会在未来的某个时间点执行给定的任务,但任务的执行不一定在调用submit()方法的当时就开始。submit()方法返回一个Future,代表这个任务的执行状态,并可以用来继续跟踪执行状态以及获取执行结果。
ExecutorService接口是以固定数量创建线程的线程池,它不会在执行之后被销毁,只要该线程池中仍有活跃线程,程序就不会结束。在实际网站建设中,这一般不是我们需要担忧的问题,如果需要,我们可以随时随地销毁该线程池。
异步和非阻塞带来的问题
- API定义
在软件工程领域有一个实践定律:隐式接口定律(海勒姆法则 - Hyrum’s Law):当用户数量达到一定规模时,合同中的承诺变得不再重要,系统的所有可观察行为都取决于其他人。换句话说,当用户数量达到一定规模时,系统所有实际表现出来的特性,无论是在文档中写了的还是没写的,甚至是漏洞、问题、局限、瓶颈,都将可能成为系统用户依赖的一部分。因此,无论如何,将一个原先是同步且阻塞的业务逻辑的 API 改造成异步且非阻塞,有很大可能性会破坏该 API调用者的业务逻辑。
最常见的解决方案很简单:创建一个新的异步且非阻塞的 API,并保留原先的同步且阻塞的API和逻辑。 - 线程池的扩容
异步非阻塞的改造只是解决了系统I/O的瓶颈,并不会变戏法般地使系统处理这些流量的速度变成非阻塞时的处理速度,也不会使这些流量凭空消失。原先当我们要确保系统性能不受流量变化影响时,只需要适时监控单机数据指标,如 CPU 和内存,然后在必要时,添加新的服务器,但要确保这些异步的线程池与系统相得益彰,需要在依然监控单机性能的前提下,额外确保上面的问题。我们可以通过一些计算来估计最佳线程数,但是更好的办法是使用压力测试(或称为负载测试)直接在实际环境中测试单机最适合的最大线程数。
在商业应用中,生产环境的配置最好是放在外部的独立存储环境中,并用一个组件封装起来。
第10章 队列
- 队列及其相关概念
在数据结构中,队列是一种“先进先出”的数据结构。
但队列其实是一种抽象概念,它不仅可以应用于微观的数据结构或算法中,也可以作为一种系统设计的结构理念,应用于大型系统的搭建中。有时候队列也叫作消息队列(MessageQueue)。
有的技术书籍和资料中会将队列分成几种类型,消息队列是其中的一种,其他种类还包括请求队列等,但实际上它们是一类系统,无论队列中的数据是消息、请求还是任务,并不改变队列的本质,也不改变其他系统与队列的整合方式以及整合后产生的特性。
生产/消费模式是设计队列的一种形式,也是最早出现的一种形式。生产(Produce)是指往队列中发送消息,即入队列操作。相应地,消费(Consume)是指从队列中获取消息并删除的动作,即出队列操作。相应地,完成这两个动作的组件,分别是生产者(Producer)和消费者(Consumer)。这种模式下,假如一个队列有两个消费者,一个显而易见的问题就在于,一个消息一旦被一个消费者获取了,另一个消费者就不可能再看到了,除非同样的消息被再次放回同一个队列。而且就算放回这个队列,在没有经过其他配置的情况下,也依然不能保证另一个消费者接收到该消息。因此,为了解决这个问题,在生产/消费模式下,如果要保证每个消息都能被所有消费者接收,一个生产者必须复制多份消息,对应多个队列,每多一个消费者,则增加一个队列,在运行时多复制一份消息副本。显然这种方式并不经济,为了保证每个消息都能被所有消费者接收,每有一个消费者,就需要增加一个队列,并且在发送时由生产者复制一份消息。
因此,另一种模式应运而生:发布/订阅模式。在发布/订阅模式中,发布(Publish)是指发送消息,而订阅(Subscribe)是指对消息感兴趣并接收消息,发布的组件称作发布者(Publisher),订阅的组件称作订阅者(Subscriber)。而这时候,严格来说,保存消息的组件不再称作队列,而称作主题(Topic)。
主题在逻辑上依然是一个“先进先出”的结构,只是当接收者接收消息之后,不会删除消息,或者说不会立即删除消息。
一个数据能否被多次接收?如果可以,就是发布/订阅模式,如果不可以,就是生产/消费模式,二者并没有根本的区别。
- 队列与网站的整合
推送模式或推送模型(Push Model)和拉取/轮询模式或拉取/轮询模型(Pull/Poll Model)。
(1)订阅者:推送模式
推送模式是指在通信过程中,有消息的一方将消息主动传递给关注消息的一方的通信模式。在队列中是指队列将接收到的消息主动推送给订阅者。
在以HTTP协议进行通信的服务器端和客户端之间,服务器端是永远不能主动联系客户端的,只能由客户端主动向服务器端发起请求并得到所要的响应。因此,推送模式从技术角度讲,在两个互相独立的微服务之间是不能实现的。但如果开发者使用的队列不是云服务,那么某些类库就可以使用推送模式。例如,RabbitMQ 在队列和客户端之间建立的就是一个 TCP 长连接,每次队列中有新的消息时,队列就会主动将消息推送给客户端,不需要客户端再次发起连接,每个客户端建立了订阅之后,就可以建立起一个专属连接。
在实现上,这些类库一般会通过定义一个回调函数(Callback)接口,让开发者实现该接口,然后将该实现传递给接收消息的调用来完成消息的推送。
推送模式在当前微服务与队列有超过两个独立微服务之间的紧密关系时,效率比较高且比较节省资源。但有的情况下,例如,开发者使用的队列是云服务时,这种模式就不太现实了,于是就要使用我们接下来介绍的拉取/轮询模式。与推送模式恰好相反,拉取/轮询模式是指在通信过程中,消息的接收方主动向消息的提供者询问并获取消息的模式。
(2)拉取/轮询模式
拉取/轮询模式需要一个持续运行的线程池,其中的线程一直发出获取消息的请求,因为服务器端框架本身不会自动为队列消息像对发往该服务器的请求那样创建线程。拉取/轮询模式在队列处于云服务上时是唯一获取消息的手段,因为使用队列的服务和队列服务是两个独立的服务,通过HTTP 协议进行通信。
但是,在实际生产实践中,服务器端往往会对这个HTTP请求进行特殊处理。普通的HTTP请求在到达服务器之后,服务器处理完就会返回,队列的云服务用于检查客户端请求的队列是否有消息,如果没有,则通过响应告知客户端没有消息。但作为一种优化,队列的云服务对该请求暂时不返回响应,直到超过某个开发者设置的时间(例如5秒、10秒,任何秒级的时间对于一般只耗时几十毫秒或几百毫秒的HTTP 求言,应时间都很长了)才返回,如果中间任何时候出现了新的消息,则队列不会等到超时而是直接返回消息。这种消息传递模式是双赢的,因为对于服务器端(队列)而言,它可以接收更少无谓的请求,减轻自己的压力,而对于客户端而言,由于这一类云服务往往都是按照客户端往云服务发送的请求数量来收费的,更少的请求也可以减少客户端的成本。当然,这么做也不是毫无成本的,因为服务器端每保留一个请求不返回,就会占用一个连接,而我们通过前几章的学习也了解到,每个 Web 服务器的连接数也是有限的。因此,一般云服务商不会提供过长的超时时间配置,一般上限为60秒。
- 队列的应用
1 流量控制;2 服务解耦 - 队列存在的问题与解决方案
- 消息积压
无论是推送模式还是拉取/轮询模式,都有一个现实问题:消息的订阅者获取和处理消息的速度可能慢于消息的发送者发送消息的速度。对于推送模式而言,可能会由于订阅者的处理速度不够快,导致连接被占用而使得回调函数不能被及时启用,而对于拉取/轮询模式而言就更显然了:线程池中的线程数量不够、处理速度太慢,或者网络速度太慢,都可能会造成消息积压在队列中。
如果有消息积压,只要服务可以处理的流量不是过小,高峰一过,就能清理掉这些积压消息,并正常处理之后接收的消息,但是除此之外,我们依然希望在不改变服务器处理能力的前提下,有技术手段解决该问题。解决方案的思想很简单:先快速将这些消息拉取下来并存储,然后慢慢处理。
该方案在处理消息的业务逻辑之前额外添加了一层,该新组件也是一个线程池,只是它要处理的事情很简单:从队列中获取消息,保存在内存中,等待系统中的另一个线程池获取消息并处理。这样做有效地解决了消息积压的问题,但是它有着自己的问题。一方面,它使得当前系统的扩容变得更复杂、更不直观。
在前几章中,笔者解释过线程池相比普通的请求线程在扩容方面更麻烦的地方,在修改架构之后,系统有两个线程池需要处理,而且这两个线程池还互相通信。它们显然不应该使用同一个线程数量,否则就失去了添加这一层新线程池的意义。而分别确定两个线程池所应该用的线程数量需要考虑的因素有很多。另一方面,也是更大的问题:线程池1将所有消息都获取到了本地,一旦本地服务器出现问题,这些消息就彻底消失了。这一问题在采用上述方案之前是不存在的,因为如果该服务器出现问题,当时它不能处理的消息只会继续待在队列中,等待被其他服务器获取走,但当线程池1已经将消息都保存到了本地服务器内存中,这时候再出现问题,队列是无法获知并再次发送的。这一原因也使得一部分从业人员并不推荐这种方案。
如果不采用这种方案,那么还有什么手段可以提高系统从队列中获取消息的能力呢?答案非常简单:增加新的服务器。但是正如本书从开头就一直强调的那样,架构的设计必须贴合网站的需求和当前的资源情况。增加新的服务器会增加更多的成本,并且在有的情况下(如非高峰时)会造成一定程度的资源浪费,如果读者想通过新增服务器来解决问题,那么需要综合各方面因素进行考虑。 - 消息的可靠传递
队列的本意就是隔离发送者和接收者,一个消息发送出来的时候,发送者理论上不应该知道有多少人在期待接收它,如果中间没有任何保障手段,发送者不知道有多少订阅者成功接收了它,而当一个接收者没有收到任何消息的时候,发送者也不能确定究竟是它没有发送消息,还是中间出了什么问题,导致接收者没有收到消息。因此,这就需要队列提供一套完善的机制,并由整合队列的开发者正确使用,才能保证所有消息被正确、可靠传递。
业内流行的主流队列产品,无论是不是云服务,都有专门的确认功能,前文所说的RabbitMQ,它有一个专门的确认API。同样地,队列云服务也有向队列服务确认并删除消息的API,为了确认消息,客户端需要专门再发一个HTTP请求表示接收成功,也就是说,对于每条消息,事实上客户端发送了两个HTTP 请求,一个表示接收,一个表示确认。所有主流队列在实现上,在消息存储区和消费者之间还有一个专门用于确认的暂存区,队列中的消息在被消费者请求后,会被标记为不可见,或被移动到暂存区,此时它没有被删除,但也不能被新的消费者请求并获取到。直到消费者确认成功之后,消息才会被从暂存区中删除。
云服务则一般会提供一个超时机制,队列会在该时间段内等待客户端回复删除,如果该时间段内客户端没有通知队列删除消息,则队列会将该消息标记为失败。失败之后,无论是哪种队列,都会有重试机制,由开发者自定义。在定义的重试次数之内,队列会将消息重新回收分发出去,如果失败次数超过了配置的可重试次数,则消息会进入另一个队列:死信队列(Deadletterqueue)。该类队列会与常规队列形成一一对应的关系,常规队列中处理失败的消息会被自动转移到该队列。
死信队列的作用主要有两个方面。其一,供开发者手动诊断用。其二,有时候开发者可以为死信队列设计重试或者其他的处理手段,例如,正常逻辑不能处理,但是可以采用特殊逻辑进行一些损坏事务的清理。又如,开发者可以选择将死信队列中的消息重新放回普通队列,尝试再次处理。
接收者如何确保它收到的消息就是队列中的所有消息并且保留了正确的顺序。解决方法就是消息的发送者为消息添加连续的编号,而接收者则在自己的业务逻辑之上添加一层检查逻辑,如果发现编号不连续,则说明检测到了消息丢失。 - 消息重复
某队列中有消息A,消费者成功从队列中获取了该消息,并且执行了一次业务逻辑,然后向队列申请删除该消息。但是,这个删除的 HTTP 请求失败了。此时,消费者既得到了消息,又成功处理了消息,但是队列却认为没有处理成功,因此,通过重试机制,又将消息发了回来。这就是一种“消息重复”的情况。
这里引入一个新的概念:幂等性(Idempotency)。所谓幂等性,是指某个事务或者某段逻辑,执行一次与执行多次造成的后果相同。
解决消息重复的最佳手段就是:确保在消费者中执行的所有业务逻辑,都是幂等的。
确保所有业务逻辑都幂等有两种可能:其一,其本身就是幂等的,但是这种情况太理想化;其二,如果业务逻辑本身不是幂等的,我们需要通过一些手段将业务逻辑改造成幂等的。
- 常见的队列产品和系统
- RabbitMQ
开源软件。其支持的协议众多,包括 AMQP、XMPP、SMTP 等,因此,灵活度非常高,适用多种需求,同时,由于其支持插件,因此,可以通过插件使用 HTTP 协议,与普通的网络应用无缝对接。RabbitMQ 由 Erlang 开发,这是其主要缺陷之一,因为部署服务器上还要安装Erlang,并且 Erlang 本身比较冷门,所以,即使它是开源软件,在开发者有二次开发的需求时,也很难上手进行修改。RabbitMQ支持的客户端语言多种多样,包括Java、Ruby等,并且都非常成熟,因此,尽管其二次开发困难,但对于大多数开发者而言在使用上没有太大问题。RabbitMQ支持消息推送模式和拉取/轮询模式,适应不同的扩容需求。它采用了Master/Slave 模式,Slave 主要用作备份,保障了数据的安全性。RabbitMQ 的主要特色是实现了代理架构(Broker),因此消息队列和客户端之间还有一层逻辑,开发者可以利用此逻辑实现消息的重新排队和负载均衡,但是也正是因为此逻辑,使得队列的效率不够高,延时较长。 - ActiveMQ
ActiveMQ 是由 Apache 开发并出品的开源软件,支持XMPP、AMOP等多种协议,因此适用的需求也非常广泛,但是总的来说应用范围不如RabbitMQ广泛。ActiveMQ 由 Java 开发,支持的客户端语言众多,包括Java、C++、Python、PHP等,因此,它适用于大多数网站开发环境。ActiveMQ支持消息推送模式和拉取/轮询模式,利用ZooKeeper实现了Master/Slave模式,Slave 主要用作备份,保障了数据的安全性。ActiveMQ的性能一直不出众,并且丢失消息的情况时有发生,因此,笔者不推荐优先考虑这款产品。 - RocketMQ
RocketMQ 是阿里巴巴开发的开源软件,由 Java开发的,因此其部署非常容易,而且二次开发也容易。
RocketMQ也支持消息的推送模式和拉取/轮询模式,并且支持数据的有序性,其适合的用例非常广。RocketMQ的主要特色是性能强、吞吐量高,并且支持Master/Slave模式,以及异步复制、同步双写等模式,因此,其不仅性能强,数据的可靠性也极高。 - Kafka
Kafka 是一款由LinkedIn开发并开源的消息队列,现在也属于Apache。它主要是由Java 和Scala 开发的,官方支持 Java 作为客户端语言,同时开源社区已经提供了PHP、Python、C++等几乎所有市面上的主流应用层语言支持。Kafka 使用的协议是自定义的协议,但开源社区提供了封装的HTTP 协议支持,因此,Kafka 也可以和网络应用无缝对接。Kafka只支持消息拉取/轮询模式,它也提供了代理架构的支持,原生支持分布式部署,并可以用于负载均衡,支持消息的有序性。Kafka 的性能也非常强,且稳定性高,因此也是很多网站应用消息队列的优秀选择。 - AWSSQS和SNS
两款云服务产品:AWS SQS和SNS。SQS (Simple Queue Service)是AWS推出的一款完全托管在云上的消息队列服务。而SNS 则是一款消息发布订阅服务。第11章 高可用
CAP 原理
CAP原理认为在分布式系统中,一致性(Consistence,即C)、可用性(Availability即A)和分区容错性(Partition Tolerance,即P)三个设计目标不可能同时达到。在最佳状况下,最多只能达到其中两个目标,在达到其中两个目标的时候,另外一个目标一定会被牺牲。冗余和隔离
冗余和隔离是整个分布式系统高可用设计目标所追求的两个本质。所有高可用的设计方法和实现手段归根结底要么是资源的冗余,要么是对错误的隔离,以这两个标准去理解所有实现高可用性的内容,思路就会非常清晰。隔离是指采取手段将系统的错误部分与没有错误的部分分割,使其不会影响系统的其他部分。
隔离的设计思想主要体现在以下几个方面。
● 服务降级,即网站服务中的一部分服务出现问题之后,退而求其次,继续运行没有问题的部分。
● 限流,即出现异常流量时,将它与正常流量隔离,使其不影响服务器的性能,从而不影响正常流量。
● 与主业务逻辑没有关系的逻辑,例如,日志、数据的收集和处理线程,应该独立于主线程,并且在失败出错之后在后台关闭,而不会影响主线程的逻辑。第12章 异地多活
异地多活方案对业务规模较大的网站可以说是必需品。但异地多活又涉及网络和数据的一致性,以及事务的多地转换和一致性等,对于业务而言是一个难度较高但又意义很大的架构问题。
- 异地多活的基本概念
异地多活指的就是在不同物理地点的多个可以提供同一个网站的服务器或服务器集群。它们的节点之间是互相平等的,并通过定期的同步来保证数据的一致性。
异地多活背后的指导思想,就是“冗余”。只是与一般情况下冗余的备份思想不同,异地多活这个词中的“活”字体现了它与备份的不同。异地多活的每个数据中心,都是与其他数据中心地位平等、可以处理业务逻辑、可以更新和修改数据的后端服务器和集群。
异地多活的作用就是冗余的指导思想:防止在意外变故中,某些服务器集群或数据库受到影响,从而破坏网站拥有者组织的现实业务。因此“异地”的意义也体现出来了:如果这些服务器集群隔得不够远,那么它们就不能达到异地多活的终极目的。
异地多活的应用场景有以下三个。
第一,业务规模要大。
第二,只对核心业务设计异地多活。这是重中之重。
第三,网站用户或用例对网站的响应速度有一定要求。
异地多活的重点在于各个“活”,即服务器之间的协同,而负载均衡的重点在服务器之前的均衡器上。
异地多活按数据中心之间的距离可以从近到远分为同城异地多活、跨城市异地多活和跨地区异地多活三种类型,虽然它们的区别是距离,但量变产生质变,距离变长之后,技术挑战就大相径庭了。
第13章 服务降级
服务降级(Service Degradation)是指在网站服务因技术原因出现重大危机的时候,服务器端通过主动或被动地关闭非核心功能、降低性能、降低用户体验等方式,保证核心业务和核心体验不受影响的技术手段。服务降级是保证网站高可用的重要手段之一,它体现了高可用架构设计中的“隔离”思想。服务降级的思路是通过分割和解耦系统内的组件,以达到系统故障被隔离的目的。需要注意的是,服务降级会降低某些单体组件的可用性并保障核心组件的运作,但是这两者之间没有必然的因果关系。服务降级可以是主动的,也可以是被动的。主动的服务降级会通过牺牲单体组件的方式来达到保障核心组件的目的。被牺牲的单体组件未必仅仅是出现故障的组件。
主动的服务降级有两种情况。
● 一种情况是关闭故障组件的功能。在整体系统的设计中,防止雪崩最快、最有效的方式就是通过隔离错误组件使得服务降级。
● 另一种情况是关闭没有故障的组件的功能。
这种情况往往出现在网站流量有爆发式增长,但网站没有预料到一些全新用例或者用户行为发生改变的时候。
- 单点故障
单点故障(Single Point Failure)是指一个系统中存在一个服务、一个组件、一个功能,甚至一小段代码,其出现故障会导致整个系统宕机,因此我们称其为单点故障。有时候也简称为单点。
注意:单点故障中的单点与它是不是核心业务没有关系!
解决单点故障和拥有服务降级的能力可以说是互为因果,解决单点故障是拥有服务降级能力的前提,而改造架构以获得服务降级的能力的时候,又能解决某些单点故障。
单点故障分为两种:数据的和业务的。
数据单点故障的唯一解决思路就是冗余,在关于读写分离的介绍中我们已经提过,在进行读写分离时我们会创建从数据库,而从数据库就是数据备份的主要手段。
业务单点故障的解决思路主要有以下两个方面。一方面,创建多个业务节点,比如我们之前介绍的异地多活就是一种主要方案,用于应对业务逻辑之外的故障。另一方面,如果某组件的业务逻辑本身有问题,那么设置多个集群或者数据中心是不能解决问题的,此时就需要从业务逻辑方面出发,将该组件改造成有能力从其他组件获取必要信息,或者能够为用户提供一个精简版、简化版的用户体验。 - 微服务与服务拆分
不是所有服务都可以不经任何改造就进行服务降级。服务降级的必要条件之一是所有子组件已经解耦完毕。而微服务(MicroService)其实指的也是和服务很像的软件,只是相比服务,这个名称更加侧重于这类软件的三个方面。第一,它的规模不大,这也是为什么叫“微”服务的原因。第二,它的功能单一,责任明确。第三,使用它不一定要有一个远程调用。
微服务与服务降级的联系在于,在进行服务降级之前需要将一个服务切割、解耦,而所有被解耦出来的组件,都会成为一个微服务。 - 系统分级
系统流程图就是表示系统组件之间互动逻辑的示意图,它对开发者分析系统和进行服务拆分非常重要。分级的目的很明显,就是在需要进行服务降级时,除错误页面以外,知道可以“丢卒保车”哪些微服务。第14章 限流
限流的基本概念
在Web开发中,限流(RateLimiting或Throttling)是指限制客户端以超过某个频率调用自身服务的一种行为。限流与封禁(Block)不同,它的目的既不是针对某个特定的用户或客户端,也不是针对某种特定的行为的,而是对自身服务被调用频率的一个总体保护。
注意:限流系统绝不是开发者不为网站好好扩容的理由!限流系统和系统扩容是两码事。系统扩容是网站开发者为应对流量增长和流量高峰必须做的事情,而限流系统仅仅应该被用作对计划之外的流量提供系统保护。
限流有很多种标准,笔者按照它们限制流量的类型,将它们分为以下四大类。● 基于时间窗口内的请求数。● 基于瞬时的请求数。● 基于当时连接数。● 基于服务器或系统指标。
限流算法
令牌桶算法与漏桶算法
令牌桶(Token Bucket)算法是指设计一个容器(即桶),由某个组件持续运行往该容器中添加令牌(Token),令牌可以是简单的数字、字符或组合,也可以仅仅是一个计数,然后每个请求进入系统时,需要从桶中领取一个令牌,所有请求都必须有令牌才能进入后端系统。当令牌桶空时,拒绝请求;当令牌桶满时,不再往其中添加新的令牌。
凡是使用令牌桶算法的限流系统,我们都会注意到它在配置时要求具备两个参数:● 平均阈值(rate 或average)。● 高峰阈值(burst或peak)。
令牌桶算法的限流系统不容易计算出它支持的最大流量,因为它能实时支持的最大流量取决于当时整个时间段内的流量变化情况,即令牌存量,而不是仅仅取决于一个瞬时的令牌存量。
令牌桶算法有一个同一思想、方向相反的变种,被称为漏桶(LeakyBucket)算法,它是令牌桶的一种改进,在商业中应用非常广泛。漏桶算法的基本思想是将请求看作水流,用一个底下有洞的桶盛装,底下的洞漏出水的速率是恒定的,所有请求进入系统的时候都会先进入这个桶,并慢慢由桶流出交给后台服务。桶有一个固定大小,当水流量超过这个大小的时候,多余的请求都会被丢弃。
同样地,漏桶算法的配置也需要具备两个参数:平均阈值和高峰阈值。只是平均阈值这时候用来表示漏出的请求数量,高峰阈值则用来表示桶中可以存放的请求数量。注意:漏桶算法和缓冲的限流思想不是一回事!
在漏桶算法中,存在桶中的请求会以恒定的速率被漏出给后端服务器,而在缓冲思想中,放在缓冲区域的请求只有等到后端服务器空闲下来了,才会被发送出去。在漏桶算法中,存放在桶中的请求是原本就应该被系统处理的,是系统对外界宣称的预期,不应该被丢失,而在缓冲思想中,放在缓冲区域的请求仅仅是对意外状况的尽量优化,并没有任何强制要求对这些请求进行处理。
令牌桶算法以固定速率补充可以转发的请求数量(令牌),而漏桶算法以固定速率转发请求。令牌桶算法限制的是预算数,漏桶算法限制的是实际请求数。
漏桶算法略优于令牌桶算法,因为漏桶算法对流量控制得更平滑,而令牌桶算法在设置的数值范围内,会将流量波动忠实地转嫁到后端服务器上。漏桶算法在 Nginx 和分布式的限流系统,如 Redis 中都有广泛应用,是目前业界十分流行的算法之一。
时间窗口算法
时间窗口算法是比较简单、基础的限流算法,由于它比较简单,所以不适合大型、流量波动大或者有更精细的流量控制需求的网站。
时间窗口算法根据确定时间窗口的方式,可以分为以下两种。● 固定时间窗口算法。● 滑动时间窗口算法。
固定时间窗口算法最简单,这种算法的思路为在固定的时间段内限定一个请求阈值,没有达到让请求通过,达到则拒绝请求。固定时间窗口算法的思路固然简单,但是它的逻辑是有问题的,它不适合流量波动大和有精细控制流量需求的网站。
因此,人们改进了固定窗口的算法,将其改为检查任何一个时间段都不超过请求数量阈值的时间窗口算法:滑动时间窗口算法。
队列法
队列法与漏桶算法很类似,都是将请求放入一个区域内,然后后端服务器从中提取请求,但是队列法采用的是完全独立的外部系统,而不依附于限流系统。
队列法最大的缺陷就是服务器端不能直接与客户端沟通,因此只适用于客户端令后端服务器执行任务且不要求响应的用例,所有客户端需要有实质响应的服务都不能使用。
服务限流需要考虑的问题
- 性能和准确性
- 如何进一步提升
第15章 下游错误处理
下游(Downstream)指的是如果一个服务调用了其他组件或服务,那么这些被调用者就称为下游。软件除了本身存在的问题,作为一个网站应用的软件,它能否正常工作还取决于它使用的组件和远程调用的其他服务。
一个网站规模越大,它使用的组件和下游服务越多,出错的可能性也越大。如何正确、快捷地处理这些错误,在其他组件的错误中尽最大努力恢复自身的业务逻辑,保持自己不受影响,是一门很重要的学问。超时机制
超时机制是指一个微服务设定了一个时间段,一般在毫秒级或秒级,然后对另一个微服务发起一个远程调用,发出调用后开始计时,如果在设定的时间段内没有收到对面响应,则将该请求视为超时并失败,放弃该连接。超时机制是调用者对自己的一种保护机制。
超时机制几乎在所有跨组件的调用之间都存在,并且几乎所有业内流行的框架和客户端库都支持超时的设置。
设置超时机制需要注意以下两个问题。
● 超时阈值的设置非常重要,需要开发者对数据长期的收集和压力测试的配合。
● 绝大多数情况下超时会被当作错误之一。超时导致的错误在微服务之间一般都会提倡重试,而重试则有一些问题需要开发者注意。
错误分类
一般来说,微服务之间的远程调用都是HTTP 调用,我们可以从HTTP 的状态码入手,对错误进行初步分类。微服务之间的调用一般会收到3种结果的状态码:2xx、4xx和5xx。其中,2xx的状态码都是成功,4xx是指客户端错误,5xx则是指服务器端错误,因此,我们需要分类的就是4xx和5xx的响应。
4xx中我们需要关注的常见状态码有以下几个:
● 400 Bad Request,即请求有问题,一般指的是请求的语义出错,或者参数出错。
● 401 Unauthorized,指的是需要用户验证。
● 403 Forbidden,指的是出于某种原因,系统有请求使用的业务或者资源,但是拒绝让客户端使用。
● 404 Not Found,即用户请求的资源在服务器上没有。
● 413 Payload Too Large,即请求过大,有的时候服务器可以返回一个Retry-After响应头,以表明该请求可以重试。
● 429 Too Many Requests,即服务器收到的请求太多,拒绝处理当前请求。
5xx中我们需要关注的常见状态码有以下几个:
● 500 Internal Server Error,即服务器遇到了未知错误。
● 502 Bad Gateway,即服务器作为代理无法给出正确响应。
● 503 Service Unavailable,即服务器暂时没有准备好处理求。
- 是否要重试,取决于我们是否对服务器下一次响应返回正确结果有信心。
- 是否要通过度量数据或者日志记录下来,取决于这个状态码是否表明调用者有严重的问题。
- 我们自己的业务逻辑能否处理这样的问题,取决于服务器端的响应是否给予客户端明确指示,可以通过修改请求来做到重试成功。
因此,根据这些状态码,笔者提出以下建议。
● 可以重试的状态码:500、502、503、429。
● 需要记录的状态码:400、403、404。
● 可以尝试处理的状态码:401、413。
早期失败
早期失败(Fail Early)或者快速失败(Fail Fast),指的是一种明知当前发出的请求无望得到正确响应时,尽快结束当前进程的思想。早期失败的作用是节省服务资源、降低无谓的消耗,最重要的是防止“雪崩”。
笔者在这里重申该思想是为了提醒读者在进行错误处理时,要务必记住:能早期失败的地方要早期失败,其次考虑重试或等待。
默认值的作用
当将一个微服务从总架构中移除时,我们不希望它影响主要业务逻辑。同样地,当错误出现的时候,我们可以选择显式地将它暴露出来,并在程序中将对应的异常向上抛,但也可以选择静悄悄地吞没它,然后返回一个默认值。
错误重试
Web服务通信之间一个主要的特点就是无论系统多健壮,总会有通信出现错误的时候,而且很多时候不一定是永久错误。因此,当对下游的调用出现错误时,一个最常见的手段是当场重试。
因为500、502、503状态码都代表服务器端遇到了问题,一般来说,错误重试都不会无限制重试,而是重试3~5次的有限次,因此我们可以重试一下这些状态码,如果是暂时的问题,500、502 和 503 都会迅速恢复,而那些短期内不能恢复的问题,由于我们不会无限重试,所以也不会造成很大破坏。
429状态码与其他状态码相比需要一个条件:更长的重试等待时间。因为如果因限流而被拒绝服务,短时间内的连续重试多半是不会有结果的,服务器的压力不会因为重试而消失,而是会随着时间推移,高峰逐渐消失,才会停止限流。因此,429状态码的重试需要比其他状态码的重试等待更长时间。
错误重试还有一个很重要的条件就是API的幂等性。在设置超时的重试之前,一定要确保API是幂等的。API是否幂等,可以通过API的描述得知。假如该API是一个RESTAPI,标准RESTAPI的GET、PUT、DELETE都是幂等的,而 POST 则不是。
因此,如果遇到的是超时请求,而且对方 API 不是幂等的,或者无法判断是不是幂等的,则不要重试。
错误重试带来的问题主要有以下3个方面。
其一,就是API的幂等性
其二,重试也会造成服务雪崩。
其三,错误重试除了尝试修复错误,还用来试图修复一个特殊情况,就是限流,即429 响应。这种响应不是一般的错误,处理时需要格外小心。
第16章 测试
从功能测试的角度,我们一般按测试规模从小到大、测试涉及组件从小到大、测试者所处位置从内到外,将测试分为4种类型。● 单元测试。● 功能测试。● 集成测试或整合测试。● 端到端测试。
单元测试(UnitTest)是指在代码层面上测试业务逻辑能否在很小的范围内工作。单元测试的代码一般与业务逻辑代码放在同一个包中,并且与代码逻辑的文件或类形成一一对应的关系,同时,之所以叫“单元”测试,是因为其测试目标是验证单个的文件和类是否能正常工作,测试范围也仅仅局限于一个文件或一个类中。单元测试是所有测试类型中涉及范围最小的测试。
从测试的理念来看,软件测试可以被分为黑盒测试(Black Box Testing)和白盒测试(White Box Testing)。它们的主要区别是测试者是否了解软件的内部实现结构。黑盒测试或称为黑箱测试,是指测试者在完全不了解所测试软件的内部实现的情况下进行的测试。
单元测试中一般有两个核心因素:mock和stub。mock 代表被测试的代码中使用的外部对象的模拟,它一般用于表示被测试的类使用的数据对象,可以由测试框架快速创建。stub 代表某个功能类或接口的模拟,它一般用于表示被测试的类调用的组件接口,并按照用户的要求返回一个结果。
第17章 上线准备
发布流程
在大型网站的发布中,我们可以通过引入以下几个方案来使流程规范化。首先,对于网站或软件的发布流程而言,要将所有步骤都作为文档记录。,在自动化之前,需要进行改造以满足如下要求。
● 尽可能减少工具的切换和环境的切换,例如,Java Spring、Node.js等都有一整套从编译到部署的工具,如果一开始出于历史原因使用了多个来源的工具,可以考虑逐渐整合到一个平台上。
● 所有过程必须留存文档,并由所有开发和维护人员掌握,减少对个人知识的依赖。
● 所有手动过程,包括手动启动脚本、编译过程等,都由一人监督、一人运行,减少手动操作中的人为失误。
其次,任何功能在正式发布之前,或最终部署到生产环境之前,都需要编写足以识别部署内容的报告,并由相关团队的负责人审批通过。
报告内容应该包括如下几项。
● 功能准备发布或准备部署的确切时间。
● 功能描述和用户预期。。当前部署或发布包含的所有修改列表。
● 出错时的最坏情况。
● 出错时的恢复流程。
● 回滚当前部署的后果。
这一流程也有其弊端,可能会增长组织内官僚主义,降低团队运作的灵活度,因此,实际操作时也可以退而求其次,选择一些最为重要的功能发布或者风险高到一定程度的部署进行执行。所有功能的发布和部署都一定要留存记录,出现问题的时候才会发现这些记录多么有用。当生产环境中出现问题时,只需要与最近的部署记录和时间戳进行对照,就会知道是哪个部署的问题。最后,所有技术资源必须属于公共的组织账户,并且有严格的权限管理。
自动化的流程
现在市面上已经有很多流行的成熟框架,例如,Jenkins、circleci、TeamCity、GitLab,以及AWS和Azure提供的云上的 CDpipeline 工具等。这些工具都非常易于配置,多数都可以自由地与常见的 Git 代码库相集成。
监控
生产环境度量
生产环境度量(Production Metrics)又称为生产环境数据,是指网站的所有服务资源在生产环境中产生的数据。
这些数据之所以不叫作数据(Data),而叫作度量(Metrics),是因为数据一般特指来自用户的数据或网站在业务方面的数据,而度量特指网站运营时网站资源本身的数据,例如,服务器单机每日或每小时的最高TPS、中位数TPS、API的错误率和延时、被调用次数,以及服务器单机的内存使用率、CPU 使用率等。
度量既是衡量系统运营的关键,也是衡量系统是否成功执行了商业需求的关键。
因此,一方面,在不考虑度量数据消耗的资源和成本的前提下,笔者建议只要能够记录度量数据就记录,数据多总比数据少要好;另一方面,我们此处讨论的度量主要侧重于监督网站正常运行的度量,因此,在这些海量的度量数据中,我们还要挑选合理的度量。
一般来说,主要关注两个方面的度量:可以用于衡量系统是否能够承担当前流量的度量;可以用于衡量系统是否拥有正常功能的度量。
对于衡量系统是否能够承担当前流量的度量而言,我们一般从单机服务器的关键度量入手,包括以下几项:● 单机服务器的CPU 使用率。● 单机服务器的内存使用率。● 单机服务器的硬盘使用率。
除此之外,服务器还会有很多其他的度量,但是一般实践经验告诉我们,以上的三个度量都是服务器承担流量的瓶颈,当服务器承担了过大的流量时,这些度量会比其他的度量先出问题。
对于系统是否拥有正常功能的度量而言,我们一般关注以下内容:● 关键API的错误率。● 关键 API 的延时。其中,延时方面我们会同时关注统计数据的P50和P90,因为前者可以代表一般情况,后者可以代表极端情况。
- 监控与警报
在确定并记录了网站服务所应该关注的度量之后,就应该为之设置监控和警报系统了。压力测试
压力测试或称为负载测试(Load Test)是指按照网站设计所承担的最大流量对网站执行短时间的测试,观察网站服务器和服务的关键度量,然后得到当前网站是否能承担该流量、如果不能是否能通过资源调整来达到目的,以及瓶颈在哪里的结论的过程。
压力测试可以找到的问题包括但不限于以下几种。
● 隐蔽的内存泄漏问题。
● 某个关键下游服务或组件其实十分脆弱,不能承担它所声称的能承担的流量。
● 日志配置的问题导致服务器硬盘被占满。
● 某个忘记设置超时的远程调用会长时间占用服务器连接。
- 如何进行压力测试
压力测试在准备上,需要注意两个方面。
● 第一,和功能测试一样,压力测试也要尽量模拟实际用户的数据。这一点和功能测试一样,看似容易操作且容易理解,但实际操作中问题很多。有时候开发者会误以为压力测试的执行方式就是大量地调用API,从而随意编写一些请求数据,甚至使用空的请求数据。
当复杂请求数据会加大服务器的 PU 和内存使用的时候,使用空或假的请求数据将不能获得有效度量数据,所测的也只不过是服务框架转发请求的能力而已。
● 第二,压力测试的流量模式必须能模拟实际用户的数据。这一点经常被开发者忽略。
如果没有任何历史数据可参考,笔者有两个建议供读者参考。
● 要求产品和公司的数据科学家介入,帮助分析和预测用户行为,给出一个合理的估计数据。
● 先进行简单的压力测试,然后发布之后持续监控一段时间(如两周),根据两周后的流量数据,再次执行压力测试。该方法比较简单,更准确,消耗资源也少,但是前提是不能期望用户量在开始的一段时间内有爆发式增长。
压力测试需要按照以下步骤执行。
(1)制订测试计划。测试计划需要包括以下几项。
● 使用什么测试用例。
● 使用什么流量比例。
● 合理的测试时长,是十分钟、半小时、一小时还是十二小时?
● 需要监控的关键度量。
(2)准备专门的压力测试代码。
(3)按阶段提升流量,缓步“开闸放水”。
不能直接一步到位执行100%的原因有以下几点。
● 压力测试不仅仅是为了看该服务能否处理目标的TPS,也是为了观察服务整体在流量增长时的表现。
● 网站服务不一定会在TPS为100%的时候才出现问题,有时候可能到了90%或者80%的时候就出现了问题,如果一步到位,即使出现了问题,我们也不知道问题出在哪个阶段,系统究竟是离目标 TPS很远,还是只需要进行一些微调就能达到目标了。
● 有的时候某些服务需要一些“预热”,在承担了中等流量之后,它可以处理短时间的大流量,但是直接让它处理大流量则会出现问题。这种情况虽然少见,但是也存在。
(4)撰写测试报告。和功能测试一样,压力测试也应该有一份详尽的测试报告。
灰度发布
灰度发布(A/B测试)是指在一个新功能发布时,让一部分用户使用旧功能,一部分用户使用新功能的测试手段。
与监控系统和度量系统相同,如非极为必要,笔者建议不用自己开发灰度发布系统。无论是阿里云、腾讯云、Google Analytics、AppAdhoc 还是Optimizely,都有非常成熟的业界灰度发布工具,从Web服务器端到客户端应有尽有。
所有涉及分布式事务和容易造成永久后果的事务的灰度发布都需慎之又慎,具体情况具体分析,确保在灰度发布调整时没有用户被遗漏在中间某个两头不沾的境地中。
维护人员
应急预案
所有网站都应该有一个应急预案文档,记录以下三个方面的内容:● 与网站相关的所有基本信息。● 最常见的问题及解决办法。● 可能出现的最严重的问题及解决步骤。
网站的应急预案文档应该包含但不仅限于以下几个方面。
● 与网站相关的基本信息如下。
》网站代码所在的包或者库。
》网站部署的步骤或控制台。
》网站资源的链接,例如,数据库、队列、缓存的所在位置等。
》网站扩容的相关信息: 当前有多少台服务器,使用了什么负载均衡等;如何修改服务器数量、负载均衡策略等。
》网站最近一次执行压力测试的过程和结论。
》网站调用的所有不属于本团队的组件和下游服务的列表,以及这些服务的拥有者的联系方式和介入手段。
● 常见问题及解决办法如下。
》出现用户流量偏大,限流系统拒绝了部分请求的情况,而系统可以忍受适度的流量增加,此时如何修改限流设置。
》按照所有警报及对应的问题的可能性列举,既然安排了自动警报,那么这些警报必须对应有可能的问题症状描述,及其诊断的发起点。
》最近一个月常见的用户报告的问题、诊断过程、诊断结果及修复方案。
》最近正在运行的所有灰度发布列表,每个灰度发布可能会出现的问题的描述,以及如何关闭这些灰度发布的步骤。
● 可能出现的最严重的问题及解决步骤。
》数据库表被破坏的恢复方法、备份步骤。
》队列出现严重消息积压的解决办法。
》缓存雪崩时重置或暂时移除的步骤。
》错误率极高(如超过50%)时: 系统重启的步骤; 部署回滚的步骤;服务降级的步骤。
该应急预案文档应该处于一种时刻迭代(IterativelyUpdated)的状态,每当团队完成一个功能时,该功能的负责人应该负责将该功能涉及的资源变化、常见问题和可能出现的最严重的问题及解决办法一一记录在该文档中。
- 人工监控
维护人员应根据所维护的服务的业务关键级别,决定人工监控的严密级别。人工监控应与警报系统对接,可以根据公司采用的联系方式来使警报系统通知维护人员。