系统与子系统
系统:系统是由一群有关联的个体组成的,
- 规则:系统内的个体需要按照指定的规则运作。规则规定了系统内个体分工和协作的方式。
- 能力:系统能力与个体能力有本质的差别,系统能力不是个体能力之和,而是产生了新的能力。
子系统:其实子系统的定义和系统定义是一样的,只是观察的角度有差异,一个系统可能是另外一个更大系统的子系统。
按照这个定义,系统和子系统比较容易理解。我们以微信为例来做一个分析。
- 微信本身是一个系统,包含聊天、登录、支付、朋友圈等子系统。
- 朋友圈这个系统又包括动态、评论、点赞等子系统。
- 评论这个系统可能又包括防刷子系统、审核子系统、发布子系统、存储子系统。
- 评论审核子系统不再包含业务意义上的子系统,而是包括各个模块或者组件,这些模块或者组件本身也是另外一个维度上的系统。例如,MySQL、Redis等是存储系统,但不是业务子系统。
模块与组件
模块和组件都是系统的组成部分,只是从不同的角度拆分系统而已。
从逻辑的角度来拆分系统后,得到的单元就是“模块”;从物理的角度来拆分系统后,得到的单元就是“组件”。划分模块的主要目的是职责分离;划分组件的主要目的是单元复用。
框架与架构
框架关注的是“规范”,架构关注的是“结构”。软件架构指软件系统的顶层结构。
首先,“系统是一群关联个体组成”,这些“个体”可以是“子系统”“模块”“组件”等;架构需要明确系统包含哪些“个体”。
其次,系统中的个体需要“根据某种规则”运作,架构需要明确个体运作和协作的规则。
第一次软件危机与结构化程序设计(20世纪60年代~20世纪70年代)
结构化程序设计的主要特点是抛弃goto语句,采取“自顶向下、逐步细化、模块化”的指导思想。本质上还是一种面向过程的设计思想,但通过“自顶向下、逐步细化、模块化”的方法,将软件的复杂度控制在一定范围内,从而从整体上降低了软件开发的复杂度。结构化程序方法成为了20世纪70年代软件开发的潮流。
第二次软件危机与面向对象(20世纪80年代)
第二次软件危机的根本原因还是在于软件生产力远远跟不上硬件和业务的发展。促进了面向对象的发展。随着软件系统规模的增加,计算相关的算法和数据结构不再构成主要的设计问题;当系统由许多部分组成时,整个系统的组织,也就是所说的“软件架构”,导致了一系列新的设计问题。例如:
系统规模庞大,内部耦合严重,开发效率低;
系统耦合严重,牵一发动全身,后续修改和扩展困难;
系统逻辑复杂,容易出问题,出问题后很难排查和修复。
软件架构的出现有其历史必然性。20世纪60年代第一次软件危机引出了“结构化编程”,创造了“模块”概念;20世纪80年代第二次软件危机引出了“面向对象编程”,创造了“对象”概 念;到了20世纪90年代“软件架构”开始流行,创造了“组件”概念。我们可以看到,“模块”“对象”“组件”本质上都是对达到一定规模的软件进行拆分,差别只是在于随着软件的复杂度不断增加,拆分的粒度越来越粗,拆分的层次越来越高。
架构设计的主要目的是为了解决软件系统复杂度带来的问题。对一个系统进行架构设计的时候,首先应识别其复杂度到底体现在哪里。(性能、可扩展性、高可用、成本...)
高性能
高性能带来的复杂度主要体现在两方面,一方面是单台计算机内部为了高性能带来的复杂度;另一方面是多台计算机集群为了高性能带来的复杂度。
单机复杂度:计算机内部复杂度最关键的地方就是操作系统。计算机性能的发展本质上是由硬件发展驱动的,尤其是CPU的性能发展。著名的“摩尔定律”表明了CPU的处理能力每隔18个月就翻一番;而将硬件性能充分发挥出来的关键就是操作系统,所以操作系统本身其实也是跟随硬件的发展而发展的,操作系统是软件系统的运行环境,操作系统的复杂度直接决定了软件系统的复杂度。
操作系统和性能最相关的就是进程和线程。最早的计算机其实是没有操作系统的,只有输入、计算和输出功能这样的处理性能很显然是很低效的。为了解决手工操作带来的低效,批处理操作系统应运而生。批处理简单来说就是形成一个指令清单,然后将交给计算机去执行,计算机执行的过程中无须等待人工手工操作,这样性能就有了很大的提升。但有一个很明显的缺点:计算机一次只能执行一个任务,如果某个任务需要从I/O设备(例如磁带)读取大量的数据,在I/O操作的过程中,CPU其实是空闲的,而这个空闲时间本来是可以进行其他计算的。为了进一步提升性能,人们发明了“进程”,用进程来对应一个任务,每个任务都有自己独立的内存空间,进程间互不相关,由操作系统来进行调度。为了达到多进程并行运行的目的,采取了分时的方式,即把CPU的时间分成很多片段,每个片段只能执行某个进程中的指令。虽然从操作系统和CPU的角度来说还是串行处理的,但是由于CPU的处理速度很快,从用户的角度来看,感觉是多进程在并行处理。
多进程虽然要求每个任务都有独立的内存空间,进程间互不相关,但从用户的角度来看,两个任务之间能够在运行过程中就进行通信,会让任务设计变得更加灵活高效。进程间通信的各种方式被设计出来了,包括管道、消息队列、信号量、共享存储等。
多进程让多任务能够并行处理任务,但本身还有缺点,单个进程内部只能串行处理,而实际上很多进程内部的子任务并不要求是严格按照时间顺序来执行的,也需要并行处理。例如,一个餐馆管理进程,排位、点菜、买单、服务员调度等子任务必须能够并行处理,否则就会出现某个客人买单时间比较长(比如说信用卡刷不出来),其他客人都不能点菜的情况。为了解决这个问题,人们又发明了线程,线程是进程内部的子任务,但这些子任务都共享同一份进程数据。为了保证数据的正确性,又发明了互斥锁机制。有了多线程后,操作系统调度的最小单位就变成了线程,而进程变成了操作系统分配资源的最小单位。
多进程多线程虽然让多任务并行处理的性能大大提升,但本质上还是分时系统,并不能做到时间上真正的并行。解决这个问题的方式显而易见,就是让多个CPU能够同时执行计算任务,从而实现真正意义上的多任务并行。目前这样的解决方案有3种:SMP(Symmetric Multi-Processor,对称多处理器结构)、NUMA(Non-Uniform Memory Access,非一致存储访问结构)、MPP(Massive Parallel Processing,海量并行处理结构)。其中SMP是我们最常见的,目前流行的多核处理器就是SMP方案。
操作系统发展到现在,如果我们要完成一个高性能的软件系统,需要考虑如多进程、多线程、进程间通信、多线程并发等技术点,而且这些技术并不是最新的就是最好的,也不是非此即彼的选择。在做架构设计的时候,需要花费很大的精力来结合业务进行分析、判断、选择、组合,这个过程同样很复杂。举一个最简单的例子:Nginx可以用多进程也可以用多线程,JBoss采用的是多线程;Redis采用的是单进程,Memcache采用的是多线程,这些系统都实现了高性能,但内部实现差异却很大。
集群的复杂度
要支持支付和红包这种复杂的业务,单机的性能无论如何是无法支撑的,必须采用机器集群的方式来达到高性能。通过大量机器来提升性能,并不仅仅是增加机器这么简单,让多台机器配合起来达到高性能的目的,是一个复杂的任务,我针对常见的几种方式简单分析一下。
1.任务分配
任务分配的意思是指每台机器都可以处理完整的业务任务,不同的任务分配到不同的机器上执行。实际上“任务”涵盖的范围很广,可以指完整的业务处理,也可以单指某个具体的任务。例如,“存储”“运算”“缓存”等都可以作为一项任务,因此存储系统、运算系统、缓存系统都可以按照任务分配的方式来搭建架构。此外,“任务分配器”也并不一定只能是物理上存在的机器或者一个独立运行的程序,也可以是嵌入在其他程序中的算法。
2.任务分解
通过任务分配的方式,我们能够突破单台机器处理性能的瓶颈,通过增加更多的机器来满足业务的性能需求,但如果业务本身也越来越复杂,单纯只通过任务分配的方式来扩展性能,收益会越来越低。例如,业务简单的时候1台机器扩展到10台机器,性能能够提升8倍(需要扣除机器群带来的部分性能损耗,因此无法达到理论上的10倍那么高),但如果业务越来越复杂,1台机器扩展到10台,性能可能只能提升5倍。造成这种现象的主要原因是业务越来越复杂,单台机器处理的性能会越来越低。为了能够继续提升性能,我们需要采取第二种方式:任务分解。那为何通过任务分解就能够提升性能呢?
主要有几方面的因素:
简单的系统更加容易做到高性能
可以针对单个任务进行扩展
高可用的定义:系统无中断地执行其功能的能力,代表系统的可用性程度。本质上都是通过“冗余”来实现高可用。
高性能增加机器目的在于“扩展”处理性能;高可用增加机器目的在于“冗余”处理单元。通过冗余增强了可用性,但同时也带来了复杂性。
存储高可用
对于需要存储数据的系统来说,整个系统的高可用设计关键点和难点就在于“存储高可用”。存储与计算相比,有一个本质上的区别:将数据从一台机器搬到到另一台机器,需要经过线路进行传输。线路传输的速度是毫秒级别,同一机房内部能够做到几毫秒;分布在不同地方的机房,传输耗时需要几十甚至上百毫秒。除了物理上的传输速度限制,传输线路本身也存在可用性问题,传输线路可能中断、可能拥塞、可能异常(错包、丢包),并且传输线路的故障时间一般都特别长,短的十几分钟,长的几个小时都是可能的。。
存储高可用的难点不在于如何备份数据,而在于如何减少或者规避数据不一致对业务造成的影响。
分布式领域里面有一个著名的CAP定理,从理论上论证了存储高可用的复杂度。也就是说,存储高可用不可能同时满足“一致性、可用性、分区容错性”,最多满足其中两个,这就要求我们在做架构设计时结合业务进行取舍。
高可用状态决策
无论是计算高可用还是存储高可用,其基础都是“状态决策”,即系统需要能够判断当前的状态是正常还是异常,如果出现了异常就要采取行动来保证高可用。
1.独裁式
独裁式决策指的是存在一个独立的决策主体,称为“决策者”,负责收集信息然后进行决策;所有冗余的个体,称为“上报者”,都将状态信息发送给决策者。
独裁式的决策方式不会出现决策混乱的问题,因为只有一个决策者,但问题也正是在于只有一个决策者。当决策者本身故障时,整个系统就无法实现准确的状态决策。如果决策者本身又做一套状态决策,那就陷入一个递归的死循环了。
2.协商式
协商式决策指的是两个独立的个体通过交流信息,然后根据规则进行决策,最常用的协商式决策就是主备决策。
协商式状态决策在某些场景总是存在一些问题的。
3.民主式
民主式决策指的是多个独立的个体通过投票的方式来进行状态决策。例如,ZooKeeper集群在选举leader时就是采用这种方式。
民主式决策和协商式决策比较类似,其基础都是独立的个体之间交换信息,每个个体做出自己的决策,然后按照“多数取胜”的规则来确定最终的状态。不同点在于民主式决策比协商式决策要复杂得多。
除了算法复杂,民主式决策还有一个固有的缺陷:脑裂。
投票节点数必须过半,虽然解决了脑裂问题,但同时降低了可用性。
可扩展性
可扩展性指系统为了应对将来需求变化而提供的一种扩展能力,当有新的需求出现时,系统不需要或者仅需要少量修改就可以支持,无须整个系统重构或者重建。
面向对象思想的提出,就是为了解决可扩展性带来的问题;后来的设计模式,更是将可扩展性做到了极致。
设计具备良好可扩展性的系统,有两个基本条件:正确预测变化、完美封装变化。
预测变化的复杂性在于:
不能每个设计点都考虑可扩展性。不能完全不考虑可扩展性。
所有的预测都存在出错的可能性。
第一种应对变化的常见方案是将“变化”封装在一个“变化层”,将不变的部分封装在一个独立的“稳定层”。
通过剥离变化层和稳定层的方式应对变化,会带来两个主要的复杂性相关的问题。
1.系统需要拆分出变化层和稳定层
2.需要设计变化层和稳定层之间的接口
接口设计同样至关重要,对于稳定层来说,接口肯定是越稳定越好;但对于变化层来说,在有差异的多个实现方式中找出共同点,并且还要保证当加入新的功能时原有的接口设计不需要太大修改,这是一件很复杂的事情
第二种常见的应对变化的方案是提炼出一个“抽象层”和一个“实现层”。
低成本
当我们设计“高性能”“高可用”的架构时,通用的手段都是增加更多服务器来满足“高性能”和“高可用”的要求;而低成本正好与此相反,我们需要减少服务器的数量才能达成低成本的 目标。因此,低成本本质上是与高性能和高可用冲突的,所以低成本很多时候不会是架构设计的首要目标,而是架构设计的附加约束。
安全
安全本身是一个庞大而又复杂的技术领域,并且一旦出问题,对业务和企业形象影响非常大。
1.功能安全
从实现的角度来看,功能安全更多地是和具体的编码相关,与架构关系不大。功能安全是一个逐步完善的过程,而且往往都是在问题出现后才能有针对性的提出解决方案,我们永远无法 预测系统下一个漏洞在哪里,也不敢说自己的系统肯定没有任何问题。
2.架构安全
如果说功能安全是“防小偷”,那么架构安全就是“防强盗”。架构设计时需要特别关注架构安全,尤其是互联网时代,理论上来说系统部署在互联网上时,全球任何地方都可以发起攻击。
传统的架构安全主要依靠防火墙,防火墙最基本的功能就是隔离网络,通过将网络划分成不同的区域,制定出不同区域之间的访问控制策略来控制不同信任程度区域间传送的数据流。
互联网系统的架构安全目前并没有太好的设计手段来实现,更多地是依靠运营商或者云服务商强大的带宽和流量清洗的能力,较少自己来设计和实现。
规模
常见的规模带来的复杂度有:
1.功能越来越多,导致系统复杂度指数级上升
例如,某个系统开始只有3大功能,后来不断增加到8大功能,虽然还是同一个系统,但复杂度已经相差很大了,具体相差多大呢?
假设系统间的功能都是两两相关的,系统的复杂度=功能数量+功能之间的连接数量,通过计算我们可以看出:
3个功能的系统复杂度= 3 + 3 = 6
8个功能的系统复杂度= 8 + 28 = 36
可以看出,具备8个功能的系统的复杂度不是比具备3个功能的系统的复杂度多5,而是多了30,基本是指数级增长的,主要原因在于随着系统功能数量增多,功能之间的连接呈指数级增长。
2.数据越来越多,系统复杂度发生质变
与功能类似,系统数据越来越多时,也会由量变带来质变。大数据单独成为了一个热门的技术领域,主要原因就是数据太多以后,传统的数据收集、加工、存储、分析的手段和工具已经无法适应,必须应用新的技术才能解决。
即使我们的数据没有达到大数据规模,数据的增长也可能给系统带来复杂性。因此,当MySQL单表数据量太大时,我们必须考虑将单表拆分为多表,这个拆分过程也会引入更多复杂性,例如:拆表的规则是什么?拆完表后查询如何处理?