在上一篇文章中,我们介绍了如何诊断Java 代码中常见的数据库性能热点问题。在本文中,我们将对在分布式面向“微”服务架构(SOA)中造成性能与可伸缩性问题的各种模式进行针对性的讨论,例如在一个低延迟连接中传输大量数据,或是由于糟糕的服务接口设计造成了过多的服务调用,以及线程与连接池耗尽等等。
近期我帮助一个应用进行了分析,就让我们以此为例。该公司迫切地需要将他们的旧式一体性应用转变为面向服务的架构,以满足这个非常受欢迎的网站不断发展的需求。这个应用的标志性功能在于它的搜索特性。搜索逻辑原先是在前端的web 展示代码中实现的,而现在则转移至新的后端搜索REST API 中。我们对它的架构进行了审查,执行了多种不同的搜索查询,其结果令人相当吃惊。每个搜索关键字会产生对后端搜索REST API 不同次数的调用。看起来其搜索结果中的每一项都会对内部的搜索REST API 进行一次调用,这是一个典型的N+1 查询问题模式。下图展示了某次搜索产生的Dynatrace 事务流的截图,它得到了33 个搜索结果。从中可以很容易地发现几个糟糕的服务访问模型,我将在本文稍后部分对其细节进行分析。我通常总是会对关键的架构指标进行分析,从这个例子中我们可以看到,该应用恐怕无法具备其设想中的伸缩能力与性能。
通过对一些关键的输入与输出的服务调用进行检查,可以使我们更容易发现服务调用的问题模式。而通过对这些数据进行检查,也使我们能够更方便地进行架构与代码的审查。
我明白,这种错误不会发生在你身上!☺
我曾在全球范围内的用户组与会议中多次展示了这个搜索功能的用例,所得到的回应通常都是“这种事情不会发生在我们身上,我们知道如何正确地开发服务”。但请继续读下去,你会发生类似的错误会经常发生在实际的生产环境代码中!没有人会有意这么做,但它确实发生了。按我的观察来看,发生这种问题的原因有三个:
1:未察觉或理解整体情况
服务开发团队通常只关注于他们负责开发的服务,他们投入了大量的精力以实现服务的可伸缩性与性能,但也因此忽略了整体情况。这个服务会以怎样的方式使用?我们是否为满足服务调用者的要求提供了正确的接口方法?
在以上示例中,搜索 REST API 团队提供了一个服务方法 GetProductDetails(int productId)。但他们真正应当提供的方法是 GetAllProductDetails(string searchQuery) 或 GetAllProductDetails(int[] productIds)。我建议每个服务团队经常性地与你的客户与调用者进行交谈,不仅要明白他们自认为需要怎样的服务,同时也要确保在你的实现中包含对服务使用情况的监控能力,在生产环境中了解每个服务的使用频率与使用者!
2:不了解框架的内部实现
大多数团队都不会自行开发自有的框架,而是依赖于现有的各种流行框架,例如 MVC 和 REST。这种方式是正确的,我们不应当每次创建新的应用时都重复发明轮子。但有一种错误是经常发生的:在启动一个新的项目时,我们通常会基于某个从 GitHub 这种公共代码库中下载的示例开发一个原型,该原型经过数次演化就会转变为一个完整的应用,但团队会因此忽略了对此进行必要的回顾的过程,以评估该框架是否是最适合这项任务的选择,以及是否对该框架进行了最适当的调整优化。我的建议是花一些时间去了解你所选择的框架的内部机制,以及如何进行最佳的配置与优化,以实现最好的吞吐量与性能。否则的话,你就要准备好面对上文所描述的情形。这种情形我每天都能观察到!
3:迁移或是重新设计架构
当你准备将现有的一体性应用进行分解时,不要单纯地认为你可以分解出提供某种功能的类,随后将其包装为一个服务。这种方式会造成原先的本地线程内与进程内调用变成跨服务 / 服务器 / 网络 / 云平台的调用,而这一点往往会被忽略,因为对这些服务的调用就像调用本地方法一样简单。
当你从一体性架构迁移至微服务时,请确保你首先理解服务的 API 到底需要提供什么功能。在多数情况下,这意味着你需要重新设计架构,并对接口进行重新定义,而不是将代码从原先的一体性项目中拷贝至多个服务项目中。
可用的诊断工具与选项
正如以上示例所示,我总是会检查一些关键的架构指标,例如服务器之间的调用频率与调用次数、所传输的数据量,并了解服务之间的相互通信是怎样的。通常来说,你可以从用于开发应用的服务框架中获取这些指标,例如 Spring、Netflix 等等。大多数框架都会提供诊断与监控相关的特性,你也可以自行选择代码性能诊断工具,或选择某种可用的应用性能监控(APM)工具,可使用完全免费的版本,或是高级 / 免费试用的版本。我所选择的工具是 Dynatrace Application Monitoring 与 UEM,只需遵守 Dynatrace 个人许可,开发者、架构师与测试人员就可以免费试用。这种工具的一个关键场景在于它能够展示整个服务基础设施中的数据以及服务的交互方式,而性能诊断工具只能够对一个单一的 JVM 进行分析,因此其功能对于我们的要求来说过于受限。
诊断糟糕的服务访问模式
现在,让我们看看这个我希望各位留心提防的服务访问模型清单。如果你希望以面向服务架构开发一个高伸缩、高性能的应用,请确保你检查应用中是否存在这些模式的痕迹。我们首先列举出这个清单,随后展示一些出现了这些模式的示例应用,以找出并克服其中的性能问题:
- 过多的服务调用:在单一的端到端用例中出现了过多的服务调用。那么多少次调用算是太多呢?这当然取决于你的应用和需求,以及你如何划分你的服务。但从经验来看:如果达到 5 次服务调用,就说明你应当开始调查其原因了。
- N+1 服务调用模式:在某个端到端的用例中对相同的服务进行多次调用。这个问题的出现表示你可能需要重新定义你的服务终结点,并提供一个更具特定性的服务。简而言之,就是“给我这个查询结果中所有产品的细节”,而不是“给我产品 X,再给我产品 Y 的细节”。
- 服务的网络传输信息过大:带宽确实很便宜,但这一点仅限于你的终结点部署地点非常接近的情况下。一旦你将服务迁移至云平台,你就要考虑到高延迟、不同的带宽限制、以及云提供商对于你的云实例所发送及接收的流量所产生的额外成本。如果我发现内部的服务调用所传输的数据量大于返回给终端用户的数据量,我就会开始研究如何对传输数据进行优化。
- 连接池与线程池的大小:服务都是通过连接进行通信的,因此我们需要对负责发送与接收的连接池与线程池进行适当的调整与观察。一旦你理解了服务之间通过哪些通道进行通信,你就可以基于对负载的预测进行适当的调整。
- 过多地使用异步线程:要实现一个事件驱动的服务调用模式,使它支持发起异步调用,并在完成后收到通过,而这一点并不容易做到。需要留意的是,有些框架会产生“虚假的”异步行为,它会产生及阻塞后台线程,直至每个服务调用的结果都返回为止。
- 对架构的违反:你的服务是否按预期的方式与其他服务进行交互?你的架构中是否出现了某些预计之外的访问反模式(打个比方,你是否直接访问了某个后台数据存储系统,而没有经过数据访问服务 API)?
- 缺少缓存:将实际的工作转移至服务是一种正确的做法,但如果这会导致该功能实际效率的下降,你或许会面临资源方面的问题。一个常见的例子是向服务器发起重复的访问,而不是在多次服务调用之间对数据进行缓存,以避免对数据库的不断请求。
好了,我将信守承诺,让我们实际地看看解决这些问题的技术吧:
示例 1:过多的服务调用与 N+1 查询模式
该示例来自于一个著名的求职网站。每当终端用户执行一次搜索请求时,前端的服务就会查询能够满足用户所输入的搜索关键字的职位信息。对返回结果中的每个职位信息,该服务都会向一个外部“搜索”REST 服务发起调用。这个过程可以很容易地进行优化,只需提供一个粗粒度的搜索 REST 调用,让它接受一个职位信息的列表,就能够极大地减少 REST 调用的次数:
对前端服务的一次职位信息搜索请求造成了对某个外部服务共 38 次 REST 调用,以获取每个职位的详细信息。可以通过提供某种更恰当的 REST 接口对其进行优化,让该接口返回一系列职位信息的结果!
仅仅从调用的次数来看,还不能肯定地说这究竟是一种糟糕的设计,还是说在前端与后端之间的 REST 接口的一种低效率的使用方式。为了了解实际情况,你需要观察实际执行的 REST 查询,将这些查询以终结点的 URL 和查询字符串进行组织。通过这种策略,就可以发现真正的问题所在,即 N+1 查询问题,每个重复的 REST 调用重用了完全相同的查询字符串:
通过对终结点与查询字符串进行观察,可找出对某个 REST 终结点的低效率调用方式。如果你的系统中出现了这些模式,可以考虑提供更恰当的接口,以一个单一的服务调用处理这些查询。
在以上这个示例中,每次进行职位查询时,对于不同的职位信息都需要重复地多次调用相同的服务。如果你的服务也遇到了相似的情况,那么合理的方式是提供一种能够更好地支持端到端用例的 REST 接口。另一种可能是你的服务已经提供了这种接口,但前端开发者(或是服务的调用者)并未意识到这种接口的存在。通过进行这种方式的使用情况分析,你实际上已经对调用者进行了一次培训,让他们了解如何更好地利用你的服务!
提示:在 Dynatrace 中,你可以在 Web Requests Dashlet 中显示某个端到端事务所产生的所有调用。请确保你在上下文菜单中对 Dashlet 进行了正确的设置,选择“Show -> All”以及“Group By -> URL + Query”模式。
示例 2:过度使用异步线程
同样是在这个搜索职位的示例中,访问了 /getResult 这个 URL 的所有调用在执行时会为每个服务调用生成一个新的后台线程,因此,在 HTTP 主线程中共生成了 35 个线程,以并行执行这些 REST 调用。这样一来,HTTP 工作线程将处于阻塞状态,直到所有的后台线程全部执行完毕为止:
分析 REST 调用的执行过程中共牵涉到多少线程。如果你的系统中出现了 N+1 服务调用模式,那么前端每次发起的请求都会消耗 N 个额外的线程!
很显然,这 35 个线程的产生是由于 N+1 服务访问模式所引起的。如果该模式得到解决,那么在每个服务调用时占有一个新的线程的问题也迎刃而解了。
示例 3:线程的比率与线程池
通过分析传入的请求 / 事务与执行过程中所牵涉到的活跃线程总数之比,也可分析出示例 2 所描述的问题。你可以通过查看 JVM 中所产生的 JMX Metrics 访问这两种指标。你甚至还可以进一步扩展你的分析,将线程数量按照线程组进行分解。如果你的应用如以上示例所示,为这些线程进行了恰当的命名,那么这种方式的价值将更为明显,同时这也是一种正确的开发模式。同时,你还应当观察 CPU 的占用情况,如果你的应用表现出性能的下降,却没有观察到 CPU 占用的提高,那就表示你的线程或许在等待 I/O 或是其他一些操作的结果。
一种优秀的实践是将传入请求的数量与活跃线程总数及 CPU 占用率情况进行关联。如果这一比例是 26 比 1,就表示你的应用为每个请求产生了大量的后台线程。并且对线程数量的持续观察能够使你了解是否遇到了“瓶颈”。正如上文中的示例所示,工作线程的最大数量是 1300,如果请求的数量超过这个值,那么后续的请求就会因为线程不足而无法处理!
在整个管道中对服务的指标进行监控
在上一篇文章中,我们提到了数据库指标,以及如何将这些指标与你的持续集成(CI)构建过程进行整合。同样的方式也可以用于服务的指标。如果你已经实现了服务的自动化测试,已经能够对搜索或某些消息通知功能进行自动化测试,那么你也应当以自动化方式监控每个单一的测试执行过程中的指标。但即使已经完成了CI,也应当继续保持对软件的监控?其原因在于你所测试的软件将被部署至预发布环境,甚至可能是生产环境,在这些环境中对于那些指标的监控也同样重要。在我看来,最有价值的部分在于你是否能够保持当服务部署至生产环境之后对类似的指标进行观察。此外,你还应当对于各种服务特性的使用情况进行监控,让你能够更好地了解调用者实际使用了哪些新的服务/ 特性。当你获取了这些指标数据之后,就能够以此决定需要对哪些特性进行改进,以提高使用率。如果特性的使用情况不符合你的预期,也可以移除这些特性。这将有助于你减少代码的体积和复杂度,并最终减少被业界称为“技术债”的东西。
我们将快速地介绍如何实现这一点,仍以我描述的示例为例,该产品的搜索特性会造成33 次服务调用。首先分析的是早期版本的软件,当时整个应用还属于一种“一体性”的应用。通过CI 中的测试,我们可获取关于消息通知以及搜索特性的相关指标,包括代码如何与数据库进行调用、产生的服务调用次数有多少、所传输的数据量有多大,以及在生产环境中有哪些特性得到了使用:
第17 号构建的分析结构展示:消息通知与搜索功能在生产环境中性能表现不佳。消息通知的使用率非常低下,原因可能在于它的响应时间过长,让用户不愿意使用这一特性。搜索功能的使用率还比较出色,但本应表现得更好。
通过对这些指标进行分析,我们就能够理解代码运行的情况,因此我们可决定对性能进行优化,将一体性的搜索特性分解为面向服务的方式。经过数次构建之后,我们完成了新的面向服务实现。但是,在经过了相同的测试之后,却出现了一些意料之外的指标数据,这使我们不得不推迟了新代码在生产环境中的发布。从下图中可以看出,我们对于搜索功能所做的变更显然违反了各种架构规则(这些数字都来自于我在开篇第一段所介绍的示例):
第25 号构建显然是一个很糟糕的构建,迁移后的微服务架构在服务调用模式上表现出非常糟糕的性能指标。请不要部署这个构建,而是修复其中的问题!
N+1 查询问题模式也造成了大量的 SQL 查询,以及通过网络进行传输的数据。修复这一问题的方式是为新的后端搜索服务接口选择一种更清晰且更高效的设计实现。它不会为每个搜索结果都调用一次后端服务,而是引入了一种“批量”式的服务,以返回搜索结果的全部细节。在完成了修复工作之后,我们终于可以部署新的构建了。从持续集成服务器上来看,一切数据都表现良好。从部署后在生产环境中产生的使用数据来看,运行在多个服务容器中的搜索功能得到了更好的性能,并表现出更好的使用率。不过,消息通知这一特性的使用率只得到了很少的提升。这或许意味着我们应当在今后的构建中移除这一特性,因为很显然它并没有提供多少价值:
第 26 号构建已经具备了一个非常扎实的技术基础,在搜索功能的使用率上也得到了提升。但消息通知的使用率并没有多少提升,因此我们决定在第 35 号构建中移除这一特性。
在前一篇关于数据库的文章中,我们提出了一种通过 Dynatrace 在测试自动化与持续集成过程中以可视的方式展现架构指标的能力。通过将其中的数据与下图进行比较,就可展现出我们的搜索服务在生产环境中产生的各种关键架构指标。举例来说,下图中的黄色、橙色与绿色部分可表示有多少个搜索请求的执行所产生的内部 REST 调用数目在 1 至 5 之间(黄色)、或是 5 个以上(亮黄色)。除此之外,我们还比较了 SQL 调用执行的次数(橙色)以及每次搜索平均产生的数据量(绿色)。这种可视化能力能够帮助我们找到服务的使用情况的变更(在 Y 轴上的总数),以及行为的变化(即某种颜色块所占部分变大或变小)。
生产环境中的服务监控:理解代码部署后的使用情况以及内部行为
尽早掌握你的服务的运行情况
如果你还没有开始基于指标对架构进行审查,我建议你立即着手实践。请观察我所描述的各种模式,并告诉我们是否还有其他一些需要留意的模式。但这一过程不应当仅限于在编写代码阶段进行人工观察,你应当以 DevOps 实践对监控工作进行自动化,并更好地决定部署哪些特性、对哪些服务进行改进、以及要放弃哪些服务。
关于作者
Andreas Grabner(@grabnerandi)是一位研究性能问题的工程师,他在过去十五年间一直致力于这一领域方面的工作。Andreas 的工作是帮助组织找出应用程序中的真正问题,并将从中获得的知识作为工程最佳实践分享给他人,使他们了解如何避免这些问题。
查看英文原文: Locating Common Micro Service Performance Anti-Patterns
评论