服务网关API路由导致的性能问题分析

背景

酷家乐是从16年初开始进行服务化改造的,因为一些特殊原因,无法直接使用主流的dubbo或spring cloud,因此酷家乐研发团队在开源的基础上做了二次开发,迅速上线了一套定制型的微服务框架。

服务网关API路由导致的性能问题分析

和其他微服务框架类似,酷家乐自己定制的微服务框架也有专门的服务网关,今天要讨论的就是服务网关中存在的一些问题。

何为服务网关?

服务网关封装了系统内部架构,为每个客户端提供一个定制的API。此外,服务网关还会提供如身份验证,限流,熔断,安全等功能。
所有的客户端都通过统一的网关接入微服务,在网关层处理所有的非业务功能。通常,网关也是提供REST/HTTP的访问API。服务网关会将客户端的请求转发到能够正常处理该请求的微服务当中。

下图简单阐述了服务网关在常见系统中所处的位置和作用

服务网关API路由导致的性能问题分析

下面会逐步介绍酷家乐在不断迭代微服务框架过程中,在服务网关处踩的各种坑。


故障回顾

高CPU占用

16年某日,服务网关持续CPU报警,排查定位过程比较简单,随意挑选了一台服务器并获取其thread dump,观察到大量的线程都在做以下操作

服务网关API路由导致的性能问题分析

上图中,除了IO操作之外,主要就是字符串操作占用CPU时间。

最初期的服务网关基本就是根据Spring MVC 的Request Mapping流程来模拟,对请求进行路由,查找匹配的服务并转发。

这里存在的问题也很明显,因为要遍历所有的API Pattern,效率比较低,此外,由于有大量的API使用了Restful风格,包含PathVariable的API数量比较多,因此正则匹配次数也特别多,所以CPU占用率居高不下。

随着系统不断庞大,这个问题会越来越明显。

研究了前面所述的Pattern格式,我们发现在REST风格的API URL中,存在以下规律:

URL基本都以“静态字符串”开头,形成了用于区别不同service的“名字空间”,动态部分一般都在URL Pattern的尾部。

根据这个规律,我们发现用字典树的结构来组织URL Pattern可以大大降低URL匹配所花费的CPU时间:

对于每个URL Pattern,我们截取第一个动态表达式之前的“静态前缀”,形成一颗字典树。根节点代表的token为空,其余每个节点中包含两个集合:

private static class Node {
    Map<String, Node> children;  // 子节点
    List<T> patterns;   // 当前URL前缀所匹配的Patterns
}

用该规则构建的一个字典树例子如下:

服务网关API路由导致的性能问题分析

对于每个请求,我们进行以下操作:

  1. 用“/”切分URL,得到多个token

  2. 从字典树的根开始,从上往下寻找匹配的最长路径

  3. 取出最后匹配的Node节点中的patterns集合,遍历该patterns集合,找出最终匹配的URL Pattern

用字典树结构重写了整个URL Route逻辑之后,CPU占用时间明显下降,达到了优化的目的。

FullGC

17年某个周四下午开始,服务网关开始间接性FullGC,每次FullGC时间持续大概2-3秒,服务网关负载瞬时飙高后马上恢复。

当时出现这个报警有点不可理解,那一周因为峰会原因暂停了部署,所以所有服务的代码都是没有改变的,并且根据监控的数据来看,当时的访问量也没有增加太多,和之前几天的差不多。究竟为何产生fullGC,还得看客观数据。

为其中一台服务器添加了HeapDumpBeforeFullGC和HeapDumpAfterFullGC后继续观察,获取到两份dump文件对比后发现,被GC掉的主要是int[]和SimpleScalar两类对象。

这里的int数组和SimpleScalar大量都是unreachable,SimpleScalar是freemarker渲染产生的,因为历史原因,还有少部分页面渲染是在服务网关处理的,观察监控中GC时段的页面请求,没有发现异常。并且SimpleScalar不是最主要成员,因此排除相应可能性。


那么这些int数组到底是哪里来的?

这里还有一个疑点,HeapdumpBeforeFullGC获取到的dump文件居然只有1.4G的大小,远远小于堆大小,这并不符合正常逻辑。

查看了fullGC前的部分gclog,发现这段时间有比较多的mix-gc,并且看到有不少G1 Humongous Allocation等信息。

看到这些,便能联想到为何那份fullGC前的dump文件小那么多了。

服务网关使用的是jdk8+G1GC,G1的各代存储地址是不连续的,每一代都使用了n个不连续的大小相同的Region,每个Region占有一块连续的虚拟内存地址。

humongous object即大小大于等于Region一半的对象,有如下几个特征:

humongous object直接分配到了老年代空间,防止了反复拷贝移动。

humongous object在global concurrent marking阶段的cleanup 和 full GC阶段回收。

在分配humongous object之前先检查是否超过 initiating heap occupancy percent和the marking threshold, 如果超过的话,就启动global concurrent marking,为的是提早回收,防止 evacuation failures 和 full GC。


因此猜测之所以dump文件大小差那么多的原因就在于产生了大量的humongous object,而这些对象在dump之前就先被回收了。

通过监控和GC日志可以看出这些对象是短时间内迅速产生的。

服务网关API路由导致的性能问题分析

为了证实猜想,并且找到产生这些对象的来源,通过监控和报警的数据,将其中一台即将执行fullGC的服务器移出线上服务,并且对其执行dump,此时获取到的内容和之前1个多G的截然不同。

以下是新的dump解析结果:

服务网关API路由导致的性能问题分析

服务网关API路由导致的性能问题分析

这就能够很清楚地看到,大量的int数组是Matcher和Pattern对象产生的,而这两个对象就直接能想到是路由匹配时的正则产生的。

Pattern Compile十分消耗性能,且会产生大量临时对象,之前这里采用的是spring3中PathMatcher的实现,这里会在每次请求时都生成对应的Pattern。

知道了原因以后就可以做对应的优化了,优化目标是减少Pattern的Compile和Matcher对象的创建。

spring在4.0版本以后也对AntPathMatcher做了优化,AntPathMatcher内部对Pattern和Matcher都进行了缓存,并且对缓存大小进行了设置,当缓存大小大于CACHE_TURNOFF_THRESHOLD时,该缓存功能会被关闭
CACHE_TURNOFF_THRESHOLD默认为65536

具体实现可以参照spring4中AntPathMatcher的getStringMatcher方法和tokenizePattern方法。

优化部署后FullGC不再发生,该问题得到解决。


进一步优化

然而这里的优化还可以更进一步

 spring的匹配实现还默认使用了SuffixPaternMatch和TrailingSlashMatch,这些功能其实目前我们都没有用到,完全做了多余的判断,产生了多余的对象,这里可以都设置为false。

上面提到的优化方式总结下来思路基本都是相同,减少正则匹配次数,但是这没有解决最根本的问题,无法很好地应对未来的扩容。

之前的网关效率最低之处在于,对于所有请求都需要进行遍历查找。那么如何解决这个问题呢?

其实对于这个问题,nginx算是老司机了,nginx的location基本都会基于前缀匹配来进行转发到对应的upstream。

spring cloud的zuul的实现也是类似,微服务场景下,后端微服务其实就对应了nginx的upstream。

 

因此,需要对微服务框架进行改造。

改进一:每个微服务可以设置自己的路由规则,服务网关优先根据路由规则进行匹配,直接将匹配到的请求转发到对应的服务。

例如diyrenderservice设置了路由规则

namespace:drs

那么所有以/drs开头的请求就会直接转发到diyrenderservice

但是如果这个路由规则没有命中,或者实际提供对应服务的微服务没有设置路由规则的话该怎么办呢?

这里只能退化为遍历查找,考虑到历史原因,还有很多api是以/api开头,并且后面几级的命名没有namespace可以区分,完全无规律,并且分布在多个服务中,这些都是服务拆分时的后遗症。

如果要把这些API都规范掉需要投入比较大的重构精力和时间,因此为了兼容,需要在路由配置没有匹配到的情况下,降级为遍历查找。

 

改进二:客户端请求指定服务名称,客户端请求每个API时都指定对应的服务名称,添加一个特殊的header存放该API对应的服务名称,服务网关先检查当前请求是否有这个特殊的header,如果有,则直接转发到指定服务。


总结

虽然通过代码优化解决了PathMatch的问题,但是这类问题的产生和我们的API命名也有比较大的关系,部分API命名特别长,并且都是PathVariable,对应的PrefixTree高度已经超过了8层,产生的Pattern数量也比较多,在路由的时候代价都比较高。

归根结底,路由部分最大的瓶颈在于正则匹配,而产生大量正则匹配的根源是Restful风格的PathVariable。

反观一下互联网模式的数据库表结构设计,为了业务扩展和性能考虑,基本都是采用反范式的思路去设计,更多采用空间换时间的做法并保证最终一致性。这和学院派的范式设计截然相反。

回到这个问题上,API命名似乎也走上了这条路,主流的Restful风格命名成了性能瓶颈的根源之一,人为将PathVariable都转换为RequestParameter可破。但是对于有SEO需求的页面来说,Restful的写法还是需要的。当然我们也不能一棒子打死,就规定不能用Restful的写法了,只是从规范角度看,少写Restful风格的API可以提升性能。

文章转载自微信公众号酷家乐研发部

本文来自投稿,不代表路由百科立场,如若转载,请注明出处:https://www.qh4321.com/2025.html

(0)
路由器

相关推荐