V2EX = way to explore
V2EX 是一个关于分享和探索的地方
现在注册
已注册用户请  登录
frank1256
V2EX  ›  Java

高并发下订单状态更新

  •  
  •   frank1256 · 114 天前 · 6214 次点击
    这是一个创建于 114 天前的主题,其中的信息可能已经有所发展或是发生改变。

    场景

    订单存在 flag 字段,0 未支付,1 支付中,2 支付完成。 发起支付场景中,会先查询订单状态是否为 0 ,然后更新为 1 ,并且调用第三方支付系统获取 h5 的支付地址(耗时操作)。用户在 h5 上完成支付后,第三方支付系统会异步通知到后台服务。进行订单更新动作,并保存流水号。

    具体代码

    支付发起

    支付发起之前,会查库,判断 flag 是否为 0 ,可以的才会继续

    异步通知

    接收到第三方系统的异步通知后,会查库,判断 flag 是否为 1 ,可以的话才会更新订单。

    问题

    高并发下,第一个线程查库,查到 flag 是 0 ,在数据库没更新完成的情况下,第二个线程也来查库,查到是 flag 也是 0.同时发起了支付。如何防止这种场景呢?假设在单节点情况下,直接加 synchronized ,可以避免。但是这样的话,是对所有的线程都进行了阻塞,实际情况下,我们只是要对相同订单进行阻塞。不同订单不进行阻塞的。

    在异步回调的情况也是一样,也是要先查订单状态 flag 为 1 的话,才会进行下一步动作,如果并发情况下出现了 2 个线程都查到是 flag 为 1 怎么处理?

    目前思路

    加锁,但是锁了所有的线程,订单 1 多个线程同时发起支付的话,需要加锁阻塞,只能有一个发起成功,但是不能影响订单 2 的发起支付。实际上只是为了锁同一笔订单。

    用乐观锁,然后数据库 update 的时候,where flag=某个条件。一定会有一个线程更新失败,更新成功的才会进行后续操作。这样的话,会对数据库有影响吗?

    想请问大佬们,这种先查库得到条件,再根据条件做后续动作的场景,在高并发下应该如何处理呢?

    78 条回复    2022-03-11 00:06:51 +08:00
    xujihua
        1
    xujihua  
       114 天前   ❤️ 1
    mysql 行锁 , 用主键或者唯一索引加锁 SELECT * FROM t1 WHERE c1 = (SELECT c1 FROM t2 FOR UPDATE) FOR UPDATE; 这种方案应该能解决你的问题
    MoYi123
        2
    MoYi123  
       114 天前
    第三方支付不是一般会让你传一个订单号的吗? 至少支付宝是有的.
    Canon1014
        3
    Canon1014  
       114 天前
    synchronized 可以根据业务的 id 上锁,搜索引擎关键字:synchronized id 上锁,当然单节点的前提是不变的
    hcven
        4
    hcven  
       114 天前
    key+订单号,用 redis 的 incr 试一下?如果==1 则可以走支付中的逻辑,支付完成后写 redis 标志位再异步写 db 。>1 再去 redis 是不是已经支付完成?
    yibo2018
        5
    yibo2018  
       114 天前
    用数据库的行锁,select ... where orderId = XXX for update 这样就能保证对于一个订单来说,只有一次请求可以获取锁
    micean
        6
    micean  
       114 天前
    flag 为 0 的时候,应该有唯一的订单号在微信 /支付宝那边阻止重复支付
    frank1256
        7
    frank1256  
    OP
       114 天前
    @MoYi123
    @micean
    我接过一些其他第三方支付,是第三方系统返回一个他们的流水号,我们调用方进行保存。这样就会导致订单会被发起 2 次支付
    MoYi123
        8
    MoYi123  
       114 天前
    @frank1256 那就`update order set flag=1 where id = 'xxx' and flag = 0` ,
    update 返回值是 1 的话发起支付, 和 cas 一个道理.
    frank1256
        9
    frank1256  
    OP
       114 天前
    @yibo2018
    @xujihua
    我了解一下
    Chinsung
        10
    Chinsung  
       114 天前
    update 之前的时候加个分布式锁,获取到锁之后进去再 check 下状态是否是 1 ,是 1 直接报已在支付中了,否则 sql 带上 where flag=0 去更新下,根据更新成功行数判断是否需要发第三方支付
    虽然你是同订单,其实并不存在高并发只存在并发,但是数据库锁最好少用,一个是得依赖唯一索引,另一个就是真高并发来了,数据库绝对会频繁报死锁
    这种场景一般都是设计 2 张表,一张商品订单表,一张支付订单表,商品订单表改状态支付中直接就改了,扔个 mq 给支付订单表去生成支付订单,然后支付订单这里根据商品订单分布式锁做幂等就行了
    timepast
        11
    timepast  
       114 天前
    两个层面的问题吧,
    1. 如何保证状态一致性,单节点加锁、select ... for update , 分布式锁 等都能解决问题,实质是最小(业务)粒度的一致性,是排他的
    2. 上面的问题解决了,订单生命周期,可以设计一个中间状态,即便是高并发,业务也应该有前提限制吧,已经有人支付中了,其他的请求应该失败重试,回调同理
    paradoxs
        12
    paradoxs  
       114 天前
    分布式锁,是现在最优的解决方案。 现实里面,考虑到微服务集群,是不可能用 synchronized 之类的去解决的,没用。
    frank1256
        13
    frank1256  
    OP
       114 天前
    @Chinsung 感谢大佬解答
    micean
        14
    micean  
       114 天前
    锁数据库是没办法解决数据库以外的问题的,订单发起多次支付不需要处理,用户永远只会操作其中一次。假如真的出现了支付 2 次的情况,跑任务退款就行了
    ksedz
        15
    ksedz  
       114 天前
    update set flag = 1 where id = xxx and flag = 0;
    然后检查更新的条数,为 1 才能继续。
    waitfree 🐶
    wowbaby
        16
    wowbaby  
       114 天前
    一个订单用户很少会重复支付吧,毕竟要付钱的,第三方支付提交的订单号不能重复支付的,至少我目前没遇到过这种问题
    hidemyself
        17
    hidemyself  
       114 天前
    分布式锁,上 redis
    frank1256
        18
    frank1256  
    OP
       114 天前
    @wowbaby 这个就是我提的一个场景,本质上就是遇到“先查后写”的场景,如何保证并发下,不会出现问题
    cheng6563
        19
    cheng6563  
       114 天前
    直接落库的业务就直接对数据库行加锁就行了。别搞太复杂。
    k9982874
        20
    k9982874  
       114 天前
    redis/etcd 加锁
    AS4694lAS4808
        21
    AS4694lAS4808  
       114 天前
    @wowbaby 昨天在美团定外卖,支付成功后不点完成,直接用返回按钮,好像会回到支付界面。。没有订阅银行卡或者支付工具通知的人,没准有可能再点一次付钱?
    xiangyuecn
        22
    xiangyuecn  
       114 天前   ❤️ 2
    这个业务不存在高并发,用户不是机器人,一个订单最多并发两个请求就不错了,前端的问题
    reeco
        23
    reeco  
       114 天前 via iPhone
    一锁二判三更新,悲观锁一把梭
    HackerJax
        24
    HackerJax  
       114 天前 via iPhone
    这个不叫并发呀,一个订单只能一个人付款吧,这个应该算作请求去重
    westoy
        25
    westoy  
       114 天前
    淘宝、京东、亚马逊、苹果碰到高并发抢购都会出现订单更新不及时、支付更新不及时(尤其像京东、淘宝大部分交易都是走的自己内部金融池子, 还不需要走银联)、掉单, 会通过限制一定周期内取消、更新订单以及客服和财务介入的方式去解决,你就别想通过程序一把梭哈了, 世界是不完美的
    wushigejiajia01
        26
    wushigejiajia01  
       114 天前 via Android
    @paradoxs

    +1
    确实是的

    我们现在做的就是用 redis 分布式锁控制的
    westoy
        27
    westoy  
       114 天前
    @westoy 限制一定周期内取消、更新订单 => 限制一定周期内禁止用户取消、更新订单

    漏字歧义了....
    timethinker
        28
    timethinker  
       114 天前
    最好在数据库层面进行并发控制,不要在你的应用层加锁。直接加一个字段使用乐观锁来保证在并发的情况下只有一个事务会成功。并且你需要问自己一个很重要的问题,那就是这个业务真的会有大量的并发请求针对同一个订单进行操作吗?
    yeyypp92
        29
    yeyypp92  
       114 天前
    高并发下,前端应该也需要做一些限制,保证用户不会短时间内发起多次支付请求
    shanghai1943
        30
    shanghai1943  
       114 天前
    这种场景我一般是乐观锁 update xxx set xxxx=xx where id=xx and flag=0 谁能更新成功那谁就有机会执行往后的逻辑
    Jooooooooo
        31
    Jooooooooo  
       114 天前
    重复支付退钱就行.
    echooo0
        32
    echooo0  
       114 天前
    前面几个说的很详细了,数据库行锁就可以搞定,这个应该不属于高并发,算是请求去重;

    而且如果是单节点的话,用 synchronized ,也可以针对 id 对象加锁,不是一定要锁住所有线程
    giiiiiithub
        33
    giiiiiithub  
       114 天前
    做成幂等接口就行吧
    oneisall8955
        34
    oneisall8955  
       114 天前 via Android
    题外话,我想问下,支付中这个状态是否有必要,多一个状态,就得维护多一个状态的情况。因为网络原因,支付失败,会不会存在永远都是 1 ,再也支付不了的情况?
    frank1256
        35
    frank1256  
    OP
       114 天前
    @oneisall8955 会存在,这种情况就是用户支付成功了,但是网络原因,支付系统没有通知过来。这个时候,需要主动发起查询动作,一般支付系统会给 2 个接口,一个是异步通知,一个是主动查询
    frank1256
        36
    frank1256  
    OP
       114 天前
    @echooo0 锁 id 对象?不太明白,是指 pojo 类的 id ,不是基本类型,而是一个对象吗?
    frank1256
        37
    frank1256  
    OP
       114 天前
    @shanghai1943 我现在的体量就很小,但是难免会遇到一点并发,所以就是想用乐观锁直接 update ,成功的才能往下走。我在想的是,这种操作本质上还是操作数据库了。能否减轻数据库的压力,减少那些会 update 失败的次数
    summerLast
        38
    summerLast  
       114 天前
    核心就是改成串行 java 相关的话 可以用 RedisLockRegistry 这个
    summerLast
        39
    summerLast  
       114 天前
    但是要注意 不要内部 方法调用 事务会失效 如 a.method(){this.dotran()}
    summerLast
        40
    summerLast  
       114 天前
    这是自己封装的例子 可以 事务注解一块修饰 并且能锁住事务
    @DistributedLock("wallet:[email protected]#{accountId}@#{shopId}")
    TradingFlow addTradingFlow(String accountId, String shopId, String outerCode, Long amount, TradingFlowTypeEnum type, String remark)
    faceRollingKB
        41
    faceRollingKB  
       114 天前
    不要锁不要队列,只看最终一致性的话,数据库的压力最小
    sanggao
        42
    sanggao  
       114 天前
    哪来的并发? 这个是幂等
    ZSeptember
        43
    ZSeptember  
       114 天前   ❤️ 1
    不是并发情况,数据库乐观锁就可以了。
    没有必要,不要引入分布式锁。
    h123123h
        44
    h123123h  
       114 天前
    你的问题其实有点奇怪,一般来说一个订单号对应一个商品,一个订单号只属于一个人的,不会属于多个人,那就不会存在高并发,只会存在重复提交问题,这个问题乐观锁就能解决。 除非你说的场景是秒杀情况
    godfunc
        45
    godfunc  
       114 天前
    我做的支付系统是这样处理的 update order set order_no = xxx, status = 2 where trade_no = xx and status = 1 ,update 成功就继续
    tyqing
        46
    tyqing  
       114 天前
    我们系统是重复支付就退款,一般这种现象极少,除非你们数据库真的性能很差排队入库。
    支付回调的地方加 redis 分布式锁保证只有同一个线程在执行支付回调操作,查 redis 中的订单状态和 mysql 的状态判断取最新值,不涉及到数据库锁。
    markgor
        47
    markgor  
       114 天前
    这个场景,我只能猜测到是不是代付场景?否则怎么会 第一个线程拉起支付,另一个也拉起支付呢....


    如果是代付场景,其实我觉得为了保证付款成功率,没必要限制只能一个人支付,
    假设 A 和 B 同时支付了,按通知时间为准,通过事务更新订单状态,另一笔支付通知走事务时发现装维为支付完成的就走退款接口。
    Hug125
        48
    Hug125  
       114 天前
    @xujihua #1 行锁+补充一个 redis 分布式锁, 这样多实例部署的服务也不会出问题了
    xsqfjys
        49
    xsqfjys  
       114 天前
    上 redis 咯
    leafre
        50
    leafre  
       114 天前
    处理好幂等即可
    watcher
        51
    watcher  
       114 天前
    这个业务不存在高并发
    codeMore
        52
    codeMore  
       114 天前
    我感觉也是幂等问题,控制好订单生成就行了
    daimubai
        53
    daimubai  
       114 天前
    是幂等性问题,用乐观锁或者 redis
    Boolean lock = redisLock.tryLock("key", "1", 10L, TimeUnit.SECONDS);
    if (lock) {
    try {
    //查库,调支付等
    return "success";
    } finally {
    redisLock.unlock(lockName, "1");
    }
    }
    return "操作过于频繁,稍后再试!";
    daimubai
        54
    daimubai  
       114 天前
    高并发是指多个线程抢一个资源。
    zzfer
        55
    zzfer  
       114 天前
    up 可以看一下接口幂等校验
    Nillouise
        56
    Nillouise  
       114 天前
    第一个线程查库,查到 flag 是 0

    感觉不需要查,直接 update flag = 1 where flag=0 就可以了,跟 ksedz 说的思路一样。

    另外,这里直接用数据库里的状态当锁不行,非要弄一个分布式锁的理由是什么?你请求的资源只涉及单个数据库,直接数据库内部操作就可以了,按我理解,分布式锁是锁住涉及多个数据库的资源,或者根本不是数据库的资源,比如一些对第三方接口的锁定。
    lingalonely
        57
    lingalonely  
       114 天前
    你这不是单体的应用吧,如果是分布式的话,还高并发,直接用分布式锁最好,至于使用数据库作支持还是 redis ,zookeeper ,取决于系统当前有什么,不要随便引入新组件就好
    littlewing
        58
    littlewing  
       114 天前
    乐观锁 注意 ABA 问题
    Amayadream
        59
    Amayadream  
       114 天前
    1.可以考虑合并未支付和支付中的状态,因为不论是什么状态,接到第三方状态都应该进行处理(自动退款或完成订单),多一个状态只能增加状态机的复杂度
    2.这种场景下存在的问题是重复支付而不是高并发,这个需要从多维度共同解决,例如前端防重复支付(支付按钮防重复点击,从第三方支付跳转回来主动调用后端查询订单状态等),对后端来说最好拆分交易和支付订单,在一笔交易订单已经存在支付完成的支付单时直接返回支付成功的结果,一笔交易订单存在多笔成功的支付订单时(即重复支付)进行自动退款处理
    DinnyXu
        60
    DinnyXu  
       114 天前
    楼上各位大佬都已经补充了很多种实现方式,我来说下海量消息消费的时候这种场景 rocketmq 是怎么处理的。rocketmq 有个消费模式,是将 id 进行 hash ,相同的 id 一定是一个线程来操作的。
    encro
        61
    encro  
       114 天前
    1,订单是可以重复支付的,用户第一次用微信发现不够,再用支付宝,那么一个订单就是有两个支付订单了!!!

    2,避免订单订单重复支付是前台要提供的,前台要阻塞。

    所以:

    1, 订单支付状态只有 0 和 1,没必要有支付中。

    2, 订单和支付最好分开来,这样每个都是可溯源了

    3, 后台只需要在 callback 时根据状态决定是否继续处理即可。

    update payment set paid=1 where paid=0 and id=x;

    根据结果再

    update order set paid=1 where paid=0 and id=x;

    根据结果再发送订单支付消息之类
    teem
        62
    teem  
       114 天前
    1 、不需要中间状态。
    2 、支付动作(拉起收银台)只生成订单,订单的更新只根据回调修改
    3 、支付回调全部进全局队列,一个一个操作。
    documentzhangx66
        63
    documentzhangx66  
       114 天前
    年代不一样了,请别再用古老的阻塞方式,包括且不限于:悲观锁、行锁、表锁甚至库锁、分布式锁。

    现代的处理方式是:

    1.水平分库。电商业务,按用户进行分库,不同库跑在不同节点上,从源头上就减少了并发量。每个节点只处理一小部分用户的数据。

    2.使用查询 + 增加数据版本的方式,来代替更新与删除。现代数据库应该尽量少地出现更新数据的操作,第一是为了保留历史数据,第二是现代设备是锁的同步代价远大于存储代价。

    3.拥抱异常,把一单多付的场景视为正常情况考虑,做好出现这种情况后进行退款的流程与自动化即可。
    Leviathann
        64
    Leviathann  
       114 天前
    @documentzhangx66 第 2 条,这样的话就把一个数据的所有历史版本都记录下来吗?
    winglight2016
        65
    winglight2016  
       113 天前
    数据库别锁了,没有价值,#31 说的对,用户真的傻夫夫支付两次,只需要退一笔就可以了。业务流程不需要阻止什么操作,只要让流程走下去,而且在需要的时候可以回退就够了。

    另外,楼上也说过,别搞第三种状态,只需要是否已支付两种状态就够了。
    Jekins
        66
    Jekins  
       113 天前
    @documentzhangx66 第二条所有历史版本都保存下来.请问后期如何统计订单数据 ?
    undefine2020
        67
    undefine2020  
       113 天前
    没搞懂,为什么一个用户可以操作出高并发的情景出来
    cheng6563
        68
    cheng6563  
       113 天前
    @Jekins 多半是把数据再导到仓库去进行数据分析。这就是现代的不计成本体量的处理方式
    documentzhangx66
        69
    documentzhangx66  
       113 天前
    @Leviathann 是的
    documentzhangx66
        70
    documentzhangx66  
       113 天前
    @Jekins

    分组或去重。
    zw1one
        71
    zw1one  
       113 天前
    ### 不同用户的不同订单,是不会出现你说的问题的(你说的全局 synchronized 又是另外一个问题了)。这里我假设你要处理的问题是: 相同用户对一笔相同订单重复提交(多个用户来提交一笔订单也成立,扫码点餐)

    - 场景 1: 用户用浏览器 A 登录,发起一笔支付,在支付结果返回前,再用浏览器 A 发起支付
    通常用前端校验,但前端校验可绕过。后台需要用"订单号 ID"加 redis 分布式锁校验,若不能获取到锁,则代表该订单有处理中且未返回的支付请求,拒绝该次请求。

    - 场景 2: 用户用浏览器 A 登录,发起一笔支付,在支付结果返回前,再用浏览器 B 发起支付
    该情况前端无法校验。后台同样是 redis 锁处理。

    - 场景 3: 用户用浏览器 A 登录,发起一笔支付,在支付结果返回后,再用浏览器 B 发起支付
    该情况前端无法校验。后台通常在数据库表加上 data_version 字段处理,这里你用订单 flag 字段判断也可解决。

    结论:
    我没理解错的话,你这个问题是接口幂等问题。需要保证一个接口被多次调用(相同或不同客户端)得到的结果相同。
    - 前端校验: 拦截部分客户端重复提交问题,但不能完全解决。
    - redis 锁校验: 解决请求未处理完成,又出现新请求的情况。直接拒绝新请求。
    - data_verison 校验(或者 flag 字段): 解决请求处理完成后,再次发起请求的情况。

    ### 至于异步回调,也是幂等问题。
    如果你的支付申请处理好了,是不会出现两次回调的,除非第三方出问题了。
    如果支付申请没处理好,出现两次回调,且订单 flag 都查到为 1 ,它们的操作都是修改订单结果为 2 ,代码运行两次是没有问题的。mysql 处理逻辑:先修改订单 flag 的事务 A 会给该条数据加写锁,事务 B 修改订单 flag 会等待获取锁。
    出现这种情况把异步回调的日志记录好就行。

    ### 其他
    - synchronized 无法处理一个应用部署多个副本的集群情况。可以按对象加锁。
    - flag 字段,0 未支付,1 支付中,2 支付完成。
    建议保留状态"支付中",该状态可以表示等待第三方回调,当请求发出去,第三方出现问题(超时、宕机)没有回调的时候,便于排查问题。
    - 做好上面这些。再来考虑异常退款给用户的人工操作。因为即使代码上处理了,还会有服务器宕机、第三方平台问题等情况出现。生产问题总归是少不了的 :)
    frank1256
        72
    frank1256  
    OP
       113 天前
    @zw1one 感谢解答
    sakasaka
        73
    sakasaka  
       113 天前
    分布式锁
    seasonsolt
        74
    seasonsolt  
       113 天前   ❤️ 1
    @documentzhangx66 本帖唯一高质量回答,和我们现在的处理方式比较接近,分布式 & lock free
    1:首先订单系统根据用户请求做了 event 分发降低了并发量,数据库设计面向 event sourcing ,只增量记录 event log 2:重复支付发生时,订单实收 > 应收,追述到支付事件的 event log ,拿到支付凭证发起退款就可以了。 3:无论是前端锁还是后端锁,用户支付体验都会受影响,而且影响系统吞吐量
    Chinsung
        75
    Chinsung  
       113 天前
    @seasonsolt #74 和你方案一样的就是高质量回答是吧,我比较好奇,你们这种做法
    1. 如果用户重复支付了,会在用户端看到自己支付了多笔并且有几笔正在退款的信息吗?
    2. 如果退款某笔失败了,你们是无限轮询还是手工处理,如果退款出现延时,难道用户就没有意见不会投诉吗?
    3. 对账的话,如果跨清算时间退款了,对账复杂度也会比较高吧
    一个用户 id+订单 id 加分布式锁的问题,对吞吐的影响真的有那么大?
    seasonsolt
        76
    seasonsolt  
       113 天前
    @Chinsung 你的疑问全部理解,其实这个帖子里的我所谓的“不那么高质量”方案,我们都经历过,或者说这个行业都经历过,敢妄评“优劣”自然是建立无数的实践和采坑的基础之上的。 实践远比我的回答有意义,你可以去测试一些非 sdk 支付场景,支付成功后立马断网(飞行模式),或者杀掉支付 app ,切进程到商户收营台 再次支付。有些平台可以正常支付,但是很快自动退款,还有戏平台,会弹出报错 “您当前订单正在支付中,balabala.......” 。最后,做个统计,是不是实力更强的平台选择了 自动退款方案,比如 麦当劳、KFC...然后锁定单是不是相对弱一些(除了滴滴打车)
    seasonsolt
        77
    seasonsolt  
       113 天前
    @Chinsung 第三个问题确实是存在的,如果恰好退款时间恰好跨清算批次了,处理起来自然是恶心的,但是概率应该很小吧。 然后是纯技术的,lock free 更多算是一种技术洁癖吧,不一定能有多大性能损失,毕竟不是每一家都能做到 jd 、tb 的量级的。
    giiiiiithub
        78
    giiiiiithub  
       113 天前
    @Chinsung 那位搞的是 cqrs ,cqrs 有 cqrs 的弊端。谈什么高质量不高质量。

    另外,做幂等接口不是必须用锁的。
    关于   ·   帮助文档   ·   API   ·   FAQ   ·   我们的愿景   ·   广告投放   ·   感谢   ·   实用小工具   ·   1086 人在线   最高记录 5497   ·     Select Language
    创意工作者们的社区
    World is powered by solitude
    VERSION: 3.9.8.5 · 29ms · UTC 22:54 · PVG 06:54 · LAX 15:54 · JFK 18:54
    Developed with CodeLauncher
    ♥ Do have faith in what you're doing.