分布式事务处理方式总结

在项目开发中,常常会须要处理分布式事务。例如数据库分库分表以后,原来在一个单库上的操做可能会跨越多个数据库。系统服务化拆分以后,原来的在一个系统上的操做可能会跨越多个系统。就连咱们平时常用到的缓存(如redis、memcache等)也可能涉及分布式事务,由于缓存和数据库是两个不一样的实体,如何保证数据在缓存和数据库间的一致性也是要重点考虑的。分布式事务就是指事务要处理的资源分别位于分布式系统中的不一样节点之上的事务。
对于单机系统,一般咱们借助数据库实现本地事务,例以下面JDBC代码实现了一个事务:redis

Connection con = datasource.getConnection();
con.setAutoCommit(false);
...
执行CRUD操做,可能会涉及到多个表
...
con.commit()/con.rollback()

因为在分布式系统中,多个系统没法共用同一个数据库连接,因此没法简单借用上面的处理方式实现分布式事务。
下面将介绍几种本人在实际开发中使用过的处理分布式事务的方式,最后再引出分布式事务的相关理论并进行总结。算法

避免出现分布式事务

因为分布式事务比较难于处理,因此应该尽可能避免分布式事务的发生。例如对于一个客户信息系统,因为注册用户数太多致使存储的数据量过大,因此对其进行分库分表存储。而客户信息模型又分为多个子模型,对应数据库中的多个表,例如客户基本信息表、客户登陆帐号表、客户登陆密码表、客户联系方式表等等。假设登陆帐号表和客户基本信息表的关联关系以下所示:数据库

clipboard.png

user_id和login_id分别是两个表的主键,user_id还做为login_info表的外键使两个表关联。在用户注册时会自动生成user_id和login_id的值。user_info和login_info两个表分别采用user_id和login_id计算分库分表规则。假设咱们对每一个模型分十库一百表存储,即存在user_info_00 ~ user_info_99一百个表,其中user_info_00 ~ user_info_09属于第一个库,user_info_10 ~ user_info_19属于第二个库,依次类推。
在分库分表以后,若是咱们不仔细考虑user_id和login_id的生成规则(例如随意生成一个数字字符串或简单使用递增sequence),就可能致使同一个用户的user_info信息和login_info信息被分别存储到两个不一样的库,这就会致使分布式事务发生。
面对这种问题,最好的解决思路就是考虑如何避免分布式事务的发生。只要想办法让跟一个用户相关的全部模型数据所有存入到一个库中,就能够避免分布式事务了。因为每一个模型数据的分库分表路由规则又是由各个表的主键id决定的(例如user_id、login_id),因此只要对各个表的主键生成规则进行定制,就能够保证一个用户的全部模型数据所有存到同一个库。假设有下面的id生成规则:api

clipboard.png

  • 开始的两位是标识模型位,例如user_id以01开头,login_id以02开头。
  • 接下来的11位是sequence递增序列号,若是想要更多的ID能够扩大这部分的位数,但对于存储用户信息而言,11位的长度足够。
  • 接下来是分库分表位,若是每一个模型的分库分表算法都相同,那么只要保证每一个模型的主键ID的分库分表位都相同,就能保证一个用户的全部模型数据都会存到同一个库中。
  • 最后一位是id校验位,这一位根据前面15位的内容生成,方便对一个id进行校验。

根据这个思想,咱们能够在用户注册的时候先生成user_id,user_id的分库分表位能够随机生成。而后在为其它模型生成主键id时(例如login_id),必须让这个模型的主键id的分库分表位与user_id的分库分表位相同。另一点也要注意,一个表的查询条件不必定只有主键id一个,若是有其它查询条件列,那就要保证那一列的生成规则也要包含相同的分库分表位,不然就不能使用该列进行查询。
经过这种方式,就能够保证一个用户的全部模型数据所有存储到同一个库中,有效的避免分布式事务的发生。缓存

事务补偿

一般状况下,应对高并发的一个主要手段就是增长分布式缓存(如redis)以提升查询性能。增长分布式缓存后系统查询数据的流程以下图:网络

clipboard.png

即先尝试从缓存中查询数据,若是缓存命中就直接返回结果,不然尝试从DB中查询数据。若是查询DB命中则将数据补充到缓存,以便下次查询时能够命中缓存。
而在更新数据时,一般是先更新DB中的数据,DB写入成功后再更新缓存中的数据。那么就有一个问题,如何保证缓存和DB间数据的一致性?因为缓存和DB是两个不一样的实体,写入DB成功后再去更新缓存,若是缓存更新失败(例如网络抖动形成短暂的缓存不可用)就会形成缓存和DB的不一致。此时按照上图的查询逻辑,先查缓存就会查询到“脏”的数据,就会严重影响业务。这也是一个典型的分布式事务问题——缓存和DB要嘛同时更新成功,要嘛同时更新失败。解决这个问题的一个较好方式就是事务补偿。
咱们能够在DB中建立一张事务补偿表transaction_log,transaction_log表能够和业务数据在一个库中,也能够在不一样的库。在更新数据前,先将要更新的模型数据记录到transaction_log中。例如咱们更新user_info表中的数据,就将userId记录到transaction_log中。
transaction_log记录成功后,再去更新业务数据表user_info中的内容,最后更新缓存中的userInfo数据。缓存更新成功后,就能够删除transaction_log表中对应的记录。
假设在更新完user_info表以后,因为网络抖动等缘由致使缓存更新失败,则transaction_log表中对应的记录就会一直存在,表示这个事务没有完成的一种记录。
应用会建立一个定时任务,周期性的扫描transaction_log表中的记录(例如每隔2S扫描一次)。发现有符合条件的记录,就尝试执行补偿逻辑。例如更新用户信息时,DB中的user_info表更新成功,但缓存更新失败,定时任务发现transaction_log表中对应的记录没有删除且已经超过正常等待时间,就尝试使缓存和DB一致(能够删除缓存中对应的数据,也能够根据userId从新查询DB再补充的缓存)。补偿任务执行完成后,就能够删除transaction_log表中对应的记录。若是补偿任务执行再次失败,就保留transaction_log表中的记录,等待下个周期再次执行。
事务补偿这种方式保证的是事务的最终一致性,即若是发生意外,会存在一个时间窗口(例如2S),在这个窗口内DB和缓存间是不一致的,但能保证最终二者的数据是一致的。至于定时任务周期的设定,要结合业务对“脏”数据的敏感程度以及系统的负载能力。并发

事务型消息

对于一个金融系统,假设有一个需求是用户注册成功后自动为用户建立一个帐户。客户的信息维护在客户中心系统,客户的帐户信息维护的帐务中心系统,若是用户注册成功,必须保证客户的帐户在帐务系统建立成功。这显然也是一个分布式事务问题。
处理这个问题,显然也能够采用上一小节介绍的事务补偿机制来处理。但注册和开户并不要求必定是同步完成,且须要感知用户注册成功事件的系统并不仅有帐务系统一个(例如营销系统可能也须要感知用户注册成功的事件,给用户发优惠券),因此使用消息机制异步通知更加合适。那么问题就变成了“若是用户注册成功,必定要保证消息发送成功”。
应对这种场景,可使用事务型消息。但前提条件是使用的MQ中间件必须支持事务型消息,好比阿里的RocketMQ。目前市面上其它一些主流的MQ中间件都不支持事务型消息,好比Kafka和RabbitMQ都不支持。
下面的序列图是事务型消息的执行流程:异步

clipboard.png

  • 相比于普通消息,发布者发送消息后,MQ并非立刻将消息发送给订阅者,而仅仅是将消息持久化存储下来。
  • 发送消息成功以后,发布者执行本地事务。例如咱们例子中提到的用户注册。
  • 根据本地事务执行是否成功,发布者决定对以前已经发送的消息是commit仍是rollback。若是是rollback,MQ会删除以前存储的消息。假设咱们这里发送commit。
  • MQ接收到发布者发送的commit后,才会将消息发送给订阅者。以后,就能够利用MQ的消息可靠传输特性促使订阅者完成剩余事务操做,例如上面例子中提到的开户操做。

细心的小伙伴会发现,若是在上图中的第5步发生问题致使发送commit失败,不仍是会致使消息发布者和消息订阅者间事务的不一致吗?为了防止这种状况的发生,增长MQ超时回调机制。
下面的序列图是事务型消息commit失败时的执行流程:分布式

clipboard.png

当MQ长时间收不到发布者的commit/rollback通知时,MQ会回调发布者应用询问本地事务是否执行成功,是commit仍是rollback以前的消息。发布者须要提供对应的callback,在callback中判断本地事务是否执行成功。高并发

TCC两阶段提交

在某些场景下,一个分布式事务可能会涉及到多个参与者,且每一个参与者须要根据本身当时的状态对事务进行响应。
假设这样一个场景,一个电商网站能够容许用户在支付时选择多种支付方式。例如总共须要支付100元钱,用户能够选择积分支付10元,帐户余额支付90元。用户的积分由营销系统负责,帐户余额由帐务系统负责,订单的状态管理由订单系统负责。

  • 首先,要先确保事务的各个参与者知足条件才能执行事务。例如积分系统要确保用户的积分超过10元钱,帐务系统要确保用户的帐户余额大于90元钱才能发起此次交易。
  • 其次,就是要知足事务的原子性。这里的用户积分、用户余额、订单状态,要嘛所有处理成功,要嘛所有保持不变。

应对这种分布式事务场景,能够采用TCC两阶段提交的方式进行处理。关于TCC的详细描述,你们也能够参考下这篇博文,我觉的讲的很好。
TCC将整个事务分红两个阶段——try和commit/cancel。TCC整个流程具备三种角色——事务发起者、事务参与者、事务协调者。以上面的订单支付为例,采用TCC实现处理事务的流程以下:

clipboard.png

  • 第一阶段try,订单系统分别调用promotion和account两个系统,询问该用户是否有足够的积分和帐户余额。为了防止资源争抢,在这个阶段会对资源进行锁定,即营销系统会锁住用户的10元积分,帐务系统会锁住用户的90元帐户余额。
  • 若是在try阶段有任何一个参与者处理失败(例如用户积分不够10元或者用户的余额不够90元),则事务发起方(订单系统)会通知事务协调组件,后者会通知全部的事务参与者cancel在try阶段锁定的资源。
  • 若是在try阶段全部的参与者都处理成功,则事务发起方通知协调者commit这个事务,协调者会通知全部的参与者完成事务的commit。这时系统会完成真正的余额和积分扣减。2.2步是假设订单系统也要更新订单的状态。

但仅是这样处理仍是有一致性问题,例如在第二阶段commit时若是发生宕机、网络抖动等异常状况,就可能致使事务处于“非最终一致”状态(参与者只执行了try阶段,没有执行第二阶段。或部分参与者第二阶段commit成功,部分参与者commit失败)。为了应对这种状况,须要增长事务日志,以便发生异常时恢复事务。
能够利用DB这种可靠存储来记录事务日志。日志中应包含事务执行过程当中的上下文、事务执行状态、事务的参与者等信息。事务日志能够由事务发起发负责记录,也能够交由事务协调方进行记录。
事务日志能够由主事务记录日志和从事务记录日志组成:

  • 主事务记录日志用于记录事务发起方信息以及事务执行的总体状态。
  • 从事务记录日志用于记录全部的事务参与者信息,以及每一个参与者所属的从事务的执行状态。与主事务记录日志是一对多的关系。

有了事务日志后,就能够周期性的不断扫描事务日志,找到异常中断的事务。根据事务日志中记录的信息,推进剩余的参与者commit或者cancel,以便使整个分布式事务达到“最终一致性”。
下面是commit阶段发生异常时的事务补偿逻辑:

clipboard.png

TCC两阶段提交的实现须要注意以下事项:

  1. 事务中的任何一个参与者都要确保在try阶段操做成功,在第二阶段就必定能commit成功。
  2. 参与者在实现commit和cancel接口时要考虑幂等,对重复的commit/cancel请求要可以正确处理。
  3. 业务上要考虑对两阶段中间状态(一阶段已完成,二阶段未开始)的处理。通常能够经过一些特殊文案,好比显示当前被冻结的帐户余额。
  4. 对于状态型数据,当多个事务共同操做同一个资源时,要确保资源隔离。例如帐户余额,确保不一样的事务操做的金额是隔离的,彼此互不影响。
  5. 因为网络丢包、乱序等因素的影响,可能会致使参与者接收到一阶段try请求后,永远收不到commit/cancel请求,致使参与者的资源一直被锁定,永远不会被释放,这种状况叫作事务悬挂。为了防止事务悬挂的发生,能够在第一阶段try成功后,指定一个最大等待时间。超过这个最大等待时间就自动释放被锁定的资源。

总结

传统的单机事务应知足A(原子性)、C(一致性)、I(隔离型)、D(持久性)四个特性,属于刚性事务。因为分布式系统具备多个节点的特色,要求彻底知足ACID这四个规范会很是的困难。因此就诞生了柔性事务BASE理论(Basic availability、Soft state、Eventual consistency)。相比于单机事务,分布式事务在A和D上仍可以严格保证,但在C和I上就要有必定程度的限制放宽(容许看到中间状态数据、最终一致性)。

相关文章
相关标签/搜索