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

事关破产, 求助如何写用户扣费逻辑

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

    我在写个功能:

    1.调用第三方企业收费 API 转卖给个人,用户充值获得积分之后才能使用 API,自己先垫付钱.

    2.第三方 API 调用失败不扣费.

    3.我搞了多个队列来限流用户的请求,大概 20 多个.

    4.数据库用的 MongoDB

    这是精简版数据模型

    数据表
    
    id
    userId // 用户 id
    status // pending progress success fail
    requestData // 用户的数据
    resultData // 调用第三方 api 的结果
    
    积分表
    
    point 
    

    先保存用户的 requestData 请求数据, status 默认为 pending, 然后把这条数据给到 queue 队列, 队列里来处理逻辑.

    下面是我的逻辑:

    // 在开始事务
    startTransaction();
    
    try{
    
    // 1.拿着 userId 查积分表 不足就 throw
    if(point < 0) throw error NotEnoughPointError
    
    // 2.如果处理成功过, 又因为某些原因又加入到 queue 里了
    if(status == success) throw error AlreadyInQueueError
    
    // 3.状态改为 progress
    await setProgress()
    
    // 4.转换一下用户格式 不修改表里的数据
    await transformRequestData()
    
    // 5.调用第三方 API 失败会抛出 ThirdAPIRrror
    const result = await thirdAPI()
    
    // 6.扣用户的积分
    await minusUserPoint()
    
    // 7.状态改为 success
    await setSuccess()
    
    // 8.提交事务
    commitTransaction()
    
    // 9.从队列里返回数据
    return result
    
    }catch(error){
    
    // 1.终止事务
    abortTransaction()
    
    // 2.设置成错误状态
    await setFail()
    
    } finally {
    
    // 1.结束事务
    endSession();
    
    }
    

    问题来了...

    1.我 20 多个队列同时大量这种请求, 队列是 Redis, 我加上这个事务是不是能解决并发扣费问题, 会不会多个操作同时扣费.

    2.如果从 try 里面的调用第三方 API 之后的 6.扣用户的积分 7.状态改为 success 8.提交事务 这三个操作内部 throw 出错了,我钱就白花了.

    3.我这逻辑有没有其他问题啊,实在没写过扣费,谢谢大家了.

    19 条回复    2024-09-26 15:41:59 +08:00
    rootx
        1
    rootx  
       67 天前   ❤️ 3
    先扣用户的 成功了 再调第三方的 出错的话 再返还积分给用户
    lizon
        2
    lizon  
       67 天前 via Android   ❤️ 1
    把日志记好,出问题好追溯
    zpfhbyx
        3
    zpfhbyx  
       66 天前   ❤️ 1
    api 无脑扣就好了 然后单写脚本来恢复积分.
    drymonfidelia
        4
    drymonfidelia  
       66 天前
    招个合格的程序员来写,这都搞不定还是别自己写了
    yhx77
        5
    yhx77  
       66 天前   ❤️ 1
    搞个分布式锁啊 放并发
    zizon
        6
    zizon  
       66 天前   ❤️ 1
    你如果关心 api 白花钱的话,把 api 响应持久化关联用户的请求特征.
    这样即使你提到的 2 的问题发生也能从持久化/缓存里取回来,避免额外 api 花费.
    skallz
        7
    skallz  
       66 天前
    实际上个人开发的应用,甚至可以不需要考虑单个用户的并发,单个用户直接 1 个队列串行处理都没啥大问题,当然要是老板的生意真的很好,单个用户的请求量特别大,那就考虑其他方案了
    8355
        8
    8355  
       66 天前   ❤️ 1
    1.必然是先扣费然后插入请求数据,此时状态待执行
    2.异步扫表统一入队列
    3.队列消费确保请求成功才 ack 掉 redis stream 可以做到 无论是否成功都记录日志,并记录请求时间。
    4.单个 request_id 最多请求几次,每次消费前先检查该请求的执行次数,超过直接消费并跳出,防止队列多入。
    5.异步检查达到失败次数的返还积分,执行成功的记录变更状态为完成。
    6.未达到失败次数上限并且超过一定时间间隔的的消息重新入队列重试。

    查积分表本身就不对
    积分余额设置无符号,默认为 0
    扣款操作直接 update points = points-10 where user_id = x
    不够扣会直接报 UNSIGNED 错的
    importmeta
        9
    importmeta  
    OP
       66 天前
    @skallz 是啊,我这功能就是能设计让用户批量转换文件...假设现在 5 个模块,每个用户都能往 5 个模块批量添加多个任务,这五个模块我每个模块用了一个 Redis 队列, 这队列应该是并行执行的吧, 不太了解底层, 他们都要读取积分表扣积分.
    importmeta
        10
    importmeta  
    OP
       66 天前
    @skallz 买的多个三方 API 都有限速, 每个 API 我都搞得队列, 用户不知道以后能有多少个, 我不搞个队列的话用户提交多了, 直接就报错了
    importmeta
        11
    importmeta  
    OP
       66 天前
    @8355 老哥,我在队列里扣费行吗
    z1829909
        12
    z1829909  
       65 天前
    信息不是很足, 这五个模块是由依赖关系吗, 必须都成功? 还是无关系, 用户发现失败了重复提交?
    另外不是很清楚为啥搞队列, 怕把第三方请求打爆, 应该直接在请求入口限流.
    8355
        13
    8355  
       65 天前   ❤️ 1
    @importmeta #10 那你要做冻结金额的逻辑,防止超发队列消息,不然一个人可以发起 n 多条消息 如果攻击就可以打崩,你请求接口响应速度不会太快,并发的话就会超售。
    importmeta
        14
    importmeta  
    OP
       65 天前
    @z1829909 我想让用户不知道第三方接口爆了,就用队列限速用户的请求,队列每秒只能处理几个
    z1829909
        15
    z1829909  
       65 天前 via Android
    @importmeta 如果用队列,消费速度固定等于第三方能承受的速度,如果用户生产速度一直大于消费速度,岂不是队列一直膨胀。
    importmeta
        16
    importmeta  
    OP
       65 天前
    @z1829909 是有这个问题, 不过这都是以后的事情了, 万一哪天人多了会有, 要么给第三方 API 加钱扩大吞吐量, 要么别的方法
    importmeta
        17
    importmeta  
    OP
       65 天前
    @8355 老哥,我改了改,这样是不是好些了...
    1.先扣费,扣费的时候
    1.1 开启事务
    1.2 积分表加行级锁
    1.3 扣费记录加一条
    1.4 改余额成 扣了之后的
    1.5 此条数据状态: 已扣费

    1 如果错误就抛给用户

    2.入队列, if 状态 = 已扣费 and 不在队列里 and 没有 jobId, 入队列后 此条数据 加上 jobId,

    2 如果错误就抛给用户

    4.队列里面

    4.1 开启事务
    try
    4.2 查询这条数据 where status = 已扣费
    4.3 状态改为成功
    4.4 此条数据状态 改成成功
    4.4 调用第三方 API
    4.5 提交事务
    4.6 记录日志
    catch
    4.7 终止事务
    4.8 把此条数据的状态改为失败(在事务外) 记录第三方 API 抛出的错误


    5.队列里面设置重试次数重试 重试次数到了 job 的状态是 job 错误

    6 在外面轮询这个 jobId 判断这个 job 的状态成功还是错误

    6.1 如果错误
    6.2 开启事务
    6.3 如果错误了 积分表加行级锁 返积分 where 扣费记录 是否返了积分 = 否
    6.4 扣费记录 是否返了积分 设为 是
    6.5 提交事务
    8355
        18
    8355  
       65 天前   ❤️ 1
    @importmeta #16
    2 如果错误就抛给用户??
    条件全都合理 报错不是应该抛给你自己吗 发钉钉或着什么提示报错啊。

    第三方 api 如果报错都应该报给项目维护者,如果参数本身有问题应该从前置校验中剔除,如果只是偶发报错,那应该记录错误代码中增加判断,直接忽略等待系统重试,没见过的发消息报给开发者。

    6.3 如果错误了 积分表加行级锁 返积分 where 扣费记录 是否返了积分 = 否
    6.4 扣费记录 是否返了积分 设为 是
    这两部分按你的描述属于退款逻辑,在原始订单进行变更,但我觉得扣费记录这表的积分变更数值可以设置为有符号,通过正负值来确定积分的变化,通过这个表都是插入数据,相当于一个积分流水表,除了关联 id 就是数值变化字段 还可以加个备注,为什么变化,前台在展示时会比较好看。
    importmeta
        19
    importmeta  
    OP
       65 天前
    @8355 好的老哥, 这个错误倒是没写全, 我自己找第三方 API 的文档错误码,自己维护了一个 Map,这个报错里面也有一些判断逻辑, 哪些可以直接返回给用户,哪些不返回. 我发帖时用的 MongoDB 所以写了个 if 0 这种判断, 现在准备切换成 postgres 了,为了钱操作和事务....
    关于   ·   帮助文档   ·   博客   ·   API   ·   FAQ   ·   实用小工具   ·   2789 人在线   最高记录 6679   ·     Select Language
    创意工作者们的社区
    World is powered by solitude
    VERSION: 3.9.8.5 · 21ms · UTC 12:47 · PVG 20:47 · LAX 04:47 · JFK 07:47
    Developed with CodeLauncher
    ♥ Do have faith in what you're doing.