本文主要写的是本人对软件架构(主要指业务架构)的认识,以及设计过程中的思维方式,没有包含具体的操作方法,看起来可能感觉没有什么实质性内容,建议读者联系自身实际工作中遇到的问题,也许能有更多的收货。
理解架构,主要想清楚几个问题。什么是架构?架构解决什么问题?架构如何产生和演进?
什么是系统?
在说架构之前,必须理解什么是系统,因为架构是指某种系统的架构。钱学森在《论系统工程》中对系统的定义非常精准。
我们把极其复杂的研制对象称为系统,即由互相作用和互相依赖的若干组成部分结合成的具有特定功能的有机整体,而且这个系统本身又是它所从属的一个更大的系统的组成部分。
什么是架构?
一个系统会由 N 个子系统构成,且每个子系统提供特定的能力服务于整体,子系统的业务范围是明确且完整自洽的,既服务于整体,又独立自主。子系统之间交换的信息是明确且简洁的,子系统间的依赖关系是层次清晰的,因为这两者本质上都受限于子系统的业务范围。即,当 B 子系统需要借助 A 子系统的能力,完成自身的业务时,B 依赖 A,B 应当提供 A 业务范围内最简洁的信息,A 应当返回在自身业务范围内 B 需要的结果。这样的 N 个子系统,就构成了系统,而架构,就是子系统的业务范围和子系统之间交互的逻辑,包括依赖关系,和交互的信息。架构设计,就是在明确业务的情况下,找到最合适的子系统划分和子系统之间的交互逻辑,再找到合适的技术手段组合以实现架构,且随着业务的发展不断调整。
为什么需要架构设计?
许多人在同一生产过程中,或在不同的但互相联系的生产过程中,有计划的一起协同劳动,这种劳动形式叫做协作。--卡尔·马克思
从工程角度来说,只要是协作,就存在“总体”和“协调”问题,当一个复杂系统的子系统彼此不协调的时候,即使每个子系统有着非常良好的实现,非常的高性能,但这个总系统,依然是不会合格的。所以在具有一定规模之后,架构设计就非常重要,也是必须的。架构设计可以解决在大规模协作的过程中的信息不对称,解决问题的路径不统一,对业务的理解不一致,之类的问题,从而有效的管理单一组件的变动对其它组件的影响。
从财务角度看,就是“降本增效”。财务关心主要三个指标,销售收入,成本,费用。良好架构设计,可以提升开发测试效率,从而提升组织运营的效率,反映到财务上,就是“费用”降低,效果类似裁员。而且很多时候,良好的架构设计,对于降低产品成本,增加可销售的商品,提升商品销售的效率,都有帮助,这取决于具体的业务,但至少不会因为系统本身设计上的缺陷反过来制约业务的发展。
善战者无赫赫之功,善医者无煌煌之名,良好的架构设计,可以让整个工作过程保持在一种比较平稳,可控,舒服的节奏中,而不是经常返工,处理紧急问题,然后这些额外占用的时间,又反过来降低软件质量,而低质量又会增加额外的时间支出,进入恶性循环。因为良好的架构对业务和技术的复杂性进行了有效的管控。
架构是主观的吗?
架构是由人设计的,似乎是主观的。但架构并不能随心所欲,实则受到多方约束。
架构从根本上来说是由业务决定的,比如一个销售的系统,当明确业务是销售的时候,最粗的架构就已经确立好了,销售系统必须清晰的表达在销售的商品,且知道商品当前能不能销售,销售完成后要留下凭据。商品,库存,交易,这三个子系统就是必须存在的,没有这三个概念,无法完成销售这件事情。在设计的过程中,必须对“业务”这一客观存在,有清晰到位的主观认知,如果认知有误,那么架构大概率会变形,从而进入低效开发状态。
架构设计,应当是代表群体做的判断,是整个团队,包括开发,产品,测试,各种角色之间,共同的对业务的理解。因为共同的理解,才能防止协作过程中的合成谬误,即大家各自认为某种设计有利于整个系统,并实施了,但由于设计理念和实现路径不一致,反而对系统本身其实是有害的,会使系统复杂度的增长速度高于业务复杂度的增长速度。在项目管理中,需要有相应的机制,尽量避免这种情况发生。
所以我认为架构是客观的,当然这里的“客观”不是自然科学意义上的客观,更类似于大众审美,对于美丑会有一个群体性判断,判断结果会随着社会发展而变化。架构也是如此,比如当数据量较多的时候,常常采用分库分表,或者分布式数据库方案,但是当单机性能足够强力,尤其是 cpu 核心数内存容量猛涨,nvme ssd 4k 随机读写速度高出 hdd 几个数量级的时候,分布式方案也许就不那么划算了。
超越需求
架构的起点,在于业务,在于真正弄清楚业务本身,而不是停留于表面的对需求的描述上。在设计架构之前,需要从,产品 prd,ork,部门规划等各种有效信息之中,通过苏格拉底式追问,现象学还原,等各种手段,还原出需求背后的目的。架构不是对需求的翻译,而是和需求平级的东西。需求和架构是对业务本身的不同形式的表达,出发点是相同的,只是一个面向技术人员,一个面向用户。架构需要在实现需求的基础上超越需求,基于需求的目的,以增强系统能力的形式来满足功能需求,这种能力的增强,也许需要增加一个新的子系统,也许需要扩展某个子系统的业务定义,且在能力增强后,系统依然保持结构清晰。
业务和技术
当我们弄清楚需求及其背后的目的之后,会得到一个清晰的业务架构,再基于业务架构,每个子系统的业务的特性,如数据量,QPS,等等,以及系统现状,设计技术架构,找到合适的技术手段,来实现各个子系统以及子系统的交互。技术架构包括但不限于,存储架构,运维架构,数据架构,安全架构,网络架构等等,要从这些不同的角度审视技术方案。每个子系统应当在逻辑上可以独立自主的提供服务,设计做到足够简单,遵循奥卡姆剃刀原则,减少依赖,减少系统内存在的概念,尽量避免重复,包括逻辑重复,实现重复,功能重复,不同组件承担的职责重复,数据重复等各种形式的重复,重复乃是万恶之源。
演进
架构本质上是实践的,实践是指当业务发生调整,系统、子系统承载的概念发生变化,业务范围扩展,从而改变子系统之间的关系,或者加入新的子系统。架构应当是个动词,架构本质上是自我批判的,架构必须随着业务的发展而发展。架构没有固定的范式,没有银弹,每一家公司的各个方面,包括技术栈,资金,时间,人员,文化等等方面都有其特殊性,因此无法用纯粹的理性推导出普适的架构,不是“发现”了某种新架构,然后再将这种架构搬到实际工作中,而是为了解决实际工作中出现的问题,找到了比较通用的解决方案,从而沉淀出新的架构,包括六边形架构,cola,mvc,云原生等等,都是如此。
所以不要被既有的范式束缚一定要用某种技术,或者某种技术必须如何使用,不要机械、教条地理解前人总结的方案,更应当关注的是产生方案的思考过程,当时的背景下面临的取舍。要着眼于问题本身,抛开问题聊技术,没有太大意义。业务发展总是出人意料,架构应当随着业务的发展而及时作出调整。在业务发生调整,本身的性质发生变化的时候,架构必须适应新的变化,也是调整架构的最佳时机。还有一点是,人必然是会犯错的,所以架构也不可能做到最佳,为了避免包袱越来越重直至失控,需要在制度层面保证具备架构调整的空间,形成良性循环。
抽象
第二部分说一下抽象,我愿称抽象为理解业务的过程。当对业务有足够深刻的理解,自然能做出到位的抽象,也就有了合适的业务架构,接着才有技术架构,安全架构,数据架构,运维架构等等,甚至是组织架构,一切都服务于要做的事情,架构设计始于抽象。常见理解业务的方法有,自上而下,自下而上,四色建模法,用例分析法,事件风暴法,等等,顺带一提,我认为 DDD 也是一种分析理解业务的方法论,其最大的作用还是在于帮助理解梳理业务,而不是一个有固定操作规程的设计范式,。抽象的起点是找到合适的看待业务的角度,同一个事情在不同角色的眼中是完全不同的。
比如卖衣服这个事情,从财务视角看,关注的是金额,卖了多少钱,毛利多少,从交易视角看,则更关注完成交易的每个阶段的状态变化,金额只是需要记录的一个普通信息而已。我个人比较习惯先自上而下,再自下而上。
第一步,自上而下分析,总结出负责的业务,在更高层级的业务中起到什么作用,尽量能用一句话总结,总结出来的这句话就会是合适的看待这部分业务的视角。再从这个视角出发,从混沌的业务现状中分析出共性,找到通用逻辑,再用这个逻辑自下而上的从细节出发重新理解和组织具体的业务。刚开始接触一个新业务时,理解是比较混沌的,经过梳理和抽象,理解就会变得有序,经过这个过程,就能得出各子系统业务边界的划分,以及依赖关系,交互方式,设计也就完成了。
最后,马克思透过资本雇佣劳动这一社会现象,发现更本质的 social power,和支配与被支配的社会关系,这一思维过程以及他使用的方法非常值得学习,对于理解业务和学习技术都很有帮助。
设计原则或者约束
最后一部分主要讲工作中的一些自我约束,不涉及具体的实际操作方法。其中多条本质上都是奥卡姆剃刀的延伸,约束适于用架构层面,也适用于函数。
不重复
消除各种层面的重复。函数层面逻辑重复,比如两个逻辑一样,但是入参却不同的函数,功能重复,比如 A 页面展示的内容和 B 页面相同,或者是子集,但是由两个独立的接口提供查询功能,可能产品层面就不应该出现两个存在包含关系的页面。完成一个行为的逻辑只应当存在一种。架构层面,多个子系统做了相同的事情,比如同时存在多个权限账号系统。数据层面,相同的数据,持久化在了相同功能的不同空间内。组织架构层面,不同的成员独立做着相同的事情等等。重复是系统腐化的开端,值得警惕。
依赖少
解决一类问题应当只使用一种手段。如常用的 CollectionUtils,用 Apache 的就行。服务注册发现,也不要 nacos 和 zk 混用。尽量将原有依赖充分利用,再引入新依赖,比如查询慢,先优化 SQL,再使用缓存。减少对外部特性的依赖,一旦依赖外部特性,内部外部就组成了不可分割的整体,扩展和替换就变得困难。比如消费者消费消息,依赖消息顺序,就要求生产者保证顺序,mq 保证顺序,异常处理,扩缩容,替换消息中间件之类的操作就变得非常困难,如果能用时间戳或者版本号或者改变业务逻辑,规避这种依赖,整个生产消费过程耦合性就低,各个环节相对独立,可以自由迭代。
概念少
技术和业务层面的概念都需要减少,避免用多个概念描述或解决一个问题,也要避免过早引入某些概念。比如 DDD,Eventbus,CQRS,等等。这些概念,通常是前人解决某些问题总结出来的最佳实践,即使不知道这些概念,遇到相应问题,寻求解决办法,解决之后就能发现,殊途同归。当然,提前知道某某技术对解决相应的问题是有指导意义的,能拓宽思路,避免闭门造车,加速解决问题。
先出现问题,再进入技术概念解决问题,而不是在没问题的时候,尝试某种技术,于是把这个技术概念引入进来,在引入新概念的时候,必须深思熟虑,权衡利弊,从技术本身、落地时间、人员、业务方向等多种维度考量。引入一个东西很简单,但是要改造,要发展,要去掉,就困难重重。我曾经见过一个 App,先原生开发,然后混合开发,之后引入 weex,接着又引入 Flutter,最后导致开发的时候要同时写 js,weex 和 flutter 的代码,工作效率极低。
面条也不错
面条代码非常适合用来实现线性业务流程。当然这里说的不是普通面条,是符合设计模式六大原则的面条。面条代码有一些不容忽视的优点,面条是最朴素,开发不经过任何训练就会的代码组织方式,所有人都会写。代码携带的信息基本上只包含了业务逻辑,即使是上千行的函数,只要有耐心,也能比较可控的把大函数拆解成多个独立的小函数,重新组织代码结构。
但是经过复杂设计的代码,业务逻辑被设计隐藏在设计者对业务理解里面,不同人,同人不同时间的理解,必然有差异,如果理解不一致的多个人往系统里增加了自己的理解,或者业务发展超出预期,系统会迅速变得非常复杂,事实上,理解不同和业务发展超出预期,几乎是必然的事情。越接近面条,越简单,但是也不能无脑面条,需要用设计模式六大原则约束面条质量,随着业务逐渐复杂,纯面条代码必然产生重复,这才是进行更复杂的抽象和设计的较好时机。由业务复杂推动系统设计变复杂,而不是一开始就用复杂的设计去实现业务,系统的复杂度应当来自于业务,而不是实现业务的方式。
注重扩展性
扩展性很关键的是数据持久化和业务逻辑层面的扩展性,表结构大改,或者数据迁移是非常困难且麻烦的。扩展性体现在两点:
1. 业务逻辑应当是系统逻辑的子集,系统能力范围应当大于业务需求。
2. 系统操作的元素的颗粒度应当小于业务颗粒度,最好直接找到最小颗粒度。
举个例子,比如需求是展示月支出。实现方式 A,每个月记录一条数据,每次进行消费的时候累加支出金额,每个月从头开始。B 记录下每一笔支出的金额以及时间,对时间范围内的支出进行求和来展示。A 虽然可以满足需求,但是几乎不具备扩展性。B 的扩展性就好的多,B 找到了该业务的两个最小颗粒度,即每一笔支出和产生这笔支出的某个时间点,B 的能力大于业务需求,系统可以支持计算任意时间段的总支出,月支出只是一种特殊的时间段。当需求变为展示周支出,或者用户选择时间段的,或展示当月最高的一笔支出,都可以轻松实现。
防御
防止外部相关上下游影响自身的稳定性和复杂度,主要分两方面:
1. 稳定性层面防御,所有参与交互的要素都是不可靠的,包括依赖的下游、上游使用方、缓存、mq、数据库,各种中间件,都是不可靠的,需要在各个部分出问题的时候,在成本可控的情况下将影响降到最低,至少做到依赖组件挂了不需要订正数据,做好防御,可以缩小故障影响范围。比如下游返回 null,导致空指针,上游出现死循环疯狂调用,上游传入奇怪参数,缓存挂了能不能降级,mq 挂了消息能否补发,等等。
2. 业务逻辑层面防御,这个主要体现在上下游业务边界的处理,防止外部复杂度入侵到系统内部。对待上游,需要明确系统自身的业务边界,本系统的定位是做什么的,系统具备什么样的能力,应当提供什么功能的接口。对待下游,如果下游提供的接口,暴露了他内部实现逻辑,且无法改动,那么应该进行封装,将下游提供的,转换为上游需要的,防止下游逻辑入侵到上游,如果下游不能给出非常符合上游需求的封装,应由上游调用者自行封装。
寻找盲点
必须追溯系统逻辑的可靠性的支点是什么,以发现一些容易忽视的薄弱环节。包括但不限于,是否存在单点故障,业务逻辑的严密性,外部依赖,外部输入的准确性是否可以保证,某些数据的准确性是否依靠人工维护,等等各种能想到的角度。比如你设计了一套非常健壮的系统,业务异常处理严谨,三地五中心,各种容灾,冗余,然而,一部分业务数据来自于人工录入,且没有从技术层面保证录入的准确性,然后录入数据的人某天不小心填错了什么,导致系统的输出错乱。必须有意识的去寻找盲点,需求评审、技术方案评审、测试用例评审,这些阶段都是发现盲点的好机会。
关注变化
此处的变化是指数据量的变化趋势,系统本质上就是在处理各种数据,数据的增长速度对系统的设计影响很大,在设计时必须预留足够的应对数据量增长的冗余,以及具备观测能力,在量变引起质变导致出现大问题之前解决数据量过大的问题。预留的尺度取决于对业务增长的预判,以及公司的目标。
隔离
隔离的目的在于将异常情况的影响面控制到最小。
业务逻辑层面的隔离,比如批量处理 N 个用户的数据时,不同用户之间应当隔离,某个用户发生任何异常都不应当影响其它用户。
不同运维级别应用的资源之间的隔离,越核心的应用,资源越应该独享,比如核心和非核心应用的数据库部署到一个实例中,非核心应用的慢查询耗尽 CPU,导致核心应用一起挂,缓存之类资源也是如此。
客户维度的流量隔离,以避免普通客户触发问题影响到重要客户,也能一定程度上缩小故障影响范围,如某些用户数据异常导致内存溢出之类场景。
自愈
依赖的组件挂了恢复之后,系统应当能够自动恢复,不需要人工干预。比如订单创建成功消息这种重要的消息,mq 挂掉恢复之后,能够自动补发。在实现业务逻辑的时候,要关注鲁棒性,TCP 的 ACK 机制就有很好的鲁棒性,在丢包的情况下,协议依然可以正常工作。
局部性
碰到比较多出问题的是空间局部性,尽量把需要读取的数据批量一次性加载到内存中,而不是在循环里单独加载,减少 IO,因局部性影响接口性能情况非常常见,做好局部性,就少了很多接口 RT 优化工作,降低因某些接口到达临界值变慢导致系统雪崩的风险。
注重用户体验
用户体验主要指对外提供的接口,在能力范围内,尽可能把方便留给别人,把麻烦留给自己。能力范围指的是应用的业务边界,应用有自己负责的业务范围,围绕这部分业务进行建模设计,这个模型应当是稳定的,这一部分描述了应用最大的能力范围,基于这部分能力,再进行封装,简化,针对上游需求,提供最方便合理的接口,类似 kernel 和 shell 的关系,就是前文提到过的,当 B 子系统需要借助 A 子系统的能力,完成自身的业务时,B 依赖 A,B 应当提供 A 业务范围内最简洁的信息,A 应当返回在自身业务范围内 B 需要的结果。
及时消除坏味道
坏味道就是违反上述原则的情况,最容易出现的就是重复。
业务的发展,总是会超出开发和产品的预期,必定会出现意想不到的情况。当业务超出预期时,系统原先的设计就会变得不合时宜,即使业务按预期发展,前期的设计也不可能面面俱到,一定会有疏忽,在迭代的过程中会发现之前设计中不合理的地方,坏味道就出现了,如果放任坏味道的增加,在坏味道的基础上继续迭代,系统必然积重难返。
坏味道应当尽可能扼杀在摇篮之中,虽然短期看可能投入了时间人力系统功能却没变化,但是从长时间尺度看,会节省更多时间,在积重难返的系统上做迭代,非常低效。功能级别的局部坏味道,可以在迭代到相关功能的时候,顺手优化,在测功能的同时能覆盖到优化,节约测试成本,如果是应用或者架构级的坏味道,就要根据后续业务发展方向看投入产出比,这些坏味道是否值得投入这么多资源去消除。如果局部坏味道可以及时消除,通常也不容易腐化到应用级。
结尾
最后,这些原则在合适的时候都是可以打破的,不要机械、教条地理解这些原则,更多是去考虑,产生这些原则的背景条件,在什么情况下适合使用这些原则,原则要达到的目的,局限性在哪里,什么时候应当舍弃。
作者简介:
楼钟炳,Zenlayer 资深软件工程师,目前关注 SDN 领域。
评论 2 条评论