这种场景 数据库 是不是只能加锁啊?
假设 数据库中有两个表 一个是流水表 也就是扣款用, 一个是 userinfo 就是余额在这看
那么并发场景下。怎么保证余额是>0 且数据无误。
我的想法是。1 查询余额 如果减去后余额 >=0 则插入扣款后的余额, 这个过程中加锁。 但是这种如果是并发高一点的话是不是很慢啊?
各位有这块的经验吗? 希望可以指点一下。或者也可以讲解一下你们公司的扣款逻辑是啥? 是如何做的 ?
1 Jooooooooo 2022-11-25 17:11:52 +08:00 单个 user 哪来的高并发? 你想问的是不是库存? |
2 runningman 2022-11-25 17:15:17 +08:00 行级锁应该就够你用了。 |
3 dqzcwxb 2022-11-25 17:21:12 +08:00 分布式锁,队列,单线程 |
4 hhhhhh123 OP @runningman 行锁 怎么避免 死锁 |
![]() | 5 opengps 2022-11-25 17:30:31 +08:00 一楼说出了核心,虽然银行类业务用户多,但是架不住最大的客户操作完一个交易也是需要时间的,这时间不会导致并发 |
6 hhhhhh123 OP @Jooooooooo 那库存这种问题 应该怎么解决? |
7 Jooooooooo 2022-11-25 17:40:58 +08:00 @hhhhhh123 库存的高并发扣减算是比较成熟的东西了, 随便一搜很多的 比如可以搞多层拦截, 如果你只卖 10 个东西, 要是有 1w 人来抢, 那绝大多数流量没有必要到后端, 反正总是能把东西卖出去的. 前端和网关可以直接随机丢弃流量, 流量到了后端后, 可以再加上 MQ 排队和缓存, 最终再到数据库里行锁扣库存. 还有手段比如把库存分散到多行数据上, 随机挑一行扣 |
![]() | 8 ElmerZhang 2022-11-25 17:42:31 +08:00 ![]() 如果并发不会很高的话不用在数据库上加锁 1. 要扣的钱为 A ,先查 amount 当前值为 B ,代码中判断 B >= A 2. 然后执行 update xxx set amount = amount - A where amount = B 3. 执行看影响行数,如果为 0 ,重新从第 1 步执行 一般只需要重试一次。 |
9 dongtingyue 2022-11-25 17:43:43 +08:00 update xxxxx set xxxx where 余额>xx 余额 用 innodb 本身就有行锁,失败返回异常,这点时间肯定要等的 |
10 coderxy 2022-11-25 18:15:32 +08:00 乐观锁就够了,修改时判断一下余额与你之前查到的余额是否一致。 |
![]() | 11 git00ll 2022-11-25 18:46:54 +08:00 一锁 二查 三更新 |
![]() | 12 CEBBCAT 2022-11-25 21:09:09 +08:00 |
13 lovelylain 2022-11-25 21:29:26 +08:00 via Android @CEBBCAT 同时更新多个才要事务,例如给一个人加余额,另一个人减余额。 |
14 awaganddong 2022-11-25 22:02:53 +08:00 ![]() |
15 richangfan 2022-11-25 22:23:11 +08:00 ![]() update users set balance = balance - 1 where user_id = 123 and balance >= 1; 只在余额大于 1 时扣除用户 123 的 1 块钱 |
![]() | 16 orzwalker111 2022-11-25 22:34:35 +08:00 @richangfan 假设网关、框架重试,会多扣款,解决手段: 1 、悲观锁,使用分布式锁 2 、乐观锁,使用 CAS ,select 得到的 balance 作为 update 的 where 条件,并添加 ver 条件解决 ABA 问题 |
![]() | 17 xuanbg 2022-11-25 22:36:05 +08:00 不要做无意义的事情,15 楼的方法可以很好的解决 OP 你的这个问题。 |
![]() | 19 louisliu813 2022-11-25 23:17:01 +08:00 @orzwalker111 是的,我们也是使用 cas ,更新时判断 version ,如果被其他事物更新到 version + 1 了,就 select 新的 balance 和 version 出来,然后基于新 version 做判断,新 balance 做更新。 |
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; } |
![]() | 23 yogogo 2022-11-26 08:09:52 +08:00 事务加行锁,扣款交易可以先入库,再用异步任务按顺序执行交易扣款。有些第三方代扣服务就是这样设计的 |
![]() | 24 dingyaguang117 2022-11-26 08:29:52 +08:00 via iPhone 乐观锁即可 |
![]() | 25 reeco 2022-11-26 08:46:13 +08:00 via iPhone 实操都是 tcc ,两步提交 |
![]() | 26 love2328 2022-11-26 08:53:33 +08:00 你的想法怕慢 实际不会很慢 并发是同等触发 实际触发时并没有的 |
28 mrpzx001 2022-11-26 09:22:29 +08:00 用事务不就完事了吗? 怎么都不提事务的? |
![]() | 29 8355 2022-11-26 10:13:21 +08:00 用户级别并发锁行锁不就可以了吗 改个余额 加订单入库 加资金流水能有几张表 能慢到哪里去啊 你这个下单接口高峰 qps 能有 1000 吗 |
30 iseki 2022-11-26 11:30:05 +08:00 via Android 如果是扣库存,只要保证别 100 个商品,结果 10000 个请求打到数据库上、也别同一时间点 100 个请求全都在数据库上扣同一个商品,就没什么可担心的,数据库的性能足够。 扣余额就更简单了,限制下不要让一个用户同时发起一堆请求(这本来也该限制吧) 实现上可以用 update cas ,但存在限制不方便时直接 Serializable 性能不一定差( PostgreSQL ) |
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 |
![]() | 33 codehz 2022-11-26 11:49:18 +08:00 @ElmerZhang 那不如直接 update xxx set amount = amount - A where amount > A ( |
34 chenqh 2022-11-26 12:48:53 +08:00 用 redis 锁不就好了吗? |
![]() | 35 vanillacloud 2022-11-26 12:56:01 +08:00 via iPhone 我觉得在 update 时 「 where 余额 = 扣款前查询的余额」这一步就能规避重复操作的风险,这不能当 standard procedure 吗? |
36 noogel 2022-11-26 13:33:16 +08:00 高并发扣减: 1. 合并请求,在保证事务的前提下,将多个扣款请求合并操作,这样只需要做一次锁操作和写操作。 2. 拆分账户,将热点账户的余额账户拆分成多个子余额账户,以此来降低单个账户扣减操作的并发度。 3. 使用内存数据库扣减,并异步写日志,所有日志结果可以回溯账户余额结果,和内存数据库做对账。 |
37 LucasLee92 2022-11-27 11:09:02 +08:00 @noogel 1 和 2 的处理对热点账户的处理都只考虑怎么解决记账问题 1 的问题在于合并记账后余额不足的怎么处理,可能拆分记有些还能成功 2 的问题在于多个账户如何协同管理 3 的实现最终还是会碰到热点账户问题,当然效率比起数据库来说要好很多了 不清楚是否有相应的成熟业务的解决方案文章能看看 |
38 hhhhhh123 OP @ElmerZhang 我理解 第一步和第二步 ,第三部不是特别理解, 为啥只递归一次? |
39 hhhhhh123 OP @vanillacloud @codehz @8520ccc @richangfan 如果这样的话, 同时有俩个 一个扣款 10 块 一个扣款 5 块。 这样只会执行其中的一个余额。 另外一个就不会执行。 我觉得 8 楼的 第三个条件挺好, 但是递归次数 又不好拿捏。 |
40 hhhhhh123 OP 我的新思路是 : 只要保证每个请求都是正确的扣钱请求。 然后参考 8 楼, 当然第三个条件只能是一直递归下去, 吧所有的请求都给操作完。 |
41 liangliplusss 327 天前 两个方案 方案一: 悲观锁 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 ), 扣减成功发送消息到消息队列更新数据库。 |