正文
O’Reilly的电子书《Microservices AntiPatterns and Pitfalls》讲述了在微服务设计实现时十种最常见的反模式和陷阱。本文基于此书,将这十个点列出。书籍地址:
www.oreilly.com/programming…
,更全的反模式和陷阱可见作者的视频:
oreil.ly/29GVuDG
数据驱动迁移反模式-Data-Driven Migration
如上图所示,此种反模式的问题在于微服务的粒度没有最终确定之前就做了数据迁移,如此当不断的调整服务粒度时,那么数据库就免不了频繁迁移,带来极大的成本。更好的方式如下图所示:
即先分离功能,数据库先保持之前的单体,等到服务粒度最终确定之后,再分离数据库。
超时反模式-The Timeout
微服务架构是由一系列分离的服务组成的,这些服务之间通过一些远程协议进行互相之间的通信。其中牵扯到了服务的可用性和响应性问题。如下图所示:
-
可用性:服务消费方能够连接服务方,并可以向其发送请求。
-
响应性:服务方能够在消费方期望时间内给予请求响应。
为了防止服务的不可用和无法响应,通常的做法就是设置一个调用超时。此种做法表面上看是没问题的,但是试想一下如下情景:发起一个购买100个商品的请求,请求成功返回一个确认号。如果当请求超时但是请求在服务端已经成功执行了,此时这个交易实际是完成的,但是消费方没有拿到确认号,如果重试请求,那么服务方需要一个复杂的机制判断这是否一次重复提交。
一种解决此问题的方案是设置一个较长的超时时间,如一个服务的通常响应耗时需要2s,最大耗时需要5s,那么超时时间可以设置为10s。但这样的问题就是如果服务不可用,所有消费方都得等待10s,这个是非常损耗性能的。
解决超时反模式的方案就是使用“断路器模式”。就类似于房屋中的电源断路器,当断路器关闭,电流可以通过,当断路器打开,那么电流中断一直到断路器关闭。断路器模式就是说当检测到服务方无法响应时就打开,后续的请求都会被拒绝掉。一旦服务方可响应了,那么断路器关闭,恢复请求。其工作模式如下图所示:
断路器会持续地监测远程服务,确保其是可响应的。只要服务可响应,那么断路器会一直关闭,允许请求通过。如果服务突然不可响应,那么断路器打开,拒绝后续的请求。而后续如果断路器又检测到服务恢复了,那么断路器会自动关闭,请求也就恢复了。此种方案与超时时间相比,最大的优势就是一旦服务不可响应,那么断路器模式可以让请求立刻返回而不是需要等待一定的时间。
Hystrix的Netflix是此种断路器模式的一种开源实现。此外,Akka中也包含了一个断路器实现:Akka CircuitBreaker类。
关于“断路器模式”的详细信息可见:
martinfowler.com/bliki/Circu…
。
共享反模式-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操作,是相关联的。而后三者则无关。为了实现服务的高内聚,合理的应该是把此服务拆分成三个服务:顾客维护、顾客通知、顾客评论。