2022年9月3号,星期六,天气晴。辛苦一周终于可以不用早起美美的睡个懒觉啦。结果事与愿违…;生产环境爆出p0级事故。
用户订单支付成功了,但是订单状态显示未支付…
毫无疑问,用户肯定会炸,结果不是客诉,就是差评。
用户感觉受到了欺诈
WX20220908-091812@2x
WX20220908-091856@2x
那么这种情况是怎么发生的呢?我们先简单分析下订单支付的完整流程:
订单支付的完整流程
1、用户从平台前端(app、小程序等)发起支付申请,到订单服务,计算本次需要支付金额;
2、订单服务向支付服务提交支付申请;
3、支付服务向第三方服务发起支付,支付渠道会响应相关参数或url信息,用于前端调起支付钱包;
4、前端通过响应参数,拉起钱包进行支付;
5、用户支付完成后,跳转回对应的前端应用内;
6、用户支付成功后,第三方支付回回调支付系统,通知用户支付信息;
7、支付服务根据第三方系统回调的支付信息,通知订单服务更新订单的支付状态,支付完成、部分支付、支付失败、支付关闭等
8、第五步完成后,前端用户查询订单系统支付状态;
我们梳理下订单支付状态流转:
发起支付后订单支付状态变成未支付,支付成功后更新状态为已支付;(省略细节)
正常来看我们整个流程没有问题,那么怎么就出现了未支付的情况?
简单分析下,出现这种情况的原因订单支付状态没有及时同步到订单服务或查询订单状态时没有获取的,我看下影响这两个地方的环节有哪些。
影响环节
- 环节6: 支付渠道的支付回调:发生了一些异常,导致支付服务没有收到支付渠道的回调通知;
- 环节7: 支付服务通知订单服务:服务内部出现异常,导致支付状态没有同步到订单服务;
- 环节8: 客户端获取订单状态:客户端通常是轮询获取状态,可能会在轮询时间内没有获取到订单状态,结果用户看到未支付;
内部系统怎么防止状态更新失败
我们先解决内部系统问题,内部系统的稳定性相对于外部系统还是比较容易保证的,发生失败的概率比较小。
环节7:支付系统通知订单系统更新订单状态,关键就在尽可能保证支付通知订单支付结果成功,我们一般采用两种方式:一、接口调用重试机制;二、消息可靠性保障;
消息确认机制保证更新成功
1、支付服务收到第三方支付渠道支付结果后,同步通知订单服务,如通知失败进行重试,防止网络抖动或其他原因下的调用失败。
2、异步消息可靠性投递:同步机制如不稳妥,可以在加个异步消息确认机制。支付服务将支付结果投递到消息队列中,订单服务获取支付结果更新订单状态,更新成功后,在进行ack消息,确保订单状态更新成功。
而订单状态更新是幂等的,可以采用两种机制双重保障。确保订单状态更新成功。
客户端获取最新状态
这种情况概率较低,没有必要做过分改造,如websocket等机制去处理,如出现了客户一般都会在刷新下,看看是不是网络的问题。
防止外部系统回调失败
相比内部系统,外部系统不稳定因素就非常多,如网络,防火墙策略,第三方渠道系统稳定性等等,不可控的因素非常多。
如只单单依靠系统回调来更新状态,风险是非常高的,那么要防止这种情况的出现,就需要系统“主动查询”。
主动查询主要有两种形式:定时任务、延迟队列;
定时任务
定时任务
定时任务方式是最常见也是最简单的方式,常见的解决方案采用xxl-job设定任务,去第三方渠道获取最新支付状态,我们提供下伪代码:
@XxlJob("getOrderStatus")
public void getOrderStatus() {
//定时查询数据库种一段时间范围内未支付的订单信息
List<Order> orders = orderService.getNonPayment();
//根据未支付的订单信息,获取该订单的支付状态
orders.forEach(item -> {
Integer payStatus = threeChannelPayService.getStatus(item.getOrderNo);
//判断获取的支付状态是否为已支付(1),如果为已支付则更新对于的订单状态信息。
if (payStatus.equals(1)) {
orderService.updatePayStatus(item.getOrderNo, payStatus);
}
});
}
以上为订单任务方式的伪代码,定时任务的最大好处肯定是简单了,但是使用定时任务方式存在几个问题。首先要考虑如何设置定时任务设置时间间隔。
如果时间间隔设置太小,会导致频繁扫描数据库,导致数据库压力增加。
如果时间间隔设置过大,导致支付状态不能及时更新,前端展示不能拿到支付的实际状态,存在数据及时性问题。
所以我们在处理这类问题的时候,不推荐大家采用定时任务的方式。
延时队列
延时队列
在发起支付之后,发送一个延时消息,前面讲到,用户跳转到钱包,通常很快会支付,所以我们希望查询支付状态这个步骤,符合这个规律,所以希望在3s、5s、1min、2min、5min、7min……这种频率去查询支付订单的状态,这里我们可以用一个队列结构实现,队列里存放下一次查询的时间间隔。
//下单完成
.....
CreateMsgBean csb = new CreateMsgBean();
csb.setBusinessNo(saleOrderResponse.getData().getOrderNo());
csb.setData(saleOrderResponse.getData().getOrderNo());
try {
pulsarTemplate.sendDelayAfter(TopicConstant.CANCEL_ORDER_TOPIC, csb, 3, TimeUnit.SECONDS);
pulsarTemplate.sendDelayAfter(TopicConstant.CANCEL_ORDER_TOPIC, csb, 5, TimeUnit.SECONDS);
pulsarTemplate.sendDelayAfter(TopicConstant.CANCEL_ORDER_TOPIC, csb, 1, TimeUnit.MINUTES);
pulsarTemplate.sendDelayAfter(TopicConstant.CANCEL_ORDER_TOPIC, csb, 2, TimeUnit.MINUTES);
pulsarTemplate.sendDelayAfter(TopicConstant.CANCEL_ORDER_TOPIC, csb, 5, TimeUnit.MINUTES);
log.info("--------------------------------------select order pay status msg send success:" + saleOrderResponse.getData().getOrderNo());
} catch (PulsarClientException e) {
log.error("--------------------------------------select order pay status msg send failure:" + e.getMessage());
}
....
这么使用的是Pulsar消息队列,使用延时队列方式进行处理。我们在下单成功后,将订单加入消息队列中,我们定义下,消息消费者,通过消费者获取相应订单编号,去第三方查询该订单的支付状态。
我们定义消费者伪代码:
@Slf4j
@Service
public class OrderPayStatusListener {
@Autowired
private PulsarTemplate<CreateMsgBean> pulsarTemplate;
@JddPulsarConsumer(topic = TopicConstant.PAY_STATUS_ORDER_TOPIC, clazz = CreateMsgBean.class, subscriptionName = "consume_pay_ststus_order")
public void consume(JddMessage<CreateMsgBean> msg) {
log.info("--------OrderPayStatusListener recv msg---- {}", JSONUtil.toJsonStr(msg));
CreateMsgBean createMsgBean = msg.getData();
try {
String saleOrderCode = createMsgBean.getBusinessNo();
log.info("---------------------prepare pay order:{}", saleOrderCode);
if (!StringUtils.isEmpty(saleOrderCode)) {
String payStatus = threeChannelPayService.getStatus(saleOrderCode);
//如果查询支付状态还在支付中,则将查询数据再次放到消息队列中,等待下次消费
if (payStatus.equals("paying")){
CreateMsgBean csb = new CreateMsgBean();
csb.setBusinessNo(saleOrderCode);
csb.setData(saleOrderCode);
pulsarTemplate.sendDelayAfter(TopicConstant.PAY_STATUS_ORDER_TOPIC, csb, nextTime, TimeUnit.SECONDS);
}
//如果订单支付完成,则更新订单状态。OrderPayStatusHandler方法需做幂等处理,因订单下单时,在队列中投递了多个消息,如果订单支付状态已经更新,则无需在次处理。
OrderPayStatusHandler saleOrderService = SpringUtil.getBean("orderPayStatusHandler", OrderPayStatusHandler.class);
saleOrderService.updatePayStatus(saleOrderCode,payStatus);
log.info("---------------------pay order success:{}", saleOrderCode);
}
} catch (Exception e) {
log.error("OrderPayStatusListener is Error {}", e.getMessage());
}
}
}
延时消息的方案相对于定时轮询方案来讲:
- 时效性更好
- 无需扫表,对数据库压力较小
本篇介绍了关于用户支付成功了,但是显示未支付的原因和解决方案,希望能够帮助到大家。