2.3.1 采用独立微服务方式的取舍与弊端
独立微服务方式解决了采用抽取通用代码到单独的库时所出现的部分问题。使用这部分代码的团队在采用单独库的方式时,他们的心态是不一样的。在你的代码库中导入一个库,该库就成为你的代码库的一部分,你要对它们负责。对比起来,采用库的方式的耦合度要比采用独立微服务方式的高得多。
与其他微服务集成时,我们就不需要考虑这么多,直接将它们当成黑盒即可。使用这种方式时唯一的集成点就是API,这些API既可以基于HTTP,也可以基于其他的协议。理论上,库的集成完全可以用类似的方法处理。然而,正如我们在2.2节所介绍的,在实际生产中,由于库在代码层面引入的依赖,我们不能将其作为黑盒对待。
调用微服务通常意味着你需要客户端库的支持,这些库会执行具体的调用逻辑,而这又会增加新的依赖。理论上,你可能再次落入前文介绍的依赖传递陷阱中。不过,在实际项目中,大多数微服务为了调用其他服务,应该都已经使用了某个客户端库。这些库可能是基于HTTP的客户端或者是基于其他协议的客户端。因此,当你需要在你的服务中执行微服务调用时,可能使用同样的HTTP客户端就可以了。如此一来,由每个被调用服务引入的额外依赖问题就迎刃而解了。
假设我们的授权服务是个独立的微服务,向外提供对应的API。我们已经知道这个方式能解决库集成方式的一些问题。然而,事物都有两面性,维护一个独立的微服务的开销是巨大的。采用这种方式,我们要做的就不仅局限于编写授权服务的代码了,还需要做很多其他的事情。
独立的微服务意味着你需要创建将代码部署到云端或者私有数据中心基础架构上的部署流程。采用库的集成方式也需要部署流程,不过它的部署流程简单、直观得多。你只需要将代码打包成一个JAR文件,将其部署到某个存储库管理器即可。而使用微服务,你还需要有人或者某种机制监控服务的健康状态,一旦发生故障或者出现错误就需要做相应的处理。注意,创建部署、维护、监控的流程等是重要的前置开销(库集成方式也有类似的前置开销)。一旦这些流程完成,后续微服务的开发要简单得多。我们接下来更深入地讲解采用独立微服务方式时都有哪些重要的因素要考虑。
部署流程
微服务会作为一个独立的进程部署和运行。这意味着这样的进程需要进行监控,出现问题或者发生失效时,需要团队的人跟进和处理。因此,创建、监控、警告都是你创建独立的微服务时需要考虑的因素。如果你的公司有一整套的微服务生态,很可能已经有现成的警告和监控方案了。如果你是公司里希望采用这种架构的第一批人之一,很可能你需要从头搭建整套解决方案。这意味着需要较高的集成开销,以及大量的额外工作。
版本
微服务的版本管理在某些方面比库的版本管理要容易得多。你的库应该遵守版本语义,大版本应该尽量保持API的兼容性。微服务的API版本也应遵守同样的准则,保持后向的兼容性。在实际项目中,监控端点的使用情况要容易得多,如果发现某些端点不再使用,即可快速地决定将这些端点弃用。如果你正在开发一个库,需要注意尽量保持其后向的兼容性,否则会导致旧版本的库无法平滑升级到新版本。破坏后向兼容性意味着升级库为新版本后,客户端无法成功编译。这样的变更是不可接受的。
如果你使用的是HTTP API的集成方式,可以通过一个简单的计数器,使用Dropwizard这样的指标库统计各个端点的使用情况。如果某个端点对应的计数器很长时间都保持不变,并且其服务仅为公司内部服务,你就可以考虑弃用这一端点了。如果端点提供的服务是开放给所有人的,并且提供了相关的文档,弃用这样的端点时需要慎重一些,你可能需要尽可能长久地支持它们。即便是收集的指标数据表明这些端点使用得比较少,甚至很长时间都没有人调用,也不能弃用这些端点。只要有公开的文档,就可能有某些用户准备使用它们。
至此,相信你已经了解了采用微服务方式能为API演进带来更高的灵活性。我们会在第12章更深入地介绍兼容性相关的内容。
资源消耗
采用库的方式时,客户端代码会消耗更多的计算资源。Payment服务处理的每一个请求,都需要由代码进行令牌验证。根据具体的情况,如果这部分代码的资源消耗比较大,你需要增加CPU或者内存资源。
如果验证逻辑由独立的服务提供的API进行处理,客户端就完全不用考虑扩展性以及这部分的资源消耗。处理会在某个微服务实例上执行。如果处理的请求过多,负责相应服务的团队有义务做对应的调整,适当增加该服务实例的数量。
需要注意的是,采用微服务方式的客户端代码会有额外的HTTP请求,因为每次验证都需要与微服务做一次应答。如果要封装在微服务API内的逻辑很简单,可能最后花费在HTTP调用上的开销就已经远超直接在客户端执行该逻辑的开销。如果这部分逻辑比较复杂,那么HTTP通信的开销与微服务计算的开销相比就可以忽略不计。决定是否要抽取某部分逻辑时,以上的利弊也是你应该考虑的。
性能
最后,你需要衡量执行额外的HTTP请求对性能的影响。用于授权的令牌通常都有对应的过期时间。因此,你可以将它们进行缓存,减少服务需要处理的请求数量。为了实现缓存功能,你需要在客户端代码中引入缓存库。
在实际项目中,这两种方式(库和外部微服务)都是常见的提供业务功能的途径。将某部分业务逻辑抽取到独立的微服务中,每个用户请求都需要执行额外的HTTP请求,这可能是要着重斟酌的不足。你需要衡量采用这样的设计会对你的服务的响应延迟以及对SLA产生什么样的影响。图2.7展示了一个这样的场景。
图2.7 增加额外的延迟会影响你的服务
举个例子,如果根据你的SLA要求,99%的请求延迟需要小于n ms,增加对其他服务的调用后,之前定义的SLA要求很可能无法满足。如果微服务99%的请求延迟小于n ms,而你希望通过并发、重试或者推测执行(speculative execution)提升服务的处理能力,这可能会让情况变糟,因为微服务处理后续99%的请求时延迟可能超过n ms。出现这种情况时,就无法满足你的SLA要求了。这时,你可能需要与相关干系人协调,增大SLA要求中延迟的范围。如果这不可行,你就需要花更多的时间研究如何降低后续99%的请求的延迟,或者使用切换到抽取库的方式。
即便你没有严苛的延迟要求,也要特别留意微服务是否存在连锁故障,并为服务依赖的微服务出现临时无法访问的情况准备预案。连锁故障问题并不是微服务引入的新问题,它在任何有外部系统依赖的场景(譬如数据库、认证API等)中都可能发生。
如果业务流程需要新增一个外部请求,你需要考虑相应外部服务无法访问时应该如何处理。你可以按照指数退避(exponential backoff)的策略进行重试,给下游服务一定的恢复时间,避免用大量请求压垮服务。采用这一策略时,你可以每隔x ms探测一次下游服务的状态,如果下游服务已恢复,则可以逐步增加流量。使用指数退避策略时,你的重试操作应该以递减的频率进行。譬如,1 s之后执行第一次重试,10 s之后执行第二次重试,30 s之后执行第三次重试,以此类推。如果使用指数退避策略一段时间(重试一定次数)后,服务依旧没有恢复,你需要使用熔断模式(circuit breaker)规避失效无限向后扩展。
你需要提供下游服务失效时的应急机制。举个例子,你有一个支付系统,如果支付服务失效了,你可能会决定确认支付,并在下游服务恢复之后再从账户中扣款。这种解决方案的实施需要万分慎重,它必须是一个经过缜密考量之后的业务决定。
可维护性
如你所见,决定创建独立的微服务时,我们要考虑大量的取舍。实际项目中,采用这种方式需要更多的计划、更大的维护开销。使用之前,最好将其与共享库的方式做充分的比较,列出各自的优缺点,通常共享库的方式更简单直接。如果需要共享的逻辑比较简单,没有大量的依赖,采用共享库的方式是比较推荐的做法。另外,如果共享的逻辑比较复杂,并且可以单独抽取出来作为一个独立的业务组件,你可以考虑创建一个新的微服务。后一种方式的工作量是比较大的,很可能要一个专门团队来支持。