先放结论:DDD作为架构设计思想帮助微服务控制规模复杂度,那它是怎么做到的呢?
一、架构设计是为了解决系统复杂度
谈到架构,相信每个技术人员都是耳熟能详,但如果深入探讨一下,“为何要做架构设计?”或者“架构设计目的是什么?”类似的问题,大部分人可能从来没有思考过,或者即使有思考,也没有太明确可信的答案。
1.1 架构设计的误区
1.1.1 每个系统都要做架构设计/公司流程要求有架构设计
知其然更要知其所以然,不能仅仅因为其他公司都在做架构设计而盲目跟从,而应该深入理解架构设计的目的和必要性,根据实际需求进行合理的设计。如果架构师或设计师只是为了找点事做而进行架构设计,不仅会浪费时间和人力,还会拖慢整体开发进度。此外,其他公司的架构设计并不一定适用于当前项目,如果强行引入,很可能会导致架构水土不服、运行不流畅等问题,最终需要不断重构或者推倒重来。因此,架构师或设计师应该深刻理解为何要进行架构设计,避免生搬硬套,针对具体需求进行合理的设计,才能保证项目的顺利进行。
1.1.2 架构设计是为了追求高性能、高可用、可扩展性等单一目标
持有这类观点的人看似具备一定架构经验或基础,但实际上他们无论面对什么系统或业务,都会不顾一切地追求这些目标,导致架构设计变得复杂、项目实施时间拖延、团队内部不和等问题的出现。这些问题使得整个项目进展缓慢,系统稳定性差,出现问题难以解决,甚至加入新功能也需要花费大量时间。这些情况不是危言耸听,而是广泛出现的现象。因此,架构师或设计师必须深入了解系统和业务需求,根据实际需要合理设计,不可盲目追求“高XX”目标,才能确保项目开发进度和系统的稳定性。
1.2 架构设计的真正目的
整个软件技术发展的历史,其实就是一部与“复杂度”斗争的历史。架构也是为了应对软件系统复杂度而提出的一个解决方案,其主要目的是为了解决软件系统复杂度带来的问题。
那到底什么是复杂度呢?John Ousterhout教授在 A Philosophy of Software Design 书中提到,复杂度就是任何使得软件难于理解和修改的因素。
复杂的系统有一些非常明显的特征,John教授将它抽象为变更放大(Change amplification)、认知负荷(Cognitive load)与未知的未知(Unknown unknowns)这3类。
变更放大(Change amplification)指得是看似简单的变更需要在许多不同地方进行代码修改。系统开发者之前没有及时重构代码,提取公共逻辑,而是省时间Ctrl-C,Ctrl-V式代码开发(这样做不会影响已有的稳定模块,不需要做比较多的回归测试,上线风险小)。当需求变化时,需要改动多处代码。
认知负荷(Cognitive load)是指系统的学习与理解成本高,开发人员的研发效率大大降低。
未知的未知(Unknown unknowns)是指不知道修改哪些代码才能使系统功能正确的运行,也不知道这行代码的改动是否会引发线上问题。这一项是复杂性中最糟糕的一个表现形式。
1.3 系统复杂度的六个来源及通用解法
本段参考 参考5
1.高性能 2.高可用 3.可扩展性 4.低成本 5.安全 6.规模
1.3.1 高性能
软件系统中高性能带来的复杂度主要体现在两方面:
1.
一方面是单台计算机内部为了高性能带来的复杂度;
2.
另一方面是多台计算机集群为了高性能带来的复杂度。
1.3.1.1 单机复杂度
计算机内部复杂度最关键的地方就是操作系统,计算机性能的发展本质上是由硬件发展驱动的,尤其是 CPU 的性能发展。而将硬件性能充分发挥出来的关键就是操作系统,所以操作系统本身也是随硬件的发展而发展的,操作系统是软件系统的运行环境,操作系统的复杂度直接决定了软件系统的复杂度。
操作系统和性能最相关的就是进程和线程。
●
进程:用进程来对应一个操作系统执行的任务,每个任务都有自己独立的内存空间,进程间互不相关,由操作系统来进行调度。
●
多进程:为了达到多进程并行运行的目的,采取了分时的方式,即把 CPU 的时间分成很多片段,每个片段只能执行某个进程中的指令。
●
进程间通信:为了解决进程在运行时相互通信的问题,人们设计了各种进程间通信,包括管道、消息队列、信号量、共享存储等。
●
多线程:多进程让多任务能够并行处理任务,但本身还有缺点,单个进程内部只能串行处理,而实际上很多进程内部的子任务并不要求是严格按照时间顺序来执行的,也需要并行处理。为了解决这个问题发明了线程,线程是进程内部的子任务,但这些子任务都共享同一份进程数据。为了保证数据的正确性,又发明了互斥锁机制。有了多线程后,操作系统调度的最小单位就变成了线程,而进程变成了操作系统分配资源的最小单位。
操作系统发展到现在,如果要完成一个高性能的软件系统,需要考虑如多进程、多线程、进程间通信、多线程并发等技术点,而且这些技术并不是最新的就是最好的,也不是非此即彼的选择。
在做架构设计的时候,需要花费很大的精力来结合业务进行分析、判断、选择、组合,这个过程同样很复杂。例如,下面的系统都实现了高性能,但是内部实现差异很大:
●
Nginx 可以用多进程也可以用多线程
●
JBoss 采用的是多线程
●
Redis 采用的是单进程
●
Memcache 采用的是多线程
1.3.1.2 集群复杂度
让多台机器配合起来达到高性能的目的,是一个复杂的任务,常见的方式有:
任务可以指完整的业务处理,也可以指某个具体的任务。
1.
任务分配:每台机器都可以处理完整的业务任务,不同的任务分配到不同的机器上执行。
2.
任务分解:业务越来越复杂,单台机器处理的性能会越来越低。为了能够继续提升性能,采用任务分解。
1.3.1.2.1任务分配
●
增加一个任务分配器,可以是硬件(F5、交换机)、软件(LVS)、负载均衡软件(Nginx、HAProxy)、自己开发的系统。
●
任务分配器与业务服务器之间的连接和交互。
●
任务分配器增加分配算法(轮询、权重、负载)。
业务量继续提升,需要增加任务分配器的数量。
●
任务分配器增加为多台,这样需要将不同的用户请求分配到不同的任务分配器上(DNS轮询、智能DNS、CDN、GSLB全局负载均衡)。
●
任务分配器和业务服务器之间从一对多变成多对多的网状结构。
●
业务服务器继续扩增,状态管理和故障处理复杂度更大。
1.3.1.2.2 任务分解
微服务架构就采用了这种思路,通过任务分配的方式,能够突破单台机器处理性能的瓶颈,通过增加更多的机器来满足业务的性能需求,但如果业务本身也越来越复杂,单纯只通过任务分配的方式来扩展性能,收益会越来越低。
通过这种任务分解的方式,能够把原来大一统但复杂的业务系统,拆分成小而简单但需要多个系统配合的业务系统。从业务的角度来看,任务分解既不会减少功能,也不会减少代码量(事实上代码量可能还会增加,因为从代码内部调用改为通过服务器之间的接口调用),任务分解能够提升性能的主要原因是:
1.
简单的系统更容易做到高性能:系统的功能越简单,影响性能的点就越少,就更加容易进行有针对性的优化。
2.
可以针对单个任务进行扩展:当各个逻辑任务分解到独立的子系统后,整个系统的性能瓶颈更加容易发现,而且发现后只需要针对有瓶颈的子系统进行性能优化或者提升,不需要改动整个系统,风险会小很多。
最终决定业务处理性能的还是业务逻辑本身,业务逻辑本身没有发生大的变化下,理论上的性能是有一个上限的,系统拆分能够让性能逼近这个极限,但无法突破这个极限。
1.3.2 高可用
系统无中断地执行其功能的能力,代表系统的可用性程度,是进行系统设计时的准则之一。
本质上都是通过“冗余”来实现高可用。高可用的“冗余”解决方案,单纯从形式上来看,和高性能是一样的,都是通过增加更多机器来达到目的,但其实本质上是有根本区别的:高性能增加机器目的在于“扩展”处理性能;高可用增加机器目的在于“冗余”处理单元。
通过冗余增强了可用性,但同时也带来了复杂性。
1.3.2.1 计算高可用
计算的特点是无论从哪台机器上进行计算,同样的算法和输入数据,产出的结果都是一样的,所以将计算从一台机器迁移到另一台对业务没有影响。
●
需要增加一个任务分配器
●
任务分配器和真正的业务服务器之间有连接和交互
●
任务分配器需要增加分配算法(主备【冷备、温备、热备】、主主、多主多倍【2主2备、4主0备】)
1.3.2.2 存储高可用
对于需要存储数据的系统而言,整个系统的高可用设计的难点和关键点在于“存储高可用”。存储和计算的本质区别在于将数据从一台机器搬移到另一台机器时需要通过线路进行传输,而线路传输是存在延迟的,速度在毫秒级别,距离越远,延迟越高。加之各种异常情况(如传输中断、丢包、拥塞),会导致延迟更高。对于高可用系统来说,在某个时间点通信中断就意味着整个系统的数据不一致。按照“数据 + 逻辑 = 业务”的公式,数据不一致将导致最终业务表现不同。如果不做冗余备份,系统的整体高可用性无法保证。因此,存储高可用的难点不在于如何备份数据,而在于如何减少或规避数据不一致对业务造成的影响。
分布式领域内著名的 CAP 定理从理论上证实了存储高可用的复杂度。存储高可用不可能同时满足“一致性、可用性、分区容忍性”,最多只能满足其中两个。因此,在进行架构设计时需要结合业务进行取舍。
1.3.3 可扩展性
可扩展性指系统为了应对将来需求变化而提供的一种扩展能力,当有新的需求出现时,系统不需要或者仅需要少量修改就可以支持,无须整个系统重构或者重建。
在软件开发领域,面向对象思想的提出,就是为了解决可扩展性带来的问题;设计模式更是将可扩展性做到了极致。
设计具备良好可扩展性的系统,有两个基本条件:
●
正确预测变化
●
完美封装变化
1.3.3.1 预测变化
“唯一不变的是变化”,按照这个标准衡量,架构师每个设计方案都要考虑可扩展性。预测变化的复杂性在于:
●
不能每个设计点都考虑可扩展性
●
不能完全不考虑扩展性
●
所有的预测都存在出错的可能性
如何把握预测的程度和提升预测结果的准确性,是一件很复杂的事情,而且没有通用的标准,更多是靠经验、直觉。
1.3.3.2 应对变化
预测变化是一回事,采取什么方案来应对变化,又是另外一个复杂的事情。即使预测很准确,如果方案不合适,则系统扩展一样很麻烦。
微服务架构中的各层进行封装和隔离也是一种应对变化的解决方式。
1.3.3.2.1 变化层VS稳定层
第一种应对变化的常见方案是将“变化”封装在一个“变化层”,将不变的部分封装在一个独立的“稳定层”。
无论是变化层依赖稳定层,还是稳定层依赖变化层都是可以的,需要根据具体业务情况来设计。
无论采取哪种形式,通过剥离变化层和稳定层的方式应对变化,都会带来两个主要的复杂性相关的问题。
1.
系统需要拆分出变化层和稳定层(如何拆分)
2.
需要设计变化层和稳定层之间的接口(稳定层接口越稳定越好,变化层接口从差异中找到共同点)
1.3.3.2.2 抽象层VS实现层
第二种常见的应对变化的方案是提炼出一个“抽象层”和一个“实现层”。
抽象层是稳定的,实现层可以根据具体业务需要定制开发,当加入新的功能时,只需要增加新的实现,无须修改抽象层。这种方案典型的实践就是策略模式。
1.3.4 低成本
在设计高性能、高可用的架构方案时,如果涉及到数百、数千甚至数万台服务器,成本就会成为一个非常重要的考虑点。为了控制成本,需要减少服务器的数量,这与增加更多服务器来提升性能和可用性的通用做法相冲突。因此,低成本往往不是架构设计的首要目标,而是一个附加约束。为了解决这个问题,需要先设定一个成本目标,然后根据高性能和高可用的要求设计方案,并评估是否能够满足成本目标。如果不能,就需要重新设计架构;如果无论如何都无法设计出满足成本要求的方案,那只能找老板调整成本目标了。 低成本给架构设计带来的主要复杂度体现在,往往只有"创新"才能达到低成本目标。"创新"的含义是开创一个全新的技术领域,或者引入新技术来解决问题。如果没有找到能够解决自己问题的新技术,那么就需要自己创造新技术了。例如,NoSQL(如Memcache、Redis等)是为了解决关系型数据库无法应对高并发访问带来的访问压力;全文搜索引擎(如Sphinx、Elasticsearch、Solr)是为了解决关系型数据库like搜索的低效问题;Hadoop则是为了解决传统文件系统无法应对海量数据存储和计算的问题。创造新技术的主要复杂度在于需要创造全新的理念和技术,并且新技术需要与旧技术相比有质的飞跃。
1.3.5 安全
从技术的角度来讲,安全可以分为两类:
●
一类是功能上的安全,
●
一类是架构上的安全。
1.3.5.1 功能安全
常见的 XSS 攻击、CSRF 攻击、SQL 注入、Windows 漏洞、密码破解等,本质上是因为系统实现有漏洞,黑客有了可乘之机,功能安全其实就是“防小偷”。
从实现的角度来看,功能安全更多地是和具体的编码相关,与架构关系不大。开发框架会内嵌常见的安全功能,但是开发框架本身也可能存在安全漏洞和风险。
所以功能安全是一个逐步完善的过程,而且往往都是在问题出现后才能有针对性的提出解决方案,我们永远无法预测系统下一个漏洞在哪里,也不敢说自己的系统肯定没有任何问题。
换句话讲,功能安全其实也是一个“攻”与“防”的矛盾,只能在这种攻防大战中逐步完善,不可能在系统架构设计的时候一劳永逸地解决。
1.3.5.2 架构安全
如果说功能安全是“防小偷”,那么架构安全就是“防强盗”。
架构设计时需要特别关注架构安全,尤其是互联网时代,理论上来说系统部署在互联网上时,全球任何地方都可以发起攻击。
传统的架构安全主要依靠防火墙,防火墙最基本的功能就是隔离网络,通过将网络划分成不同的区域,制定出不同区域之间的访问控制策略来控制不同信任程度区域间传送的数据流。
防火墙的功能虽然强大,但性能一般,所以在传统的银行和企业应用领域应用较多。但在互联网领域,防火墙的应用场景并不多。
互联网系统的架构安全目前并没有太好的设计手段来实现,更多地是依靠运营商或者云服务商强大的带宽和流量清洗的能力,较少自己来设计和实现。
1.3.6 规模
规模带来复杂度的主要原因就是“量变引起质变”,当数量超过一定的阈值后,复杂度会发生质的变化。常见的规模带来的复杂度有:
1.功能越来越多,系统复杂度指数级上升
2.数据越来越多,系统复杂度发生质变
1.3.6.1 功能越来越多,系统复杂度指数级上升
例如,某个系统开始只有 3 大功能,后来不断增加到 8 大功能,虽然还是同一个系统,但复杂度已经相差很大了,具体相差多大呢?我以一个简单的抽象模型来计算一下,假设系统间的功能都是两两相关的,系统的复杂度 = 功能数量 + 功能之间的连接数量,通过计算我们可以看出:3个功能的系统复杂度为3+3=6
8个功能的系统复杂度为8+(7+0)*8/2=36 可以看出,具备8个功能的系统的复杂度不是比具备 3 个功能的系统的复杂度多5,而是多了30,基本是指数级增长的,主要原因在于随着系统功能数量增多,功能之间的连接呈指数级增长。下图形象地展示了功能数量的增多带来了复杂度。
1.3.6.2 数据越来越多,系统复杂度发生质变
随着数据量的不断增长,传统的数据处理和管理方式已经无法适应,因此“大数据”这一概念应运而生。大数据的诞生主要是为了解决数据规模变得越来越大时,传统的数据收集、存储、分析等方式无法胜任的问题。Google的三篇技术论文,即Google File System、Google Bigtable和Google MapReduce则分别开创了大数据文件存储、列式数据存储和大数据运算的技术领域。即便数据规模没有达到大数据的水平,数据增长仍然可能会给系统带来复杂性。例如,在使用关系数据库存储数据时,当单表数据达到一定规模时,就会导致添加索引、修改表结构等操作变得很慢,可能需要几个小时,这就会对业务造成不良影响。因此,必须考虑将单表拆分为多表来解决这个问题,但这个过程也会引入更多的复杂性。
1.4 简单的复杂度分析案例
我们来分析一个简单的案例,一起来看看如何将“架构设计的真正目的是为了解决软件系统复杂度带来的问题”这个指导思想应用到实践中。
当我们设计一个大学的学生管理系统时,我们需要考虑该系统的复杂度以及如何解决这些复杂度带来的问题。首先,我们可以将该系统的复杂度分为以下几个方面:
性能 该系统的访问频率并不高,因此性能并不是一个很大的问题。我们可以使用MySQL作为存储,Nginx作为Web服务器,无需考虑缓存。
可扩展性 该系统的功能比较稳定,可扩展的空间并不大,因此可扩展性方面也不是一个很大的问题。
高可用 数据丢失是不可接受的,故该系统的高可用性方面需要考虑多种异常情况,如机器故障、机房故障等。为此,我们需要设计MySQL同机房主备方案和MySQL跨机房同步方案。
安全性 该系统存储的信息涉及到学生的隐私,因此需要考虑安全性。我们可以使用Nginx提供的ACL控制、用户账号密码管理和数据库访问权限控制来保证系统的安全性。
成本 由于该系统比较简单,基本上几台服务器就可以搞定,因此成本方面并不需要太多关注。
规模 同上,规模复杂度无需过度关注。
总体来说,我们需要在架构设计中充分考虑系统的复杂度,同时根据不同问题选择合适的解决方案,以提高系统的可靠性和安全性。
1.5 总结
第一章提出了架构的根本目是解决系统复杂度,并简要说明系统复杂度的六个来源及通用解法,为我们设计架构提供了清晰可执行的操作思路。
二、微服务架构解决了高可用、可扩展问题,但性能下降、成本&规模复杂度暴增
我们知道,这些年来随着设备和新技术的发展,软件的架构模式发生了很大的变化。软件架构模式大体来说经历了从单机、集中式到分布式微服务架构三个阶段的演进 。随着分布式技术的快速兴起,我们已经进入到了微服务架构时代。
2.1 微服务架构的优点
与传统单体应用架构相比,微服务架构有很多优点,具体表现如下:
2.1.1 高可用
当架构中的某一组件发生故障时,在单一进程的传统架构下,故障很有可能在进程内扩散,导致整个应用不可用。在微服务架构下,故障会被隔离在单个服务中。若设计良好,其他服务可通过重试、平稳退化等机制实现应用层面的容错。
2.1.4 可扩展
单个服务应用也可以实现横向扩展,这种扩展可以通过将整个应用完整的复制到不同的节点中实现。当应用的不同组件在扩展需求上存在差异时,微服务架构便体现出其灵活性,因为每个服务可以根据实际需求独立进行扩展。
2.2 微服务的缺点
2.2.1 复杂度高
与单体式架构相比,微服务会导致复杂性上升,因为多个团队会在更多地方创建更多服务。若管理不当,则会导致开发速度和效率降低。
2.2.2 基础设施成本呈指数级增长
每个新的微服务都有自己的成本,例如测试工具、托管基础架构和监控工具等方面。
2.2.3 性能下降
微服务之间通过REST、RPC等形式进行交互,通信的时延会受到较大的影响。
三、DDD帮助微服务控制规模复杂度
3.1 控制成本&规模复杂度需要明晰微服务的边界
由1.3.6.1 所述,随着微服务数量上升,规模复杂度呈指数级上升,那么我们理应控制微服务的数量,这就带来了争论和疑惑:微服务的粒度应该多大呀?微服务到底应该如何拆分和设计呢?
长期以来,微服务架构缺乏一套系统的理论和方法来指导其拆分,这导致了一些人对微服务架构的理解出现了一些曲解。有人简单地认为微服务只是将原来的单体应用程序拆分为多个部署包或更换为支持微服务架构的技术框架,便可成为微服务。还有人认为,微服务越小越好。
然而,在过去的几年中,一些项目由于在前期过度拆分微服务,导致项目复杂度过高,无法进行上线和运维的情况已经出现。综合来看,我认为微服务拆分困境的根本原因在于不清楚业务或微服务的边界在哪里。换句话说,只有确定了业务边界和应用边界,这个困境才能迎刃而解。
3.2 DDD能够帮我们设计出清晰的领域和应用边界
DDD 包括战略设计和战术设计两部分。战略设计主要从业务视角出发,建立业务领域模型,划分领域边界,建立通用语言的限界上下文,这些限界上下文可以作为微服务设计的参考边界。而战术设计则从技术视角出发,着重于领域模型的技术实现,包括聚合根、实体、值对象、领域服务、应用服务和资源库等代码逻辑的设计和实现。
在战略设计过程中,领域模型的建立是重中之重。为此,DDD 提出了事件风暴这一建立领域模型的方法。事件风暴是一个从发散到收敛的过程,通常采用用例分析、场景分析和用户旅程分析,全面分解业务领域,梳理领域对象之间的关系,这是一个发散的过程。在事件风暴过程中,会产生很多实体、命令、事件等领域对象,我们将这些领域对象从不同的维度进行聚类,形成如聚合、限界上下文等边界,建立领域模型,这就是一个收敛的过程。(参考6有详细介绍)
因此,DDD 可以帮助软件工程师建立清晰的领域模型,划分业务和应用边界,以指导微服务的设计和拆分。事件风暴是建立领域模型的主要方法,通过其发散的过程和聚合的过程,建立起合理的领域模型,从而实现高效的软件开发和落地。
我们可以用三步来划定领域模型和微服务的边界。
●
在领域驱动设计的战略设计中,我们采用事件风暴方法梳理业务过程中的用户操作、事件以及外部依赖关系等,从而梳理出领域实体等领域对象。
●
然后,我们根据领域实体之间的业务关联性,形成聚合,并确定聚合中的聚合根、值对象和实体。
●
接着,根据业务及语义边界等因素,将一个或多个聚合划定在一个限界上下文内,形成领域模型。这个过程中,我们建立了领域模型,划定了业务领域的边界,建立了通用语言和限界上下文,确定了领域模型中各个领域对象的关系。这些限界上下文可以作为微服务设计的参考边界,从而确定了应用端的微服务边界。
在从业务模型向微服务落地的过程中,即从战略设计向战术设计的实施过程中,我们会将领域模型中的领域对象与代码模型中的代码对象建立映射关系,将业务架构和系统架构进行绑定。当调整业务架构和领域模型以响应业务变化时,系统架构也会同时发生调整,并同步建立新的映射关系。这种方式可以帮助我们实现高效的软件开发和落地,从而更好地应对业务需求变化。
因此,通过领域驱动设计的战略设计和战术设计,我们可以清晰地划定领域边界,建立领域模型,帮助我们实现微服务的设计和拆分,同时也能够有效地响应业务变化,提高软件开发和落地的效率。
3.3 微服务与DDD的异同
DDD 是一种面向复杂业务领域的设计方法论,而微服务是一种面向分布式系统的架构风格。它们的共同目标都是通过将系统分解为更小,更易于管理的组件来提高系统的可维护性和可扩展性。
●
在 DDD 中,我们关注的是业务领域的划分和领域模型的设计,以便更好地理解业务需求,并将其转化为可执行的代码。
●
在微服务中,我们主要关注的是运行时的通信,容错和故障隔离,以及服务治理等问题,以确保各个微服务可以独立开发、测试、构建和部署。
●
通过结合 DDD 和微服务,我们可以更好地实现高效的业务逻辑,同时也可以充分利用微服务的优点,提高应用程序的可扩展性和可维护性。因此,DDD 和微服务是互补的,可以共同用于构建可靠且高效的应用程序。
四、参考
1.01 架构、复杂度与三原则
3.https://www.itcast.cn/news/20220329/17575248138.shtml
4.https://zq99299.github.io/note-book2/ddd/01/01.html#%E8%BD%AF%E4%BB%B6%E6%9E%B6%E6%9E%84%E6%A8%A1%E5%BC%8F%E7%9A%84%E6%BC%94%E8%BF%9B
5.https://time.geekbang.org/column/article/6990