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

88 天前
 importmeta

我在写个功能:

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.我这逻辑有没有其他问题啊,实在没写过扣费,谢谢大家了.

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

查积分表本身就不对
积分余额设置无符号,默认为 0
扣款操作直接 update points = points-10 where user_id = x
不够扣会直接报 UNSIGNED 错的
importmeta
87 天前
@skallz 是啊,我这功能就是能设计让用户批量转换文件...假设现在 5 个模块,每个用户都能往 5 个模块批量添加多个任务,这五个模块我每个模块用了一个 Redis 队列, 这队列应该是并行执行的吧, 不太了解底层, 他们都要读取积分表扣积分.
importmeta
87 天前
@skallz 买的多个三方 API 都有限速, 每个 API 我都搞得队列, 用户不知道以后能有多少个, 我不搞个队列的话用户提交多了, 直接就报错了
importmeta
87 天前
@8355 老哥,我在队列里扣费行吗
z1829909
87 天前
信息不是很足, 这五个模块是由依赖关系吗, 必须都成功? 还是无关系, 用户发现失败了重复提交?
另外不是很清楚为啥搞队列, 怕把第三方请求打爆, 应该直接在请求入口限流.
8355
87 天前
@importmeta #10 那你要做冻结金额的逻辑,防止超发队列消息,不然一个人可以发起 n 多条消息 如果攻击就可以打崩,你请求接口响应速度不会太快,并发的话就会超售。
importmeta
87 天前
@z1829909 我想让用户不知道第三方接口爆了,就用队列限速用户的请求,队列每秒只能处理几个
z1829909
87 天前
@importmeta 如果用队列,消费速度固定等于第三方能承受的速度,如果用户生产速度一直大于消费速度,岂不是队列一直膨胀。
importmeta
87 天前
@z1829909 是有这个问题, 不过这都是以后的事情了, 万一哪天人多了会有, 要么给第三方 API 加钱扩大吞吐量, 要么别的方法
importmeta
86 天前
@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
86 天前
@importmeta #16
2 如果错误就抛给用户??
条件全都合理 报错不是应该抛给你自己吗 发钉钉或着什么提示报错啊。

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

6.3 如果错误了 积分表加行级锁 返积分 where 扣费记录 是否返了积分 = 否
6.4 扣费记录 是否返了积分 设为 是
这两部分按你的描述属于退款逻辑,在原始订单进行变更,但我觉得扣费记录这表的积分变更数值可以设置为有符号,通过正负值来确定积分的变化,通过这个表都是插入数据,相当于一个积分流水表,除了关联 id 就是数值变化字段 还可以加个备注,为什么变化,前台在展示时会比较好看。
importmeta
86 天前
@8355 好的老哥, 这个错误倒是没写全, 我自己找第三方 API 的文档错误码,自己维护了一个 Map,这个报错里面也有一些判断逻辑, 哪些可以直接返回给用户,哪些不返回. 我发帖时用的 MongoDB 所以写了个 if 0 这种判断, 现在准备切换成 postgres 了,为了钱操作和事务....

这是一个专为移动设备优化的页面(即为了让你能够在 Google 搜索结果里秒开这个页面),如果你希望参与 V2EX 社区的讨论,你可以继续到 V2EX 上打开本讨论主题的完整版本。

https://www.v2ex.com/t/1075495

V2EX 是创意工作者们的社区,是一个分享自己正在做的有趣事物、交流想法,可以遇见新朋友甚至新机会的地方。

V2EX is a community of developers, designers and creative people.

© 2021 V2EX