有朋友问我:沉先生,我们有生意。在同一用户并发“查询、逻辑计算、扣费”的情况下,可能会出现余额不一致的情况。有什么优化方法吗?今天就和大家聊聊这个问题。画外音:文章较长,建议提前收藏。问题一:用户扣费的业务场景是什么?用户在购买商品的过程中,需要查询和修改余额。一般业务流程如下:第一步:从数据库中查询用户现有余额:SELECTmoneyFROMt_yueWHEREuid=$uid;不妨设置查询$uidold_money=100元。第二步,业务层实现业务逻辑计算,例如:(1)首先查看所购买商品的价格,例如80元;(2)然后查看商品是否有活动,活动折扣,比如10%off;(3)查看余额是否足够,足够了再往下走;if($old_money>80*0.9){$new_money=$old_money-80*0.9=28}else{return"Notenoughminerals";}第三步,修改数据库中的余额。更新t_yueSETmoney=$new_moneyWHEREuid=$uid;在低并发的情况下,这个过程是没有问题的。原金额100元,满80元购买10折产品(72元),还剩28元。问题二:同一用户并发扣款可能会出现什么问题?在分布式环境下,如果并发量很大,这种“查询+修改”的业务,有一定概率出现数据不一致。极端情况下,可能会出现这样的异常流程:第1步,业务1和业务2同时查询余额,余额为100元。画外音:这些并发查询是在不同的站点实例/服务实例上做的,进程内互斥肯定解决不了。第二步:业务1和业务2同时进行逻辑计算,计算出各自业务的余额。假设商家1计算的余额为28元,商家2计算的余额为38元。第三步,业务1先修改数据库中的余额,设置为28元。业务2修改数据库中的余额,设置为38元。这时,异常出现了。原金额100元,业务1扣72元,业务2扣62元,最后还剩38元。画外音:假设业务1先回写余额,业务2再回写余额。问题三:常见的解决方法有哪些?对于这种情况,当同一个用户并发扣款时,有小概率会出现异常。可以对每个用户进行分布式锁互斥,例如:在redis/zk中抢一个key继续操作,否则禁止操作。这种悲观锁方案确实可行,但是会引入额外的组件(redis/zk),会降低吞吐量。对于小概率的不一致,有乐观锁的解决方案吗?进一步分析并发扣款发现:(1)业务1回写时,旧余额为100,为初始状态;新余额为28,这是一个结束状态。理论上只有当旧余额为100时,新余额才能成功回写。业务1并发回写时,旧余额确实是100,应该回写成功。(2)业务2回写时,旧余额为100,为初始状态;新余额为28,这是一个结束状态。理论上只有当旧余额为100时,新余额才能成功回写。但实际上此时数据库中的amount已经变成了28,所以业务2的并发回写应该不会成功。如何低成本实现乐观锁?回写集合时,添加初始状态条件比较。只有保持初始状态不变,才允许成功回写集合。CompareAndSet(CAS)是减少读写锁冲突和保证数据一致性的常用方法。性方法。这个时候业务应该怎么变?使用CAS解决高并发时的数据一致性问题,只需要在执行set操作时比较初始值即可。如果初始值发生变化,则不允许设置成功。具体针对这种情况,只需要升级:UPDATEt_yueSETmoney=$new_moneyWHEREuid=$uid;至:更新t_yueSETmoney=$new_moneyWHEREuid=$uidANDmoney=$old_money;.当并发操作发生时:业务1执行:UPDATEt_yueSETmoney=28WHEREuid=$uidANDmoney=100;业务2执行:UPDATEt_yueSETmoney=38WHEREuid=$uidANDmoney=100;这两个操作同时进行时,只有其中一个可能成功。如何判断哪个并发执行成功,哪个并发执行失败?事实上,set操作成功与失败无关紧要。业务可以通过影响行判断:如果回写成功,则影响行数为1;如果回写成功,则影响行数为1;(CompareandSet)解决数据一致性问题。对应业务,就是在设置的时候,加上初始条件的比较。优化不难,只改了半行SQL,但确实能解决问题。问题4:直接扣除法是否可以UPDATEt_yueSETmoney=money-$diffWHEREuid=$uid;用于余额扣除?显然不是,在并发的情况下,钱会被扣除为负数。问题5:为了保证余额不会被扣为负数,增加一个where条件:UPDATEt_yueSETmoney=money-$diffWHEREuid=$uidANDmoney-$diff>0;这可行吗?不幸的是,仍然无法正常工作。这个方案不是幂等的。那么什么是幂等性呢?在谈幂等性之前,我们先来看另一个测试用例。假设有一个注册新用户的服务接口:boolRegisterUser($uid,$name){//检查uid是否已经存在selectuidfromt_userwhereuid=$uid;//不是新用户,返回失败if(rows>0)returnfalse;else{//向用户表插入一个新用户insertintot_uservalues($uid,$name);//返回成功returntrue;}}有个测试工程师为这个接口写了一个测试用例:boolTestCase_RegisterUser(){//Createsomefakedatalonguid=123;Stringname='神剑';//调用待测接口boolresult=RegisterUser(uid,name);//期望注册成功,对结果进行断言判断Assert(result,true);//返回测试结果returnresult;}这是一个好的测试用例吗?这个用例有什么问题?你会发现,在同样的条件下,这个测试用例执行两次,结果是不同的:第一次执行,第一次创建数据,调用接口,注册成功;第二次执行,再次创建同样的数据,如果调用接口,会注册失败;这不是一个好的测试用例,多次执行的结果是不同的。什么是幂等性?在相同的条件下,执行相同的请求,得到相同的结果,具有幂等性。画外音:谷歌一下,解释的比我好,但是意思应该很清楚。如何把上面的测试用例改成符合“幂等性”的测试用例呢?只需添加一行代码:boolTestCase_RegisterUser(){//创建一些假数据longuid=123;Stringname='神剑';//先删除这个假用户DeleteUser(uid);//调用待测接口boolresult=RegisterUser(uid,name);//预期注册成功,断言结果Assert(result,true);//returnthetestresultreturnresult;}这样在相同的条件下,无论用例执行多少次,得到的测试结果都是一样的。读取请求通常是幂等的。写入请求,视情况而定:insertx,一般来说,不是幂等的,多次插入得到的结果不一定相同;deletex,一般来说是幂等的,多次删除得到的结果还是一样的;seta=x是幂等的;seta=a-x不是幂等的;...因此,像这样扣除余额:UPDATEt_yueSETmoney=$new_moneyWHEREuid=$uidANDmoney=$old_money;是一个幂等操作。如果这样扣除余额:UPDATEt_yueSETmoney=money-$diffWHEREuid=$uidANDmoney-$diff>0;不是幂等操作。说到这里,可能有朋友要提出争论了。测试用例将被重复执行。扣除怎么可能重复执行呢?重试。重试是异常处理中非常常用的方法。你在写业务的时候有没有写过这样的代码:result=DoSomething();if(false==result||TIMEOUT){//错误,或者超时,重试result=DoSomething();}returnresult;当然以后还会有朋友再抬杠的,我再也不尝试了!!!画外音:嗯,这个合格不合格?你可以决定业务代码怎么写,但你不能决定底层框架代码怎么写:站点框架是否自动重试?服务框架是否有自动重试?服务连接池和数据库连接池是否自动重试?画外音:在服务分层架构中,建议只重试入口层,不要重试服务层,防止雪崩;在dubbo底层,调用超时默认重试,这不是一个好的设计;因此,当有重试的架构体系中,幂等性是一个需要考虑的问题。所以,借记充值业务,一般使用:select&set,withCASscheme而不是:setmoney-=Xscheme问题5:CAS方案,会不会有ABA问题?什么是ABA问题?CAS乐观锁机制确实可以提高吞吐量和保证一致性,但极端情况下可能会出现ABA问题。考虑以下操作:并发1(上图):获取数据初始值为A,后续计划实现CAS乐观锁。预计当数据还是A的时候,可以修改成功。并发2:修改数据为B并发3:修改数据修改回A并发1(下):CAS乐观锁,检测发现初始值还是A,修改数据在上面的并发环境下,当并发1在修改数据,虽然还是A,但是已经不是初始条件的A了,中间发生了一些事情,如果把A改成B,再把B改成A,这个A就不再是A了,但是修改数据成功,可能会出错。这就是所谓的CAS引起的ABA问题。在balance操作中,ABA问题不会影响业务,因为对于“balance”属性来说,前面的A是100的余额,后面的A是100的余额,本质上是一样的。但在其他场景下可能并非如此。举个栈操作的例子:并发1(上):读取栈顶元素为“A1”1(下):实现CAS乐观锁,发现栈顶还是“A1”,所以改为A2,此时会出现系统错误,因为这个“A1”不是另一个“A1”,ABA问题怎么优化呢?ABA问题的原因是“值”只是在CAS过程中检查。在某些情况下,相同的“值”不会引入错误的业务逻辑(比如余额)。在某些情况下,虽然“值”相同,但它不再是原始数据(如堆栈)。因此,CAS不能只比对“值”,还必须保证是原始数据,才能成功修改。一种常见的做法是将“值”比较升级为“版本号”比较,一个版本的数据,版本发生变化,即使值相同,修改也不应该成功。在并发读写balance的例子中,引入版本号的具体做法如下:(1)balance表需要升级。t_yue(uid,money)升级为:t_yue(uid,money,version)(2)查询余额时,同时查询版本号。SELECTmoneyFROMt_yueWHEREsid=$sid升级为:SELECTmoney,versionFROMt_yueWHEREsid=$sid如果有并发操作,会查询版本号(3)设置余额时,版本号必须是一样,版本号必须是Revise。旧版本“值”比较:UPDATEt_yueSETmoney=38WHEREuid=$uidANDmoney=100升级到“版本号”比较:UPDATEt_yueSETmoney=38,version=$version_newWHEREuid=$uidANDversion=$version_old这个时候假设有并发操作。第一次操作请求会修改版本号,并发操作失败。画外音:版本是通用的,这个例子只是以版本为例,其实这个例子可以比作余额的“值”。总结一下select&set的业务场景,并发时会出现一致性问题。幂等性是一个需要考虑的问题。基于“值”的CAS乐观锁可能会导致ABA问题。CAS乐观锁必须保证修改时的“本数据”是“其他数据”,应该通过“值”进行比较,优化为“版本号”,比较的思路比结论更重要。
