近期工作中遇到一个问题:API接口性能始终达不到设定要求,虽然不是什么大型电商系统,但是100用户的并发量都慢的要命就有点说不过去了。
1.问题描述
系统的入口–登录接口功能正常,进行并发测试时会出现卡顿、延迟等现象,并且测试结果并不稳定,有时快有时慢。单次访问时长在3s以内,100现场并发访问总时长能到达1-3分钟。初期以为是硬件资源及网络的限制,在硬件资源扩容后仍没有明显改善。此外在配置了负载均衡策略后也没有显著提升。因此就有了以下从头到尾的问题排查和分析过程。
2.原因分析
测试工具输出的结果只是表现,想要挖出问题的根源还是要耐心分析,从总体结构到代码细节逐个琢磨。
2.1.结构分析
系统总体上使用微服务架构,前端应用通过网关访问后端服务接口。用户登录时通过网关访问验证授权服务Admin获取token令牌,之后携带token令牌访问其他应用服务,应用服务通过比对缓存中的令牌判定身份,确认权限后返回相应的资源。
总体结构上比较常规,出现问题的可能性不大,即使架构出问题了,也没法去改,毕竟成本太高。况且登录接口是所有业务接口的前置条件,牵一发动全身,这一步要是搞不定,后面附带的问题会更多。
2.2.流程分析
刚开始时测试人员和开发人员只关注总时长和平均时长这两个指标,导致我自己排查问题时也只看这两个指标。在一次测试过程中发现总时长和平均时长大幅度下降,增加到万级以上竟然平均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,因此内在代码逻辑实际上是分为两步:登录、获取用户信息。
详细流程是:
-
前端请求登录网关GatewayHost的接口Login_OAuth/Login
-
网关GatewayHost将请求重定向至授权验证服务Admin的接口OAuth2/OAuthLgoin
-
a. 用户名及密码验证成功后,Admin创建登录令牌LoginToken并存储至Redis,过期时间为1分钟
-
b. 网关GatewayHost接收到Admin接口OAuth2/OAuthLogin的正确响应(含LoginToken令牌)后开始执行接口Login_OAuth/GetOAuthUserInfo
-
网关GatewayHost将接口Login_OAuth/GetOAuthUserInfo重定向(携带LoginToken令牌)至Admin的接口OAuth2/Get_UserInfo
-
授权验证服务Admin在接口OAuth2/Get_UserInfo中验证LoginToken令牌
-
身份验证成功后创建会话令牌SessionToken并存储值Redis,过期时间为1小时
-
在执行完查询用户信息等逻辑后,返回用户信息
以相同的用户名和密码再次请求与上一次请求的区别在于:
步骤3.a处,重新创建LoginToken令牌,与原有LoginToken令牌无关,且原有LoginToken令牌过期自动销毁
步骤6,创建SessionToken令牌,并覆盖原有SessionToken令牌,以账号AcountId为标识,重新计算过期时间
2.3.代码分析
经过详细代码调试发现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)。
所以症结就在这里了,关键就要找到问什么两次方法执行间隔这么长?
继续通过代码进行排除法分析。
总体架构或者请求的流程是:前端发起请求,网关服务Gateway转发请求,授权验证服务Admin处理请求后原路返回。按照这个流程设计了几个场景来排查问题。
- 直接访问授权验证服务Admin的接口
同样是并发100,不经过网关Gateway,直接访问授权验证服务Admin的接口。不到1秒跑完,快到飞起,不敢再说是授权验证服务Admin的问题了。 - 授权验证服务Admin的接口不做业务处理直接返回
注释掉授权验证服务Admin接口中的所有业务逻辑,直接返回200,应该没有比这更快了吧。并发100,跑起…卡死…大约两分钟跑完。原来叛徒在这里,网关服务Gateway的问题没错了。
看来问题就在网关这里,网关服务引用了Ocelot组件,并配合Consul使用。都属于常规操作,跟网上的大部分示例都一致,配置文件也没找到毛病。配置文件修改后结果也没有发生很大变化,可以排除Ocelot和Consul的嫌疑了。
这时接口重定向的代码逻辑成功引起了我的怀疑,之前也怀疑过它,但由于它还算能正常工作也没多想。现在看来这部分逻辑确实值得探讨,来看看它的逻辑。
应用服务类Login_OAuth为开发给外部的接口,在顶部实例化Realize类,通过Realize实例来进行实际接口的跳转。同时也对传递的参数进行转换。
在Realize实例的GetOAuthOfAdmin方法中才是真正实现接口跳转的地方。核心代码就是iAdmin.GetAdminOfOAuth。iAdmin是接口IAdmin的实例,接口实例是在Realize实例的开始阶段获取。
首先是获取一个数据对象SignDto,再从SignDto中解析IAdmin的实例。
对象SignDto的实例化过程比较复杂,从代码的执行逻辑来看,既包含了IAdmin接口的注册(将IAdmin接口注册为HttpApi),也包含了IAdmin实例的解析(解析出当前实例化的HttpApi对象)
另外就是SignDto内包含了一个线程共享的单例对象,用于读取存储应用基本配置,包含应用Id、应用key和租户id。
从代码逻辑可以看出当时的设计思路:线程共享的单例对象SignDto包含应用配置信息、HttpApi接口的注册和解析,既能在线程间实时更新和共享配置信息,也能控制要注册的接口和要解析的实例,从而动态的实现Http请求。
单次运行这个逻辑并没有问题,运行也是正常的。但是遇到并发请求时,问题就来了。Realize类的实例化、SignDto的实例化、HttpApi接口的注册、HttpApi实例的解析在每次请求都会执行一遍,不断的装箱拆箱势必耗费不少时间。
另外SignDto的单例对象为了避免线程抢占锁死使用了volatile关键字,然而从查询资料的情况来看这个关键字并不能达到期望的效果。
其实网关这一堆逻辑的核心只有一个,在Gateway接口内部创建Http请求访问授权验证服务Admin接口,也就是请求套请求。通过一系列的接口是实现了网关服务与接口约定的解耦,性能却也不可避免的降低了。
3.解决过程
经过上述一系列分析后,问题的根源已经被挖掘出来,解决过程也是顺利成章的事情。关键的处理方式有:
3.1.修改SessionToken机制
从目前的流程来看LoginToken只是在身份验证成功后获取用户信息的临时令牌,而且只使用一次,短时间内过期后自动销毁。访问其他资源使用的才是会话令牌SessionToken,在目前的登录流程中并无任何用处。既然用户身份已经验证成功了,为什么还要拿着临时令牌去换正式令牌?直接给正式令牌不行吗?所以有必要在OAuthLoginIn接口身份验证成功后就创建正式的会话令牌SessionToken,获取用户信息时验证SessionToken,时间足够长,不用担心过期。
但随之而来的一个问题是,每次身份验证成功,会话令牌SessionToken会被刷新。如果短时间内使用相同用户名和密码并发访问登录接口,SessionToken会被不断重置刷新,在获取用户信息时仍有可能用的是旧的令牌,依然会报错。所以需要将SessionToken的机制改为若存在则不刷新,不存在则创建。
3.2.修改HttpApi接口注册机制
项目引用了类库WebApiClient,这里继续使用。WebApiClient是开源的第三方类库,对HttpClient进行了封装,性能接近原生的HttpClient。
在启动类Startup中引入WebApiClient,并进行配置。
services.AddHttpApi<RunGo.OAuth2._0_Base.IRealize>();
services.ConfigureHttpApi<RunGo.OAuth2._0_Base.IRealize>(option =>
{
option.HttpHost = new System.Uri(_Configuration["Sign:AdminHost"]);
//option.JsonSerializeOptions.Converters.Add(new IsoDateTimeConverter("yyyy-MM-dd HH:mm:ss"));
});
IRealize是定义HttpApi接口的接口类。
public interface IRealize:IDisposable, IHttpApi
{
#region 管理中心提供的接口
/// <summary>
/// 登录
/// </summary>
/// <returns></returns>
[HttpPost("/api/services/Admin/OAuth2/OAuthLoginIn")]
Task<string> GetOAuthOfAdmin([JsonContent] GetOAuthOfAdminInput input);
#endregion
}
在需要使用的地方通过构造函数实现依赖注入。同时注入配置管理器IConfiiguration和部分局部变量,用于获取配置信息。
在使用时直接调用,剩下的交给依赖注入框架。
/// <summary>
/// 登录接口
/// </summary>
/// <returns></returns>
[Route("api/services/app/Login_OAuth/Login")]
[HttpPost]
[HttpOptions]
[DontWrapResult]
public virtual Task<dynamic> Login(LoginDto loginDto)
{
var info = $"网关独立请求测试";
GetOAuthOfAdminInput inputDto = new GetOAuthOfAdminInput()
{
userName=loginDto.userName,
passWord=loginDto.passWord,
response_type="code",
client_id=clientId,
client_secret= clientSecret,
tenantId=tenantId,
redirect_uri=redirectUri,
state=loginDto.state,
source=loginDto.source
};
string resultJson = _realize.GetOAuthOfAdmin(inputDto).Result;
info = new { result = resultJson }.ToJson();
return Task.FromResult<dynamic>(info);
}
经过改造后的接口再进行测试,结果就能令人接受了。
并发100,总时长1s,平均249ms;并发500,总时长4s,平均3000ms;并发1000,总时长16s,平均10200ms;并发1500,总时长45s,平均12000ms;跟之前比已经有质的提升了。
4.经验总结
- 解决问题不能只看问题表象,还是要刨根问题找根源。
- 测试工作要全面,功能可用并不是结束,而是开始。
- 项目前期要多花时间在设计上,否则在项目后期需要花更多时间去填补设计缺陷。
- 工作交接要到位,不会有人愿意去翻看别人一年前写的代码,更不要说写代码的人已离职,逻辑全凭推断。
- 开发过程代码质量要监督到位,否则等到发现问题时已经是屎山巨大、烂不可闻。
- 问题分析要有耐心,认真思考绝不会错。
好的代码不是一天练成的,烂的项目也不是一日造就的。