![160c1c5352f2b9d30868c369d57ae036.png](https://i-blog.csdnimg.cn/blog_migrate/42dc0eb6783ea0e0438d615d312de8e7.jpeg)
用户购买商品的过程中,要对余额进行查询与修改,大致的业务流程如下:
第一步,从数据库查询用户现有余额:
SELECT money FROM t_yue WHERE uid=$uid;
不妨设查询出来的$old_money=100元。
第二步,业务层实施业务逻辑计算,比如:
(1)先查询购买商品的价格,例如是80元;
(2)再查询产品是否有活动,以及活动折扣,例如是9折;
(3)比对余额是否足够,足够时才往下走;
if($old_money> 80*0.9){
$new_money=$old_money-80*0.9=28
} else {
return "Not enough minerals";
}
第三步,将数据库中的余额进行修改。
UPDATE t_yue SET money=$new_money WHERE uid=$uid;
在并发量低的情况下,这个流程没有任何问题,原有金额100元,购买了80元的九折商品(72元),剩余28元。
同一个用户,并发扣款可能出现什么问题?
在分布式环境中,如果并发量很大,这种“查询+修改”的业务有一定概率出现数据不一致。
极限情况下,可能出现这样的异常流程:
步骤一,业务1和业务2并发查询余额,是100元。
![7dcdc9254ee3c62c89dbe63d49927c9a.png](https://i-blog.csdnimg.cn/blog_migrate/fcbf164af53af1ed9992c228ed588280.jpeg)
步骤二,业务1和业务2并发进行逻辑计算,算出各自业务的余额,假设业务1算出的余额是28元,业务2算出的余额是38元。
![73351115d0b2723fbd502b6bc04d6a39.png](https://i-blog.csdnimg.cn/blog_migrate/5dc7a08ab446ee7b3c661ba8e0289af4.jpeg)
步骤三,业务1对数据库中的余额先进行修改,设置成28元。
业务2对数据库中的余额后进行修改,设置成38元。
![6fb98b71519023c3080ecaf92ac2666d.png](https://i-blog.csdnimg.cn/blog_migrate/544e0a6ffa668ecf4f60e6f3a75a81b6.jpeg)
此时异常出现了,原有金额100元,业务1扣除了72元,业务2扣除了62元,最后剩余38元。
常见的解决方案?
1. 分布式悲观锁方案,但要引入额外的组件(redis/zk),并且会降低吞吐量。
2. 开启事务,在事务内执行select for update & update,这种依赖数据库的排它锁的方案对性能影响更加严重。
3. CAS乐观锁
使用CAS解决高并发时数据一致性问题,只需要在进行set操作时,compare初始值,如果初始值变换,不允许set成功。
具体到这个case,只需要将:
UPDATE t_yue SET money=$new_money WHERE uid=$uid;
升级为:
UPDATE t_yue SET money=$new_money WHERE uid=$uid AND money=$old_money;
(为了避免ABA问题,也可以不使用money,而是增加一个version字段)
即可。
并发操作发生时:
业务1执行:
UPDATE t_yue SET money=28 WHERE uid=$uid AND money=100;
业务2执行:
UPDATE t_yue SET money=38 WHERE uid=$uid AND money=100;
这两个操作同时进行时,只可能有一个执行成功。
怎么判断哪个并发执行成功,哪个并发执行失败呢?
set操作,其实无所谓成功或者失败,业务能通过affect rows来判断:
- 写回成功的,affect rows为1
- 写回失败的,affect rows为0
失败以后业务可以继续”自旋“,在死循环中执行”查询余额,更新新余额“的操作,直到affect rows为1,或者余额小于扣款额的时候退出循环。
拓展
为什么一定要用select&set的方式进行余额写回:
UPDATE t_yue SET money=$new_money WHERE uid=$uid AND money=$old_money;
为什么不能采用直接扣减的方法:
UPDATE t_yue SET money=money-$diff WHERE uid=$uid;
很人说,在并发情况下,会将money扣成负数。
为了保证余额不被扣成负数,再加一个where条件:
UPDATE t_yue SET money=money-$diff WHERE uid=$uid AND money>$diff;
这样是否可行?
很遗憾,仍然不行。原因是不幂等。
什么是幂等性?
相同条件下,执行同一请求,得到的结果相同,才符合幂等性。
读请求,一般是幂等的。
写请求,视情况而定:
- insert x,一般来说不是幂等的,重复插入得到的结果不一定一样
- delete x,一般来说是幂等的,删除多次得到的结果仍相同
- set a=x是幂等的
- set a=a-x不是幂等的
因此,这么扣减余额:
UPDATE t_yue SET money=$new_money WHERE uid=$uid AND money=$old_money;
是幂等操作。
要是这么扣减余额:
UPDATE t_yue SET money=money-$diff WHERE uid=$uid AND money>$diff;
不是幂等操作。
扣款怎么会重复执行呢?
重试。
重试,是异常处理里很常见的手段。
你在写业务的时候有没有写过这样的代码:
result = DoSomething();
if(false==result || TIMEOUT){
//错误,或者超时,重试一次
result= DoSomething();
}
return result;
当然,又会有朋友抬杠了,我从来不重试!!!
你可以决定业务代码怎么写,你不能决定底层框架代码怎么写:
(1)站点框架有没有自动重试?
(2)服务框架有没有自动重试?
(3)服务连接池,数据库连接池有没有自动重试?
画外音:
(1)服务化分层的架构中,建议只入口层重试,服务层不要重试,防止雪崩;
(2)dubbo底层,调用超时是默认重试的(failover),这个设计不好;
因此,在有重试的架构体系里,幂等性是需要考虑的一个问题。
总结:
扣款和充值业务,一般使用:
- select&set,配合CAS方案
而不使用:
set money-=X方案