微服务的十个反模式和陷阱
O’Reilly的电子书《Microservices AntiPatterns and Pitfalls》讲述了在微服务设计实现时十种最常见的反模式和陷阱。本文基于此书,将这十个点列出。书籍地址: https://www.oreilly.com/programming/free/microservices-antipatterns-and-pitfalls.csp,更全的反模式和陷阱可见作者的视频: http://oreil.ly/29GVuDG
数据驱动迁移反模式-Data-Driven Migration
如上图所示,此种反模式的问题在于微服务的粒度没有最终确定之前就做了数据迁移,如此当不断的调整服务粒度时,那么数据库就免不了频繁迁移,带来极大的成本。更好的方式如下图所示:
即先分离功能,数据库先保持之前的单体,等到服务粒度最终确定之后,再分离数据库。
超时反模式-The Timeout
微服务架构是由一系列分离的服务组成的,这些服务之间通过一些远程协议进行互相之间的通信。其中牵扯到了服务的可用性和响应性问题。如下图所示:
- 可用性:服务消费方能够连接服务方,并可以向其发送请求。
- 响应性:服务方能够在消费方期望时间内给予请求响应。
为了防止服务的不可用和无法响应,通常的做法就是设置一个调用超时。此种做法表面上看是没问题的,但是试想一下如下情景:发起一个购买100个商品的请求,请求成功返回一个确认号。如果当请求超时但是请求在服务端已经成功执行了,此时这个交易实际是完成的,但是消费方没有拿到确认号,如果重试请求,那么服务方需要一个复杂的机制判断这是否一次重复提交。
一种解决此问题的方案是设置一个较长的超时时间,如一个服务的通常响应耗时需要2s,最大耗时需要5s,那么超时时间可以设置为10s。但这样的问题就是如果服务不可用,所有消费方都得等待10s,这个是非常损耗性能的。
解决超时反模式的方案就是使用“断路器模式”。就类似于房屋中的电源断路器,当断路器关闭,电流可以通过,当断路器打开,那么电流中断一直到断路器关闭。断路器模式就是说当检测到服务方无法响应时就打开,后续的请求都会被拒绝掉。一旦服务方可响应了,那么断路器关闭,恢复请求。其工作模式如下图所示:
断路器会持续地监测远程服务,确保其是可响应的。只要服务可响应,那么断路器会一直关闭,允许请求通过。如果服务突然不可响应,那么断路器打开,拒绝后续的请求。而后续如果断路器又检测到服务恢复了,那么断路器会自动关闭,请求也就恢复了。此种方案与超时时间相比,最大的优势就是一旦服务不可响应,那么断路器模式可以让请求立刻返回而不是需要等待一定的时间。
Hystrix的Netflix是此种断路器模式的一种开源实现。此外,Akka中也包含了一个断路器实现:Akka CircuitBreaker类。
关于“断路器模式”的详细信息可见: https://martinfowler.com/bliki/CircuitBreaker.html。
共享反模式-I Was Taught to Share
微服务被普遍认为是一种不共享任何东西的架构。但实际上只能是尽可能地少共享,毕竟在某些层面代码被多个服务共享也能带来一定好处。例如,与单独部署一套安全服务(验证和认证)其他所有服务都通过远程访问此服务相比,把安全相关的功能封装成jar包(security.jar),然后其他服务都集成此jar包,就能够避免每次都要发起对安全服务的访问,从而提高性能和可靠性。但后面的方案带来的问题就是依赖噩梦:每一个服务都依赖多个自定义的jar包。如此不仅打破了服务之间的边界上下文,同时也引入了诸如总体可靠性、变更控制、易测试性、部署等问题。
在一个使用面向对象编程语言的单体应用中,使用abstract类和接口实现代码复用和共享是一个良好的实践。但当从单体切换到微服务架构时,对于很多自定义的共享类和工具类(日期、字符串、计算)的处理要考虑到微服务间共享的东西越少越有利于保持服务间的边界上下文,从而更利于快速测试和部署。以下是几种推荐的方式,也是解决“共享反模式”的方案:
-
共享项目
将共享的代码作为一个项目在编译期与各个服务集成。此种方式便于变更和开发软件,但是最大的问题在于很难发觉哪一个共享模块被修改以及修改的原因,也无法确定自己的服务是否需要这些变更。尤其是在服务发布前期发现某一个共享模块发生了变动的话需要再一次的测试才能走后续流程。
-
共享库
此种方式即将共享的代码作为类库集成到服务中。如此每次共享的库有改动,服务都需要重新打包、测试、重启。但相比起第一种,其有版本标记,能够更好地控制服务的部署和开发,服务开发者可以自己控制何时将共享库的改动集成进来。
更进一步的,如果采用此种方案,一定要避免把所有共享的代码都打包进一个jar包中如common.jar。否则会很难确定何时要把库的变动集成到服务中。更好的做法是将共享代码分成几个单独上下文的库,如:security.jar、dateutils.jar、persistence.jar等,如此会比较容易的确定何时去集成共享库的变动。
-
冗余
此种方案违反DRY原则,在每一服务中都冗余一份共享代码,能够避免依赖共享也能够保持边界上下文。但是一旦共享的代码有变动,那么所有服务都需要改动。因此,此种方案适用于共享模块非常稳定,极小可能变动的情况。
-
服务合并
当多个服务共享的代码变动比较频繁时可以采用此种方案合并成一个服务,如此就避免了多了服务频繁的测试和部署,也避免了依赖共享库。
可达性报告反模式-Reach-in Reporting
微服务中各个服务以及其相应的数据都是包含在一个单独的边界上下文中的,也就是说数据是隔离到多个数据库中的。因此,这也会使得收集微服务的各种数据生成报告变得相对困难。一般来说有四种方案解决这个问题。其中,前三种都是从各个微服务中拉取数据,是这里所说的反模式,被称作“Reach-in Reporting”。
-
数据库拉取模式
报告服务直接从各个服务的数据库中拉取数据从而生成各种报告。此种方式简单迅速,但是会让报告服务和业务服务相互依赖,是一种数据库共享集成风格(通过共享的数据库将多个应用耦合在一起)。如此一旦数据库有改动,所有相关服务都要改动,也就打破了微服务中极为重要的边界上下文。
-
HTTP拉取模式
与数据库拉取模式相比,此种方式不再是直接去访问服务的数据库,而是通过HTTP接口去请求服务的数据。此种方式能够保持服务的边界上下文,但是性能比较慢,而且HTTP请求无法很好的承载大数据。
-
批量拉取模式
此种方式会有一个单独的报告数据库/数据仓库来存储各个服务的聚合数据。会通过一个批量任务(离线或者基于增量实时)将服务更新的数据导入到报告数据库/数据仓库中。与数据库拉取模式一样,此种方式这也是一种数据库共享集成风格,会打破服务的边界上下文。
-
异步事件推送模式
此种方式即解决“Reach-in Reporting”反模式的方案。每个服务都把自己的发生的事件异步推送到一个数据捕获服务,后续数据捕获服务会将数据解析存储到报告数据库中。此种方式实现起来较复杂,需要在服务和数据捕获服务之间制定一种协议用于异步传输事件数据。但其能够保持服务的边界上下文,同时也能保证数据的时效性。
沙粒陷阱-Grains of Sand
微服务实现中最有挑战的问题在于如何拆分service,如何控制服务的粒度,而正确的服务粒度则决定了微服务是否能够成功实现。服务粒度也能够影响到性能、健壮性、可靠性、易测试性、部署等。
“沙粒陷阱”即把服务拆分的太细。其中的一个原因就是很多时候开发者会把一个class与一个服务等同。合理的,应该是一个服务组件(Service component)对应一个服务。一个服务组件具有清晰、简洁的角色、职责,具有一组定义好的操作。其一般通过多个模块(Java Class)实现。如果组件和模块是一对一的关系,那么不仅仅会造成服务粒度过细同时也是一种不好的编程实践:服务的实现都是通过一个Class,那么此Class会非常大并且承担太多的责任,不利于测试和维护。
更进一步的,服务的粒度并不应该受其中实现类的数目影响:有些服务可能只需要一个类就可以实现,而有些服务会需要多个类来实现。
为了避免“沙粒陷阱”,可以通过以下三种测试来判断服务粒度是否合理:
-
分析服务范围和功能
要明确服务用来干什么?有哪些操作?一般通过使用文档或者语言来描述服务的范围和功能就能够看出来服务是否做的工作太多。如果在描述中使用了“和”(“and”)或者“此外”(“in addition”)之类的词,很有可能就是此服务职责太多。
服务的高内聚是一种良好的实践,其明确一个服务提供的操作之间必须要是有关联的。如对于一个顾客服务,有以下操作:
- 添加顾客
- 更新顾客信息
- 获取顾客信息
- 通知顾客
- 记录顾客评论
- 获取顾客评论
其中的前三个操作都是对顾客的CRUD操作,是相关联的。而后三者则无关。为了实现服务的高内聚,合理的应该是把此服务拆分成三个服务:顾客维护、顾客通知、顾客评论。
如此,以粗粒度的服务开始,然后逐渐拆分成细粒度的服务有利于对微服务的拆分。
-
分析数据库事务
传统的关系型数据库都提供了ACID事务特性用于把多个更新操作打包成一个整体提交,要么都成功,要么都失败。而在微服务中,由于服务都是一个个分离的应用,很难实现ACID,一般实现BASE事务(basic availability、soft state、eventual consistence)即可。但是无法避免的,仍然会有一些场景是需要ACID的。因此,当你不断的需要在BASE和ACID事务做判断和取舍的时候,很有可能就是服务粒度过细。
如果业务场景无法接受最终一致性,那么最好就是将服务粒度粗化一些,把多个更新操作放到一个服务中。
-
分析服务编排
这里主要说的是服务之间的互相通信。由于对服务的调用都是一次远程调用,因此服务编排会非常大的影响微应用总体的性能。此外,它也会影响系统整体的健壮性和可靠性,越多的远程调用,那么越高的几率会有失败或者超时的请求出现。
如果发现完成一次业务逻辑需要调用太多的远程服务,就说明服务的粒度可能太细了。这时候就需要将服务粗化。而合并细粒度服务还能够提高性能,提升总体的健壮性和可靠性。同时也减少了多个服务间的依赖,更利于测试和部署。
此外,使用响应式编程技术异步并行调用远程服务也是一种提升性能和可靠性的方案。
无因的开发者陷阱-Developer Without a Cause
此陷阱主要讲的是开发者或者架构师在做设计时很多时候是拍脑袋在做,没有任何合理的原因或者原因是错误的,也不会做取舍。而想要解决此问题,不仅仅是架构师,开发者也需要同时了解技术带来的好处以及缺陷,从中做权衡。
了解业务驱动是避免此陷阱的关键一步。每一个开发者和架构师都应该清楚的了解下面这些问题的答案:
- 为什么要使用微服务?
- 最重要的业务驱动是什么?
- 架构中的哪一点是最为重要的?
假如易部署性、性能、健壮性、可扩展性是系统最看重的特性,那么对于不同的业务侧重点,微服务的粒度需求也是不同的。细粒度的服务能够达到更好的易测试性和易部署性,而粗粒度的服务则有更好的性能、健壮性以及可靠性。
追随流行陷阱-Jump on the Bandwagon
微服务是目前非常流行的架构理念,越来越多的公司也都在紧跟这个潮流纷纷转型微服务架构,而不管到底自己是否真的需要。为了避免此陷阱,需要首先了解微服务的优点和缺点。
优点:
- 易部署:容易部署是微服务的一个很大的优点。毕竟相比起一个庞大的单体应用,一个小并且职责单一的微服务的部署非常简单并且带来的风险也会小很多。而持续部署技术则进一步放大了这个优点。
- 易测试:职责单一、共享依赖少使得测试一个微服务是很容易的。而基于微服务做回归测试与单体大应用相比也是很容易的。
- 控制变更:每个服务的范围和边界上下文使得很容易控制服务的功能变动。
- 模块化:微服务就是一个高度模块化的架构风格。这种风格也是一种敏捷方式的表达,能够很快的响应变化。一个系统模块化程度越高,就越容易测试、部署和发布变更。一个服务粒度划分合理的微服务系统是所有架构中模块化程度最高的架构形式。
- 可扩展性:由于每一个服务都是一个职责单一的细粒度服务,因此此种架构风格是所有架构分隔中可扩展性最高的。其非常容易扩展某一个或者某几个功能从而满足整体系统的需求。而得益于服务的容器化特性以及各种运维监控工具,服务也能够自动化进行启动和关闭。
缺点:
- 组织变动:微服务需要组织在很多层面进行变动。研发团队需要包含UI、后端开发、规则处理、数据库处理建模等多种职位,从而使得一个小的团队能够具有实现微服务的所有技术栈。同时,传统的单体、分层应用架构的软件发布流程也需要更新为自动化、高效的部署流水线。
- 性能:由于服务都是隔离的,因此发起对服务的远程调用肯定是会影响性能的。服务编排、运行环境都是影响性能的很大因素。了解远程调用的延迟、需要与多少服务通信都是与性能相关的需要掌握的信息。
- 可靠性:和性能一样。服务的远程调用越多,那么失败的几率就越高,总体的可靠性就会越低。
- DevOps:随着微服务架构而来的是成千上百的服务。手动管理这么多的服务是很不现实的。这就对于自动化运维部署、协作提出了很高的挑战。需要依赖非常多的操作工具和实践,是一个非常复杂的工作。目前差不多有12种类型的操作工具(监控工具、服务注册、发现工具、部署工具等)和框架在微服务架构中被使用,其中每一种又包含了很多具体的工具和产品供选择。对于这些工具和框架的选择一般都会需要将近数月的研究、测试、权衡分析才能做出最适合的技术选型。
了解了微服务的优缺点后,下一步则需要根据实际的业务来分析微服务是不是解决这些问题的最佳方案。可以采取以下问题:
- 业务和技术的目标是什么?
- 使用微服务是为了完成什么?
- 目前和可预知的痛点是什么?
- 应用的最关键的技术特性是什么?(性能、易部署性、易测试性、可扩展性)
回答这些问题再结合微服务的优缺点能够让你明确现在是否是使用微服务的适当时机。
除了微服务以外,还有其他7种比较普遍使用的架构供选择:
- 基于服务的架构(Service-Based)
- 面向服务的架构(Service-Oriented)
- 分层架构(Layered)
- 微内核架构(Microkernel)
- 基于空间的架构(Space-Based)
- 事件驱动架构(Event-Driven)
- 流水线架构(Pipeline)
静态合约陷阱-The Static Contract
微服务的消费方和服务提供方之间会有一个合约/协议用来规定输入输出数据的格式、操作名称等等。一般情况下这个合约是不变的。但是如果没有使用版本号来管理服务接口,那么就会进入“静态合约”陷阱。
给合约打上版本标记不仅仅能够避免巨大的变动(服务提供方修改合约使得所有消费方也都得修改),还能够提供向后兼容性。这里有两种技术可以实现合约的版本号:
-
在头部信息附加版本号
如图,此种方式即在远程访问协议的头部添加版本信息。而如果远程协议使用的是REST,那么还可以使用vendor mime type(vnd)来指定合约的版本号。如下:
POST /trade/buy Accept: application/vnd.svc.trade.v2+json
服务接受到请求,能够通过正则等手段简单解析出其中的合约版本号再根据版本号做相应的处理。
如果使用消息队列,那么可以将版本号放置在属性部分(Property section)。JMS的一个例子如下:
String msg = createJSON("acct","12345","sedol","2046251","shares","1000"); jsmContext.createProducer() .setProperty("version",2) .send(queue,msg);
-
在合约本身中附加版本号
此种方式版本号独立于远程访问协议,与头部信息版本号相比,这也是其最大的优点。但与此同时,其缺点比较多。首先要从请求信息主体中解析版本号,会出现很多解析的问题。其次,合约的模式可能会非常复杂,使得很难做数据转换。最后,服务还要引入对模式的验证逻辑。
我们到了吗陷阱-Are We There Yet
微服务架构中,各个服务都是独立的个体,也就意味着所有客户端或者API层和服务之间的通信都是一次远程调用。如果对这些远程调用的耗时没有什么概念,那么就陷入了“Are We There Yet”陷阱。合理的做法需要去测试远程访问的平均延迟、长尾延迟(95%、99%、99.%之外的请求延迟)等指标。而很多时候即使有很好的平均延迟,但是较差的长尾延迟会造成非常大的破坏。
在生产环境或者准生产环境测试有助于去了解应用的真实性能。例如,一个业务请求需要调用四个服务,假设一个服务调用的延迟是100毫秒,那么加上业务请求本身的延迟,完成此次业务请求共需要500毫秒的延迟。这和单单从代码上去看得出的结论是不一样的。
了解目前所用协议的平均延迟是一方面,另一方面则需要对比其他远程协议的延迟,从而在合适的地方使用合适的协议。如:JMS、AMQP、MSMQ。
如图,AMQP协议的性能是最好的。那么结合业务场景,就可以选择REST作为客户端与服务间的通信协议,AMQP做为服务之间的通信协议以提高应用的性能。
当然,性能并非在选择远程协议时唯一考虑的因素。下一节中就会考虑利用消息队列的一些额外功能。
REST使用陷阱-Give It a Rest
REST现在是微服务中用的最多的通信协议。流行的开发框架如DropWizard、Spring Boot都提供了REST支持。但是如果只选择REST这一种协议,不去考虑其他诸如消息队列的优势,那么就陷入了“REST使用”陷阱。毕竟异步通信、广播、合并请求事务这些需求,REST是很难实现的。
消息队列标准目前包括平台特定和平台无关两种。前者包括Java平台中的JMS和C#平台的MSMQ,后者则是AMQP。对于平台特定的消息标准JMS,其规范了API,因此切换broker实现(ActiveMQ、HornetQ)时无需修改API,但由于底层通信协议是不同的,集成的客户端或者服务端jar包需要随着修改。对于平台无关的消息标准,其规范了协议实现标准,并没有规范API。使得不同平台之间都可以互相通信,而不管实际产品是什么。如一个使用了RabbitMQ的客户端可以很容易地与一个StormMQ通信(假设使用的协议相同)。也就是其独立于平台的特性使得RabbitMQ成为微服务架构中最流行的消息队列。
-
异步请求
异步通信是消息队列适用的场景之一。服务消费者发起请求后无需等待服务方响应能够提高总体的性能,同时调用方无需担心调用超时,也就无需使用断路器,从而提高了系统的可靠性。
-
广播
将消息广播给多个service是消息队列的又一个适用场景。一个消息生产者向多个消息接受者发送消息,无需知道谁在接受消息以及如何处理它。
-
事务请求
消息系统提供了对事务消息的支持:如果多个消息被发送到了在一个交易上下文的多个队列或者主题中时,那么直到消息发送者commit,服务才会真正的接受到相应的所有消息(在commit之前会一直保存在队列中)。
因此对于服务消费者需要合并多个远程请求到一个事务中的场景可以选择事务消息。