标题:解析一个简单的分布式事务Case 出处:Felix021 时间:Fri, 02 Mar 2018 22:41:33 +0000 作者:felix021 地址:https://www.felix021.com/blog/read.php?2184 内容: 我注意到过去几个月我司有些同学还在踩一个简单的分布式事务Case的坑,而这个坑在两年以前就已经有同学踩过了,这里简单解析一下这个case和合适的处理方案,供参考。 1. 踩过的坑 这个case有很多变种,先从我们在XX业务踩过的坑开始,大约是16年9月,核心业务需求是很简单的:在用户发起支付请求的时候,从用户的银行卡扣一笔钱。负责这个需求的同学是这么写的代码(去除其他业务逻辑的简化版): $dbTrans = $db->beginTransaction(); try { $order = PayRequest::model()->newPayRequest(...); #在数据库中插入一条支付请求记录,状态为待支付 //其他业务改动 $result = PaySvr::pay($order->id, $order->amount); #请求PaySvr(或第三方支付通道)扣款 if ($result['code'] == PaySvr::E_SUCCESS) { $order->setAsSucceeded(); } else { $order->setAsPending(); } $dbTrans->commit(); } catch (Exception $e) { $dbTrans->rollback(); } 乍一看好像是没有什么毛病,测试的case都顺利提供过,也没有人去仔细review这一小段代码,于是就这么上线了。但问题很快就暴露出来,PaySvr在支付成功以后尝试回调,XX业务系统报错”订单不存在”。查询线上日志发现,这笔订单在请求第三方支付通道时网络超时,Curl抛了timeout异常,导致支付记录被回滚。有心的同学可以自己复现一下这个问题,观察BUG的发生过程。 代码修复起来倒是很简单,在请求PaySvr之前提交事务,将支付请求安全落库即可。 $dbTrans = $db->beginTransaction(); try { $order = PayRequest::model()->newPayRequest(...); //其他业务改动 $dbTrans->commit(); #先将支付请求落地 } catch (Exception $e) { $dbTrans->rollback(); } #再请求PaySvr $result = PaySvr::pay($order->id, $order->amount); #根据PaySvr结果修改支付请求和其他业务记录的状态 $dbTrans = $db->beginTransaction(); try { if ($result['code'] == PaySvr::E_SUCCESS) { $order->setAsSucceeded(); //其他业务改动 } else { $order->setAsPending(); //其他业务改动 } } catch (Exception $e) { $dbTrans->rollback(); } 把这个实现代入多个不同的业务下,还会衍生出更多问题,比如被动代扣业务,就可能因为重试导致用户被多次扣款,引起投诉(支付通道对投诉率的要求非常严格,甚至可能导致通道被关停);更严重的是放款业务,可能出现重复放款,给公司造成直接损失。据说某友商就是因为重复放款倒闭的,所以在实现类似业务时特别注意,考虑周全。 2. 归纳总结 我们往后退一步再审视这个case,这段简单的代码涉及了两个系统:XX业务系统(本地数据库)、PaySvr(外部系统)。可以看得出这段代码的本意,是期望将当前系统的业务和外部系统的业务,合并到一个事务里面,要么一起成功提交,要么一起失败回滚,从而保持两个系统的一致性。 之所以没能达到预期,直接原因是,在失败(异常)回滚的时候,只回滚了本地事务,而没有回滚远端系统的状态变化。按这个思路去考虑,似乎只要加一个 PaySvr::rollbackRequest($order->id) 好像就可以解决问题。但仔细想想就会发现远没这么简单,从业务上来说,如果已经给用户付款了,那实际上就是要给用户退款,而往往这时候是掉单(支付请求结果未知),我们又无法直接给用户退款;更极端一点,如果这个rollback请求也超时了呢,那本地可以rollback吗? 这就是分布式事务棘手的地方了,只靠这样的逻辑是无法保证跨系统的一致性的。解决这个问题的方法是引入两段式提交(2 Phase Commit,常常简写为2PC),其基本逻辑是,在两个系统分别完成业务,然后再分别提交。 例如我们上面的解决方案,实际上就是2PC的一个实现:我们把业务需求作为一整个事务,它可以拆成两个子事务(第三方支付通道完成代扣,在XX业务系统记录支付请求成功并修改相应业务状态),每个子事务又分成两个阶段:第一阶段,是在本地先记录支付请求(状态为待确认),并向第三方支付发出代扣请求(结果不确定);第二阶段,在确认第三方代扣成功以后,修改本地支付请求的状态修改为成功,或者是代扣结果为失败,本地支付请求状态记为失败。两个阶段都完成,这个事务才是真的完成了。 3. Case变种 仔细思考我们曾经实现过的需求,可能会在很多看似不起眼的地方发现分布式事务,例如我们在的存管匹配系统里面,就有这样一个Case。 由于与XX银行存管系统交互的延迟比较大,所以我们的匹配系统实现是异步的,匹配系统在撮合了资金和资产以后,会生成一条债权关系记录在本地,随后再发送到XX银行执行资金的划拨。为了提高执行的效率,我们希望在债权关系生成以后,尽快执行资金的划拨,因此我们会把资金划拨的指令通过LPush放进Redis的list里;List的另一端,那些使用BLPOP监听数据的worker会立刻被激活去执行。 如果没有仔细思考,代码可能会这么写: #匹配系统 function matcher() { $dbTrans = $db->beginTransaction(); try { foreach (matchCapitalAndProject() as $match_result) { list($capital_id, $project_id, $amount) = $match_result; $relation = Relation::model()->create($capital_id, $project_id, $amount); $redis->lPush($relation->id); } $dbTrans->commit(); } catch (Exception $e) { $dbTrans->rollback(); } } #Worker function Worker() { while (true) { $id = $redis->brPop(); $relation = Relation::model()->findByPk($id); if ($relation) { BankApi::invest($relation->capital_id, $relation->project_id, $amount); } } } 在实际执行这段代码的时候,如果没有仔细测试(尤其是在有补单逻辑,捞出未执行成功的划拨指令再发送给银行),可能就不会发现,实际上有很多指令并不是马上被执行的,因为relation_id被送进list以后,worker马上就会读出来执行,但这时事务可能还没有提交。但这只是影响了业务的效率,还没有对业务的正确性产生影响。 为了修复这个问题,似乎可以这么做:把 [capital_id, project_id, amount] 发送到redis,worker直接取出执行,这样就不用从数据库读取relation,保证尽快将请求发送到银行。但如果因为某些原因,事务最终没有被提交呢?找银行rollback这些指令的执行,那就麻烦多了。 正确的做法是,在事务提交了以后,再lPush到Redis里: #匹配系统 function matcher() { $arr_relation = []; $dbTrans = $db->beginTransaction(); try { foreach (matchCapitalAndProject() as $match_result) { list($capital_id, $project_id, $amount) = $match_result; $relation = Relation::model()->create($capital_id, $project_id, $amount); $arr_relation[] = $relation; } $dbTrans->commit(); } catch (Exception $e) { $dbTrans->rollback(); } foreach ($arr_relation as $relation) { $redis->lPush($relation->id); } } 注:foreach要放到try-catch后面。 最后想说,我相信有很多同学知道这个Case,或者就算不知道也不会犯这样的错误,因此也许会觉得没必要专门揪出来这样分享 —— 但“知识的诅咒”就是这样,“我会的东西都是简单的”,然而对于没有踩过坑的同学来说,其实都是宝贵的经验;另一方面,有些别人觉得简单的问题、踩过的坑,也许自己是不知道的。所以希望大家都能分享自己在工作学习中踩过的坑、解决过的问题,互相交流,互相提高。 Generated by Bo-blog 2.1.0