用户A给用户B转账的问题

最近遇到一个场景命题,觉得有必要重新梳理下分析过程和结果,所以有了这篇文章,命题是这样的:
题目:编写两个用户之间的转账接口及其内部实现。
要求:完成接口设计并实现内部逻辑,已完成用户A转账给用户B的功能。用户的账户不再同一个数据库下,注意需要手写代码,尽量不要用伪代码。
提示:接口发布后会暴露给外部应用进行服务掉用,请考虑接口规范,安全,幂等,重试,并发,有可能的异常分支,事务一致性,用户投诉,资金安全等的处理。

 

分析过程:
     从题目中可知用户A,B在不同数据库中,我认为这里是设置的第一门槛。如果两个用户在同一个库中,可以利用数据库的事务同时完成扣减和增加余额的操作,不在同一个库,意味着存在夸库的事务,所以直接利用数据库事务无法实现。那能否将操作先在本地记录下来,利用数据库的事务,完成操作日志和扣减余额的一致性操作,然后再在另外一个库完成操作日志的重放呢?这样还可以满足题目中的另外一个需求:用户投诉。如果运营人员可以准确的看到用户每一笔收支的明细,对于处理用户投诉将是一个特别大的帮助。同样的,日志的重放也是需要保证数据的一致性的,重复消费也会出现数据错误。
     所以,如果按照以上思路实现的话,问题变成了需要在操作的记录和重放两个阶段分阶段的保证事务的一致性,保证每个阶段都是幂等,可重试,考虑并发问题的。

 

设定一些条件方便说明方案:
用户A,有余额99块,在数据库1
用户B,有余额101块,在数据库2
实现方案中不能依赖除数据库,应用以外的第三方组件。

 

接口设计:
//from 转账发起者,to 转账接收者, money 转账金额
transfer(User from, User to, BigDecimal money,long seqId)
//获取事务id,type事务类型,比如:转账
getSeqId(String type);

 

调用过程如下
接口客户端调用逻辑:
//先通过getSeqId方法获取类似流水号的东西  生成的方式可以很多种,当然需要保证全局唯一,
//里面包含时间信息是个很好的方式,可以方便追溯生成顺序,这里不是重点,不展开讨论。
long seq_id=getSeqId("转账"); 
//调用转账接口实现转账
transfer("A ","B ",49.00,seq_id); 

核心的实现如下:

//用户A扣减阶段

//略过check用户A状态,余额是否充足等等。

begin transaction{//开启事务
	try{
	//更新用户A的余额,实现预扣,同时对A用户的更新操作在此次事务提交前将处于block状态。利用数据库事务排队。
	   int moidfy =  update user set mount=mount+#{remain} where  user='A';
	}catch(OutOfRangeException){    //因为没做扣减检查,可能存在扣减为负数的情况,如果有异常,回滚事务。
	   rollback;//回滚事务
	   return;
	}

	if( modify < 1 ){
	   rollback;   //如果更新影响行数为0,回滚更新操作。接口提示客户端重试。
	   return;
	}

	try{
//在库1中插入操作日志或者叫事务日志,记录“谁转给谁,转了多少钱”,用了前一步的流水号作为log表的主键ID。
	   insert into 1.log(id,from,to,money) values(seq_id,'A','B',49.00);
	}catch(DuplicateExcetion){//如果捕获到主键ID的异常,说明该操作已经执行过,回滚事务即可。
	   rollback;//回滚事务
	   return;
	}
	commit;// 如果日志插入成功,提交事务
}

//用户B增加阶段 
//从库2中查询出已经处理的最大ID,这里存在脏读的情况,但是插入是可以避免,见下面注释。 

long maxSeqId = "select * from 2.log where to ='B' and type='转账' " order by id desc 

//从库1中查询出需要处理的操作日志或者事务日志。
List logList = "select * from 1.log where to ='B' and type='转账' and id>maxSeqId "
for( Log log : logList ){
	begin transaction{//开启事务
            //更新库2中用户B的余额。
	   int moidfy =  update user set mount=mount+#{log.money} where user = #{log.to }

	   if( modify < 1 ){
		rollback;   //如果更新影响行数为0,回滚更新操作。接口提示客户端重试。
		return;
	   }

	   try{
    //在库2中插入日志,如上文所说,存在更新冲突的问题,如果冲突说明该日志已经正确处理过,回滚掉更新,跳过这条日志即可。
		insert into 2.log(id,from,to,money) values(log.id,'A','B',49.00);
   	   }catch(DuplicateExcetion){
		rollback;//回滚事务
		continue;//跳过这条日志,处理下一条。
	   }
	   commit;// 如果日志插入成功,提交事务

	}
}

下面分别从幂等,重试,并发,安全的角度说明下:

 

1. 幂等:幂等的定义是多次操作获得同样的结果,对应到这里,我们可以理解为:含有相同seqId的转账请求多次达到系统后,A用户从99变成50,B用户从101变成150。
          分两种情况讨论:
          a. 两个及以上转账请求(相同seqId )同时到达,在扣减阶段的第二步,插入操作日志的时候,第一次执行会成功插入,后续执行会收到DuplicateExcetion异常,扣减事务回滚,用户A账户只扣减一次。
          b. 两个及以上转账请求(相同seqId ) 先后达到(比如网络丢包,或者故障导致请求达到存在时间差),第一个事务已经执行完毕的情况,同样的,在第二步会扣减失败,用户A账户只扣减一次。

 

2. 重试:重试的定义是重复相同操作,期望操作成功的情况,对应到这里是如果客户端发起调用失败后,再重新发起请求的情况。
          这个问题在这里可以解决,因为操作日志使用了seqId作为主键,主键存在=操作完成  反之操作没完成=主键不存在,客户端发起重试时,可以不用更新seqId,再次尝试调用,如果数据没有变化,在事务块内完成扣减操作后,可以正常插入操作日志,提交事务,获得转账成功的结果。

 

3. 并发:指在同一时间,有多个相同操作准备执行的情况。对应到这里,有多个情况,分开讨论:
          a. A转账给B,A转账给C:扣减阶段,更新用户A的余额时,其中一个转账请求会阻塞,未阻塞的请求更新余额成功,插入日志成功,提交事务,阻塞的请求恢复,更新余额成功,插入日志成功(因为A转B,A转C的seqId 肯定是不一样的),提交事务。两个转账请求成功。
          b. A转账个B,B转账给A,因为扣减和增加存在顺序情况,所以总体有以下6种情况:
          1.  执行顺序是A扣减 -> B增加 -> B扣减-> A增加
          2.  执行顺序是A扣减 -> B扣减 -> B增加-> A增加
          3.  执行顺序是A扣减 -> B扣减 -> A增加 ->  B增加
          4.  执行顺序是B扣减 -> A增加 -> A扣减 -> B增加
          5.  执行顺序是B扣减 -> A扣减 -> B增加-> A增加
          6.  执行顺序是B扣减 -> A扣减 -> A增加 -> B增加
          重点在于中间的第二和第三步同时发生:如果是A或B扣减增加同时执行的情况,
          其中一个线程会更新成功,另一个线程进入阻塞状态,更新成功的会继续写入日志的操作,然后写入成功,提交事务,事务提交,数据库独占锁释放后领一个线程更新成功,写入日志,因为seqId不同,写入正常,成功提交事务,完成两次转账请求,这里还会存在一个情况是:A增加前,先扣减。 这个时候账户余额会有成为负数的可能,如果数据库字段使用的时候无符号数应用线程会收到“Out of range”的异常,此时可以回滚事务,返回给客户端扣减失败,由客户端决定是否需要重试。
          如果是A扣减(或增加)的同时 B增加(或扣减)的情况。因为update的记录不同,两个线程会同时更新,在插入日志时,因为发生在不同的数据库,也不会有冲突的情况产生。

 

4. 安全,从以上的分析可以看到,暴露给外部的参数只有转账发起者,转账接受者,流水号和转账金额,没有晦涩的字段,同时,在重试时也可以不要求客户端更新流水号,即便是客户端使用了错误的流水号,对于接口来说也只是一笔新的交易,并不会导致数据的错乱。
     还有一点需要注意的是,转账请求可能会存在乱序的问题,在接口内可以对同一用户的转账请求先放入有序队列后再依次处理,但不能根本解决问题,如果要尽最大可能防止这种情况,可能需要借助到接口层面外,比如调用方一起解决。

 

总结:
     总得来说,该方案的本质是将“大的 ”跨库的“虚拟”事务转化为小的本地事务,分步骤事务更新来求解这个问题。
tx
0.00 avg. rating (0% score) - 0 votes

One thought on “用户A给用户B转账的问题

发表评论

电子邮件地址不会被公开。 必填项已用*标注