从概念中我们可以提取三个关键词:可独立运行、可协同工作、小。这三个词高度概括了微服务的核心特性。下面我们就对这三个词作详细解释。
随着大数据时代的到来,业务系统的数据量日益增大,数据存储能力逐渐成为影响系统性能的瓶颈。目前主流的关系型数据库单表存储上限为1000万条记录,而这一存储能力显然已经无法满足大数据背景下的业务系统存储要求了。随着微服务架构、分布式存储等概念的出现,数据存储问题也渐渐迎来了转机。而数据分片是目前解决海量数据持久化存储与高效查询的一种重要手段。数据分库分表的过程在系统设计阶段完成,要求系统设计人员根据系统预期的业务量,将未来可能出现瓶颈的数据库、数据表按照一定规则拆分成多个库、多张表。这些数据库和数据表需要部署在不同的服务器上,从而将数据读写压力分摊至集群中的各个节点,提升数据库整体处理能力,避免出现读写瓶颈的现象。
数据库扩展一共有四种分配方式,分别是:垂直分库、垂直分表、水平分表、水平数据分片。每一种策略都有各自的适用场景。
垂直分库 垂直分库即是将一个完整的数据库根据业务功能拆分成多个独立的数据库,这些数据库可以运行在不同的服务器上,从而提升数据库整体的数据读写性能。这种方式在微服务架构中非常常用。微服务架构的核心思想是将一个完整的应用按照业务功能拆分成多个可独立运行的子系统,这些子系统称为“微服务”,各个服务之间通过RPC接口通信,这样的结构使得系统耦合度更低、更易于扩展。垂直分库的理念与微服务的理念不谋而合,可以将原本完整的数据按照微服务拆分系统的方式,拆分成多个独立的数据库,使得每个微服务系统都有各自独立的数据库,从而可以避免单个数据库节点压力过大,影响系统的整体性能,如下图所示。
垂直分表 垂直分表如果一张表的字段非常多,那么很有可能会引起数据的跨页存储,这会造成数据库额外的性能开销,而垂直分表可以解决这个问题。垂直分表就是将一张表中不常用的字段拆分到另一张表中,从而保证第一章表中的字段较少,避免出现数据库跨页存储的问题,从而提升查询效率。而另一张表中的数据通过外键与第一张表进行关联,如下图所示。
水平分表 如果一张表中的记录数过多(超过1000万条记录),那么会对数据库的读写性能产生较大的影响,虽然此时仍然能够正确地读写,但读写的速度已经到了业务无法忍受的地步,此时就需要使用水平分表来解决这个问题。水平分表是将一张含有很多记录数的表水平切分,拆分成几张结构相同的表。举个例子,假设一张订单表目前存储了2000万条订单的数据,导致数据读写效率极低。此时可以采用水平分表的方式,将订单表拆分成100张结构相同的订单表,分别叫做order_1、order_2……、order_100。然后可以根据订单所属用户的id进行哈希取模后均匀地存储在这100张表中,从而每张表中只存储了20万条订单记录,极大提升了订单的读写效率,如下图所示。当然,如果拆分出来的表都存储在同一个数据库节点上,那么当请求量过大的时候,毕竟单台服务器的处理能力是有限的,数据库仍然会成为系统的瓶颈,所以为了解决这个问题,就出现了水平数据分片的解决方案。
水平分库分表 水平数据分片与数据分片区别在于:水平数据分片首先将数据表进行水平拆分,然后按照某一分片规则存储在多台数据库服务器上。从而将单库的压力分摊到了多库上,从而避免因为数据库硬件资源有限导致的数据库性能瓶颈,如下图所示。
目前常用的数据分片策略有两种,分别是连续分片和离散分片。
众所周知,数据库能实现本地事务,也就是在同一个数据库中,你可以允许一组操作要么全都正确执行,要么全都不执行。这里特别强调了本地事务,也就是目前的数据库只能支持同一个数据库中的事务。但现在的系统往往采用微服务架构,业务系统拥有独立的数据库,因此就出现了跨多个数据库的事务需求,这种事务即为“分布式事务”。那么在目前数据库不支持跨库事务的情况下,我们应该如何实现分布式事务呢?本文首先会为大家梳理分布式事务的基本概念和理论基础,然后介绍几种目前常用的分布式事务解决方案。
事务由一组操作构成,我们希望这组操作能够全部正确执行,如果这一组操作中的任意一个步骤发生错误,那么就需要回滚之前已经完成的操作。也就是同一个事务中的所有操作,要么全都正确执行,要么全都不要执行。
说到事务,就不得不提一下事务著名的四大特性。
注意:事务只能保证数据库的高可靠性,即数据库本身发生问题后,事务提交后的数据仍然能恢复;而如果不是数据库本身的故障,如硬盘损坏了,那么事务提交的数据可能就丢失了。这属于『高可用性』的范畴。因此,事务只能保证数据库的『高可靠性』,而『高可用性』需要整个系统共同配合实现。
在事务的四大特性ACID中,要求的隔离性是一种严格意义上的隔离,也就是多个事务是串行执行的,彼此之间不会受到任何干扰。这确实能够完全保证数据的安全性,但在实际业务系统中,这种方式性能不高。因此,数据库定义了四种隔离级别,隔离级别和数据库的性能是呈反比的,隔离级别越低,数据库性能越高,而隔离级别越高,数据库性能越差。
我们先来看一下在不同的隔离级别下,数据库可能会出现的问题:
当有两个并发执行的事务,更新同一行数据,那么有可能一个事务会把另一个事务的更新覆盖掉。当数据库没有加任何锁操作的情况下会发生。
一个事务读到另一个尚未提交的事务中的数据。该数据可能会被回滚从而失效。 如果第一个事务拿着失效的数据去处理那就发生错误了。
不可重复度的含义:一个事务对同一行数据读了两次,却得到了不同的结果。它具体分为如下两种情况:
虚读:在事务1两次读取同一记录的过程中,事务2对该记录进行了修改,从而事务1第二次读到了不一样的记录。 幻读 :事务1在两次查询的过程中,事务2对该表进行了插入、删除操作,从而事务1第二次查询的结果发生了变化。
不可重复读 与 脏读 的区别? 脏读读到的是尚未提交的数据,而不可重复读读到的是已经提交的数据,只不过在两次读的过程中数据被另一个事务改过了。
数据库一共有如下四种隔离级别:
1.Read uncommitted 读未提交在该级别下,一个事务对一行数据修改的过程中,不允许另一个事务对该行数据进行修改,但允许另一个事务对该行数据读。 因此本级别下,不会出现更新丢失,但会出现脏读、不可重复读。
2.Read committed 读提交在该级别下,未提交的写事务不允许其他事务访问该行,因此不会出现脏读;但是读取数据的事务允许其他事务的访问该行数据,因此会出现不可重复读的情况。
3.Repeatable read 重复读在该级别下,读事务禁止写事务,但允许读事务,因此不会出现同一事务两次读到不同的数据的情况(不可重复读),且写事务禁止其他一切事务。
4.Serializable 序列化该级别要求所有事务都必须串行执行,因此能避免一切因并发引起的问题,但效率很低。
隔离级别越高,越能保证数据的完整性和一致性,但是对并发性能的影响也越大。对于多数应用程序,可以优先考虑把数据库系统的隔离级别设为Read Committed。它能够避免脏读取,而且具有较好的并发性能。尽管它会导致不可重复读、幻读和第二类丢失更新这些并发问题,在可能出现这类问题的个别场合,可以由应用程序采用悲观锁或乐观锁来控制。
到此为止,所介绍的事务都是基于单数据库的本地事务,目前的数据库仅支持单库事务,并不支持跨库事务。而随着微服务架构的普及,一个大型业务系统往往由若干个子系统构成,这些子系统又拥有各自独立的数据库。往往一个业务流程需要由多个子系统共同完成,而且这些操作可能需要在一个事务中完成。在微服务系统中,这些业务场景是普遍存在的。此时,我们就需要在数据库之上通过某种手段,实现支持跨数据库的事务支持,这也就是大家常说的“分布式事务”。 这里举一个分布式事务的典型例子——用户下单过程。 当我们的系统采用了微服务架构后,一个电商系统往往被拆分成如下几个子系统:商品系统、订单系统、支付系统、积分系统等。整个下单的过程如下:
上述步骤2、3、4需要在一个事务中完成。对于传统单体应用而言,实现事务非常简单,只需将这三个步骤放在一个方法A中,再用Spring的@Transactional注解标识该方法即可。Spring通过数据库的事务支持,保证这些步骤要么全都执行完成,要么全都不执行。但在这个微服务架构中,这三个步骤涉及三个系统,涉及三个数据库,此时我们必须在数据库和应用系统之间,通过某项黑科技,实现分布式事务的支持。
CAP理论说的是:在一个分布式系统中,最多只能满足C、A、P中的两个需求。 CAP的含义:
CAP理论告诉我们,在分布式系统中,C、A、P三个条件中我们最多只能选择两个。那么问题来了,究竟选择哪两个条件较为合适呢? 对于一个业务系统来说,可用性和分区容错性是必须要满足的两个条件,并且这两者是相辅相成的。业务系统之所以使用分布式系统,主要原因有两个:
提升整体性能当业务量猛增,单个服务器已经无法满足我们的业务需求的时候,就需要使用分布式系统,使用多个节点提供相同的功能,从而整体上提升系统的性能,这就是使用分布式系统的第一个原因。
实现分区容错性单一节点 或 多个节点处于相同的网络环境下,那么会存在一定的风险,万一该机房断电、该地区发生自然灾害,那么业务系统就全面瘫痪了。为了防止这一问题,采用分布式系统,将多个子系统分布在不同的地域、不同的机房中,从而保证系统高可用性。
这说明分区容错性是分布式系统的根本,如果分区容错性不能满足,那使用分布式系统将失去意义。 此外,可用性对业务系统也尤为重要。在大谈用户体验的今天,如果业务系统时常出现“系统异常”、响应时间过长等情况,这使得用户对系统的好感度大打折扣,在互联网行业竞争激烈的今天,相同领域的竞争者不甚枚举,系统的间歇性不可用会立马导致用户流向竞争对手。因此,我们只能通过牺牲一致性来换取系统的可用性和分区容错性。这也就是下面要介绍的BASE理论。
CAP理论告诉我们一个悲惨但不得不接受的事实——我们只能在C、A、P中选择两个条件。而对于业务系统而言,我们往往选择牺牲一致性来换取系统的可用性和分区容错性。不过这里要指出的是,所谓的“牺牲一致性”并不是完全放弃数据一致性,而是牺牲强一致性换取弱一致性。下面来介绍下BASE理论。
BA:Basic Available 基本可用整个系统在某些不可抗力的情况下,仍然能够保证“可用性”,即一定时间内仍然能够返回一个明确的结果。只不过“基本可用”和“高可用”的区别是:
S:Soft State:柔性状态同一数据的不同副本的状态,可以不需要实时一致。 E:Eventual Consisstency:最终一致性 同一数据的不同副本的状态,可以不需要实时一致,但一定要保证经过一定时间后仍然是一致的。
ACID能够保证事务的强一致性,即数据是实时一致的。这在本地事务中是没有问题的,在分布式事务中,强一致性会极大影响分布式系统的性能,因此分布式系统中遵循BASE理论即可。但分布式系统的不同业务场景对一致性的要求也不同。如交易场景下,就要求强一致性,此时就需要遵循ACID理论,而在注册成功后发送短信验证码等场景下,并不需要实时一致,因此遵循BASE理论即可。因此要根据具体业务场景,在ACID和BASE之间寻求平衡。
下面介绍几种实现分布式事务的协议。
8.1 两阶段提交协议 2PC分布式系统的一个难点是如何保证架构下多个节点在进行事务性操作的时候保持一致性。为实现这个目的,二阶段提交算法的成立基于以下假设:
1、第一阶段(投票阶段):
2、第二阶段(提交执行阶段):
当协调者节点从所有参与者节点获得的相应消息都为"同意"时:
如果任一参与者节点在第一阶段返回的响应消息为"中止",或者 协调者节点在第一阶段的询问超时之前无法获取所有参与者节点的响应消息时:
不管最后结果如何,第二阶段都会结束当前事务。
二阶段提交看起来确实能够提供原子性的操作,但是不幸的事,二阶段提交还是有几个缺点的:
为此,Dale Skeen和Michael Stonebraker在“A Formal Model of Crash Recovery in a Distributed System”中提出了三阶段提交协议(3PC)。
8.2 三阶段提交协议 3PC与两阶段提交不同的是,三阶段提交有两个改动点。
也就是说,除了引入超时机制之外,3PC把2PC的准备阶段再次一分为二,这样三阶段提交就有CanCommit、PreCommit、DoCommit三个阶段。
CanCommit阶段3PC的CanCommit阶段其实和2PC的准备阶段很像。协调者向参与者发送commit请求,参与者如果可以提交就返回Yes响应,否则返回No响应。
PreCommit阶段协调者根据参与者的反应情况来决定是否可以记性事务的PreCommit操作。根据响应情况,有以下两种可能。 假如协调者从所有的参与者获得的反馈都是Yes响应,那么就会执行事务的预执行。
假如有任何一个参与者向协调者发送了No响应,或者等待超时之后,协调者都没有接到参与者的响应,那么就执行事务的中断。
doCommit阶段该阶段进行真正的事务提交,也可以分为以下两种情况。 1、执行提交
2、中断事务协调者没有接收到参与者发送的ACK响应(可能是接受者发送的不是ACK响应,也可能响应超时),那么就会执行中断事务。
分布式事务的解决方案有如下几种:
全局事务基于DTP模型实现。DTP是由X/Open组织提出的一种分布式事务模型——X/Open Distributed Transaction Processing Reference Model。它规定了要实现分布式事务,需要三种角色:
它就是我们开发的业务系统,在我们开发的过程中,可以使用资源管理器提供的事务接口来实现分布式事务。
- 分布式事务的实现由事务管理器来完成,它会提供分布式事务的操作接口供我们的业务系统调用。这些接口称为TX接口。 - 事务管理器还管理着所有的资源管理器,通过它们提供的XA接口来同一调度这些资源管理器,以实现分布式事务。 - DTP只是一套实现分布式事务的规范,并没有定义具体如何实现分布式事务,TM可以采用2PC、3PC、Paxos等协议实现分布式事务。 复制代码
这种实现分布式事务的方式需要通过消息中间件来实现。假设有A和B两个系统,分别可以处理任务A和任务B。此时系统A中存在一个业务流程,需要将任务A和任务B在同一个事务中处理。下面来介绍基于消息中间件来实现这种分布式事务。
上述过程可以得出如下几个结论:
上述过程中,如果任务A处理失败,那么需要进入回滚流程,如下图所示:
上面所介绍的Commit和Rollback都属于理想情况,但在实际系统中,Commit和Rollback指令都有可能在传输途中丢失。那么当出现这种情况的时候,消息中间件是如何保证数据一致性呢?——答案就是超时询问机制。
系统A除了实现正常的业务流程外,还需提供一个事务询问的接口,供消息中间件调用。当消息中间件收到一条事务型消息后便开始计时,如果到了超时时间也没收到系统A发来的Commit或Rollback指令的话,就会主动调用系统A提供的事务询问接口询问该系统目前的状态。该接口会返回三种结果:
若获得的状态是“提交”,则将该消息投递给系统B。
若获得的状态是“回滚”,则直接将条消息丢弃。
若获得的状态是“处理中”,则继续等待。
消息中间件的超时询问机制能够防止上游系统因在传输过程中丢失Commit/Rollback指令而导致的系统不一致情况,而且能降低上游系统的阻塞时间,上游系统只要发出Commit/Rollback指令后便可以处理其他任务,无需等待确认应答。而Commit/Rollback指令丢失的情况通过超时询问机制来弥补,这样大大降低上游系统的阻塞时间,提升系统的并发度。
下面来说一说消息投递过程的可靠性保证。 当上游系统执行完任务并向消息中间件提交了Commit指令后,便可以处理其他任务了,此时它可以认为事务已经完成,接下来消息中间件一定会保证消息被下游系统成功消费掉!那么这是怎么做到的呢?这由消息中间件的投递流程来保证。
消息中间件向下游系统投递完消息后便进入阻塞等待状态,下游系统便立即进行任务的处理,任务处理完成后便向消息中间件返回应答。消息中间件收到确认应答后便认为该事务处理完毕!
如果消息在投递过程中丢失,或消息的确认应答在返回途中丢失,那么消息中间件在等待确认应答超时之后就会重新投递,直到下游消费者返回消费成功响应为止。当然,一般消息中间件可以设置消息重试的次数和时间间隔,比如:当第一次投递失败后,每隔五分钟重试一次,一共重试3次。如果重试3次之后仍然投递失败,那么这条消息就需要人工干预。
这就涉及到整套分布式事务系统的实现成本问题。我们知道,当系统A将向消息中间件发送Commit指令后,它便去做别的事情了。如果此时消息投递失败,需要回滚的话,就需要让系统A事先提供回滚接口,这无疑增加了额外的开发成本,业务系统的复杂度也将提高。对于一个业务系统的设计目标是,在保证性能的前提下,最大限度地降低系统复杂度,从而能够降低系统的运维成本。
不知大家是否发现,上游系统A向消息中间件提交Commit/Rollback消息采用的是异步方式,也就是当上游系统提交完消息后便可以去做别的事情,接下来提交、回滚就完全交给消息中间件来完成,并且完全信任消息中间件,认为它一定能正确地完成事务的提交或回滚。然而,消息中间件向下游系统投递消息的过程是同步的。也就是消息中间件将消息投递给下游系统后,它会阻塞等待,等下游系统成功处理完任务返回确认应答后才取消阻塞等待。为什么这两者在设计上是不一致的呢?
首先,上游系统和消息中间件之间采用异步通信是为了提高系统并发度。业务系统直接和用户打交道,用户体验尤为重要,因此这种异步通信方式能够极大程度地降低用户等待时间。此外,异步通信相对于同步通信而言,没有了长时间的阻塞等待,因此系统的并发性也大大增加。但异步通信可能会引起Commit/Rollback指令丢失的问题,这就由消息中间件的超时询问机制来弥补。
那么,消息中间件和下游系统之间为什么要采用同步通信呢?
异步能提升系统性能,但随之会增加系统复杂度;而同步虽然降低系统并发度,但实现成本较低。因此,在对并发度要求不是很高的情况下,或者服务器资源较为充裕的情况下,我们可以选择同步来降低系统的复杂度。 我们知道,消息中间件是一个独立于业务系统的第三方中间件,它不和任何业务系统产生直接的耦合,它也不和用户产生直接的关联,它一般部署在独立的服务器集群上,具有良好的可扩展性,所以不必太过于担心它的性能,如果处理速度无法满足我们的要求,可以增加机器来解决。而且,即使消息中间件处理速度有一定的延迟那也是可以接受的,因为前面所介绍的BASE理论就告诉我们了,我们追求的是最终一致性,而非实时一致性,因此消息中间件产生的时延导致事务短暂的不一致是可以接受的。
最大努力通知也被称为定期校对,其实在方案二中已经包含,这里再单独介绍,主要是为了知识体系的完整性。这种方案也需要消息中间件的参与,其过程如下:
上面是一个理想化的过程,但在实际场景中,往往会出现如下几种意外情况:
对于第一种情况,消息中间件具有重试机制,我们可以在消息中间件中设置消息的重试次数和重试时间间隔,对于网络不稳定导致的消息投递失败的情况,往往重试几次后消息便可以成功投递,如果超过了重试的上限仍然投递失败,那么消息中间件不再投递该消息,而是记录在失败消息表中,消息中间件需要提供失败消息的查询接口,下游系统会定期查询失败消息,并将其消费,这就是所谓的“定期校对”。
如果重复投递和定期校对都不能解决问题,往往是因为下游系统出现了严重的错误,此时就需要人工干预。
对于第二种情况,需要在上游系统中建立消息重发机制。可以在上游系统建立一张本地消息表,并将 任务处理过程 和 向本地消息表中插入消息 这两个步骤放在一个本地事务中完成。如果向本地消息表插入消息失败,那么就会触发回滚,之前的任务处理结果就会被取消。如果这量步都执行成功,那么该本地事务就完成了。接下来会有一个专门的消息发送者不断地发送本地消息表中的消息,如果发送失败它会返回重试。当然,也要给消息发送者设置重试的上限,一般而言,达到重试上限仍然发送失败,那就意味着消息中间件出现严重的问题,此时也只有人工干预才能解决问题。
对于不支持事务型消息的消息中间件,如果要实现分布式事务的话,就可以采用这种方式。它能够通过重试机制+定期校对实现分布式事务,但相比于第二种方案,它达到数据一致性的周期较长,而且还需要在上游系统中实现消息重试发布机制,以确保消息成功发布给消息中间件,这无疑增加了业务系统的开发成本,使得业务系统不够纯粹,并且这些额外的业务逻辑无疑会占用业务系统的硬件资源,从而影响性能。
因此,尽量选择支持事务型消息的消息中间件来实现分布式事务,如RocketMQ。
TCC即为Try Confirm Cancel,它属于补偿型分布式事务。顾名思义,TCC实现分布式事务一共有三个步骤:
这个过程并未执行业务,只是完成所有业务的一致性检查,并预留好执行所需的全部资源
这个过程真正开始执行业务,由于Try阶段已经完成了一致性检查,因此本过程直接执行,而不做任何检查。并且在执行的过程中,会使用到Try阶段预留的业务资源。
若业务执行失败,则进入Cancel阶段,它会释放所有占用的业务资源,并回滚Confirm阶段执行的操作。
下面以一个转账的例子来解释下TCC实现分布式事务的过程。
假设用户A用他的账户余额给用户B发一个100元的红包,并且余额系统和红包系统是两个独立的系统。
在传统事务机制中,业务逻辑的执行和事务的处理,是在不同的阶段由不同的部件来完成的:业务逻辑部分访问资源实现数据存储,其处理是由业务系统负责;事务处理部分通过协调资源管理器以实现事务管理,其处理由事务管理器来负责。二者没有太多交互的地方,所以,传统事务管理器的事务处理逻辑,仅需要着眼于事务完成(commit/rollback)阶段,而不必关注业务执行阶段。
TCC服务是由Try/Confirm/Cancel业务构成的,其Try/Confirm/Cancel业务在执行时,会访问资源管理器(Resource Manager,下文简称RM)来存取数据。这些存取操作,必须要参与RM本地事务,以使其更改的数据要么都commit,要么都rollback。 这一点不难理解,考虑一下如下场景:
假设图中的服务B没有基于RM本地事务(以RDBS为例,可通过设置auto-commit为true来模拟),那么一旦[B:Try]操作中途执行失败,TCC事务框架后续决定回滚全局事务时,该[B:Cancel]则需要判断[B:Try]中哪些操作已经写到DB、哪些操作还没有写到DB:假设[B:Try]业务有5个写库操作,[B:Cancel]业务则需要逐个判断这5个操作是否生效,并将生效的操作执行反向操作。
不幸的是,由于[B:Cancel]业务也有n(0<=n<=5)个反向的写库操作,此时一旦[B:Cancel]也中途出错,则后续的[B:Cancel]执行任务更加繁重。因为,相比第一次[B:Cancel]操作,后续的[B:Cancel]操作还需要判断先前的[B:Cancel]操作的n(0<=n<=5)个写库中哪几个已经执行、哪几个还没有执行,这就涉及到了幂等性问题。而对幂等性的保障,又很可能还需要涉及额外的写库操作,该写库操作又会因为没有RM本地事务的支持而存在类似问题。。。可想而知,如果不基于RM本地事务,TCC事务框架是无法有效的管理TCC全局事务的。
反之,基于RM本地事务的TCC事务,这种情况则会很容易处理:[B:Try]操作中途执行失败,TCC事务框架将其参与RM本地事务直接rollback即可。后续TCC事务框架决定回滚全局事务时,在知道“[B:Try]操作涉及的RM本地事务已经rollback”的情况下,根本无需执行[B:Cancel]操作。
换句话说,基于RM本地事务实现TCC事务框架时,一个TCC型服务的cancel业务要么执行,要么不执行,不需要考虑部分执行的情况。
一般认为,服务的幂等性,是指针对同一个服务的多次(n>1)请求和对它的单次(n=1)请求,二者具有相同的副作用。
在TCC事务模型中,Confirm/Cancel业务可能会被重复调用,其原因很多。比如,全局事务在提交/回滚时会调用各TCC服务的Confirm/Cancel业务逻辑。执行这些Confirm/Cancel业务时,可能会出现如网络中断的故障而使得全局事务不能完成。因此,故障恢复机制后续仍然会重新提交/回滚这些未完成的全局事务,这样就会再次调用参与该全局事务的各TCC服务的Confirm/Cancel业务逻辑。
既然Confirm/Cancel业务可能会被多次调用,就需要保障其幂等性。那么,应该由TCC事务框架来提供幂等性保障?还是应该由业务系统自行来保障幂等性呢?个人认为,应该是由TCC事务框架来提供幂等性保障。如果仅仅只是极个别服务存在这个问题的话,那么由业务系统来负责也是可以的;然而,这是一类公共问题,毫无疑问,所有TCC服务的Confirm/Cancel业务存在幂等性问题。TCC服务的公共问题应该由TCC事务框架来解决;而且,考虑一下由业务系统来负责幂等性需要考虑的问题,就会发现,这无疑增大了业务系统的复杂度。
当我们完成业务代码的开发后,就需要进入部署阶段。在部署过程中,我们将会引入持续集成、持续交付、持续部署,并且阐述如何在微服务中使用他们。
在介绍这三个概念之前,我们首先来了解下使用了这三个概念之后的软件开发流程,如下图所示:
首先是代码的开发阶段,当代码完成开发后需要提交至代码仓库,此时需要对代码进行编译、打包,打包后的产物被称为“构建物”,如:对Web项目打包之后生成的war包、jar包就是一种构建物。此时的构建物虽然没有语法错误,但其质量是无法保证的,必须经过一系列严格的测试之后才能具有部署到生产环境的资格。我们一般会给系统分配多套环境,如开发环境、测试环境、预发环境、生产环境。每套环境都有它测试标准,当构建物完成了一套环境的测试,并达到交付标准时,就会自动进入下一个环境。构建物依次会经过这四套环境,构建物每完成一套环境的验证,就具备交付给下一套环境的资格。当完成预发环境的验证后,就具备的上线的资格。
测试和交付过程是相互伴随的,每一套环境都有各自的测试标准。如在开发环境中,当代码提交后需要通过编译、打包生成构建物,在编译的过程中会对代码进行单元测试,如果有任何测试用例没通过,整个构建流程就会被中止。此时开发人员需要立即修复问题,并重新提交代码、重新编译打包。
当单元测试通过之后,构建物就具备了进入测试环境的资格,此时它会被自动部署到测试环境,进行新一轮的测试。在测试环境中,一般需要完成接口测试和人工测试。接口测试由自动化脚本完成,这个过程完成后还需要人工进行功能性测试。人工测试完成后,需要手动触发进入下一个阶段。
此时构建物将会被部署到预发环境。预发环境是一种“类生产环境”,它和生产环境的服务器配置需要保持高度一致。在预发环境中,一般需要对构建物进行性能测试,了解其性能指标是否能满足上线的要求。当通过预发验证后,构建物已经具备了上线的资格,此时它可以随时上线。
上述过程涵盖了持续集成、持续交付、持续部署,那么下面我们就从理论角度来介绍这三个概念。
1.1 持续集成“集成”指的是修改后/新增的代码向代码仓库合并的过程,而“持续集成”指的是代码高频率合并。这样有什么好处呢?大家不妨想一想,如果我们集成代码的频率变高了,那么每次集成的代码量就会变少,由于每次集成的时候都会进行单元测试,从而当出现问题的时候问题出现的范围就被缩小的,这样就能快速定位到出错的地方,寻找问题就更容易了。此外,频繁集成能够使问题尽早地暴露,这样解决问题的成本也就越低。因为在软件测试中有这样一条定律,时间和bug修复的成本成正比,也就是时间越长,bug修复的成本也就越大。所以持续集成能够尽早发现问题,并能够及时修复问题,这对于软件的质量是非常重要的。
1.2 持续部署“持续部署”指的是当存在多套环境时,当构建物成完上一套环境的测试后,自动部署到下一套环境并进行一系列的测试,直到构建物满足上线的要求为止。
1.3 持续交付当系统通过了所有的测试之后,就具备了部署到生产环境的资格,这个过程也就被称为“交付”。“持续交付”指的是每个版本的构建物都具有上线的资格,这就要求每当代码库中有新的版本后,都需要自动触发构建、测试、部署、交付等一系列流程,当构建物在某个阶段的测试未通过时,就需要开发人员立即解决这个问题,并重新构建,从而保证每个版本的构建物都具备上线的资格,可以随时部署到生产环境中。
当我们了解了持续集成后,下面来介绍微服务如何与持续集成相整合。当我们对系统进行了微服务化后,原本单一的系统被拆分成多个课独立运行的微服务。单服务系统的持续集成较为简单,代码库、构建和构建物之间都是一对一的关系。然而,当我们将系统微服务化后,持续集成就变得复杂了。下面介绍两种在微服务中使用持续集成的方法,分别是单库多构建和多库多构建,并依次介绍这两种方式的优缺点及使用场景。
2.1 单库多构建“单库”指的是单个代码仓库,即整个系统的多个模块的代码均由一个代码仓库维护。“多构建”指的是持续集成平台中的构建项目会有多个,每个构建都会生成一个构建物,如下如所示:
在这种持续集成的模式中,整个项目的所有代码均在同一个代码仓库中维护。但在持续集成平台中,每一项服务都有各自独立的构建,从而持续集成平台能够为每一项服务产出各自的构建物。
这种持续集成的模式在微服务架构中显然是不合理的。首先,一个系统的可能会有很多服务构成,如果将这些服务的代码均在同一个代码仓库中维护,那么一个程序员在开发服务A代码的时候很有可能会因为疏忽,修改了服务B的代码,此时服务B构建之后就会存在安全隐患,如果这个问题在服务B上线前被发现,那么还好,但无疑增加了额外的工作量;但如果这个问题及其隐讳,导致之前的测试用例没有覆盖到,从而服务B会带着这个问题进入生产环境,这可能会给企业带来巨大的损失。所以,在微服务架构中,尽量选择多库多构建模式来实现持续集成,它将带来更大的安全性。
虽然这种模式不合理,但它也有存在的必要性,当我们在项目建设初期的时候,这种模式会给我们带来更多的便利性。因为项目在建设初期,服务之间的边界往往是比较模糊的,而且需要经过一段时间的演化才能够构建出稳定的边界。所以如果在项目建设初期直接使用微服务架构,那么服务边界频繁地调整会极大增加系统开发的复杂度,你要知道,在多个系统之间调整边界比在单个系统的多个模块之间调整边界的成本要高很多。所以在项目建设初期,我们可以使用单服务结构,服务内部采用模块作为未来各个微服务的边界,当系统演化出较为清晰、稳定的边界后再将系统拆分成多个微服务。此时代码在同一个代码仓库中维护是合理的,这也符合敏捷开发中快速迭代的理念。
当系我们的系统拥有了稳定、清晰的边界后,就可以将系统向微服务架构演进。与此同时,持续集成模式也可以从单库多构建向多库多构建演进。
在多库多构建模式中,每项服务都有各自独立的代码仓库,代码仓库之间互不干扰。开发团队只需关注属于自己的某几项服务的代码仓库即可。每一项服务都有各自独立的构建。这种方式逻辑清晰,维护成本较低,而且能避免单库多构建模式中出现的影响其他服务的问题。
持续集成平台对源码编译、大包后生成的产物称为“构建物”。根据打包的粒度不同,可以将构建物分为如下三种:平台构建物、操作系统构建物和镜像构建物。
3.1 平台构建物平台构建物指的是由某一特定平台生成的构建物,比如JVM平台生成的Jar包、War包,Python生成的egg等都属于平台构建物。但平台构建物运行需要部署在特定的容器中,如war需要运行在Servlet容器中,而Servlet容器又依赖的JVM环境。所以若要部署平台构建物,则需要先给它们提供好运行所需的环境。
3.2 操作系统构建物操作系统构建物是将系统打包成一个操作系统可执行程序,,如CentOS的RPM包、Windows的MSI包等。这些安装包可以在操作系统上直接安装运行。但和平台构建物相同的是,操作系统构建物往往也需要依赖于其他环境,所以也需要在部署之前搭建好安装包所需的依赖。此外,配置操作系统构建物的复杂度较大,构建的成本较高,所以一般不使用这种方式,这里仅作介绍。
3.3 镜像构建物平台构建物和操作系统构建物都有一个共同的缺点就是需要安装构建物运行的额外依赖,增加部署复杂度,而镜像构建物能很好地解决这个问题。
我们可以把镜像理解成一个小型操作系统,这个操作系统中包含了系统运行所需的所有依赖,并将系统也部署在这个“操作系统”中。这样当持续集成平台构建完这个镜像后,就可以直接运行它,无需任何依赖的安装,从而极大简化了构建的复杂度。但是,镜像往往比较庞大,构建镜像的过程也较长,从而当我们将生成的镜像从持续集成服务器发布到部署服务器的时间将会很长,这无疑降低了部署的效率。不过好在Docker的出现解决了这一问题。持续集成平台在构建过程中并不需要生成一个镜像,而只需生成一个镜像的Dockerfile文件即可。Dockerfile文件用命令定义了镜像所包含的内容,以及镜像创建的过程。从而持续集成服务器只需将这个体积较小的镜像文件发布到部署服务器上即可。然后部署服务器会通过docker build命令基于这个Dockerfile文件创建镜像,并创建该镜像的容器,从而完成服务的部署。
相对于平台构建物和操作系统构建物而言,镜像构建物在部署时不需要安装额外的环境依赖,它把环境依赖的配置都在持续集成平台构建Dockerfile文件时完成,从而简化了部署的过程。