业务场景介绍

为了提高投资的便利性,我们系统将后端的多个借款标的封装统一的理财产品,用户直接投资封装好的理财产品,然后经过系统后端匹配到借款标的。所以一般理财产品的投资额度就是后端对应的标的的总的借款额,为了能够做到用户投资总额与后端借款标的借款额的100%的匹配,不允许系统出现超卖情况,即投资额大于了后端借款标的总借款额。

PS:

1)为了说明技术问题,暂时简化了业务模型,业务看起来不怎么合规,公司也在改进。

2)库存扣减,超卖等概念借鉴于电商系统,其实金融系统对超卖的容忍度更低。

扣减超卖问题的产生

为了控制超卖,理财产品上有个余额的字段,记录当前还剩多少可投资额度。每次用户投资时候,都会先校验投资amount是否小于等于理财产品余额available amount,如果校验通过,再继续其他逻辑,最后更新available amount = available amount - amount; 整个逻辑的伪代码如下:

invest() {
  select available amount from db
  if (available amount < amount) {
    return invest failed.
  }

  // …. main logic ….

  update available amount = available amount - amount;
}

这种CAS操作会有误判情况,比如两个并行执行投资的线程都从db中select到available amount 1000,validation发现都能满足投资条件,一个投资800,一个投资700,然后都执行成功,并且update了available amount,导致超卖800+700-1000=500.

要解决两个问题:

1)扣减超卖—本文重点

2)高并发下投资 — 顺带说明一下如何来做

线程加锁解决方案

采用synchronize或者concurrent util lock对上面这段代码加线程锁,多线程下串行执行,看着挺好,其实有几个问题:

1)只对单进程下有效,多进程多jvm下无效!

2)需要注意事务和加锁的顺序,数据库事务隔离级别,不然高并发下会有事务问题,这个问题不展开讨论了,单独另一篇文章里讲。

3)一把大锁加住所有逻辑,性能可想而知的低。

所以这种方案在极少数特殊场景下才可用,不展开讨论了!

数据库锁select for update

利用数据库锁做分布式锁,select for update会锁住某行数据,直到事务提交。这种方法最简单,在大部分情况下都能满足要求。但是也存在一些问题,我们逐一解释:

1)事务的隔离级别需要是Read Committed

select for update会一直阻塞到事务提交,一旦事务提交锁就释放掉了。比如:使用spring的transaction机制,在service层添加事务。

@Tranactional
invest() {
  select available amount from db
  if (available amount < amount) {
    return invest failed.
  }

  // …. main logic ….

  update available amount = available amount - amount;
}

需要注意的是事务隔离机制必须是read committed,不然高并发情况下,一个已经启动的事务无法看到另个事务提交的数据。对应我们的代码就是无法看到update更新之后的available amount,也是会导致超卖问题!

2)超时问题处理

高并发情况下,会导致很多投资请求同时排队等待select for update锁,会导致一些接口请求一直阻塞,如果对于响应时间敏感的应用来说,比如面向用户的应用,显然是不能接受的,相比于一直阻塞,导致客户端超时,处于一个不确定状态更烦人,不如锁等待太久就直接fail fast。

两种sql防止一直等待锁的方式:

select for update wait 1; 等待1s,超时获取不到锁报错ORA-00054

select for update nowait; 获取不到锁,直接报错

除此之外,还可以使用spring transaction的超时机制。

3)避免表锁

Oracle和Mysql的锁机制是不一样的,我在另一篇文章来讲,哪些情况会产生行锁,哪些情况会产生表锁。对于产生表锁的sql语句,一定要谨慎使用。

4)避免死锁

如果多个业务都涉及到加锁逻辑,并且都涉及相同的多个select for update加锁操作,如果顺序控制不好,很容易出现死锁问题,因为oracle有自动检测死锁机制,会自动断开死锁,但仍然会导致很多锁等待,而且因为涉及到多个系统很难排查错误。

所以最好是通过模块化,soa,微服务思想,将db操作,加锁相关的操作收口到统一的系统中,这样可以做到加锁顺序同一个team来控制,容易做到一致。

Redis,ZK分布式锁方案

前面采用db来实现分布式锁,当然也可以采用其他系统,比如redis,zookeeper来实现。但是引入越多的系统,系统的复杂度越高,出错的情况就阅读,按照KISS架构原则,能用已有系统(比如db)解决就不要引入更多的系统。

而且用redis,zookeeper来实现分布式锁性能也不见得有多高,虽然笔者没有测试过。

CAS乐观锁解决方案

上面的解决思路其实差不多,一把大锁把并发逻辑变成串行执行。性能可想而知,肯定是最先出现瓶颈的最需要优化的地方。优化的基本思想是:尽量的减小锁粒度甚至不要加锁。

减小锁粒度要结合具体的业务逻辑,将非race condition的代码移到锁外,我们详细讲下如何使用CAS乐观锁来做到不加锁。

invest() {
  select available amount from db
  if (available amount < amount) {
    return invest failed.
  }

  // …. main logic ….

  update available amount = available amount - amount;
}

这个是之前的代码,我们不使用任何锁,在update的地方改为:

update available amount = available amount - amount where available amount >= amount;

在更新available amount之前,再校验一下是否available amount >= amount; 如果return row num=1则说明成功,如果等于0,则说明available amount在线程执行过程中被其他线程更改了。

看起来很完美,如果逻辑仅包含CAS(compare and swap) available amount,那这块逻辑完全没问题。但是我们看到代码中还有一大块main logic,如果这一块有写逻辑,并且基于的是available amount >= amount,那如何回滚main logic的操作呢?

我们继续改一下代码:

@Transactional
invest() {
  select available amount from db
  if (available amount < amount) {
    return invest failed.
  }

  // …. main logic ….
 
  update available amount = available amount - amount where available amount>=amount;
  if (update return row num == 0) {
    throw XRuntimeException;
  }
}

还是存在问题:

因为事务的ACID特性,如果一个线程执行完update之后,就阻塞住了,另一个线程启动的事务是看不到update之后的值的,导致不应该通过的校验通过了,并且update available amount成功,就会导致两条udpate都成功了。

这种情况虽然只有在极端情况下才能发生,但也是需要考虑的。为什么cas不好用了,就是因为我们加了transaction,但不加又不行。我们来看下面这种解决方案,也是我们项目中用的。

基于订单reconcile的解决方案

抛开业务谈架构都是耍流氓,我们的投资业务中,不仅仅扣减available amount(库存),而且更重要的操作是创建投资记录(订单)。

还是上面的代码,我们改为下面的逻辑:

invest() {
  update available amount = available amount - amount if available amount > amount;
  if (update row num == 0) {
    return invest failed.
  }

  // …. main logic ….
}

注意update操作在一个独立的事务中,剩下的逻辑可以放到一个事务中。这样先扣减库存,就不会出现超卖情况了。

但是新的问题又出现了,可能会存在扣减了available amount,但投资记录没有创建成功的情况,比如用户钱包钱不够了,虽然不会出现超卖情况了,但又会出现少卖情况,我们继续改进代码:

invest() {
  update available amount = available amount - amount if available amount > amount;
  if (update row num == 0) {
    return invest failed.
  }

  // …. main logic ….

  if (create order failed) {
    update available amount = available amount + amount;
  }
}

当创建订单失败了(在预期之内),则将available amount再加回去。但是如果创建订单失败之后在re-update available amount之前,服务挂掉了(比如OOM了,机器宕机了,掉电了等),还是会出现少卖的情况。

继续改进,来解决少卖情况:

投资订单中的总金额是最准确的,可以通过这个值来reconcile available amount,至于这个思想,我们后台启动一个job,定期reconcile sum(投资记录上的金额) 到available amount。存在一个致命问题,就是我们投资记录一直不停的产生,应该按哪一时刻的sum值来订正available amount呢?

其实,并非一直都要reconcile,只有在出现available amount小于等于0的时候,我们再触发reconcile即可。所以方案继续改进一下:

invest() {
  If (closed) return; // 如果投资慢了,则标记为closed,则直接返回

  update available amount = available amount - amount if available amount > amount;
  if (update row num == 0) {
    mark closed=true in db
    return invest failed.
  }

  // …. main logic ….

  if (create order failed) {
    update available amount = available amount + amount;
  }
}

后台job在发现closed=true之后,发起reconcile操作,这个过程所有的投资行为都暂停了,如果reconcile发现少买了,再重新更正available amount并且将closed标记为false。

现在这种方案,没有加任何锁,大部分情况下又不会出现超卖少买情况,只有极端服务中断情况下才会发生少买情况,也通过job做了reconcile,只是实时性可能没有那么高,对用户体验来说,会出现之前投不进去,之后又能投进去的情况,但是时间窗口会尽可能的短,影响很小,在我们的业务接受范围之内。

扣减库存update available amount和创建订单create invest transaction实际上是两个业务域的操作,按照拆分思想,一般公司都会将其解耦到两个服务,或者是两个接口,又或者是两个独立的事务中。上面的解决思路对于这种拆分的情况也是适用的。

基于队列的异步排队解决方案

基于业务上的妥协改造来应对高并发有很多策略,比如基于消息队列将投资请求排队异步处理,但这并非我们本文的讨论主题。



转自:https://zhuanlan.zhihu.com/p/34378854