基于TCC的补偿型实现高并发场景下的分布式事务(一)

1,418 阅读5分钟

最近随着业务的扩展,我开始感觉到传统ACID 模型的一些局限性。比如说,长事务导致的tps降低无法保证高并发,业务跨多数据库时数据一致性的难以保证。我开始思考一些其他的解决方案。然后我看到了一篇阿里早年发布的《大规模SOA系统中的分布事务处事》,突然有一种豁然开朗的感觉。拿来跟大家分享一下。

首先列举下本文中会出现的一些概念:

  1. 刚性事务,柔性事务
    • 刚性事务:要求强一致性,遵循ACID原则,一般情况下指代数据库本身事务实现,相对开销较大。
    • 柔性事务:强调最终一致性,遵循 本质上是通过降低对于一致性的要求来置换可用性。实现算法中具有代表性的比如说Paxos算法(看过我之前那篇“基于Zookeeper的分布式锁实现”的人应该不会陌生)。
  2. TCC(Try-Confirm-Cancel)他是实现分布式事务的其中一种思想,与之相对的比如说传统的XA协议。

TCC在如今已经有很多成熟的方案了,比较出名的:支付宝的XTS。

我们先来设想一个场景,来对比下数据库本地事务与TCC实现的不同:

  1. 酒店下单接口,需要先冻结酒店房间库存如果没有库存则下单失败,然后调用酒店的api通知酒店以及提交入住人信息,成功后扣除酒店房间冻结库存(酒店api比较辣鸡,单次响应大概需要1-2s,但是tps比较高)
  2. 假设每天晚上5点,有一批用户集中在这个时间点预定某一家酒店,并发为30/s
  3. 不能超卖!不能超卖!不能超卖!重要的事情说三遍

基于mysql事务的实现

  1. 首先需要冻结酒店房间的库存,这里会先开启一个事务-》start transaction
  2. 然后调用api通知酒店订单情况-》Thread.sleep(1s)
  3. 最后扣除酒店冻结的库存,同时提交事务-》commit

咋一看没啥问题嘛,程序跑的很完美perfect!注意场景第二点,每秒30的并发。 这里会有两种情况

  • 冻结酒店库存数据没有使用悲观锁(for update)。这种情况下当第二个请求进入时,第一次请求还没执行完成事务没有提交,所以检测库存还是没有扣除过的状态。库存的查询检测就会失效,导致超卖。
  • 冻结酒店库存数据使用悲观锁。当第一个请求进入冻结库存时,会将该酒店的这一条库存记录锁住。当第二个请求,由于记录被锁它会等待第一个事务提交后继续执行。然后第三第四个请求进入。你会发现你们家的系统会一下子,pong!

TCC的补偿型的设计:

TCC本身将单次事务的整体拆分为主业务服务以及若干个从业务服务的概念,而主业务服务负责所有从业务的调度工作。 而每个业务服务都需要实现两个接口:

do: 真正执行业务,完成业务处理。同时业务处理结果上报至业务活动管理器。

compensate:业务补偿,当业务发生错误时,上报至业务活动管理器。业务活动管理器就会执行所有已经完成do的业务服务

这样再实际实现酒店下单场景的时候,我们就可以做出如下设计

  1. 酒店下单作为一个主业务服务
  2. 同时将酒店库存冻结,酒店api通知,以及酒店库存冻结扣除作为三个从业务服务存在。每一个业务自身需要满足原子性。
/**
用于酒店库存的冻结
**/
public interface FreezeHotelAction {
    /**
    执行 update hotel_room_info set freeze_count = freeze_count + 1 ,able_count = able_count-1 where able_count >0 and hotel_id = #{***}
    当update返回影响条数为0时,表示业务扣除失败上报异常
    **/
    void do();
    /**
    酒店冻结补偿,需要实现业务自身幂等
    执行 update hotel_room_info set freeze_count = freeze_count - 1 ,able_count = able_count + 1 where hotel_id = #{***}
    **/
    void compensate();
}

/**
用户酒店通知
**/
public interface NoticeHotelAction {
    /**
    调用酒店api,上报用户信息
    当api返回预定失败时,上报业务异常
    **/
    void do();
    /**
    酒店通知无需补偿
    **/
    void compensate();
}

/**
用于酒店库存冻结
**/
public interface NoticeHotelAction {
    /**
     执行 update hotel_room_info set freeze_count = freeze_count - 1 where freeze_count >0 and hotel_id = #{***}
    当update返回影响条数为0时或执行报错时,表示业务扣除失败上报异常
    **/
    void do();
    /**
    酒店解冻补偿,需要实现业务自身幂等
     update hotel_room_info set freeze_count = freeze_count + 1 where hotel_id = #{***}
    **/
    void compensate();
}

于是当第一个请求进入冻结库存时,本身具有原子性的冻结操作就会直接扣除数据库的余额。当第N个请求进入,酒店余额不足时,业务就能直接检测出库存不足。 最终会发现业务的并发都会堆积在"酒店api通知"这一层,而这个问题是可以通过服务器节点的横向扩展解决的。 通过这种方式将本粒度过大的刚性事务拆成多个小事务,来提升整个酒店下单接口的QPS。

总结

TCC的本质是通过将原本依赖数据库的事务回滚交由程序去实现,进而降低整个事务对于数据的负担,从而提高并发量。 但随之而来的是开发成本的增加,更加考量开发者的全局设计能力。

本期的内容就讲到这里了,我是 IHAP亚楠小萌新,更多精彩内容,尽在 ihap 技术黑洞。