在上一篇文章中,我们介绍了如何 诊断Java代码中常见的数据库性能热点问题 。在本文中,我们将对在分布式面向“微”服务架构(SOA)中造成性能与可伸缩性问题的各种模式进行针对性的讨论,例如在一个低延迟连接中传输大量数据,或是由于糟糕的服务接口设计造成了过多的服务调用,以及线程与连接池耗尽等等。
近期我帮助一个应用进行了分析,就让我们以此为例。该公司迫切地需要将他们的旧式一体性应用转变为面向服务的架构,以满足这个非常受欢迎的网站不断发展的需求。这个应用的标志性功能在于它的搜索特性。搜索逻辑原先是在前端的web展示代码中实现的,而现在则转移至新的后端搜索REST API中。我们对它的架构进行了审查,执行了多种不同的搜索查询,其结果令人相当吃惊。每个搜索关键字会产生对后端搜索REST API不同次数的调用。看起来其搜索结果中的每一项都会对内部的搜索REST API进行一次调用,这是一个典型的N+1查询问题模式。下图展示了某次搜索产生的Dynatrace事务流的截图,它得到了33个搜索结果。从中可以很容易地发现几个糟糕的服务访问模型,我将在本文稍后部分对其细节进行分析。我通常总是会对关键的架构指标进行分析,从这个例子中我们可以看到,该应用恐怕无法具备其设想中的伸缩能力与性能。
通过对一些关键的输入与输出的服务调用进行检查,可以使我们更容易发现服务调用的问题模式。而通过对这些数据进行检查,也使我们能够更方便地进行架构与代码的审查。
我曾在全球范围内的用户组与会议中多次展示了这个搜索功能的用例,所得到的回应通常都是“这种事情不会发生在我们身上,我们知道如何正确地开发服务”。但请继续读下去,你会发生类似的错误会经常发生在实际的生产环境代码中!没有人会有意这么做,但它确实发生了。按我的观察来看,发生这种问题的原因有三个:
服务开发团队通常只关注于他们负责开发的服务,他们投入了大量的精力以实现服务的可伸缩性与性能,但也因此忽略了整体情况。这个服务会以怎样的方式使用?我们是否为满足服务调用者的要求提供了正确的接口方法?
在以上示例中,搜索REST API团队提供了一个服务方法GetProductDetails(int productId)。但他们真正应当提供的方法是GetAllProductDetails(string searchQuery)或GetAllProductDetails(int[] productIds)。我建议每个服务团队经常性地与你的客户与调用者进行交谈,不仅要明白他们自认为需要怎样的服务,同时也要确保在你的实现中包含对服务使用情况的监控能力,在生产环境中了解每个服务的使用频率与使用者!
大多数团队都不会自行开发自有的框架,而是依赖于现有的各种流行框架,例如MVC和REST。这种方式是正确的,我们不应当每次创建新的应用时都重复发明轮子。但有一种错误是经常发生的:在启动一个新的项目时,我们通常会基于某个从GitHub这种公共代码库中下载的示例开发一个原型,该原型经过数次演化就会转变为一个完整的应用,但团队会因此忽略了对此进行必要的回顾的过程,以评估该框架是否是最适合这项任务的选择,以及是否对该框架进行了最适当的调整优化。我的建议是花一些时间去了解你所选择的框架的内部机制,以及如何进行最佳的配置与优化,以实现最好的吞吐量与性能。否则的话,你就要准备好面对上文所描述的情形。这种情形我每天都能观察到!
当你准备将现有的一体性应用进行分解时,不要单纯地认为你可以分解出提供某种功能的类,随后将其包装为一个服务。这种方式会造成原先的本地线程内与进程内调用变成跨服务/服务器/网络/云平台的调用,而这一点往往会被忽略,因为对这些服务的调用就像调用本地方法一样简单。
当你从一体性架构迁移至微服务时,请确保你首先理解服务的API到底需要提供什么功能。在多数情况下,这意味着你需要重新设计架构,并对接口进行重新定义,而不是将代码从原先的一体性项目中拷贝至多个服务项目中。
正如以上示例所示,我总是会检查一些关键的架构指标,例如服务器之间的调用频率与调用次数、所传输的数据量,并了解服务之间的相互通信是怎样的。通常来说,你可以从用于开发应用的服务框架中获取这些指标,例如Spring、Netflix等等。大多数框架都会提供诊断与监控相关的特性,你也可以自行选择代码性能诊断工具,或选择某种可用的应用性能监控(APM)工具,可使用完全免费的版本,或是高级/免费试用的版本。我所选择的工具是Dynatrace Application Monitoring与UEM,只需遵守 Dynatrace个人许可 ,开发者、架构师与测试人员就可以免费试用。这种工具的一个关键场景在于它能够展示整个服务基础设施中的数据以及服务的交互方式,而性能诊断工具只能够对一个单一的JVM进行分析,因此其功能对于我们的要求来说过于受限。
现在,让我们看看这个我希望各位留心提防的服务访问模型清单。如果你希望以面向服务架构开发一个高伸缩、高性能的应用,请确保你检查应用中是否存在这些模式的痕迹。我们首先列举出这个清单,随后展示一些出现了这些模式的示例应用,以找出并克服其中的性能问题:
好了,我将信守承诺,让我们实际地看看解决这些问题的技术吧:
示例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