|
| 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 | + |
| 20 | + |
| 21 | + |
| 22 | + |
| 23 | +那么掉单是怎么来的呢? |
| 24 | + |
| 25 | +我们先来看看订单支付的完整流程: |
| 26 | + |
| 27 | + |
| 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 | + |
| 43 | + |
| 44 | + |
| 45 | + |
| 46 | +* 未支付:用户在点击支付之后,支付服务请求支付渠道之前,处于未支付状态 |
| 47 | +* 支付中:用户发起支付后,到跳转到支付钱包,再到完成支付,支付服务获取到最终支付结果之间,属于支付中状态,这个状态下,可以说是一个迷雾状态,电商系统对于用户的支付是不确定 |
| 48 | +* 支付成功/失败/取消/关闭:电商系统最终确定了用户在第三方钱包的支付最终结果 |
| 49 | + |
| 50 | +看起来没什么问题啊,怎么就掉单了?简单说,就是支付的状态没有同步到,或者没有及时同步到。 |
| 51 | + |
| 52 | + |
| 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 | + |
| 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 | + |
| 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 | + |
| 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 | + |
| 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 | + |
| 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),出处:三分恶,整理:沉默王二 |
0 commit comments