在项目开发中,经常需要处理分布式事务。比如数据库分库分表后,原来对单个数据库的操作可能会跨越多个数据库。系统服务拆分后,原来在一个系统上的操作可能会跨越多个系统。甚至我们经常使用的缓存(如redis、memcache等)也可能会涉及到分布式事务,因为缓存和数据库是两个不同的实体,如何保证缓存和数据库之间数据的一致性也是一个重点考虑的问题.分布式事务是指事务所要处理的资源位于分布式系统中的不同节点上的事务。对于单机系统,我们通常使用数据库来实现本地事务。例如,下面的JDBC代码实现了一个事务:Connectioncon=datasource.getConnection();con.setAutoCommit(false);...执行CRUD操作可能会涉及多个表...con.commit()/con.rollback()因为在分布式系统中,多个系统不能共享同一个数据库连接,所以不可能简单地借用上面的处理方式来实现分布式事务。下面将介绍我在实际开发中使用过的几种处理分布式事务的方法,最后介绍分布式事务的相关理论并进行总结。避免分布式事务由于分布式事务很难处理,所以应该尽量避免分布式事务。例如,对于客户信息系统,由于注册用户较多,存储的数据量过大,因此采用单独的数据库和表进行存储。客户信息模型分为多个子模型,对应数据库中的多个表,如客户基本信息表、客户登录账号表、客户登录密码表、客户联系信息表等。假设登录账号表和客户基本信息表的关系如下: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的生成规则(比如随机生成一个数字串或者单纯使用自增序列),可能会导致user_info信息和login_info信息相同user被存储在两个不同的库中,这导致了分布式事务。面对这种问题,最好的解决办法就是考虑如何避免分布式事务的发生。只要想办法把一个用户相关的所有模型数据都存储在一个库中,就可以避免分布式事务。由于每个模型数据的分库分表路由规则是由每个表的主键id(如user_id、login_id)决定的,所以只要自定义每个表的主键生成规则,所有模型数据可以保证一个用户的所有存储在同一个库中。假设有如下id生成规则:前两位为标识型号数字,例如user_id以01开头,login_id以02开头,后面的11位为序列递增序号。如果想要更多的ID,可以扩展这部分的位数,但是对于存储用户信息,11位的长度就足够了。接下来是分库分表位置。如果每个模型的分库分表算法相同,只要每个模型的主键ID的分库分表位置相同,就可以保证所有模型数据用户将存储在同一个库中。最后一位为身份校验位,根据前15位内容生成,方便验证身份。按照这个思路,我们可以在用户注册的时候生成user_id,user_id的分库分表可以随机生成。那么在为其他模型(如login_id)生成主键id时,该模型的主键id的分库分表必须与user_id的分库分表相同。还有一点需要注意的是,一张表的查询条件不一定只有主键id。如果还有其他查询条件列,需要保证该列的生成规则也包括相同的分库分表位置,否则不能使用。要查询的列。这样可以保证一个用户的所有模型数据都存储在同一个库中,有效避免了分布式事务的发生。事务补偿通常,应对高并发的主要手段之一是增加分布式缓存(如redis)来提高查询性能。添加分布式缓存后,系统查询数据的流程如下:即先尝试从缓存中查询数据,如果缓存命中则直接返回结果,否则尝试从DB中查询数据。如果查询DB命中,数据会被添加到缓存中,以便下一次查询命中缓存。更新数据时,通常先更新DB中的数据,DB写入成功后,再更新缓存中的数据。那么就有一个问题,如何保证缓存和DB之间数据的一致性呢?由于缓存和DB是两个不同的实体,缓存是在数据库写入成功后更新的。如果缓存更新失败(比如网络抖动临时缓存不可用),缓存和DB就会不一致。这时候按照上图的查询逻辑,如果先检查缓存,就会发现“脏”数据,会严重影响业务。这也是一个典型的分布式事务问题——要么缓存和DB同时更新成功,要么同时更新失败。解决这个问题更好的方法是交易补偿。我们可以在数据库中创建一个事务补偿表transaction_log。transaction_log表可以和业务数据同库,也可以不同库。在更新数据之前,先将要更新的模型数据记录在transaction_log中。比如我们更新user_info表中的数据,我们就会在transaction_log中记录userId。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。下面的时序图是事务性消息的执行流程:与普通消息相比,发布者发送消息后,MQ不会立即将消息发送给订阅者,而只是将消息持久化。消息发送成功后,发布者执行本地事务。比如我们例子中提到的用户注册。根据本地事务执行是否成功,发布者决定是提交还是回滚之前发送的消息。如果是回滚,MQ会删除之前存储的消息。假设我们在这里发送一个提交。MQ收到发布者发送的commit后,会将消息发送给订阅者。之后可以利用MQ的消息可靠传输特性,提示订阅者完成剩余的交易操作,比如上面例子中提到的开户操作。细心的朋友会发现,如果上图中的第5步出现问题,发送提交失败,那不还是会造成消息发布者和消息订阅者之间的事务不一致吗?为了防止这种情况发生,添加了MQ超时回调机制。下面的时序图是事务消息提交失败时的执行流程:当MQ长时间没有收到发布者的提交/回滚通知时,MQ会回调发布者的应用询问本地事务是否执行成功,是否是在提交或回滚信息之前。发布者需要提供相应的回调,在回调中判断本地事务是否执行成功。TCC两阶段提交在某些场景下,一个分布式事务可能涉及多个参与者,每个参与者都需要根据自己当前的状态对事务做出响应。假设这样一个场景,一个电子商务网站可以让用户在支付的时候选择多种支付方式。比如一共需要支付100元,用户可以选择用积分支付10元,用账户余额支付90元。营销系统负责用户积分,账户余额由记账系统负责,订单状态管理由订单系统负责。首先,在执行交易之前,需要确保交易的每个参与者都满足条件。比如积分系统要保证用户的积分超过10元,记账系统要保证用户的账户余额大于90元才能发起这笔交易。其次,要满足事务的原子性。这里的用户积分、用户余额、订单状态要么全部处理成功,要么全部保持不变。针对这种分布式事务场景,可以使用TCC两阶段提交进行处理。TCC将整个事务分为两个阶段——尝试和提交/取消。TCC的整个流程有3个角色——交易发起者、交易参与者、交易协调者。以上述订单支付为例,使用TCC实现交易流程如下:第一阶段try,订单系统分别调用促销和账户系统,询问用户是否有足够的积分和账户余额。为了防止资源竞争,这个阶段会锁定资源,即营销系统会锁定用户的10元积分,会计系统会锁定用户的90元账户余额。如果任何参与者在尝试阶段处理失败(例如用户积分不够10元或用户余额不够90元),交易发起方(订单系统)将通知交易协调组件,会通知所有事务参与者取消在try阶段锁定的资源。如果所有参与者在try阶段都处理成功,则事务发起者通知协调者提交事务,协调者通知所有参与者完成事务的提交。此时,系统将完成真实余额和扣点。步骤2.2假设订单系统也更新了订单的状态。但是,仅仅这样还是存在一致性问题。例如,如果在第二阶段提交时出现宕机、网络抖动等异常情况,则事务可能处于“非最终一致”状态(参与者只执行了try阶段,没有执行第二阶段。或者部分参与者第二阶段commit成功,部分参与者commit失败)。为了应对这种情况,需要增加事务日志,以便在出现异常时可以恢复事务。您可以使用DB作为可靠的存储来记录事务日志。日志中应该包含事务执行时的上下文、事务执行状态、事务参与者等信息。事务日志可以由事务发起者记录,也可以由事务协调者记录。事务日志可以由主事务日志和从事务日志组成:主事务日志用于记录事务发起者信息和事务执行的整体状态。从事务日志用于记录所有事务参与者信息,以及每个参与者所属的从事务的执行状态。与主事务日志是一对多的关系。有了事务日志,可以定期扫描事务日志,发现异常中断的事务。根据事务日志中记录的信息,推动剩余参与者提交或取消,从而使整个分布式事务达到“最终一致性”。以下是提交阶段异常时的事务补偿逻辑:TCC两阶段提交的实现需要注意以下事项:事务中的任何参与者都必须保证在try阶段操作成功,并且commit将在第二阶段成功。参与者在实现提交和取消接口时必须考虑幂等性,并且必须能够正确处理重复的提交/取消请求。在业务上,需要考虑两阶段中间状态的处理(第一阶段已经完成,第二阶段还没有开始)。一般可以使用一些特殊的文案,比如显示当前冻结账户余额。对于有状态数据,当多个事务操作同一个资源时,必须保证资源隔离。比如账户余额,保证不同交易操作的金额是隔离的,互不影响。由于网络丢包、乱序等因素,参与者在收到单阶段尝试请求后,可能永远不会收到提交/取消请求,导致参与者的资源被锁定,永远不会释放。这种情况称为交易暂停。为了防止事务挂起的发生,可以指定第一阶段try成功后的最大等待时间。如果超过最大等待时间,则自动释放锁定的资源。综上所述,传统的单机事务要满足A(原子性)、C(一致性)、I(隔离性)、D(持久性)四个特性,属于刚性事务。由于分布式系统多节点的特点,完全满足四个ACID规范会非常困难。于是灵活事务BASE理论(Basicavailability,Softstate,Eventualconsistency)诞生了。与单机事务相比,分布式事务在A和D上仍然可以得到严格保证,但对C和I的限制要有一定程度的放宽(允许看到中间状态数据,最终一致性)。
