最近面试问到了银行转账的高并发问题,回答的不是很理想,小编整理了下,题目大概如下:
有一张银行账号表(银行账号字段、金额字段),A账号要给B账号转账,A扣款,B收款,在多线程高并发情况下,A账户的金额不能小于0,问如何设计架构比较合理?
我一开始脑抽地回答了两个方案:
方案一:事务+同步锁/分布式锁(更新sql控制扣款update的账户金额要大于扣款金额)
方案二:将数据库缓存于redis,通过lua语句去执行查询判断扣款和收款,然后保证异步通知数据库更新
先来看第一个方案哈,同步锁在单节点的情况下确实可以解决问题,但是首先颗粒度大(不管哪个转账都得排队),且复杂度高的情况下就效率慢,其次若是多节点集群的情况下,同步锁就不适用,那我们看分布式锁(redis),分布式锁确实可以降低颗粒度,可以控制到A账户作为key锁,但是面试官提到了一个概念,redis脑裂(可能产生数据丢失),因此可能出现假锁的情况,因为面试的这家公司是做数字银行的,对于风险把控很严格,因此对于这类情况风险他们对这个方案也pass掉,不过我后面补充的这个扣款时sql需要增加当前账户金额需要大于扣款金额才能扣款,这个其实是可行的,这个后续代码会演示。
再来看第二个方案哈,这个缓存于redis的方案,其实我当时为什么会这么直接想到这个方案呢,首先是因为redis的单机命令操作,以及lua能保证多语句的执行,若账户金额不够扣款则不会进行转账,但是其实金额数据一般是不会缓存在redis中的,有一定的风险性且增加了系统复杂度,若数据库异常或其他情况导致的缓存数据不一致,金额这方面无法保证。
面试官还提到关于数据库隔离级别能否解决问题,其实我验证后关于可串行化其实也是只是对当前sql语句执行进行加锁,开启事务时可串行化也并非是对事务进行加锁,依然可能出现金额问题。(查询金额存在多个一样的情况)(但是其实可串行化在我之前了解的资料里理论上应该是可行的,有强制事务串行化,即按顺序提交)
代码验证
锁机制在一定程度上可以,sql条件扣款时控制也是可以
@Service
@Slf4j
public class OperateAccountImpl implements OperateAccount {
@Autowired
private AccountMapper accountMapper;
@Autowired
private TransactionTemplate transactionTemplate;
/**
* 处理账户转账方案总结:
* 方案一:事务+同步锁(颗粒度大,逻辑复杂效率慢,只适用单机)/分布式锁(可能出现脑裂假锁的情况)
* 方案二:事务+sql条件控制(账户金额需大于等于扣款金额,但是查询时可能出现一次可扣款数据)
*/
@Override
// @Transactional(rollbackFor= Exception.class)
public String transfer(String accountFrom, String accountTo, double amount) {
// 设置隔离级别为串行化(这里测试出来会死锁,按理解串行化应该是事务串行化,不应该抢锁才对)
//transactionTemplate.setIsolationLevel(TransactionDefinition.ISOLATION_SERIALIZABLE);
//线程标记
String threadName = "【"+Thread.currentThread().getName()+"】";
String msg = "";
List<String> msgList = new ArrayList<>();
//同步锁也是实现方法之一
// synchronized(this){
//
// }
try{
//开启Spring事务
transactionTemplate.execute(new TransactionCallbackWithoutResult() {
protected void doInTransactionWithoutResult(TransactionStatus status) {
try {
QueryWrapper<AccountDto> queryWrapper =new QueryWrapper<>();
queryWrapper.select("account","money").eq("account",accountFrom);
// 查询金额是否够扣
AccountDto accountDto = accountMapper.selectOne(queryWrapper);
// AccountDto accountDto = accountMapper.selectByForUpdate(accountFrom);
if(null == accountDto){
throw new Exception("账户"+accountFrom+"不存在");
}
log.info("{} 查询到扣款账户{} 余额:{}",threadName,accountFrom,accountDto.getMoney());
if (accountDto.getMoney() < amount) {
throw new Exception("余额不足,账户"+accountFrom+"只剩:"+accountDto.getMoney());
}
// 扣款
int count1 = accountMapper.update(null,transferUpdate(-1 * amount,accountFrom));
if (count1 == 0) {
throw new Exception("账户"+accountFrom+"扣款失败,检查余额");
}
// 收款
int count2 = accountMapper.update(null,transferUpdate(amount,accountTo));
if (count2 == 0) {
throw new Exception("账户"+accountTo+"收款失败");
}
log.info(threadName+"转账成功");
msgList.add(threadName+"转账成功");
}catch (Exception e){
log.info(threadName+e.getMessage());
msgList.add(threadName+e.getMessage());
// 回滚
status.setRollbackOnly();
}
}
});
}catch (Exception e){
// msgList.add(e.getMessage());
}
// log.info(threadName+"结束:"+msgList.toString());
return msgList.get(0);
}
// 更新sql操作
public UpdateWrapper<AccountDto> transferUpdate(double updateMoney,String account){
UpdateWrapper<AccountDto> updateWrapper = Wrappers.update();
// 修改表中money字段为指定的数据
// updateWrapper.set("money", updateMoney);
updateWrapper.setSql("money = money + "+ updateMoney);
if (updateMoney<0){
// 修改条件为account=?且大于等于扣款金额的数据
updateWrapper.eq("account", account).and(wq ->wq.ge("money",-1 * updateMoney));
}else{
// 修改条件为account=?的数据
updateWrapper.eq("account", account);
}
// //若使用事务隔离级别为最高级,测试出来的结果加锁的是sql并不是事务,因此查询值依然没有顺序之分,分开sql依旧会出现问题(实际上不会扣款扣多,但是会死锁,因为会抢占sql的锁)
// updateWrapper.eq("account", account);
return updateWrapper;
}
}
通过多线程并发执行测试
测试结论:
方案一:事务+同步锁(颗粒度大,逻辑复杂效率慢,只适用单机)/分布式锁(可能出现脑裂假锁的情况)
方案二:事务+sql条件控制(账户金额需大于等于扣款金额,但是查询时可能出现一次可扣款数据)
@Slf4j
@RestController
@RequestMapping("/test")
public class TestController {
@Autowired
private OperateAccount operateAccount;
//设置固定线程池
ExecutorService executorService = Executors.newFixedThreadPool(10);
@RequestMapping("/transfer")
public String transfer(HttpServletRequest request){
//创建同步计数器(10个一起跑)
CountDownLatch countDownLatch = new CountDownLatch(10);
//用于堵塞线程等待全部结果
CountDownLatch countDownLatch1 = new CountDownLatch(10);
//扣款账户
String accountFrom = request.getParameter("accountFrom");
//收款账户
String accountTo = request.getParameter("accountTo");
//转账金额
double amount = Double.parseDouble(request.getParameter("amount"));
List<String> msgList = new ArrayList<>();
try {
// 模拟转账操作
for (int i = 0; i < 10; i++) {
executorService.submit(() -> {
try {
countDownLatch.await();//统一等待
} catch (InterruptedException e) {
e.printStackTrace();
}
String msg = operateAccount.transfer(accountFrom, accountTo, amount);
msgList.add(msg);
countDownLatch1.countDown();
});
//处理完同步计数器线程数减一,待计数器为0统一执行所有转账操作
countDownLatch.countDown();
}
countDownLatch1.await();
executorService.shutdown();
}catch (Exception e){
e.printStackTrace();
}
// log.info("转账结果:{}",msgList.toString());
return msgList.toString();
}
}
小编比较菜…欢迎评论区讨论更好的方法❀❀❀