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

高并发下怎么做余额扣减?

  •  
  •   hhhhhh123 · 2022-11-25 17:10:18 +08:00 · 7368 次点击
    这是一个创建于 735 天前的主题,其中的信息可能已经有所发展或是发生改变。

    这种场景 数据库 是不是只能加锁啊?
    假设 数据库中有两个表 一个是流水表 也就是扣款用, 一个是 userinfo 就是余额在这看
    那么并发场景下。怎么保证余额是>0 且数据无误。

    我的想法是。1 查询余额 如果减去后余额 >=0 则插入扣款后的余额, 这个过程中加锁。 但是这种如果是并发高一点的话是不是很慢啊?

    各位有这块的经验吗? 希望可以指点一下。或者也可以讲解一下你们公司的扣款逻辑是啥? 是如何做的 ?

    41 条回复    2024-11-25 17:43:20 +08:00
    Jooooooooo
        1
    Jooooooooo  
       2022-11-25 17:11:52 +08:00
    单个 user 哪来的高并发?

    你想问的是不是库存?
    runningman
        2
    runningman  
       2022-11-25 17:15:17 +08:00
    行级锁应该就够你用了。
    dqzcwxb
        3
    dqzcwxb  
       2022-11-25 17:21:12 +08:00
    分布式锁,队列,单线程
    hhhhhh123
        4
    hhhhhh123  
    OP
       2022-11-25 17:25:02 +08:00
    @runningman 行锁 怎么避免 死锁
    opengps
        5
    opengps  
       2022-11-25 17:30:31 +08:00
    一楼说出了核心,虽然银行类业务用户多,但是架不住最大的客户操作完一个交易也是需要时间的,这时间不会导致并发
    hhhhhh123
        6
    hhhhhh123  
    OP
       2022-11-25 17:31:40 +08:00
    @Jooooooooo 那库存这种问题 应该怎么解决?
    Jooooooooo
        7
    Jooooooooo  
       2022-11-25 17:40:58 +08:00
    @hhhhhh123

    库存的高并发扣减算是比较成熟的东西了, 随便一搜很多的

    比如可以搞多层拦截, 如果你只卖 10 个东西, 要是有 1w 人来抢, 那绝大多数流量没有必要到后端, 反正总是能把东西卖出去的. 前端和网关可以直接随机丢弃流量, 流量到了后端后, 可以再加上 MQ 排队和缓存, 最终再到数据库里行锁扣库存.

    还有手段比如把库存分散到多行数据上, 随机挑一行扣
    ElmerZhang
        8
    ElmerZhang  
       2022-11-25 17:42:31 +08:00   ❤️ 6
    如果并发不会很高的话不用在数据库上加锁
    1. 要扣的钱为 A ,先查 amount 当前值为 B ,代码中判断 B >= A
    2. 然后执行 update xxx set amount = amount - A where amount = B
    3. 执行看影响行数,如果为 0 ,重新从第 1 步执行
    一般只需要重试一次。
    dongtingyue
        9
    dongtingyue  
       2022-11-25 17:43:43 +08:00
    update xxxxx set xxxx where 余额>xx 余额 用 innodb 本身就有行锁,失败返回异常,这点时间肯定要等的
    coderxy
        10
    coderxy  
       2022-11-25 18:15:32 +08:00
    乐观锁就够了,修改时判断一下余额与你之前查到的余额是否一致。
    git00ll
        11
    git00ll  
       2022-11-25 18:46:54 +08:00
    一锁 二查 三更新
    CEBBCAT
        12
    CEBBCAT  
       2022-11-25 21:09:09 +08:00
    @ElmerZhang
    @dongtingyue
    @coderxy

    看三位的回答中好像没有提到事务,不用事务的话遇到意外停机怎么办呢?或者是我理解错了
    lovelylain
        13
    lovelylain  
       2022-11-25 21:29:26 +08:00 via Android
    @CEBBCAT 同时更新多个才要事务,例如给一个人加余额,另一个人减余额。
    awanganddong
        14
    awanganddong  
       2022-11-25 22:02:53 +08:00   ❤️ 1
    https://www.51cto.com/article/720873.html

    并发扣款,如何保证一致性
    沈剑 大佬的文章可以看看
    richangfan
        15
    richangfan  
       2022-11-25 22:23:11 +08:00   ❤️ 2
    update users set balance = balance - 1 where user_id = 123 and balance >= 1;
    只在余额大于 1 时扣除用户 123 的 1 块钱
    orzwalker111
        16
    orzwalker111  
       2022-11-25 22:34:35 +08:00
    @richangfan 假设网关、框架重试,会多扣款,解决手段:
    1 、悲观锁,使用分布式锁
    2 、乐观锁,使用 CAS ,select 得到的 balance 作为 update 的 where 条件,并添加 ver 条件解决 ABA 问题
    xuanbg
        17
    xuanbg  
       2022-11-25 22:36:05 +08:00
    不要做无意义的事情,15 楼的方法可以很好的解决 OP 你的这个问题。
    CEBBCAT
        18
    CEBBCAT  
       2022-11-25 23:04:47 +08:00
    @jobmailcn 是的。我看楼主这个 case 就是需要一边扣钱,一边发放什么东西。
    louisliu813
        19
    louisliu813  
       2022-11-25 23:17:01 +08:00
    @orzwalker111 是的,我们也是使用 cas ,更新时判断 version ,如果被其他事物更新到 version + 1 了,就 select 新的 balance 和 version 出来,然后基于新 version 做判断,新 balance 做更新。
    rqrq
        20
    rqrq  
       2022-11-26 01:16:38 +08:00
    try {
    BEGIN;
    SELECT balance FROM userinfo WHERE user_id = xxx FOR UPDATE;
    逻辑判断,有问题就 throw Exception
    UPDATE userinfo...
    COMIT;
    } catch {
    ROLLBACK;
    }
    rqrq
        21
    rqrq  
       2022-11-26 01:20:13 +08:00
    BEGIN 写在 try 外面。
    brust
        22
    brust  
       2022-11-26 07:56:04 +08:00
    @hhhhhh123 #8
    库存的话
    如果是热点 SKU 可以分段锁 比如有 1w 个库存 可以分成 10 个 1000 出来
    yogogo
        23
    yogogo  
       2022-11-26 08:09:52 +08:00
    事务加行锁,扣款交易可以先入库,再用异步任务按顺序执行交易扣款。有些第三方代扣服务就是这样设计的
    dingyaguang117
        24
    dingyaguang117  
       2022-11-26 08:29:52 +08:00 via iPhone
    乐观锁即可
    reeco
        25
    reeco  
       2022-11-26 08:46:13 +08:00 via iPhone
    实操都是 tcc ,两步提交
    love2328
        26
    love2328  
       2022-11-26 08:53:33 +08:00
    你的想法怕慢 实际不会很慢 并发是同等触发 实际触发时并没有的
    xyjincan
        27
    xyjincan  
       2022-11-26 09:15:34 +08:00 via Android
    @love2328 有一次网卡,连点很多下,10 冲了 50
    mrpzx001
        28
    mrpzx001  
       2022-11-26 09:22:29 +08:00
    用事务不就完事了吗? 怎么都不提事务的?
    8355
        29
    8355  
       2022-11-26 10:13:21 +08:00
    用户级别并发锁行锁不就可以了吗

    改个余额
    加订单入库
    加资金流水能有几张表
    能慢到哪里去啊
    你这个下单接口高峰 qps 能有 1000 吗
    iseki
        30
    iseki  
       2022-11-26 11:30:05 +08:00 via Android
    如果是扣库存,只要保证别 100 个商品,结果 10000 个请求打到数据库上、也别同一时间点 100 个请求全都在数据库上扣同一个商品,就没什么可担心的,数据库的性能足够。
    扣余额就更简单了,限制下不要让一个用户同时发起一堆请求(这本来也该限制吧)
    实现上可以用 update cas ,但存在限制不方便时直接 Serializable 性能不一定差( PostgreSQL )
    love2328
        31
    love2328  
       2022-11-26 11:44:51 +08:00
    @xyjincan 点的时候 没有置等待 ? 要么场景不够经验 要么坑用户
    8520ccc
        32
    8520ccc  
       2022-11-26 11:48:19 +08:00 via iPhone
    @ElmerZhang update xxx set amount = amount - A where amount = B where amount-A>0
    codehz
        33
    codehz  
       2022-11-26 11:49:18 +08:00
    @ElmerZhang 那不如直接 update xxx set amount = amount - A where amount > A (
    chenqh
        34
    chenqh  
       2022-11-26 12:48:53 +08:00
    用 redis 锁不就好了吗?
    vanillacloud
        35
    vanillacloud  
       2022-11-26 12:56:01 +08:00 via iPhone
    我觉得在 update 时 「 where 余额 = 扣款前查询的余额」这一步就能规避重复操作的风险,这不能当 standard procedure 吗?
    noogel
        36
    noogel  
       2022-11-26 13:33:16 +08:00
    高并发扣减:
    1. 合并请求,在保证事务的前提下,将多个扣款请求合并操作,这样只需要做一次锁操作和写操作。
    2. 拆分账户,将热点账户的余额账户拆分成多个子余额账户,以此来降低单个账户扣减操作的并发度。
    3. 使用内存数据库扣减,并异步写日志,所有日志结果可以回溯账户余额结果,和内存数据库做对账。
    LucasLee92
        37
    LucasLee92  
       2022-11-27 11:09:02 +08:00
    @noogel 1 和 2 的处理对热点账户的处理都只考虑怎么解决记账问题
    1 的问题在于合并记账后余额不足的怎么处理,可能拆分记有些还能成功
    2 的问题在于多个账户如何协同管理
    3 的实现最终还是会碰到热点账户问题,当然效率比起数据库来说要好很多了
    不清楚是否有相应的成熟业务的解决方案文章能看看
    hhhhhh123
        38
    hhhhhh123  
    OP
       2022-11-28 09:36:05 +08:00
    @ElmerZhang 我理解 第一步和第二步 ,第三部不是特别理解, 为啥只递归一次?
    hhhhhh123
        39
    hhhhhh123  
    OP
       2022-11-28 09:41:58 +08:00
    @vanillacloud @codehz @8520ccc @richangfan 如果这样的话, 同时有俩个 一个扣款 10 块 一个扣款 5 块。 这样只会执行其中的一个余额。 另外一个就不会执行。 我觉得 8 楼的 第三个条件挺好, 但是递归次数 又不好拿捏。
    hhhhhh123
        40
    hhhhhh123  
    OP
       2022-11-28 09:46:21 +08:00
    我的新思路是 : 只要保证每个请求都是正确的扣钱请求。 然后参考 8 楼, 当然第三个条件只能是一直递归下去, 吧所有的请求都给操作完。
    liangliplusss
        41
    liangliplusss  
       4 天前
    两个方案
    方案一: 悲观锁
    consume(var accountId,var amount) {
    //先查询余额
    "select accountId,balance from xxx where accountId = $accountId for update";
    //计算
    $new_balance = $old_balance - $amount;
    update xxx balance = $new_balance where accountId = $accountId
    }

    方案二: 乐观锁
    consume(var accountId,var amount) {
    flag = false,retires = 3
    // CAS + 重试
    while(!flag && retries > 0) {
    flag = consume0(accountId,amount);
    retries--;
    }

    }

    boolean consume0(var accountId,var amount) {
    //先查询余额(只是查询不加锁)
    "select accountId,balance from xxx where accountId = $accountId";
    //计算
    $new_balance = $old_balance - $amount;
    row = update xxx balance = $new_balance where accountId = $accountId and balance = $old_balance
    return row == 1;
    }

    备选方案:(高并发,单个用户消费并发超过 1000 )缓存 + 消息中间件,
    用户消费操作是扣减缓存中余额(注意这里原子性查询和扣减两个动作,例如 redis 可以使用 lua ), 扣减成功发送消息到消息队列更新数据库。
    关于   ·   帮助文档   ·   博客   ·   API   ·   FAQ   ·   实用小工具   ·   5102 人在线   最高记录 6679   ·     Select Language
    创意工作者们的社区
    World is powered by solitude
    VERSION: 3.9.8.5 · 26ms · UTC 09:44 · PVG 17:44 · LAX 01:44 · JFK 04:44
    Developed with CodeLauncher
    ♥ Do have faith in what you're doing.