Skip to content

Commit ed1cf6a

Browse files
committed
钱付了,订单还是未支付,用户炸了!——聊聊如何防止支付掉单!
1 parent 7b3683d commit ed1cf6a

File tree

2 files changed

+300
-0
lines changed

2 files changed

+300
-0
lines changed
Lines changed: 300 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,300 @@
1+
---
2+
title: 钱付了,订单还是未支付,用户炸了!——聊聊如何防止支付掉单!
3+
shortTitle: 钱付了,订单还是未支付,用户炸了!——聊聊如何防止支付掉单!
4+
description: 不说了,给客服下跪道歉去了……
5+
author: 老三
6+
category:
7+
- 微信公众号
8+
head:
9+
---
10+
11+
## 好好的支付,怎么就掉单了?
12+
13+
听说过下单、买单、脱单……掉单是什么东西?
14+
15+
所谓的掉单,就是用户下单支付,在钱包里完成了支付,结果回到电商APP一看,订单还是未支付……
16+
17+
毫无疑问,用户肯定会炸,结果不是投诉,就是差评。
18+
19+
![用户感觉受到了欺诈](https://mmbiz.qpic.cn/mmbiz_png/PMZOEonJxWdT0rfYoeWzHsS1w2bkOj7wT63sPX6ichSmQ91OT49RuGEZGZaxDRoarYB48GRZfeicZBjNEHT1ckXg/640?wx_fmt=png)
20+
21+
22+
23+
那么掉单是怎么来的呢?
24+
25+
我们先来看看订单支付的完整流程:
26+
27+
![钱包支付的完整流程](https://mmbiz.qpic.cn/mmbiz_png/PMZOEonJxWdT0rfYoeWzHsS1w2bkOj7wTq5d6ejibWdTv6nADy9Xgty4JJSDzibSlzW8jBNe4TSLAvcVW0yI8DhA/640?wx_fmt=png)
28+
29+
30+
31+
1. 用户从电商应用点击支付,客户端向服务端发起支付请求
32+
2. 支付服务会向第三方的支付渠道发起支付,支付渠道会响应对应的url
33+
3. 以APP为例,客户端通常是会拉起对应的钱包,用户跳到对应的钱包
34+
4. 用户在钱包里完成支付
35+
5. 用户完成支付后,跳转回对应的电商APP
36+
6. 客户端轮询订单服务,获取订单状态
37+
7. 支付渠道回调支付服务,通知支付结果
38+
8. 支付服务通知订单服务,更新订单状态
39+
40+
对于支付订单而言,大概可以分为这么几个状态:
41+
42+
![支付状态](https://mmbiz.qpic.cn/mmbiz_png/PMZOEonJxWdT0rfYoeWzHsS1w2bkOj7w4HUd8LvG1Hkmz8zApvNYoHPKfqficXNMxDQ2sqjjsBN6ibUUcVBMBUug/640?wx_fmt=png)
43+
44+
45+
46+
* 未支付:用户在点击支付之后,支付服务请求支付渠道之前,处于未支付状态
47+
* 支付中:用户发起支付后,到跳转到支付钱包,再到完成支付,支付服务获取到最终支付结果之间,属于支付中状态,这个状态下,可以说是一个迷雾状态,电商系统对于用户的支付是不确定
48+
* 支付成功/失败/取消/关闭:电商系统最终确定了用户在第三方钱包的支付最终结果
49+
50+
看起来没什么问题啊,怎么就掉单了?简单说,就是支付的状态没有同步到,或者没有及时同步到。
51+
52+
![掉单发生](https://mmbiz.qpic.cn/mmbiz_png/PMZOEonJxWdT0rfYoeWzHsS1w2bkOj7whbLnyWAQcziaibjUuwpNjED3Kruiad7ADibJRLVchHZosicGDVKiaVw8jl4g/640?wx_fmt=png)
53+
54+
55+
56+
1. 支付渠道的支付回调
57+
58+
发生了一些异常,导致支付服务没有收到支付渠道的回调通知
59+
60+
2. 支付服务通知订单服务
61+
62+
服务内部出现异常,导致支付状态没有同步到订单服务
63+
64+
3. 客户端获取订单状态
65+
66+
客户端通常是轮询获取状态,可能会在轮询时间内没有获取到订单状态,结果用户看到未支付
67+
68+
其中1可以称之为外部掉单,2和3可以称之为内部掉单。
69+
70+
接下来我们看看,怎么预防掉单问题。
71+
72+
## 怎么防止内部掉单
73+
74+
我们先从系统内部的掉单说起,当然在系统内部,稳定性更容易保证,发生掉单的概率还是比较小的。
75+
76+
### 服务端防止掉单
77+
78+
支付服务和订单服务之间防止掉单,关键就在于尽可能保证支付通知订单支付结果成功,我们一般通过这两种方式。
79+
80+
![服务端防止掉单](https://mmbiz.qpic.cn/mmbiz_png/PMZOEonJxWdT0rfYoeWzHsS1w2bkOj7w0yl68aus90LddXrGPX394mm6upzmibvWNsaMmjwp7d1AraCcRfic0BhA/640?wx_fmt=png)
81+
82+
83+
84+
1. 同步调用重试机制
85+
86+
支付服务调用订单服务的时候,要进行失败重试,防止网络抖动情况下的调用失败。
87+
88+
2. 异步消息可靠性投递
89+
90+
同步不稳妥,那就再加一个异步。支付服务投递一个支付成功消息,订单服务消费支付成功消息,整个过程要尽可能保证可靠性,例如订单服务要在完成订单状态更新后再确认完成消息消费。
91+
92+
同步+异步两手策略,基本上可以防范服务端的内部掉单。
93+
94+
至于引入分布式事务(事务消息、Seata)来保证状态一致,我觉得也没有必要。
95+
96+
### 客户端如何防止掉单
97+
98+
用户支付完成后,跳回电商系统,客户端会轮询一下订单的状态,通常两三秒内,就会得到订单完成支付的结果,这个过程出现问题的概率相比是非常低的。
99+
100+
但是也不排除,很小概率下,客户端轮询一段时间,还没得到结果,那么只能结束轮询,给用户展示未支付。
101+
102+
这种情况,通常问题也是出在服务端,没有及时更新订单的状态,最主要的还是要处理服务端的掉单,保证服务端能及时同步支付订单的状态。
103+
104+
但是一旦服务端的订单状态变更了,也要尽可能同步到客户端,不能让用户一直看到未支付。
105+
106+
客户端和服务端之间,同步状态,无非就是推和拉:
107+
108+
1. 客户端轮询
109+
110+
客户端判断用户未支付之后,通常会进行订单倒计时。
111+
112+
![倒计时](https://mmbiz.qpic.cn/mmbiz_png/PMZOEonJxWdT0rfYoeWzHsS1w2bkOj7wGgAZHcMmibfN0KTKM0UqqDJuyP9Gn8h776kYmZ6vCumEwSNb4Oyl1Gg/640?wx_fmt=png)
113+
114+
115+
116+
这里再提一下?大家觉得这种倒计时是怎么实现的呢?纯客户端组组件倒计时吗?
117+
118+
——肯定不行,通常是客户端组件倒计时,定期向服务端请求,检查倒计时时间。同样的,这种情况下,客户端也可以检查支付状态。
119+
120+
2. 服务端推送
121+
122+
说真的,服务端推送,看上去是一种很美好的方案,Web端可以使用Websocket,APP端可以用自定义Push,大家可以看看[7种实现web实时消息推送的方案](https://mp.weixin.qq.com/s/FAA0xksVNiVuPGY8-kaONg)。但实际上,推送的成功率经常不那么理想。
123+
124+
## 怎么防止外部掉单
125+
126+
相比较内部掉单,外部掉单发生的概率就大很多,毕竟和外部渠道的对接,不可控的因素更多。
127+
128+
要防止外部掉单,核心就是四个字:“`主动查询`”,如果只是等待第三方的回调通知,风险还是比较大的,支付服务要主动向第三方查询支付状态,即使有什么异常,也能及时感知到。
129+
130+
主动查询,主要就是两种形式:
131+
132+
### 定时任务查询
133+
134+
毫无疑问,最简单的肯定就是定时任务了,支付服务,定时查询一段时间内`支付中`的支付订单,向第三方渠道查询支付结果,查询到终态之后,就去更新支付订单状态、通知订单服务:
135+
136+
![定时查询支付状态](https://mmbiz.qpic.cn/mmbiz_png/PMZOEonJxWdT0rfYoeWzHsS1w2bkOj7wQPM28icTetO9qchibW1WtZAf6P6mUsbGQdkic1x7WcCbHvhOMTSJazJicQ/640?wx_fmt=png)
137+
138+
139+
140+
实现也很简单,用xxl-job之类的定时任务框架,定时扫表,向第三方查询就行了,大概代码如下:
141+
142+
```
143+
    @XxlJob("syncPaymentResult")
144+
    public ReturnT<String> syncPaymentResult(int hour) {
145+
        //……
146+
        //查询一段之间支付中的流水
147+
        List<PayDO> pendingList = payMapper.getPending(now.minusHours(hour));
148+
        for (PayDO payDO : pendingList) {
149+
            //……
150+
            // 主动去第三方查
151+
            PaymentStatusResult paymentStatusResult = paymentService.getPaymentStatus(paymentId);
152+
            // 第三方支付中
153+
            if (PaymentStatusEnum.PENDING.equals(paymentStatusResult.getPayStatus())) {
154+
                continue;
155+
            }
156+
            //支付完成,获取到终态
157+
            //……
158+
            // 1.更新流水
159+
            payMapper.updatePayDO(payDO);
160+
            // 2.通知订单服务
161+
            orderService.notifyOrder(notifyLocalRequestVO);
162+
        }
163+
        return ReturnT.SUCCESS;
164+
    }
165+
```
166+
167+
定时任务的最大好处肯定是简单了,但是它也有一些问题:
168+
169+
1. 查询的结果不实时
170+
171+
定时任务频率的设置永远是个不好确定的事情,间隔短对数据库压力大,间隔长了不实时,很容易出现,上面提到的用户回到APP,结果轮询不到支付成功状态的情况。
172+
173+
实际上,用户跳转钱包之后,通常会很快完成支付,如果短时间内没有完成支付,那么一般也不会再付了。所以其实,发起支付开始,从第三方查询支付结果的频率应该是递减的。
174+
175+
2. 对数据库有压力
176+
177+
定时任务扫表,对数据库肯定是会有压力的,扫表的时候,经常会看到数据库的监控出现一个小突刺,如果数据量大的话,可能影响更大。
178+
179+
可以单独创建一个支付中流水表,定时任务扫描这张表,获取到支付最终态之后,就删除掉对应的记录。
180+
181+
### 延时消息查询
182+
183+
定时任务存在一些问题,那么有没有什么其它办法呢?答案是延时消息。
184+
185+
![延时消息查询支付状态](https://mmbiz.qpic.cn/mmbiz_png/PMZOEonJxWdT0rfYoeWzHsS1w2bkOj7wficDDPD4SpbBjc4ia7IWRdtz55fDFc5Esj92AibP0K0JLmssKWnshsDmA/640?wx_fmt=png)
186+
187+
188+
189+
* 在发起支付之后,发送一个延时消息,前面讲到,用户跳转到钱包,通常很快会支付,所以我们希望查询支付状态这个步骤,符合这个规律,所以希望在10s、30s、1min、1min30s、2min、5min、7min……这种频率去查询支付订单的状态,这里我们可以用一个队列结构实现,队列里存放下一次查询的时间间隔。
190+
191+
大概代码如下:
192+
193+
```
194+
//……
195+
//控制查询频率的队列,时间单位为s
196+
Deque<Integer> queue = new LinkedList<>();
197+
queue.offer(10);
198+
queue.offer(30);
199+
queue.offer(60);
200+
//……
201+
//支付订单号
202+
PaymentConsultDTO paymentConsultDTO = new PaymentConsultDTO();
203+
paymentConsultDTO.setPaymentId(paymentId);
204+
paymentConsultDTO.setIntervalQueue(queue);
205+
//发送延时消息
206+
Message message = new Message();
207+
message.setTopic("PAYMENT");
208+
message.setKey(paymentId);
209+
message.setTag("CONSULT");
210+
message.setBody(toJSONString(paymentConsultDTO).getBytes(StandardCharsets.UTF_8));
211+
try {
212+
//第一个延时消息,延时10s
213+
long delayTime = System.currentTimeMillis() + 10 * 1000;
214+
// 设置消息需要被投递的时间。
215+
message.setStartDeliverTime(delayTime);
216+
SendResult sendResult = producer.send(message);
217+
//……
218+
} catch (Throwable th) {
219+
log.error("[sendMessage] error:", th);
220+
}
221+
```
222+
223+
> PS:这里用的是RocketMQ云服务器版,支持任意级别的延时消息,开源版的RocketMQ只支持固定级别的延时消息,不得不感慨充钱才能变强。有实力的开发团队,可以在开源基础上,进行二次开发。
224+
225+
* 在消费到延时消息之后,向第三方查询支付订单的状态,如果还在支付中,就继续发送下一个延时消息,延时间隔从队列结构中取。如果获取到最终态,就去更新支付订单状态、通知订单服务。
226+
227+
```
228+
@Component
229+
@Slf4j
230+
public class ConsultListener implements MessageListener {
231+
    //消费者注册,监听器注册
232+
    //……
233+
  
234+
    @Override
235+
    public Action consume(Message message, ConsumeContext context) {
236+
        // UTF-8解析
237+
        String body = new String(message.getBody(), StandardCharsets.UTF_8);
238+
        PaymentConsultDTO paymentConsultDTO= JsonUtil.parseObject(body, new TypeReference<PaymentConsultDTO>() {
239+
        });
240+
        if (paymentConsultDTO == null) {
241+
            return Action.ReconsumeLater;
242+
        }
243+
        //获取支付流水
244+
        PayDO payDO=payMapper.selectById(paymentConsultDTO.getPaymentId());
245+
        //……
246+
        //查询支付状态
247+
        PaymentStatusResult paymentStatusResult=payService.getPaymentStatus(paymentStatusContext);
248+
        //还在支付中,继续投递一个延时消息
249+
        if (PaymentStatusEnum.PENDING.equals(paymentStatusResult.getPayStatus())){
250+
            //发送延时消息
251+
            Message msg = new Message();
252+
            message.setTopic("PAYMENT");
253+
            message.setKey(paymentConsultDTO.getPaymentId());
254+
            message.setTag("CONSULT");
255+
           //下一个延时消息的频率
256+
            Long delaySeconds=paymentConsultDTO.getIntervalQueue().poll();        message.setBody(toJSONString(paymentConsultDTO).getBytes(StandardCharsets.UTF_8));
257+
            try {
258+
                Long delayTime = System.currentTimeMillis() + delaySeconds * 1000;
259+
                // 设置消息需要被投递的时间。
260+
                message.setStartDeliverTime(delayTime);
261+
                SendResult sendResult = producer.send(message);
262+
                //……
263+
            } catch (Throwable th) {
264+
                log.error("[sendMessage] error:", th);
265+
            }
266+
            return Action.CommitMessage;
267+
        }
268+
        //获取到最终态
269+
        //更新支付订单状态
270+
        //…… 
271+
        //通知订单服务
272+
        //……
273+
        return Action.CommitMessage;
274+
    }
275+
}
276+
```
277+
278+
延时消息的方案相对于定时轮询方案来讲:
279+
280+
不过大家也看到,我这里的实现是利用的是充钱版的RocketMQ,所以看起来不太复杂,但是如果用开源方案,那就没那么简单。
281+
282+
![充钱就能解决](https://mmbiz.qpic.cn/mmbiz_png/PMZOEonJxWdT0rfYoeWzHsS1w2bkOj7wbwesLkeYBBdwEfcUW71cjDEDtNb2WNXiciaVL5aWOUEYVdicDyB8qm7iaQ/640?wx_fmt=png)
283+
284+
285+
286+
* 时效性更好
287+
* 无需扫表,对数据库压力较小
288+
289+
## 结语
290+
291+
这篇文章介绍了一个让用户炸毛,让客服恼火,让开发挠头的问题——掉单,包括为什么会掉单,怎么防止掉单。
292+
293+
其中内部掉单,发生的概率相对较少,掉单最主要的原因还是所谓的外部掉单。
294+
295+
外部掉单解决的关键点是`主动查询`,有两种常用的方案:`定时任务查询``延时消息查询`,前者简单一些,后者功能上更加出色。
296+
297+
298+
299+
300+
>参考链接:[https://mp.weixin.qq.com/s/zMRXR-kVqvN5rqQxsV2HSA](https://mp.weixin.qq.com/s/zMRXR-kVqvN5rqQxsV2HSA),出处:三分恶,整理:沉默王二
Loading

0 commit comments

Comments
 (0)