业务背景
公司最近正在准备为邮储银行开展一个营销活动,活动规则是:用户使用邮储银行卡在线上支付一分钱,就可以领取50元现金券,卡券领取完毕后,系统会自动退还消费者的1分钱。(相当于免费给邮储用户发放50元现金券),因为发券的入口要做在小程序里面,于是这个需求就落到了C端这边(公司的另一个业务小组),而我主要负责B端支付模块。经过我们商讨后,初步制定的业务逻辑为:用户打开C端小程序进行支付、然后C端将支付请求转给B端支付模块、B端支付模块向微信下单、等待消费者完成支付后B端支付模块通知C端交易完成并返回其支付方式、C端判断支付方式是否为邮储银行卡(是邮储银行卡则发券)、然后C端调用B端支付模块进行退款。为了方便大家理解,我呕心沥血的画出了系统调用的时序图。
生产环境发现的问题
1、NoHttpResponseException导致退款失败
功能上线后,我便开始监控B端支付模块的交易数据,前两天的数据并没有什么异常,支付完成的订单都已经退款完成。然后在第三天快下班时,我又统计了一遍数据,发现竟然存在一笔没退款的订单,我整个人一下子就支棱了起来(不会又写了个Bug吧~),我先在数据库中查到订单号,然后找运维同事拿了一下日志,发现支付回调是正常的,并且下游系统也响应了success,但是却没有调用退款接口进行退款。排查到这里基本已经可以确定不是支付模块这边的问题了,但问题毕竟还是要解决的,于是我联系了C端的同事,暂时先通过接口的方式把消费者的钱进行退款。然后开始排查C端系统的问题,通过C端的日志发现,在请求支付模块进行退款时存在一个异常信息,报错信息如下
看到这个报错,我不禁陷入了思考:C端这个日志表明确实是发起了退款请求,但是B端支付模块根本没收到这个退款请求,这样一来就比较尴尬了,双方系统竟然都没问题,那只能是网络问题了(找不到人背锅,只能推给网络了~~哈哈),刚开始只有一笔,我没怎么在意,过了几天后,陆陆续续发现了好几笔类似的情况,平均几千笔订单就会出现一笔退款失败的,并且这些订单之间毫无规律,搞得我这几天是干啥啥不香,于是痛下决心要深入研究一下这个问题。
2、 异常情况分析
目前能够提供帮助的信息并不多,只有这一个报错日志,通过在网上收集到的一些相关资料,发现了几篇比较有借鉴价值的文章,他们的观点也都几乎一致:服务端主动断开TCP链接,然后客户端使用半断开的链接发起请求时,服务端响应RST包导致此异常情况的发生。 大多数文章的建议是:捕获NoHttpResponseException异常进行重试。
3、验证思路
既然有了上述猜想,那么下一步肯定是要做验证的,验证一下在这个场景下确实会出现此现象。刚开始的验证思路比较简单,就是在服务端通过工具模拟FIN包,然后再用HttpClient继续请求,观察其结果,然而抓包结果显示Httpclient会创建一个新的tcp链接进行请求,木得办法,解铃还须系铃人,恐怕要看一下HttpClient源码才能解释这个现象了。
HttpClient的相关源码,大家可以参考我的另一篇文章《HttpClient源码探索——Tcp链接建立时机及http请求发送时机》
通过阅读HttpClient源码,大致找到了两个比较关键的逻辑点
- HttpClient建立tcp链接的时机(三次握手的时机)
- 发送http请求的时机
tip:在三次握手之前会检查当前tcp链接是否处于Open状态,若处于Open状态则复用此链接,若不处于Open状态则打开一个新的tcp链接,这样一来就解释的通为什么之前HttpClient又重新创建了一个TCP链接的现象了。
4、NoHttpResponseException复现
然后接下来是要做的就是根据之前的猜想来复现NoHttpResponseException场景,具体的思路如下
- 在Httpclient源码中,等待tcp链接建立完成后,打上断点
- 等服务器主动发送FIN包断开链接后,再发起请求,然后观察结果
成功复现了NoHttpResponseException现象,抓包结果如下所示
通过抓包结果分析,可以得出"服务端主动断开TCP链接,然后客户端使用半断开的链接发起请求"确实会导致NoHttpResponseException现象,至于服务端什么情况下会主动断开tcp链接?间隔多久主动断开tcp链接?这里就不再讨论了,读者可以自行了解一下keep-alive机制。分析到这里,问题基本上算是解决了,生产环境出现此问题的执行时序应该如下所示
- 客户端HttpClient复用之前已经Open的链接
- 然后进行检查(因为此时服务端还未关闭tcp链接,所以链接可用)
- 紧接着服务端主动关闭链接导致链接不可用
- 服务端针对客户端的请求响应了RST包
5、解决方案
从业务层面考虑,即使修复了这个问题,也还是会有很大的风险,毕竟网络是未知的,因此我建议C端同事做一个补偿机制,用来处理退款失败情况。
当然网络层面该优化的也得优化,具体步骤是在HttpClient初始化时添加重试策略。
private static CloseableHttpClient init() {
// 配置请求的超时设置
RequestConfig requestConfig = RequestConfig.custom()
.setConnectionRequestTimeout(CONNECTION_REQUEST_TIMEOUT)
.setConnectTimeout(CONNECT_TIMEOUT)
.setSocketTimeout(SOCKET_TIMEOUT)
.build();
// 重试策略 RETRY_COUNT=3 代表NoHttpResponseException异常重试3次
HttpRequestRetryHandler retryHandler = (exception, executionCount, context) -> {
return executionCount <= RETRY_COUNT && exception instanceof NoHttpResponseException;
};
return HttpClients.custom()
.setConnectionManager(new PoolingHttpClientConnectionManager())
.setRetryHandler(retryHandler)
.setDefaultRequestConfig(requestConfig)
.build();
}
6、引发的思考
- HttpClientPool的链接管理策略(复用、回收等等)。
- Keep-alive机制
- 计算机网络
7、尾言
我的公众号是《敲得码黛》,欢迎大家关注我的个人公众号,一起学习,一起成长!