近期在项目中遇到系统登录接口始终无法满足压力测试要求的问题,在多线程并发情况下响应速度忽快忽慢,即使做了负载均衡依旧没有质的改善,下面对问题分析过程进行说明。
想要分析问题原因,就需要先了解系统结构。
系统总体上使用微服务架构,前端应用通过网关访问后端服务接口。用户登录时通过网关访问验证授权服务Admin获取token令牌,之后携带token令牌访问其他应用服务,应用服务通过比对缓存中的令牌判定身份,确认权限后返回相应的资源。
单次访问时,一切看起来那么美好。响应速度225ms,还是不错的。
增加到10个线程并发,总时长1s,平均响应速度159ms,看起来更快了。
增加到100个线程,总时长长达2.35分钟,平均响应速度152195ms,简直是灾难。
多次重复测试,更诡异的事情发生了,总时长有时长有时段,并不稳定在某个区间范围内。之后又增加到500、1000、5000个线程,总时长同样忽长忽短,平均响应速度也保持在较高水平。
最开始认为是硬件资源的问题,因为在生产环境中的服务器配置较低,后来经过业主申请将所有服务器的CPU及内存全部翻倍,然而并没有太多的改善,可以排除是硬件的因素了。
之后考虑是并发负载的问题,在网关处增加了负载均衡的相关配置,并将授权验证服务的实例增加到多个,使网关根据每个服务实例的负载情况动态分派请求,经过测试平均响应速度是稍微快了那么一点,总时长仍然是很长。看似解决了,但又没有完全解决。
后来又考虑是网络的问题,经了解运营商提供的是百兆专用带宽,再怎么样不至于一个接口的请求数据就不够用了?网络的因素也可以排除了。
事出反常必有妖。经过了一系列的分析,似乎陷入了僵局,再回头仔细分析整个系统架构和测试数据,问题出现了转机。
不是真的不要相信JMeter的测试数据,而是不要单单只盯着JMeter的那几个数字。刚开始时测试人员和开发人员只关注总时长和平均时长这两个指标,导致我自己排查问题时也只看这两个指标。在一次测试过程中发现总时长和平均时长大幅度下降,增加到万级以上竟然平均15ms的响应,有这么厉害吗?仔细检查一看,原来是接口地址写错了,所有请求都是404,当然很快了。这时我意识到总时长和平均时长并不能反映真实的压力测试结果。单次请求时长只是一次HTTP请求到响应的间隔,并不代表每次请求返回的都是正确结果,404、501、200这些状态的响应都会被放在一起计算。
再次测试果然发现了问题的端倪,以100个线程并发测试为例。共进行了10次测试,每次总时长约2.35分钟,平均响应时间15万毫秒。每100次请求中有4次响应超时,有12次为501错误,有84次为正确结果,而且响应超时造成了测试进程的堵塞,往往能达到1分钟左右。因此只要有响应超时的问题存在,无论是并发100还是并发10000,总时长总是要两分钟以上。
从一开始开发人员就没有“代码可能有问题”的考虑,毕竟登录接口已经实际使用这么长时间了,没发现有什么问题。但是在并发测试中出现的响应超时和501内部错误该如何解释,一切现象的矛头都指向一个地方--“代码逻辑有问题”。经过本地测试再对登录接口的逻辑进行了梳理,整个登录过程的时序图如下:
登录过程涉及三个重要节点:网关GatewayHost,授权验证服务AdminService和缓存数据库Redis。网关GatewayHost提供两个接口Login_Auth/Login(登录)、Login_Auth/GetOAuthUserInfo(获取用户信息),分别映射到授权验证服务AdminService的接口OAuth2/OAuthLogin(登录)、OAuth2/Get_UserInfo(获取用户信息)。其中网关GatewayHost的接口Login_Auth/Login内部又调用了接口Login_Auth/GetOAuthUserInfo,因此内在代码逻辑实际上是分为两步:登录、获取用户信息。
详细流程是:
1.前端请求登录网关GatewayHost的接口Login_OAuth/Login
2.网关GatewayHost将请求重定向至授权验证服务Admin的接口OAuth2/OAuthLgoin
3.1用户名及密码验证成功后,Admin创建登录令牌LoginToken并存储至Redis,过期时间为1分钟
3.2网关GatewayHost接收到Admin接口OAuth2/OAuthLogin的正确响应(含LoginToken令牌)后开始执行接口Login_OAuth/GetOAuthUserInfo
4.网关GatewayHost将接口Login_OAuth/GetOAuthUserInfo重定向(携带LoginToken令牌)至Admin的接口OAuth2/Get_UserInfo
5.授权验证服务Admin在接口OAuth2/Get_UserInfo中验证LoginToken令牌
6.身份验证成功后创建会话令牌SessionToken并存储值Redis,过期时间为7天
7.在执行完查询用户信息等逻辑后,返回用户信息
以相同的用户名和密码再次请求与上一次请求的区别在于:
经过详细代码调试发现501错误发生在步骤5,即验证LoginToken令牌的地方,错误详情为:“令牌无效或者过期,请重新登录”,也就是代码中的这部分。
也就是代码执行到Step2(Get_UserInfo)时,在Step1(OAuthLoginIn)中创建的LoginToken已经过期销毁了,这也意味着从执行OAuthLogin到执行Get_UserInfo中间间隔了1分钟以上。看起来不可思议,实际上确实如此,之前测试结果中的平均响应时间也证明了这一点。
为什么会发生这种情况?看来还是要从并发的实际过程说起。
举个例子,把服务看作柜台(GatewayHost是A柜台、Admin是B柜台),把接口看作窗口(Login_OAuth/Login是A1窗口、Login_OAuth/GetOAuthUserInfo是A2窗口、OAuth2/OAuthLoginIn是B1窗口、OAuth2/Get_UserInfo是B2窗口),把请求看作人。100个人同时进入办事大厅到A柜台的A1窗口排队,A1窗口告诉他们到B1窗口领取凭证,于是一堆人呼啦啦又去B1窗口排队,在B1窗口拿到凭证后,又被告知要去A2窗口排队领取结果,又是一堆人去A2窗口排队,排队结果是被告知领取结果是在B2窗口,又是一堆人去B2窗口排队领取结果,领到结果,一个人的任务才算完成。这个过程中的关键点在于无法保证每个人在不同的窗口排队时有同样的位次,同一个人在A1窗口可能排第1位,在下一个窗口可能就是排第100位。每个人的每次排队都是一次HTTP请求,B1到B2的间隔时间太长凭证就会过期(501 internal error),A1到B2的间隔时间太长就会操作超时(operation timed out)。
所以症结就在这个逻辑上。
在分析了具体的问题后,就考虑从以下几方面解决问题:
1.前端拆分为两次请求
前端改为分两次请求,不在登录接口中获取用户信息,确保职责的单一性。调用登录接口成功后,在调用获取用户信息接口。但这个方案仍然存在登录令牌LoginToken过期的隐患,而且概率更高,需要同时修改LoginToken的过期时间。
注:前端和后端都需要修改,而且前端工作量较大,暂不考虑
2.取消LgoinToken,将创建SessionToken的时机前移
从目前的流程来看LoginToken只是在身份验证成功后获取用户信息的临时令牌,而且只使用一次,短时间内过期后自动销毁。访问其他资源使用的才是会话令牌SessionToken,在目前的登录流程中并无任何用处。既然用户身份已经验证成功了,为什么还要拿着临时令牌去换正式令牌?直接给正式令牌不行吗?所以有必要在OAuthLoginIn接口身份验证成功后就创建正式的会话令牌SessionToken,获取用户信息时验证SessionToken,时间足够长,不用担心过期。
但随之而来的一个问题是,每次身份验证成功,会话令牌SessionToken会被刷新。如果短时间内使用相同用户名和密码并发访问登录接口,SessionToken会被不断重置刷新,在获取用户信息时仍有可能用的是旧的令牌,依然会报错。所以需要将SessionToken的机制改为若存在则不刷新,不存在则创建。
3.将登录接口和获取用户信息接口真正合并在一起
由于业务的需要,登录成功后要及时获取用户的菜单权限。按照目前的流程,从前端的角度只调用的一次接口,而实际上却经过了至少两次HTTP请求(不算上网关与授权验证服务之间的重定向),而且是HTTP请求的嵌套。所以不如将身份验证和获取用户信息的逻辑代码放在一个接口中实现,实际上身份验证和获取用户信息的代码耗时并不长。
4.将不经常发生变化的信息存放在缓存里
比较复杂的逻辑都在获取用户信息部分中,涉及到多次的数据库查询,主要是账号对应的人员、角色、菜单权限信息,但这些信息相对固定,变化频率较低,可以存储在Redis缓存中,也能稍微缩短一点查询速度。但同时也要在修改角色、菜单权限的地方增加更新缓存的代码,确保缓存与数据库中的数据始终一致。
综上所述,按照上面的思路进行修改。
-------------------------------------------------------------------------------------------------------
其他
目前的压力测试方案可能并符合实际情况,“使用同一个账号同时请求100次”与“使用100个不同账号同时请求”是不同的,使用100个账号同时请求应该更符合实际情况,但也不代表压力测试就通过,毕竟代码屎山巨大,非一日之功也。