Mar
2
解析一个简单的分布式事务Case
我注意到过去几个月我司有些同学还在踩一个简单的分布式事务Case的坑,而这个坑在两年以前就已经有同学踩过了,这里简单解析一下这个case和合适的处理方案,供参考。
1. 踩过的坑
这个case有很多变种,先从我们在XX业务踩过的坑开始,大约是16年9月,核心业务需求是很简单的:在用户发起支付请求的时候,从用户的银行卡扣一笔钱。负责这个需求的同学是这么写的代码(去除其他业务逻辑的简化版):
乍一看好像是没有什么毛病,测试的case都顺利提供过,也没有人去仔细review这一小段代码,于是就这么上线了。但问题很快就暴露出来,PaySvr在支付成功以后尝试回调,XX业务系统报错”订单不存在”。查询线上日志发现,这笔订单在请求第三方支付通道时网络超时,Curl抛了timeout异常,导致支付记录被回滚。有心的同学可以自己复现一下这个问题,观察BUG的发生过程。
代码修复起来倒是很简单,在请求PaySvr之前提交事务,将支付请求安全落库即可。
把这个实现代入多个不同的业务下,还会衍生出更多问题,比如被动代扣业务,就可能因为重试导致用户被多次扣款,引起投诉(支付通道对投诉率的要求非常严格,甚至可能导致通道被关停);更严重的是放款业务,可能出现重复放款,给公司造成直接损失。据说某友商就是因为重复放款倒闭的,所以在实现类似业务时特别注意,考虑周全。
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会立刻被激活去执行。
如果没有仔细思考,代码可能会这么写:
在实际执行这段代码的时候,如果没有仔细测试(尤其是在有补单逻辑,捞出未执行成功的划拨指令再发送给银行),可能就不会发现,实际上有很多指令并不是马上被执行的,因为relation_id被送进list以后,worker马上就会读出来执行,但这时事务可能还没有提交。但这只是影响了业务的效率,还没有对业务的正确性产生影响。
为了修复这个问题,似乎可以这么做:把 [capital_id, project_id, amount] 发送到redis,worker直接取出执行,这样就不用从数据库读取relation,保证尽快将请求发送到银行。但如果因为某些原因,事务最终没有被提交呢?找银行rollback这些指令的执行,那就麻烦多了。
正确的做法是,在事务提交了以后,再lPush到Redis里:
注:foreach要放到try-catch后面。
最后想说,我相信有很多同学知道这个Case,或者就算不知道也不会犯这样的错误,因此也许会觉得没必要专门揪出来这样分享 —— 但“知识的诅咒”就是这样,“我会的东西都是简单的”,然而对于没有踩过坑的同学来说,其实都是宝贵的经验;另一方面,有些别人觉得简单的问题、踩过的坑,也许自己是不知道的。所以希望大家都能分享自己在工作学习中踩过的坑、解决过的问题,互相交流,互相提高。
转载请注明出自 ,如是转载文则注明原出处,谢谢:)
RSS订阅地址: https://www.felix021.com/blog/feed.php 。
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();
}
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();
}
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);
}
}
}
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);
}
}
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,或者就算不知道也不会犯这样的错误,因此也许会觉得没必要专门揪出来这样分享 —— 但“知识的诅咒”就是这样,“我会的东西都是简单的”,然而对于没有踩过坑的同学来说,其实都是宝贵的经验;另一方面,有些别人觉得简单的问题、踩过的坑,也许自己是不知道的。所以希望大家都能分享自己在工作学习中踩过的坑、解决过的问题,互相交流,互相提高。
欢迎扫码关注:
转载请注明出自 ,如是转载文则注明原出处,谢谢:)
RSS订阅地址: https://www.felix021.com/blog/feed.php 。
lovelucy
2018-3-7 17:52
so 你們的業務技術棧是 PHP 的
felix021 回复于 2018-3-7 18:43
厉害!这都被你发现了!
分页: 1/1 1