当前位置: 首页 > news >正文

电商系统中的掉单问题

什么是掉单?

所谓掉单,就是指用户下单支付后,在钱包里完成了支付,结果回到电商系统中查看,订单还是处于未支付的状态。

掉单的产生

在这里插入图片描述

  1. 用户从电商应用点击支付,客户端向服务端发起支付请求
  2. 支付服务会向第三方的支付渠道发起支付,支付渠道会响应对应的 url
  3. 以 APP 为例,客户端通常是会拉起对应的钱包,然后用户跳转到对应的钱包
  4. 用户在钱包里完成支付
  5. 用户在完成支付后,跳转回对应的电商 APP
  6. 客户端轮询订单服务,获取订单状态
  7. 支付渠道回调支付服务,通知支付结果
  8. 支付服务通知订单服务,更新订单状态

对于支付订单而言,大体可划分为以下几种状态。

  • 未支付

在用户点击支付之后,支付服务请求支付渠道之前,处于未支付状态。

  • 支付中

在用户发起支付之后,跳转到支付钱包完成支付,支付服务获取到最终的支付结果之间,处于支付中状态。在这个状态下,电商系统对于用户的支付结果是不确定的。

  • 支付成功|失败|取消|关闭

电商系统最终确定了用户在第三方钱包的最终支付结果。

综上所述,发生掉单就是因为支付状态没有同步,或者没有及时同步。

在这里插入图片描述

  • 支付渠道的支付回调异常

发生了异常,导致支付服务没有收到支付渠道的回调通知。

  • 支付服务通知订单服务异常

服务内部出现了异常,导致支付状态没有同步到订单服务。

  • 客户端获取订单状态异常

客户端通常是轮询获取状态,可能会在轮询期间内没有获取到订单状态,导致用户看到订单一直处于未支付状态。

其中第一类可以称之为外部掉单,其余的可以称之为内部掉单。

防止内部掉单

服务端防止掉单

在这里插入图片描述

支付服务和订单服务之间防止掉单,关键在于要尽可能地保证支付服务通知订单服务支付结果成功,一般可以采用以下两种方式。

  • 同步调用重试机制

在支付服务调用接口通知订单服务的时候,要进行失败重试,防止因网络抖动而导致的调用失败。

  • 异步消息可靠性投递

同步不稳妥,那就再加上一个异步。支付服务投递一个支付成功的消息,订单服务消费消息,这整个过程要尽可能保证可靠性(例如订单服务要在完成订单状态更新后,再确认完成消息消费)。

客户端防止掉单

用户在支付完成后,跳转回电商系统,客户端会轮询一下订单的状态,通常是在两到三秒内,就能得到订单支付完成的结果,这个过程出现问题的概率相对较低。

但是也不能排除,客户端在轮询了一段时间,还没能得到结果,那么只能结束轮询,给用户展示未支付。

这种情况,通常问题也是出在服务端没有及时更新订单的状态,最主要的还是要处理服务端的掉单,保证服务端能及时同步支付订单的状态。

但是一旦服务端的订单状态变更了,也要尽可能同步到客户端,不能让用户一直看到订单处于未支付。

  • 客户端轮询

客户端判断用户未支付之后,通常会进行订单倒计时。

通常是客户端组件倒计时,定期向服务端请求,检查倒计时时间。这种情况下,客户端也可以检查支付状态。

  • 服务端推送

防止外部掉单

相较于内部掉单,外部掉单发生的概率大很多,毕竟和外部渠道的对接,不可控的因素更多。

要防止外部掉单,核心要以是四个字:“主动查询”,如果只是单纯地等待第三方的回调通知,风险还是比较大的,支付服务要主动向第三方查询支付状态,这样即使有什么异常,也能及时感知到。

定时任务查询

在这里插入图片描述

支付服务,定时查询在一段时间内支付中的支付订单,向第三方渠道查询支付结果,查询到终态后,就去更新支付订单的状态,并通知订单服务。

@XxlJob("syncPaymentResult")
public ReturnT<String> syncPaymentResult(int hour) {
    // 查询一段之间支付中的流水
    List<PayDO> pendingList = payMapper.getPending(now.minusHours(hour));
    for (PayDO payDO : pendingList) {
        // 主动去第三方查
        PaymentStatusResult paymentStatusResult = paymentService.getPaymentStatus(paymentId);
        // 第三方支付中
        if (PaymentStatusEnum.PENDING.equals(paymentStatusResult.getPayStatus())) {
            continue;
        }
        // 支付完成, 获取到终态
        // 1.更新流水
        payMapper.updatePayDO(payDO);
        // 2.通知订单服务
        orderService.notifyOrder(notifyLocalRequestVO);
    }
    return ReturnT.SUCCESS;
}

定时任务虽然简单,但是也存在以下问题。

  • 查询结果不实时

定时任务的频率设置不好把控,间隔短容易对数据库造成较大压力;间隔长则不实时,容易出现轮询不到支付成功状态的情况。

事实上,在用户跳转钱包之后,通常会很快完成支付,如果在短时间内没有完成支付,那么一般也不会再进行支付了。所以从发起支付开始,从第三方查询支付结果的频率应该是递减的。

  • 对数据库有压力

定时任务扫表,肯定会对数据库造成压力,如果数据量大,可能影响会更大。

可以考虑单独创建一张支付中流水表,定时任务去扫描这张表,获取到支付最终态后,就删除对应的记录。

延时消息查询

在这里插入图片描述

在发起支付后,发送一个延时消息,因为用户跳转到钱包后,通常会很快支付,所以可以以 10s、30s、1min、1min30s、2min、5min、7min…这种频率去查询支付订单的状态,这里可以用一个队列结构实现,队列里存放下一次查询的时间间隔。

延时消息的方案相较于定时轮询的方案而言,时效性更好,而且无需扫表,对数据库造成的压力也较小。

// 控制查询频率的队列, 时间单位为s
Deque<Integer> queue = new LinkedList<>();
queue.offer(10);
queue.offer(30);
queue.offer(60);

// 支付订单号
PaymentConsultDTO paymentConsultDTO = new PaymentConsultDTO();
paymentConsultDTO.setPaymentId(paymentId);
paymentConsultDTO.setIntervalQueue(queue);

// 发送延时消息
Message message = new Message();
message.setTopic("PAYMENT");
message.setKey(paymentId);
message.setTag("CONSULT");
message.setBody(toJSONString(paymentConsultDTO).getBytes(StandardCharsets.UTF_8));

try {
    // 第一个延时消息, 延时10s
    long delayTime = System.currentTimeMillis() + 10 * 1000;
    // 设置消息需要被投递的时间
    message.setStartDeliverTime(delayTime);
    SendResult sendResult = producer.send(message);
} catch (Throwable th) {
    log.error("[sendMessage] error: ", th);
}

// 在消费到延时消息后, 向第三方查询支付订单的状态, 如果还在支付中, 就继续发送下一个延时消息, 延时间隔从队列结构中取.
// 如果获取到最终态, 就去更新支付订单状态, 并通知订单服务.
@Component
@Slf4j
public class ConsultListener implements MessageListener {
    //消费者注册, 监听器注册
    @Override
    public Action consume(Message message, ConsumeContext context) {
        // UTF-8解析
        String body = new String(message.getBody(), StandardCharsets.UTF_8);
        PaymentConsultDTO paymentConsultDTO = JsonUtil.parseObject(body, new TypeReference<PaymentConsultDTO>() {
        });
        if (paymentConsultDTO == null) {
            return Action.ReconsumeLater;
        }
        // 获取支付流水
        PayDO payDO = payMapper.selectById(paymentConsultDTO.getPaymentId());
        // 查询支付状态
        PaymentStatusResult paymentStatusResult = payService.getPaymentStatus(paymentStatusContext);
        // 如果还在支付中, 则继续投递下一个延时消息
        if (PaymentStatusEnum.PENDING.equals(paymentStatusResult.getPayStatus())){
            // 发送延时消息
            Message msg = new Message();
            message.setTopic("PAYMENT");
            message.setKey(paymentConsultDTO.getPaymentId());
            message.setTag("CONSULT");
            // 下一个延时消息的频率
            Long delaySeconds = paymentConsultDTO.getIntervalQueue().poll();        
            message.setBody(toJSONString(paymentConsultDTO).getBytes(StandardCharsets.UTF_8));
            try {
                Long delayTime = System.currentTimeMillis() + delaySeconds * 1000;
                // 设置消息需要被投递的时间
                message.setStartDeliverTime(delayTime);
                SendResult sendResult = producer.send(message);
            } catch (Throwable th) {
                log.error("[sendMessage] error: ", th);
            }
            return Action.CommitMessage;
        }
        // 获取到最终态
        // 更新支付订单状态
        // 通知订单服务
        return Action.CommitMessage;
    }
}

参考文档

  • https://mp.weixin.qq.com/s/nzR9eKFgEbpPFIkMF40vYw

相关文章:

  • CONV1D卷积神经网络运算过程(举例:n行3列➡n行6列)
  • 数据结构c语言版第二版(严蔚敏)第一章练习
  • python练习Ⅱ--函数
  • 3D多模态成像市场现状及未来发展趋势分析
  • vscode 1.71变化与关注点(多配置预设/旧合并器回归等)
  • SQL面试题之区间合并问题
  • Linux用户和权限之一
  • 回溯法就是学不会2 —— 括号生成问题
  • ESP32 ESP-IDF TFT-LCD(ST7735 128x160) LVGL演示
  • 信息论学习笔记(二):离散无噪声系统
  • CentOS7启动SSH服务报错
  • 大咖说*计算讲谈社|商用车智能驾驶商业化实践
  • python笔记Ⅶ--函数返回值、作用域与命名空间、递归
  • 03 RocketMQ - Broker 源码分析
  • Java日志系列——规范化日志
  • 网络传输文件的问题
  • JS 中的深拷贝与浅拷贝
  • $translatePartialLoader加载失败及解决方式
  • 【JavaScript】通过闭包创建具有私有属性的实例对象
  • Android系统模拟器绘制实现概述
  • Android优雅地处理按钮重复点击
  • ES学习笔记(10)--ES6中的函数和数组补漏
  • HashMap剖析之内部结构
  • IP路由与转发
  • linux学习笔记
  • Mithril.js 入门介绍
  • Mysql优化
  • STAR法则
  • 记一次用 NodeJs 实现模拟登录的思路
  • 思维导图—你不知道的JavaScript中卷
  • 赢得Docker挑战最佳实践
  • 用element的upload组件实现多图片上传和压缩
  • ​软考-高级-系统架构设计师教程(清华第2版)【第9章 软件可靠性基础知识(P320~344)-思维导图】​
  • $HTTP_POST_VARS['']和$_POST['']的区别
  • ()、[]、{}、(())、[[]]等各种括号的使用
  • (0)Nginx 功能特性
  • (C)一些题4
  • (多级缓存)多级缓存
  • (二)windows配置JDK环境
  • (二)学习JVM —— 垃圾回收机制
  • (七)Java对象在Hibernate持久化层的状态
  • (五)大数据实战——使用模板虚拟机实现hadoop集群虚拟机克隆及网络相关配置
  • (转)菜鸟学数据库(三)——存储过程
  • .Net Core和.Net Standard直观理解
  • .NET面试题解析(11)-SQL语言基础及数据库基本原理
  • @Autowired注解的实现原理
  • @autowired注解作用_Spring Boot进阶教程——注解大全(建议收藏!)
  • [@Controller]4 详解@ModelAttribute
  • [20171101]rman to destination.txt
  • [BT]BUUCTF刷题第9天(3.27)
  • [BZOJ 3680]吊打XXX(模拟退火)
  • [C#]winform使用引导APSF和梯度自适应卷积增强夜间雾图像的可见性算法实现夜间雾霾图像的可见度增强
  • [C]整形提升(转载)
  • [Django ]Django 的数据库操作
  • [EFI]Acer Aspire A515-54g电脑 Hackintosh 黑苹果efi引导文件