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