把书读薄之『从0开始学架构』
0、引语
小到某个功能的开发方案,大到整个业务的系统设计,都可以看到架构设计的影子,但是架构设计的目的到底是什么?『从0开始学架构』的作者给我们的解答是:架构设计的主要目的是为了解决软件系统复杂度带来的问题。
这里其实有两个重点:一是问题,二是解决。
首先得知道我们要解决的问题在哪里?面前的系统到底有什么复杂度导致的问题?只有知道了问题才能选择解法,不能拿着锤子找钉子。
当知道了当前面临的问题后,就要利用前人的智慧和自身的经验,设计出合理的架构方案来解决问题。
因此对整本书的内容,分成以下四节,第一节主要描述我们通常面临的系统复杂度及问题在哪,后面三节则是对常见的三个问题下的常用解法进行阐述。
1、基本概念与设计方法
在讲解架构思想之前,先统一介绍一下基本概念的含义,避免每个人对系统、框架、架构这些名词的理解不一致导致的误解。下面是作者对每个名词的定义,其作用域仅限本文范畴,不用纠结其在其他上下文中的意义。
- 系统:系统泛指由一群有关联的个体组成,根据某种规则运作,能完成个别元件不能单独完成的工作的群体。
- 子系统:子系统也是由一群有关联的个体所组成的系统,多半会是更大系统中的一部分。
- 模块:从业务逻辑的角度来拆分系统后,得到的单元就是“模块”。划分模块的主要目的是职责分离。
- 组件:从物理部署的角度来拆分系统后,得到的单元就是“组件”。划分组件的主要目的是单元复用。
- 框架:是一整套开发规范,是提供基础功能的产品。
- 架构:关注的是结构,是某一套开发规范下的具体落地方案,包括各个模块之间的组合关系以及它们协同起来完成功能的运作规则。
由以上定义可见,所谓架构,是为了解决软件系统的某个复杂度带来的具体问题,将模块和组件以某种方式有机组合,基于某个具体的框架实现后的一种落地方案。
而讨论架构时,往往只讨论到系统与子系统这个顶层的架构。
可见,要进行架构选型,首先应该知道自己要解决的业务和系统复杂点在哪里,是作为秒杀系统有瞬间高并发,还是作为金融科技有极高的数据一致性和可用性要求等。
一般来说,系统的复杂度来源有以下几个方面:
高性能:
如果业务的访问频率或实时性要求较高,则会对系统提出高性能的要求。
如果是单机系统,需要利用多进程、多线程技术。
如果是集群系统,则还涉及任务拆分、分配与调度,多机器状态管理,机器间通信,当单机性能达到瓶颈后,即使继续加机器也无法继续提升性能,还是要针对单个子任务进行性能提升。
高可用:
如果业务的可用性要求较高,也会带来高可用方面的复杂度。高可用又分为计算高可用和存储高可用。
针对计算高可用,可以采用主备(冷备、温备、热备)、多主的方式来冗余计算能力,但会增加成本、可维护性方面的复杂度。
针对存储高可用,同样是增加机器来冗余,但这也会带来多机器导致的数据不一致问题,如遇到延迟、中断、故障等情况。难点在于怎么减少数据不一致对业务的影响。
既然主要解决思路是增加机器来做冗余,那么就涉及到了状态决策的问题。即如果判断当前主机的状态是正常还是异常,以及异常了要如何采取行动(比如切换哪台做主机)。
对主机状态的判断,多采用机器信息采集或请求响应情况分析等手段,但又会产生采集信息这一条通信链路本身是否正常的问题,下文会具体展开讨论。事实上,状态决策本质上不可能做到完全正确。
而对于决策方式,有以下几种方式:
- 独裁式:存在一个独立的决策主体来收集信息并决定主机,这样的策略不会混乱,但这个主体本身存在单点问题。
- 协商式:两台备机通过事先指定的规则来协商决策出主机,规则虽然简单方便,但是如果两台备机之间的协商链路中断了,决策起来就会很困难,比如有网络延迟且机器未故障、网络中断且机器未故障、网络中断其机器已故障,多种情况需要处理。
- 民主式:如果有多台备机,可以使用选举算法来投票出主机,比如Paxos就是一种选举算法,这种算法大多数都采取多数取胜的策略,算法本身较为复杂,且如果像协商式一样出现连接中断,就会脑裂,不同部分会各自决策出不同结果,需要规避。
可扩展性:
众所周知在互联网行业只有变化才是永远不变的,而开发一个系统基本都不是一蹴而就的,那应该如何为系统的未来可能性进行设计来保持可扩展性呢?
这里首先要明确的一个观点就是,在做系统设计时,既不可能完全不考虑可扩展性,也不可能每个设计点都考虑可扩展性,前者很明显,后者则是为了避免舍本逐末,为了扩展而扩展,实际上可能会为不存在的预测花费过多的精力。
那么怎么考虑系统的未来可能性从而做出相应的可扩展性设计呢?这里作者给出了一个方法:只预测两年内可能的变化,不要试图预测五年乃至十年的变化。因为对于变化快的行业来说,预测两年已经足够远了,再多就可能计划赶不上变化。而对变化慢的行业,则预测的意义更是不大。
要应对变化,主要是将变与不变分隔开来。
这里可以针对业务,提炼变化层和稳定层,通过变化层将变化隔离。比如通过一个DAO服务来对接各种变化的存储载体,但是上层稳定的逻辑不用知晓当前采用何种存储,只需按照固定的接口访问DAO即可获取数据。
也可以将一些实现细节剥离开来,提炼出抽象层,仅在实现层去封装变化。比如面对运营上经常变化的业务规则,可以提炼出一个规则引擎来实现核心的抽象逻辑,而具体的规则实现则可以按需增加。
如果是面对一个旧系统的维护,接到了新的重复性需求,而旧系统并不支持较好的可扩展性,这时是否需要花费时间精力去重构呢?作者也提出了《重构》一书中提到的原则:事不过三,三则重构。
简而言之,不要一开始就考虑复杂的做法去满足可扩展性,而是等到第三次遇到类似的实现时再来重构,重构的时候采取上述说的隔离或者封装的方案。这一原则对
这一原则对新系统开发也是适用的。总而言之就是,不要为难以预测的未来去过度设计,为明确的未来保留适量的可扩展性即可。
低成本:
上面说的高性能、高可用都需要增加机器,带来的是成本的增加,而很多时候研发的预算是有限的。换句话说,低成本往往并不是架构设计的首要目标,而是设计架构时的约束限制。
那如何在有限的成本下满足复杂性要求呢?往往只有“创新”才能达到低成本的目标。举几个例子:
- NoSQL的出现是为解决关系型数据库应对高并发的问题。
- 全文搜索引擎的出现是为解决数据库like搜索效率的问题。
- Hadoop的出现是为解决文件系统无法应对海量数据存储与计算的问题。
- Facebook的HipHop PHP和HHVM的出现是为解决PHP运行低效问题。
- 新浪微博引入SSD Cache做L2缓存是为解决Redis高成本、容量小、穿透DB的问题。
- Linkedin引入Kafka是为解决海量事件问题。
上述案例都是为了在不显著增加成本的前提下,实现系统的目标。
这里还要说明的是,创造新技术的复杂度本身就是很高的,因此一般中小公司基本都是靠引入现有的成熟新技术来达到低成本的目标;而大公司才更有可能自己去创造新的技术来达到低成本的目标,因为大公司才有足够的资源、技术和时间去创造新技术。
安全:
安全是一个研发人员很熟悉的目标,从整体来说,安全包含两方面:功能安全和架构安全。
功能安全是为了“防小偷”,即避免系统因安全漏洞而被窃取数据,如SQL注入。常见的安全漏洞已经有很多框架支持,所以更建议利用现有框架的安全能力,来避免重复开发,也避免因自身考虑不够全面而遗漏。在此基础上,仍需持续攻防来完善自身的安全。
架构安全是为了“防强盗“,即避免系统被暴力攻击导致系统故障,比如DDOS攻击。这里一方面只能通过防火墙集运营商或云服务商的大带宽和流量清洗的能力进行防范,另一方面也需要做好攻击发现与干预、恢复的能力。
规模:
架构师在宣讲时往往会先说自己任职和设计过的大型公司的架构,这是因为当系统的规模达到一定程度后,复杂度会发生质的变化,即所谓量变引起质变。
这个量,体现在访问量、功能数量及数据量上。
访问量映射到对高性能的要求。功能数量需要视具体业务会带来不同的复杂度。而数据量带来的收集、加工、存储、分析方面的挑战,现有的方案基本都是给予Google的三篇大数据论文的理论:
- Google File System 是大数据文件存储的技术理论
- Google Bigtable 是列式数据存储的技术理论
- Google MapReduce 是大数据运算的技术理论
经过上面的分析可以看到,复杂度来源很多,想要一一应对,似乎会得到一个复杂无比的架构,但对于架构设计来说,其实刚开始设计时越简单越好,只要能解决问题,就可以从简单开始再慢慢去演化,对应的是下面三条原则:
- 合适原则:不需要一开始就挑选业界领先的架构,它也许优秀,但可能不那么适合自己,比如有很多目前用不到的能力或者大大超出诉求从而增加很多成本。其实更需要考虑的是合理地将资源整合在一起发挥出最大功效,并能够快速落地。
- 简单原则:有时候为了显示出自身的能力,往往会在一开始就将系统设计得非常复杂,复杂可能代表着先进,但更可能代表着“问题”,组件越多,就越可能出故障,越可能影响关联着的组件,定位问题也更加困难。其实只要能够解决诉求即可。
- 演化原则:不要妄想一步到位,没有人可以准确预测未来所有发展,软件不像建筑,变化才是主题。架构的设计应该先满足业务需求,适当的预留扩展性,然后在未来的业务发展中再不断地迭代,保留有限的设计,修复缺陷,改正错误,去除无用部分。这也是重构、重写的价值所在。
即使是QQ、淘宝这种如今已经非常复杂的系统,刚开始时也只是一个简单的系统,甚至淘宝都是直接买来的系统,随着业务发展也只是先加服务器、引入一些组件解决性能问题,直到达到瓶颈才去重构重写,重新在新的复杂度要求下设计新的架构。
明确了设计原则后,当面对一个具体的业务,可以按照如下步骤进行架构设计:
- 识别复杂度:无论是新设计一个系统还是接手一个混乱的系统,第一步都是先将主要的复杂度问题列出来,然后根据业务、技术、团队等综合情况进行排序,优先解决当前面临的最主要的复杂度问题。复杂度的主要来源上文已经说过,可以按照经验或者排查法进行分析。
- 方案对比:先看看业界是否有类似的业务,了解他们是怎么解决问题的,然后提出3~5个备选方案,不要只考虑做一个最优秀的方案,一个人的认知范围常常是有限的,逼自己多思考几个方案可以有效规避因为思维狭隘导致的局限性,当然也不要过多,不用给出非常详细的方案,太消耗精力。备选方案的差异要比较明显,才有扩宽思路和对比的价值。
- 设计详细方案:当多个方案对比得出最终选择后,就可以对目标方案进行详细的设计,关键细节需要比较深入,如果方案本身很复杂,也可以采取分步骤、分阶段、分系统的实现方式来降低实现复杂度。当方案非常庞大的时候,可以汇集一个团队的智慧和经验来共同设计,防止因架构师的思维盲区导致问题。
2、高性能架构模式
2.1、存储高性能
互联网业务大多是数据密集型的业务,其对性能的压力也常常来自于海量用户对数据的高频读写压力上,因此解决高性能问题,首先要解决数据读写的存储高性能问题。
读写分离:
在大多数业务中,用户查询和修改数据的频率是不同的,甚至是差别很大的,大部分情况下都是读多写少的,因此可以将对数据的读和写操作分开对待,对压力更大的读操作提供额外的机器分担压力,这就是读写分离。
读写分离的基本实现是搭建数据库的主从集群,根据需要提供一主一从或一主多从。
注意是主从不是主备,从和备的差别在于从机是要干活的。
通常在读多写少的情况下,主机负责读写操作,从机只负责读操作,负责帮主机分担读操作的压力。而数据会通过复制机制(如全同步、半同步、异步)同步到从机,每台服务器都有所有业务数据。
既然有数据的同步,就一定存在复制延迟导致的从机数据不一致问题,针对这个问题有几种常见的解法,如:
- 写操作后同一用户一段时间内的读操作都发给主机,避免数据还没同步到从机,但这个逻辑容易遗漏。
- 读从机失败后再读一次主机,该方法只能解决新数据未同步的问题,无法解决旧数据修改的问题(不会读取失败),且二次读取主机会给主机带来负担,容易被针对性攻击。
- 关键读写操作全部走主机,从机仅负责非关键链路的读,该方法是基于保障关键业务的思路。
除了数据同步的问题之外,只要涉及主从机同时支持业务访问的,就一定需要制定请求分配的机制。上面说的几个问题解法也涉及了一些分配机制的细节。具体到分配机制的实现来说,有两种思路:
- 程序代码封装:实现简单,可对业务定制化,但每个语言都要自己实现一次,且很难做到同步修改,因此适合小团队。
- 中间件封装:独立出一套系统管理读写的分配,对业务透明,兼容SQL协议,业务服务器就无需做额外修改适配。需要支持多语言、完整的SQL语法,涉及很多细节,容易出BUG,且本身是个单点,需要特别保障性能和可用性,因此适合大公司。
分库分表:
除了高频访问的压力,当数据量大了以后,也会带来数据库存储方面的压力。此时就需要考虑分库分表的问题。分库分表既可以缓解访问的压力,也可以分散存储的压力。
先说分库,所谓分库,就是指业务按照功能、模块、领域等不同,将数据分散存储到不同的数据库实例中。
比如原本是一个MySQL数据库实例,在库中按照不同业务建了多张表,大体可以归类为A、B两个领域的数据。现在新建一个库,将原库中A领域的数据迁移到新的库中存储,还是按需建表,而B领域的数据继续留在原库中。
分库一方面可以缓解访问和存储的压力,另一方面也可以增加抗风险能力,当一个库出问题后,另一个库中的数据并不会受到影响,而且还能分开管理权限。
但分库也会带来一些问题,原本同一个库中的不同表可以方便地进行联表查询,分库后则会变得很复杂。由于数据在不同的库中,当要操作两个库中的数据时,无法使用事务操作,一致性也变得更难以保障。而且当增加备库来保障可用性的时候,成本是成倍增加的。
基于以上问题,初创的业务并不建议在一开始就做这种拆分,会增加很多开发时的成本和复杂度,拖慢业务的节奏。
再说分表,所谓分表,就是将原本存储在一张表里的数据,按照不同的维度,拆分成多张表来存储。
按照诉求与业务的特性不同,可以采用垂直分表或水平分表的方式。
垂直分表相当于垂直地给原表切了一刀,把不同的字段拆分到不同的子表中,这样拆分后,原本访问一张表可以获取的所有字段,现在则需要访问不同的表获取。
垂直分表适合将表中某些不常用又占了大量空间的列(字段)拆分出去,可以提升访问常用字段的性能。
但相应的,当真的需要的字段处于不同表中时,或者要新增记录存储所有字段数据时,要操作的表变多了。
水平分表相当于横着给原表切了一刀,那么原表中的记录会被分散存储到不同的子表中,但是每张子表的字段都是全部字段。
水平分表适合表的量级很大以至影响访问性能的场景,何时该拆分并没有绝对的指标,一般记录数超过千万时就需要警觉了。
不同于垂直分表依然能访问到所有记录,水平分表后无法再在一张表中访问所有数据了,因此很多查询操作会受到影响,比如join操作就需要多次查询后合并结果,count操作也需要计算多表的结果后相加,如果经常用到count的总数,可以额外维护一个总数表去更新,但也会带来数据一致性的问题。
值得特别提出的是范围查询,原本的一张表可以通过范围查询到的数据,分表后也需要多次查询后合并数据,如果是业务经常用到的范围查询,那建议干脆就按照这种方式来分表,这也是分表的路由方式之一:范围路由。
所谓路由方式是指:分表后当新插入记录时,如何判断该往哪张表插入。常用的插入方式有以下三种:
- 范围路由:按照时间范围、ID范围或者其他业务常用范围字段路由。这种方式在扩充新的表时比较方便,直接加表给新范围的数据插入即可,但是数量和冷热分布可能是不均匀的。
- Hash路由:根据Hash运算来路由新记录插入的表,这种方式需要提前就规划好分多少张表,才能决定Hash运算方式,但表数量其实很难预估,导致未来需要扩充新表时很麻烦,但数据在不同表中的分布是比较均匀的。
- 配置路由:新增一个路由表来记录数据id和表id的映射,按照自定义的方式随时修改映射规则,设计简单,扩充新表也很方便,但每次操作表都需要额外操作一次路由表,其本身也成为了单点瓶颈。
无论是垂直分表还是水平分表,单表切分为多表后,新的表即使在同一个数据库服务器中,也可能带来可观的性能提升,如果性能能够满足业务要求,可以不拆分到多台数据库服务器,毕竟分库也会引入很多复杂性的问题;如果单表拆分为多表后,单台服务器依然无法满足性能要求,那就不得不再次进行业务分库的设计了。
NoSQL数据库:
上面发分库分表讨论的都是关系型数据库的优化方案,但关系型数据库也有其无法规避的缺点,比如无法直接存储某种结构化的数据、扩展表结构时会锁表影响线上性能、大数据场景下I/O较高、全文搜索的功能比较弱等。
基于这些缺点,也有很多新的数据库框架被创造出来,解决其某方面的问题。
比如以Redis为代表的的KV存储,可以解决无法存储结构化数据的问题;以MongoDB为代表的的文档数据库可以解决扩展表结构被强Schema约束的问题;以HBase为代表的的列式数据库可以解决大数据场景下的I/O问题;以ES为代表的的全文搜索引擎可以解决全文检索效率的问题等。
这些数据库统称为NoSQL数据库,但NoSQL并不是全都不能写SQL,而是Not Only SQL的意思。
NoSQL数据库除了聚焦于解决某方面的问题以外也会有其自身的缺点,比如Redis没有支持完整的ACID事务、列式存储在更新一条记录的多字段时性能较差等。因此并不是说使用了NoSQL就能一劳永逸,更多的是按需取用,解决业务面临的问题。
关于NoSQL的更多了解,也可以看看《NoSQL精粹》这本书。
缓存:
如果NoSQL也解决不了业务的高性能诉求,那么或许你需要加点缓存。
缓存最直接的概念就是把常用的数据存在内存中,当业务请求来查询的时候直接从内存中拿出来,不用重新去数据库中按条件查询,也就省去了大量的磁盘IO时间。
一般来说缓存都是通过Key-Value的方式存储在内存中,根据存储的位置,分为单机缓存和集中式缓存。单机缓存就是存在自身服务器所在的机器上,那么势必会有不同机器数据可能不一致,或者重复缓存的问题,要解决可以使用查询内容做路由来保障同一记录始终到同一台机器上查询缓存。集中式缓存则是所有服务器都去一个地方查缓存,会增加一些调用时间。
缓存可以提升性能是很好理解的,但缓存同样有着他的问题需要应对或规避。数据时效性是最容易想到的问题,但也可以靠同时更新缓存的策略来保障数据的时效性,除此之外还有其他几个常见的问题。
如果某条数据不存在,缓存中势必查不到对应的KEY,从而就会请求数据库确认是否有新增加这条数据,如果始终没有这条数据,而客户端又反复频繁地查询这条数据,就会变相地对数据库造成很大的压力,换句话说,缓存失去了保护作用,请求穿透到了数据库,这称为缓存穿透。
应对缓存穿透,最好的手段就是把“空值”这一情况也缓存下来,当客户端下次再查询时,发现缓存中说明了该数据是空值,则不会再问询数据库。但也要注意如果真的有对应数据写入了数据库,应当能及时清除”空值“缓存。
为了保障缓存的数据及时更新,常常都会根据业务特性设置一个缓存过期时间,在缓存过期后,到再次生成期间,如果出现大量的查询,会导致请求都传递到数据库,而且会多次重复生成缓存,甚至可能拖垮整个系统,这就叫缓存雪崩,和缓存穿透的区别在于,穿透是面对空值的情况,而雪崩是由于缓存重新生成的间隔期大量请求产生的连锁效应。
既然是缓存更新时重复生成所导致的问题,那么一种解法就是在缓存重新生成前给这个KEY加锁,加锁期间出现的请求都等待或返回默认值,而不去都尝试重新生成缓存。
另一种方法是干脆不要由客户端请求来触发缓存更新,而是由后台脚本统一更新,同样可以规避重复请求导致的重复生成。但是这就失去了只缓存热点数据的能力,如果缓存因空间问题被清除了,也会因为后台没及时更新导致查不到缓存数据,这就会要求更复杂的后台更新策略,比如主动查询缓存有效性、缓存被删后通知后台主动更新等。
虽说在有限的内存空间内最好缓存热点数据,但如果数据过热,比如微博的超级热搜,也会导致缓存服务器压力过大而崩溃,称之为缓存热点问题。
可以复制多份缓存副本,来分散缓存服务器的单机压力,毕竟堆机器是最简单有效。此处也要注意,多个缓存副本不要设置相同的缓存过期时间,否则多处缓存同时过期,并同时更新,也容易引起缓存雪崩,应该设置一个时间范围内的随机值来更新缓存。
2.2、计算高性能
讲完存储高性能,再讲计算高性能,计算性能的优化可以先从单机性能优化开始,多进程、多线程、IO多路复用、异步IO等都存在很多可以优化的地方,但基本系统或框架已经提供了基本的优化能力,只需使用即可。
负载均衡:
如果单机的性能优化已经到了瓶颈,无法应对业务的增长,就会开始增加服务器,构建集群。对于计算来说,每一台服务器接到同样的输入,都应该返回同样的输出,当服务器从单台变成多台之后,就会面临请求来了要由哪一台服务器处理的问题,我们当然希望由当前比较空闲的服务器去处理新的请求,这里对请求任务的处理分配问题,就叫负载均衡。
负载均衡的策略,从分类上来说,可以分为三类:
- DNS负载均衡:通过DNS解析,来实现地理级别的均衡,其成本低,分配策略很简单,可以就近访问来提升访问速度,但DNS的缓存时间长,由于更新不及时所以无法快速调整,且控制权在各域名商下,且无法根据后端服务器的状态来决定分配策略。
- 硬件负载均衡:直接通过硬件设备来实现负载均衡,类似路由器路由,功能和性能都很强大,可以做到百万并发,也很稳定,支持安全防护能力,但是同样无法根据后端服务器状态进行策略调整,且价格昂贵。
- 软件负载均衡:通过软件逻辑实现,比如nginx,比较灵活,成本低,但是性能一般,功能也不如硬件强大。
一般来说,DNS 负载均衡用于实现地理级别的负载均衡;硬件负载均衡用于实现集群级别的负载均衡;软件负载均衡用于实现机器级别的负载均衡。
所以部署起来可以按照这三层去部署,第一层通过DNS将请求分发到北京、上海、深圳的机房;第二层通过硬件负载均衡将请求分发到当地三个集群中的一个;第三层通过软件策略将请求分发到具体的某台服务器去响应业务。
就负载均衡算法来说,多是我们很熟悉的算法,如轮询、加权轮询、负载最低优先、性能最优优先、Hash分配等,各有特点,按需采用即可。
3、高可用架构模式
3.1、理论方法
CAP与BASE:
在说高可用之前,先来说说CAP理论,即:
在一个分布式系统(指互相连接并共享数据的节点的集合)中,当涉及读写操作时,只能保证一致性(Consistence)、可用性(Availability)、分区容错性(Partition Tolerance)三者中的两个,另外一个必须被牺牲。
大家可能都知道CAP定理是什么,但大家可能不知道,CAP定理的作者(Seth Gilbert & Nancy Lynch)其实并没有详细解释CAP三个单词的具体含义,目前大家熟悉的解释其实是另一个人(Robert Greiner)给出的。而且他还给出了两版有所差异的解释。
第二版解释算是对第一版解释的加强,他要加强的点主要是:
- CAP描述的分布式系统,是互相连结并共享数据的节点的集合。因为其实并不是所有的分布式系统都会互连和共享数据。
- CAP理论是在涉及读写操作的场景下的理论,而不是分布式系统的所有功能。
- 一致性只需要保障客户端读操作能读到最新的写操作结果,并不要求时时刻刻分布式系统的数据都是一致的,这是不现实的,只要保障客户读到的一致即可。
- 可用性要求非故障的节点在合理的时间内能返回合理的响应,所谓合理是指非错误、非超时,即使数据不是最新的数据,也是合理的“旧数据”,是符合可用性的。
- 分区容错性要求网络分区后系统能继续履行职责,不仅仅要求系统不宕机,还要求能发挥作用,能处理业务逻辑。比如接口直接返回错误其实也代表系统在运行,但却没有履行职责。
在分布式系统下,P(分区容忍)是必须选择的,否则当分区后系统无法履行职责时,为了保障C(一致性),就要拒绝写入数据,也就是不可用了。
在此基础上,其实我们能选择的只有C+P或者A+P,根据业务特性来选择要优先保障一致性还是可用性。
在选择保障策略时,有几个需要注意的点:
- CAP关注的其实是数据的粒度,而不是整个系统的粒度,因此对于系统内的不同数据(对应不同子业务),其实是可以按照业务特性采取不同的CAP策略的。
- CAP实际忽略了网络延迟,也就是允许数据复制过程中的短时间不一致,如果某些业务比如金融业务无法容忍这一点,那就只能对单个对象做单点写入,其他节点备份,无法做多点写入。但对于不同的对象,其实可以分库来实现分布式。
- 当没有发生分区现象时,也就是不用考虑P时,上述限制就不存在,此时应该考虑如何保障CA。
- 当发生分区后,牺牲CAP的其中一个并不代表什么都不用做,而是应该为分区后的恢复CA做准备,比如记录分区期间的日志以供恢复时使用。
伴随CAP的一个退而求其次,也更现实的追求,是BASE理论,即基本可用,保障核心业务的可用性;软状态,允许系统存在数据不一致的中间状态;最终一致性,一段时间后系统应该达到一致。
FMEA分析法:
要保障高可用,怎么下手呢?俗话说知己知彼才能有的放矢,因此做高可用的前提是了解系统存在怎样的风险,并且还要识别出风险的优先级,先治理更可能发生的、影响更大的风险。说得简单,到底怎么做?业界其实已经提供了排查系统风险的基本方法论,即FMEA(Failure mode and effects analysis)——故障模式与影响分析。
FMEA的基本思路是,面对初始的架构设计图,考虑假设其中某个部件发生故障,对系统会造成什么影响,进而判断架构是否需要优化。
具体来说,需要画一张表,按照如下步骤逐个列出:
- 功能点:列出业务流程中的每个功能点。
- 故障模式:量化描述该功能可能发生怎样的故障,比如mysql响应时间超过3秒。
- 故障影响:量化描述该每个故障可能导致的影响,但不用非常精确,比如20%用户无法登录。
- 严重程度:设定标准,给每个影响的严重程度打分。
- 故障原因:对于每个故障,考虑有哪些原因导致该故障。
- 故障概率:对于每个原因,考虑其发生的概率,不用精确,分档打分即可。
- 风险程度:=严重程度 * 故障概率,据此就可以算出风险的处理优先级了,肯定是程度分数越高的越应该优先解决。
- 已有措施、解决措施、后续规划:用于梳理现状,思考未来的改进方案等。
基于上面这套方法论,可以有效地对系统的风险进行梳理,找出需要优先解决的风险点,从而提高系统的可用性。
除了FMEA,其实还有一种应用更广泛的风险分析和治理的理论,即BCP——业务连续性计划,它是一套基于业务规律的规章流程,保障业务或组织在面对突发状况时其关键业务功能可以持续不中断。
相比FMEA,BCP除了评估风险及重要程度,还要求详细地描述应对方案、残余风险、灾备恢复方案,并要求进行相应故障的培训和演习安排,尽最大努力保障业务连续性。
知道风险在哪,优先治理何种风险之后,就可以着手优化架构。和高性能架构模式一样,高可用架构也可以从存储和计算两个方面来分析。
3.2、存储高可用
存储高可用的本质都是通过将数据复制到多个存储设备,通过数据冗余的方式来提高可用性。
双机架构:
让我们先从简单的增加一台机器开始,即双机架构。
当机器变成两台后,根据两台机器担任的角色不同,就会分成不同的策略,比如主备、主从、主主。
主备复制的架构是指一台机器作为客户端访问的主机,另一台机器纯粹作为冗余备份用,当主机没有故障时,备机不会被客户端访问到,仅仅需要从主机同步数据。这种策略很简单,可以应对主机故障情况下的业务可用性问题,但在平常无法分担主机的读写压力,有点浪费。
主从复制的架构和主备复制的差别在于,从机除了复制备份数据,还需要干活,即还需要承担一部分的客户端请求(一般是分担读操作)。当主机故障时,从机的读操作不会受到影响,但需要增加读操作的请求分发策略,且和主备不同,由于从机直接提供数据读,如果主从复制延迟大,数据不一致会对业务造成更明显的影响。
对于主备和主从两种策略,如果主机故障,都需要让另一台机器变成主机,才能继续完整地提供服务,如果全靠人工干预来切换,会比较滞后和易错,最好是能够自动完成切换,这就涉及双机切换的策略。
在考虑双机切换时,要考虑什么?首先是需要感知机器的状态,是两台机器直连传递互相的状态,还是都传递给第三方来仲裁?所谓状态要包含哪些内容才能定义一台主机是故障呢?是发现一次问题就切换还是多观察一会再切换?切换后如果主机恢复了是切换回来还是自动变备机呢?需不需要人工二次确认一下?
这些问题可能都得根据业务的特性来得出答案,此处仅给出三种常见的双机切换模式:
- 互连式:两台机器直接连接传递信息,并根据传递的状态信息判断是否要切换主机,如果通道本身发生故障则无法判断是否要切换了,可以再增加一个通道构成双通道保障,不过也只是降低同时故障的概率。
- 中介式:通过第三方中介来收集机器状态并执行策略,如果通道发生断连,中介可以直接切换其他机器作为主机,但这要求中介本身是高可用的,已经有比较成熟的开源解决方案如zookeeper、keepalived。
- 模拟式:备机模拟成客户端,向主机发送业务类似的读写请求,根据响应情况来判断主机的状态决定是否要切换主机,这样可以最真实地感受到客户端角度下的主机故障,但和互连式不同,能获取到的其他机器信息很少,容易出现判断偏差。
最后一种双机架构是主主复制,和前面两种只有一主的策略不同,这次两台都是主机,客户端的请求可以达到任何一台主机,不存在切换主机的问题。但这对数据的设计就有了严格的要求,如果存在唯一ID、严格的库存数量等数据,就无法适用,这种策略适合那些偏临时性、可丢失、可覆盖的数据场景。
数据集群:
采用双机架构的前提是一台主机能够存储所有的业务数据并处理所有的业务请求,但机器的存储和处理能力是有上限的,在大数据场景下就需要多台服务器来构成数据集群。
如果是因为处理能力达到瓶颈,此时可以增加从机帮主机分担压力,即一主多从,称为数据集中集群。这种集群方式需要任务分配算法将请求分散到不同机器上去,主要的问题在于数据需要复制到多台从机,数据的一致性保障会比一主一从更为复杂。且当主机故障时,多台从机协商新主机的策略也会变得复杂。这里有开源的zookeeper ZAB算法可以直接参考。
如果是因为存储量级达到瓶颈,此时可以将数据分散存储到不同服务器,每台服务器负责存储一部分数据,同时也备份一部分数据,称为数据分散集群。数据分散集群同样需要做负载均衡,在数据分区的分配上,hadoop采用独立服务器负责数据分区的分配,ES集群通过选举一台服务器来做数据分区的分配。除了负载均衡,还需要支持扩缩容,此外由于数据是分散存储的,当部分服务器故障时,要能够将故障服务器的数据在其他服务器上恢复,并把原本分配到故障服务器的数据分配到其他正常的服务器上,即分区容错性。
数据分区:
数据集群可以在单台乃至多台服务器故障时依然保持业务可用,但如果因为地理级灾难导致整个集群都故障了(断网、火灾等),那整个服务就不可用了。面对这种情况,就需要基于不同地理位置做数据分区。
做不同地理位置的数据分区,首先要根据业务特性制定分区规则,大多还是按照地理位置提供的服务去做数据分区,比如中国区主要存储中国用户的数据。
既然分区是为了防灾,那么一个分区肯定不止存储自身的数据,还需要做数据备份。从数据备份的策略来说,主要有三种模式:
- 集中式:存在一个总备份中心,所有的分区数据都往这个总中心备份,设计起来简单,各个分区间没有联系,不会互相影响,也很容易扩展新的分区。但总中心的成本较高,而且总中心如果出故障,就要全部重新备份。
- 互备式:每个分区备份另一个分区的数据,可以形成一个备份环,或者按地理位置远近来搭对备份,这样可以直接利用已有的设备做数据备份。但设计较复杂,各个分区间需要联系,当扩展新分区时,需要修改原有的备份线路。
- 独立式:每个分区配备自己的备份中心,一般设立在分区地理位置附近的城市,设计也简单,各个分区间不会影响,扩展新分区也容易。但是成本会很高,而且只能防范城市级的灾难。
3.3、计算高可用
从存储高可用的思路可以看出,高可用主要是通过增加机器冗余来实现备份,对计算高可用来说也是如此。通过增加机器,分担服务器的压力,并在单机发生故障的时候将请求分配到其他机器来保障业务可用性。
因此计算高可用的复杂性也主要是在多机器下任务分配的问题,比如当任务来临(比如客户端请求到来)时,如何选择执行任务的服务器?如果任务执行失败,如何重新分配呢?这里又可以回到前文说过的负载均衡相关的解法上。
计算服务器和存储服务器在多机器情况下的架构是类似的,也分为主备、主从和集群。
主备架构下,备机仅仅用作冗余,平常不会接收到客户端请求,当主机故障时,备机才会升级为主机提供服务。备机分为冷备和温备。冷备是指备机只准备好程序包和配置文件,但实际平常并不会启动系统。温备是指备机的系统是持续启动的,只是不对外提供服务,从而可以随时切换主机。
主从架构下,从机也要执行任务,由任务分配器按照预先定义的规则将任务分配给主机和从机。相比起主备,主从可以发挥一定的从机性能,避免成本空费,但任务的分配就变得复杂一些。
集群架构又分为对称集群和非对称集群。
对称集群也叫负载均衡集群,其中所有的服务器都是同等对待的,任务会均衡地分配到每台服务器。此时可以采用随机、轮询、Hash等简单的分配机制,如果某台服务器故障,不再给他分配任务即可。
非对称集群下不同的服务器有不同的角色,比如分为master和slave。此时任务分配器需要有一定的规则将任务分配给不同角色的服务器,还需要有选举策略来在master故障时选择新的master。这个选举策略的复杂度就丰俭由人了。
异地多活:
讲存储高可用已经说过数据分区,计算高可用也有类似的高可用保障思路,归纳来说,它们都可以根据需要做异地多活,来提高整体的处理能力,并防范地区级的灾难。异地多活中的”异地“,就是指集群部署到不同的地理位置,“活”则强调集群是随时能提供服务的,不同于“备”还需要一个切换过程。
按照规模,异地多活可以分为同城异区、跨城异地和跨国异地。显而易见,不同模式下能够应对的地区级故障是越来越高的,但同样的,距离越远,通信成本与延迟就越高,对通信通道可用性的挑战也越高。因此跨城异地已经不适合对数据一致性要求非常高的业务,而跨国异地往往是用来给不同国家的用户提供不同服务的。
由于异地多活需要花费很高的成本,极大地增加系统复杂度,因此在设计异地多活架构时,可以不用强求为所有业务都做异地多活,可以优先为核心业务实现异地多活。尽量保障绝大部分用户的异地多活,对于没能保障的用户,通过挂公告、事后补偿、完善失败提示等措施进行安抚、提升体验。毕竟要做到100%可用性是不可能的,只能在能接受的成本下尽量逼近,所以当可用性达到一定瓶颈后,补偿手段的成本或许更低。
在异地部署的情况下,数据一定会冗余存储,物理上就无法实现绝对的实时同步,且距离越远对数据一致性的挑战越大,虽然可以靠减少距离、搭建高速专用网络等方式来提高一致性,但也只是提高而已,因此大部分情况下, 只需考虑保障业务能接受范围下的最终一致性即可。
在同步数据的时候,可以采用多种方式,比如通过消息队列同步、利用数据库自带的同步机制同步、
通过换机房重试来解决同步延迟问题、通过session id让同一数据的请求都到同一机房从而不用同步等。
可见,整个异地多活的设计步骤首先是对业务分级,挑选出核心业务做异地多活,然后对需要做异地多活的数据进行特征分析,考虑数据量、唯一性、实时性要求、可丢失性、可恢复性等,根据数据特性设计数据同步的方案。最后考虑各种异常情况下的处理手段,比如多通道同步、日志记录恢复、用户补偿等,此时可以借用前文所说的FMEA等方法进行分析。
接口级故障:
前面讨论的都是较为宏观的服务器、分区级的故障发生时该怎么办,实际上在平常的开发中,还应该防微杜渐,从接口粒度的角度,来防范和应对接口级的故障。应对的核心思路依然是优先保障核心业务和绝大部分用户可用。
对于接口级故障,有几个常用的方法:限流、排队、降级、熔断。其中限流和排队属于事前防范的措施,而降级和熔断属于接口真的故障后的处理手段。
限流的目的在于控制接口的访问量,避免被高频访问冲垮。
从限流维度来说,可以基于请求限流,即限制某个指标下、某个时间段内的请求数量,阈值的定义需要基于压测和线上情况来逐步调优。还可以基于资源限流,比如根据连接数、文件句柄、线程数等,这种维度更适合特殊的业务。
实现限流常用的有时间窗算法和桶算法。
时间窗算法分为固定时间窗和滑动时间窗。
固定时间窗通过统计固定时间周期内的量级来决定限流,但存在一个临界点的问题,如果在两个时间窗的中间发生超大流量,而在两个时间窗内都各自没有超出限制,就会出现无法被限流拦截的接口故障。因此滑动时间窗采用了部分重叠的时间统计周期来解决临界点问题。
桶算法分为漏桶和令牌桶。
漏桶算法是将请求放入桶中,处理单元从桶里拿请求去进行处理,如果桶堆满了就丢弃掉新的请求,可以理解为桶下面有个漏斗将请求往处理单元流动,整个桶的容量是有限的。这种模式下流入的速率取决于请求的频率,当桶内有堆积的待处理请求时,流出速率是匀速的。漏桶算法适用于瞬时高并发的场景(如秒杀),处理可能慢一点,但可以缓存部分请求不丢弃。
令牌桶算法是在桶内放令牌,令牌数是有限的,新的请求需要先到桶里拿到令牌才能被处理,拿不到就会被丢弃。和漏桶匀速流出处理不同,令牌桶还能通过控制放令牌的速率来控制接收新请求的频率,对于突发流量,可靠累计的令牌来处理,但是相对的处理速度也会突增。令牌桶算法适用于控制第三方服务访问速度的场景,防止压垮下游。
除了限流,还有一种控制处理速度的方法就是排队。当新请求到来后先加入队列,出队端通过固定速度出队处理请求,避免处理单元压力过大。队列也有长度限制,其机制和漏桶算法差不多。
如果真的事前防范真的被突破了,接口很可能或已经发生了故障,还能做什么呢?
一种手段是熔断,即当处理量达到阈值,就主动停掉外部接口的访问能力,这其实也是一种防范措施,对外的表现虽然是接口访问故障,但系统内部得以被保护,不会引起更大的问题,待存量处理被消化完,或者外部请求减弱,或完成扩容后,再开放接口。熔断的设计主要是阈值,需要按照业务特点和统计数据制定。
当接口故障后(无论是被动还是主动断开),最好能提供降级策略。降级是丢车保帅,放弃一下非核心业务,保障核心业务可用,或者最低程度能提供故障公告,让用户不要反复尝试请求来加重问题了。比起手动降级,更好的做法也是自动降级,需要具备检测和发现降级时机的机制。
4、 可扩展架构模式
再回顾一遍互联网行业的金科玉律:只有变化才是不变的。在设计架构时,一开始就要抱着业务随时可能变动导致架构也要跟着变动的思想准备去设计,差别只在于变化的快慢而已。因此在设计架构时一定是要考虑可扩展性的。
在思考怎样才是可扩展的时候,先想一想平常开发中什么情况下会觉得扩展性不好?大都是因为系统庞大、耦合严重、牵一发而动全身。因此对可扩展架构设计来说,基本的思想就是拆分。
拆分也有多种指导思想,如果面向业务流程来谈拆分,就是分层架构;如果面向系统服务来谈拆分,就是SOA、微服务架构;如果面向系统功能来拆分,就是微内核架构。
分层架构:
分层架构是我们最熟悉的,因为互联网业务下,已经很少有纯单机的服务,因此至少都是C/S架构、B/S架构,也就是至少也分为了客户端/浏览器和后台服务器这两层。如果进一步拆分,就会将后台服务基于职责进行自顶向下的划分,比如分为接入层、应用层、逻辑层、领域层等。
分层的目的当然是为了让各个层次间的服务减少耦合,方便进行各自范畴下的优化,因此需要保证各层级间的差异是足够清晰、边界足够明显的,否则当要增加新功能的时候就会不知道该放到哪一层。各个层只处理本层逻辑,隔离关注点。
额外需注意的是一旦确定了分层,请求就必须层层传递,不能跳层,这是为了避免架构混乱,增加维护和扩展的复杂度,比如为了方便直接跨层从接入层调用领域层查询数据,当需要进行统一的逻辑处理时,就无法切面处理所有请求了。
SOA架构:
SOA架构更多出现在传统企业中,其主要解决的问题是企业中IT建设重复且效率低下,各部门自行接入独立的IT系统,彼此之间架构、协议都不同,为了让各个系统的服务能够协调工作,SOA架构应运而生。
其有三个关键概念:服务、ESB和松耦合。
服务是指各个业务功能,比如原本各部门原本的系统提供的服务,可大可小。由于各服务之间无法直接通信,因此需要ESB,即企业服务总线进行对接,它将不同服务连接在一起,屏蔽各个服务的不同接口标准,类似计算机中的总线。松耦合是指各个服务的依赖需要尽量少,否则某个服务升级后整个系统无法使用就麻烦了。
这里也可以看出,ESB作为总线,为了对接各个服务,要处理各种不同的协议,其协议转换耗费了大量的性能,会成为整个系统的瓶颈。
微服务:
微服务是近几年最耳熟能详的架构,其实它和SOA有一些相同之处,比如都是将各个服务拆分开来提供能力。但是和SOA也有一些本质的区别,微服务是没有ESB的,其通信协议是一致的,因此通信管道仅仅做消息的传递,不理解内容和格式,也就没有ESB的问题。而且为了快速交付、迭代,其服务的粒度会划分地更细,对自动化部署能力也就要求更高,否则部署成本太大,达不到轻量快速的目的。
当然微服务虽然很火,但也不是解决所有问题的银弹,它也会有一些问题存在。如果服务划分的太细,那么互相之间的依赖关系就会变得特别复杂,服务数量、接口量、部署量过多,团队的效率可能大降,如果没有自动化支撑,交付效率会很低。由于调用链太长(多个服务),因此性能也会下降,问题定位会更困难,如果没有服务治理的能力,管理起来会很混乱,不知道每个服务的情况如何。
因此如何拆分服务就成了每个使用微服务架构的团队的重要考量点。这里也提供一些拆分的思路:
- 三个火枪手原则:考虑每三个人负责一个服务,互相可以形成稳定的人员备份,讨论起来也更容易得出结论,在此基础上考虑能负责多大的一个服务。
- 基于业务逻辑拆分:最直观的就是按逻辑拆分,如果职责不清,就参考三个火枪手原则确定服务大小。
- 基于稳定性拆分:按照服务的稳定性分为稳定服务和变动服务,稳定服务粒度可以粗一些,变动服务粒度可以细一些,目的是减少变动服务之间的影响,但总体数量依然要控制。
- 基于可靠性拆分:按照可靠性排序,要求高的可以拆细一些,由前文可知,服务越简单,高可用方案就会越简单,成本也会越低。优先保障核心服务的高可用。
- 基于性能拆分:类似可靠性,性能要求越高的,拆出来单独做高性能优化,可有效降低成本。
微服务架构如果没有完善的基础设施保障服务治理,那么也会带来很多问题,降低效率,因此根据团队和业务的规模,可以按以下优先级进行基础设施的支持:
- 优先支持服务发现、服务路由、服务容错(重试、流控、隔离),这些是微服务的基础。
- 接着支持接口框架(统一的协议格式与规范)、API网关(接入鉴权、权限控制、传输加密、请求路由等),可以提高开发效率。
- 然后支持自动化部署、自动化测试能力,并搭建配置中心,可以提升测试和运维的效率。
- 最后支持服务监控、服务跟踪、服务安全(接入安全、数据安全、传输安全、配置化安全策略等)的能力,可以进一步提高运维效率。
微内核架构:
最后说说微内核架构,也叫插件化架构,顾名思义,是面向功能拆分的,通常包含核心系统和插件模块。在微内核架构中,核心系统需要支持插件的管理和链接,即如何加载插件,何时加载插件,插件如何新增和操作,插件如何和核心引擎通信等。
举一个最常见的微内核架构的例子——规则引擎,在这个架构中,引擎是内核,负责解析规则,并将输入通过规则处理后得到输出。而各种规则则是插件,通常根据各种业务场景进行配置,存储到数据库中。
5、总结
人们通常把某项互联网业务的发展分为四个时期:初创期、发展期、竞争期和成熟期。
在初创期通常求快,系统能买就买,能用开源就用开源,能用的就是好的,先要活下来;到了发展期开始堆功能和优化,要求能快速实现需求,并有余力对一些系统的问题进行优化,当优化到顶的时候就需要从架构层面来拆分优化了;进入竞争期后,经过发展期的快速迭代,可能会存在很多重复造轮子和混乱的交互,此时就需要通过平台化、服务化来解决一些公共的问题;最后到达成熟期后,主要在于补齐短板,优化弱项,保障系统的稳定。
在整个发展的过程中,同一个功能的前后要求也是不同的,随着用户规模的增加,性能会越来越难保障,可用性问题的影响也会越来越大,因此复杂度就来了。
对于架构师来说,首要的任务是从当前系统的一大堆纷繁复杂的问题中识别出真正要通过架构重构来解决的问题,集中力量快速突破,但整体来说,要徐徐图之,不要想着用重构来一次性解决所有问题。
对项目中的问题做好分类,划分优先级,先易后难,才更容易通过较少的资源占用,较快地得到成果,提高士气。然后再循序渐进,每个阶段控制在1~3个月,稳步推进。
当然,在这个过程中,免不了和上下游团队沟通协作,需要注意的是自己的目标和其他团队的目标可能是不同的,需要对重构的价值进行换位思考,让双方都可以合作共赢,才能借力前进。
还是回到开头的那句话,架构设计的主要目的是为了解决软件系统复杂度带来的问题。首先找到主要矛盾在哪,做到有的放矢,然后再结合知识、经验进行设计,去解决面前的问题。
祝人人都成为一名合格的架构师。