last modified: 2021-11-13 12:37
不能简单的叙述,比如“单体架构是坏的,微服务架构是好的”。要用“生长的观念”来看待问题:每一个技术的发展和演进都是有原因的,“好用和优秀”并非放之四海皆准。这也是架构存在的意义,架构师需要依据对业务、团队现实状况的理解,“因地制宜”。
“生长的观念”是我在BitTiger上学来的,正暗合作者的观念。因此用“生成的观念”,梳理一下软件架构发展的脉络。
20世纪70年代末到80年代初,计算机经历了从大型机到微型机的蜕变。此时的微型机通常具有16位寻址、不足5MHz时钟频率的CPU和128KB左右的内存地址空间。硬件上有限的运算能力,直接影响了单台计算机上软件能够达到的最大规模。于是产生了对分布式最原始的探索:如何使用多台计算机共同协作来支撑同一套软件系统。
期间OSF(Open Software Foundation)联合计算机厂商制定了DCE(Distributed Computing Environment)的分布式技术体系,如RPC的鼻祖DCE/RPC(源自NCA),分布式文件系统DCE/DFS(源自AFS),源自Kerberos的服务认证规范。
然而分布式体系太过复杂,将一个系统拆分到不同的机器运行,为解决这样做带来的服务发现、跟踪、通信、容错、隔离、配置、传输、数据一致性和编码复杂度等方面的问题所付出的代价已远超分布式所带来的收益。并且随着摩尔定律的黄金时代到来,单机的性能问题得以缓解,软件转向单体系统时代。
讨论单体架构前,先要厘清一些基本概念。
大型的单体系统
许多微服务的资料中,单体系统都被贴上反面角色的标签,比如著名的《微服务架构设计模式》中第1章名字就是“逃离单体地狱”。其实这些资料中都有一个隐含的定语“大型的单体系统“。单体系统的不足,必须在软件的性能需求超过了单机、软件的开发人员明显超过了”2 Pizza Team”范畴的前提下才有讨论的价值。
单体系统并非指不可拆分(巨石)
单体与微服务架构的软体一样,也可以纵向分层,比如MVC架构。也可以横向按照技术、功能、职责等维度,将软件拆成各种模块,以便重用和管理代码。
单体系统真正的缺陷不在如何拆分,而在拆分之后的自治和隔离能力上。单体系统的优势在于,所有代码运行在同一进程中,所有模块、方法的调用都无须考虑网络分区、对象复制这些麻烦的事和性能损失,但同时也意味着,任何一部分代码出现缺陷,所造成的影响是全局性的、难以隔离的。同时也无法做到单个模块的停止、更新和升级,无法实现技术异构。(技术异构不是目标,而是解决特定问题的一种思路。)
随着软件架构的演进,构建可靠系统的观念也完成从“追求尽量不出错”到正视“出错是必然”的转变,这个观念转变,是微服务架构挑战和逐步取代单体架构的基石。
再来回顾一下,单体系统的缺点,除了算力之外,还有自治和隔离。自治和隔离需要将大型单体系统进行拆分,让每个子系统都可以独立部署、运行、更新。开发者尝试了“烟囱式架构(Information Silo Architecture)”,“微内核架构(Microkernel Architecture)”,“事件驱动架构(Event-Driven Architecture)”等各种解决方案。
软件架构来到SOA时代,其包含的许多概念、思想都能在微服务中找到对应身影,比如服务之前的松散耦合、注册、发现、治理,隔离、编排等。SOA针对这些问题提出了具体的解决方案,甚至针对“软件开发”这件事情本身,进行了更具体、更系统的的探索。
更具体:SOAP协议族,ESB,BPM,SDO,SCA…
更系统:指SOA的宏大理想,它的终极目标是总结出一套自上而下的软件研发方法论,做到企业只需要跟着SOA的思路,就能够一揽子解决掉软件开发过程中的全部问题。
但因SOA过于精密的流程和理论需要懂得复杂概念的专业人员才能够驾驭,它注定只能是阳春白雪式的奢侈品,很难作为普适性的软件架构风格推广。
2012年,在波兰克拉科夫举行的“33rd Degree Conference”大会上,Thoughtworks首席咨询师James Lewis做了题为“Microservices - Java, the UNIX Way”的演讲,其中提到单一职责、康威定律、自动扩展、领域驱动设计等原则,却只字未提SOA,反而号召应该重拾UNIX的设计哲学(As Well Behaved UNIX Service)。微服务于2014年,Martin Fowler与James Lewis合作的文章“Microservices: A Definition of This New Architectural Term”发表后“兴起”。
对于SOA“过于精密的流程和理论”,UNIX设计哲学成为治病良方,微服务追求更加自由的架构风格,摒弃了几乎所有SOA里面可以抛弃的约束和规范,提倡以“实践标准”代替“规范标准”。
Fowler的文中列举了微服务的9个核心的业务与技术特征:
微服务的风格,注重的是一种意境,但却不教给我们具体的方式。好处是让我们抛却SOA的心智负担,但同时亦是一把双刃剑。对于服务的注册发现、跟踪治理、负载均衡、故障隔离、认证授权、伸缩扩展、传输通信、事务处理等问题,微服务中不再有统一的解决方案。比如一个服务发现,就有Netfix的Eureka,HashiCorp的Consul,阿里的Nacos,Apache的Zookper,CoreOS的etcd,CNCF的CoreDNS等。
微服务时代充满着自由的气息,微服务时代充斥着迷茫的选择。
服务注册发现、跟踪治理等,是分布式中无法避免的问题,但换个思路思考一下,这些问题一定要由软件系统自己来解决吗?
答案是:No。虚拟化和容器技术(Docker,Kubernetes)、Service Mesh技术的发展,为在基础设施层面解决分布式系统的问题带来了答案。这是后微服务的时代,也称为云原生时代。
所有的RPC实现,都是在解决这3个问题:
不同语言,不同操作系统,不同硬件指令集,不同数据宽度、字节序。解决方案是各种序列化和反序列化协议。
不仅传递参数和返回结果,还要处理如异常、超时、安全、认证授权、事务等信息交换的需求。专门有一个名词Wire Protocol来表示这种在两个Endpoint之间交换数据的行为。
IDL(Interface Description Language)
事务保障系统中的数据都符合预期,且相关联的数据不产生矛盾。用一个词描述就是C(Consistency),要达成C,按经典数据库理论,需要A(Atomic)、I(Isolation)和D(Durability)来共同保障。
AID在保障单服务访问单数据源场景下是有用的,但分布式场景中会存在多服务访问多数据源的场景需求,因此事务的讨论中,我们需要分场景:
要实现事务的原子性和持久性,数据必须落入磁盘。但写入磁盘过程中,程序可能崩溃,另外写入磁盘不仅有”已写入”和”未写入”两处状态,还存在着”正在写”的中间状态。
这种崩溃和中间状态要求我们有其它机制保证原子性和持久性。
修改磁盘数据不能像修改内存中的变量值一样直接修改,而应该把这个操作的所有信息,包括修改什么数据,数据物理上位于哪个磁盘块,从什么值修改成什么值等,以及日志的形式,以append的方式记录到文件中。
只有在日志安全落盘,数据库在日志中看到代表事务成功的”Commit Record”之后,才根据日志上的数据真正修改数据,修改完成后再在日志追加一条”End Record”表示事务已经完成持久化。这种实现方式称为”Commit Logging”。
Redo Log就是Commit Logging的具体实现。
它的原理不难理解,当数据库未写”Commit Record”时,事务失败,将上一个成功commit日志后的日志标记为回滚即可。
当Commit Record成功,但End Record失败,数据库根据日志重做事务,然后写入End Record。注意这里日志中的操作都应该是幂等的。
Commit Logging有一个很大的缺陷,因为只有在Commit Record日志写入后,才进行真实的事务的数据修改,在这之前即使I/O负载低,也不能利用到。
因此ARIES(后面介绍,这是数据库的一篇重要论文)提出了优化方案:”Write-Ahead Logging”,即允许在事务提交前写入变动的数据。
Write-Ahead Logging依据事务提交的时间点,将何时写入数据变动分为FORCE和STEAL两种情况。
Commit Logging支持NON-FORCE,但不支持STEAL。
Write-Ahead Logging即支持NON-FORCE,也支持STEAL。它实现的方式是增加另一种Undo Log的日志类型,当变动数据写入磁盘前,必须先记录Undo Log标注修改了哪里的什么数据,由什么值改为什么值,以便在崩溃或回滚时,对提前写入的数据进行擦除。Undo Log也应该被设计成幂等的。
隔离性保证每个事务各自读取,不相互影响。隔离跟并发相关,如果所有事务串行,那不需要任何隔离。
要实现并发访问数据,最简单的方案是加锁。现代数据库都提供3种锁,写锁(X-Lock,eXclusive Lock)、读锁(S-Lock,Shared Lock)和范围锁(Range Lock)。
以锁为手段来解决数据库隔离性问题,导致数据库表现出不同的隔离级别:串行化(Serializable),可重复读(Repeatable Read),读已提交(Read Committed)和读未提交(Read Uncommitted)。以及幻读、不可重复读和脏读等现象。事务隔离级别并不是数据库固有的属性,这点要有清晰认知。
除了锁的实现外,以上幻读、不可重复读和脏读等问题,都有一个共同特点:都是读的过程中,另一个写数据的事务影响而破坏了隔离性,针对这种“一个事务读+一个事务写”的问题,目前主流数据库采用MVCC(Multi-Version Concurrency Control)的无锁优化方案。MVCC不再赘述。
除了“读+写”,另外还一种“写+写”的情形,只能通过锁来实现,区别是“乐观锁”还是”悲观锁“,这里要注意的一点,不要迷信乐观锁一定比悲观锁性能好,要看具体场景,在竞争激烈的情况下,悲观锁性能更优。
全局事务这里指单服务访问多数据源。XA是处理全局事务的通用规范 ,JAVA中的实现为JTA。核心是2PC即两阶段提交。
2PC的缺点是:

这里的分布式事务与DTP(Distributed Transaction Processing)模型中的概念不同。DTP中的分布式是针对多数据源来说的,不涉及服务。而这里的分布式是相对于服务来说的。
事务中,我们追求的是C,手段是ADI,而分布式下,由于CAP理论,我们通常放弃C而追求AP。这与事务的目的相违背。我们把CAP,ADIC下讨论的一致性称为“强一致性”,把在分布式下,实现AP但又追求的C称为“弱一致性”。
由于一致性的定义变化,事务一词的含义也被扩展了,人们把ACID的事务称为“刚性事务”,把接下来的几种分布式事务统称为“柔性事务”。
2008, Dan Pritchett, “Base: An Acid Alternative”
使用可靠事件队列的一次购物行为事务过程:账号服务扣款,商家服务收款,仓库服务扣库存

2007, Pat Helland, “Life beyond Distributed Transactions: an Apostate’s Opinion”
可靠事件队列的问题在于隔离性,它会导致超卖的问题。而对于刚性事务,只要隔离级别足够,比如Repeatable Read,则该问题可完全避免,只要加锁失败即可。
而TCC方案天生适合对隔离性有要求的业务。以下是购物使用TCC的事务过程

TCC类似2PC,但它不是在基础设施层面实现,而是在业务代码层面实现,对业务有比较大的侵入性。
TCC在隔离性、性能上表现都非常好,但是对业务有较强的侵入性。除了上面提到的需要业务编码,更重要的是它要求的技术可控性上的约束。比如上述购物场景,如果用户不需要在系统充值,而是直接走网银服务,银行不太可能配合实现冻结、解冻这些操作,所以TCC的第一步Try将无法实施。这种情况只能考虑另一种柔性事务方案:SAGA。
1987, SAGA, SAGAS
SAGA事务原理:
将一个大的分布式事务,拆解成一系列子事务(本地事务)集合。SAGA由两部分操作组成:
如果T1到Tn都成功提交,那事务顺利完成。否则采取下面两种恢复策略之一
没有一个包治百病的分布式事务方案,都是在隔离性、性能及架构上做的权衡。
这章讲的都是基础知识,但作者的归纳很新颖:“客户端缓存、域名解析、传输链路、CDN、负载均衡、服务端缓存,都是为了达成‘透明分流’这个目标所采用的工具和手段,高可用架构、高并发则是通过‘透明分流’所获得的价值。”

CDN的核心是通过CNAME,将解析主导权交由CDN厂商架设的CNAME权威DNS服务器,该服务器可以根据网络拓扑、时延、容量等信息和一定策略,选择一个最合适的IP给用户。
略
略
软件研发中任何一项技术、方法、架构都不可能是银弹