1 LAF-DTX要解决的问题
分布式事务产生的根本原因在于一段业务逻辑中涉及到多个数据的一致性问题,这里的多个数据,可能是多个数据库表,这也是大家通常所理解的分布式事务的范畴。但是还有一种“多个数据”的情况也普遍存在,那就是数据库数据与非数据库数据混合存在的情况,在该情况中对数据库的操作被称为“主流程”,对非数据库的操作被称为“边缘流程”,比如发jmq消息、发短信、发邮件、写缓存、写ES等等。LAF-DTX所要解决的就是所谓的“主流程与边缘流程”的分布式事务问题。
例如在“用户报名课程”这个场景中,主流程:用户报名课程的数据库操作,边缘流程:发送邮件和短信通知。在电商场景中,也存在类似情况,比如完成库存扣减的数据库操作后,发消息给其他应用。这里的“主流程和边缘流程”的场景必须符合事务特性,在上述例子中,如果用户报名的数据库操作失败,但是已经给用户发“已报名”的短信和邮件,则会给用户带来误解,严重的话可能会发生:用户去上课了,发现自己还没有报名,使用户白跑一趟;如果用户报名的数据库操作成功,但是没有收到短信或邮件,则用户有可能错失去上课的机会,也可能给用户造成一定的经济损失(课程费用)。
2 LAF-DTX的设计目标
众所周知,对于涉及多个数据库表的分布式事务,业界已经有XA两阶段提交、TCC(Try-Complete-Cancel)、Saga等解决方案,XA由于提供强一致性而引入成本很高的全局锁机制,造成其并发性能差,不适用于互联网行业这种强调“高并发”的场景。因此,TCC和Saga采用了“柔性事务”的策略:单数据库操作靠数据库事务保证强一致,多数据库间是最终一致。但是TCC和Saga都需要业务重度参与,提供反向或补偿的业务代码,因此使用成本很高,但是它们提供的性能是满足业务需要的。
结合上述解决方案的优劣以及我们要解决的“主流程和边缘流程”这种分布式事务的自身特点,LAF-DTX提出了如下的设计目标:
柔性事务
保证数据最终一致性,确保性能足够好;
业务无侵入,简单好用
只需加一个注解,业务零成本使用,简单易懂;
轻量架构
依赖资源少,部署简单;支持单独使用;
3 LAF-DTX的实现架构
LAF-DTX的架构设计如图1所示,非常简单,除JED外无任何其他依赖:
LAF-DTX SDK
完成分布式事务的核心控制逻辑,包括注解及AOP处理、事务恢复、Task补偿调度等;
LAF-DTX Server端
完成事务的协调管理,包括事务状态管理等;
JED
采用JED作为存储,存放LAF-DTX的元数据;
4 LAF-DTX的技术实现
4.1 LAF-DTX的事务模型
边缘流程都是非数据库的操作(其本身不提供事务特性),对于该类操作的标准做法是:a)获取必需的信息;b)利用各种前置条件逻辑对这些信息进行校验;c)拼装好边缘流程操作所需的参数并发出请求。当在a或b中发生错误时,可以通过抛出RuntimeException或其子类的异常,引发本地数据库操作的事务回滚。而造成c失败的根本原因无非是网络异常、底层系统异常等等,而这些错误基本上都可以通过“多次重试”来解决。因此,LAF-DTX的事务模型只需要保证以下两点:1)当上述的a或b出现错误时,有能力将包含本地数据库操作在内的所有操作一起进行回滚;2)当上述的c出现错误时,可以通过“多次重试”的机制加以解决。
基于以上思路,在LAF-DTX的事务模型中,将各个边缘流程抽象为一个个Task对象,在Task对象中封装了运行该边缘流程所需的要素(类、方法、参数等),并持久化在Task表中,以便“多次重试”。LAF-DTX分布式事务模型包含两部分功能:A)保证本地业务数据库和Task数据库的数据一致性(注意这两个库位于物理上的不同地方,无法在一个JDBC事务中自动完成),在此我们借鉴了TCC事务模型的思路来保证这两个库的数据一致性,但是无需用户提供TCC事务模式所需的反向/补偿操作,而由LAF-DTX自身提供。另外通过在我们的注解中合并@Transactional注解(Spring的事务机制)完成了前面的1)点所要求的功能。B)通过Task任务补偿机制来异步地运行Task,从而真正地完成边缘流程的操作。
该方案已被整理成一项专利,并且已经通过了公司的审核。TCC相关的流程参加见图3、4和5:
4.2 边缘流程的处理
如前所述,边缘流程将被抽象为Task对象而被异步执行。在LAF-DTX中,用户可以通过一个配置文件告知哪些类的什么方法是边缘流程。在用户不改变代码的条件下,如何在实际运行过程中,将边缘流程的真正执行过程忽略掉,转而“偷换”成创建Task对象的逻辑呢?答案就是Java字节码增强技术。通过使用Javaassist技术,对用户指定的方法进行增强,需要达到如下效果:1)当该方法参与了分布式事务时,将真正执行过程忽略掉,换成创建Task对象的逻辑;2)当该方法没有参与分布式事务时,执行原来的真正逻辑。
在LAF-DTX中,该增强逻辑被封装成一个jar包,业务需要在启动时通过javaagent参数来加载该jar包,才能取得预期效果。
4.3 任务的补偿调度
Task表除了记录运行一个边缘流程所需的要素外,还要记录产生这个Task的应用信息以及一个哈希值(由应用信息和实例ip计算产生)。LAF-DTX SDK中有一个后台线程持续向LAF-DTX Server端请求需要在自己所在实例上运行的Task列表,然后依次执行。
在LAF-DTX的任务调度实现中没有所谓的“中心控制节点”,即由一个中心化的控制模块来决定任务该分配给谁,而完全是由LAF-DTX SDK根据特定算法“自主”得出要在自己所在的实例上运行的Task列表,然后构造出对应的查询条件从LAD-DTX Server端请求得到这些Task。秘诀就在于Task表中的哈希值,同属于一个应用的所有实例都对应一个哈希值,这些哈希值共同构成了一个“一致性Hash环”。缺省情况下,每个实例都应该去LAF-DTX Server端请求哈希值为自己的哈希值(根据应用信息和实例ip自己算出)且应用信息相等的Task列表;当某个实例出现问题时(比如宕机或者因故障被永久摘除了),在Hash环上跟它相邻的(按顺时针计算)且状态正常的实例将替它去运行对应的任务,这样就避免了任务被漏掉且保证任务以较均匀的方式被分配。
为了预防极端情况下,同一个Task被多个实例获取而造成“多次运行同一Task”的问题,在Task表中还增加了版本字段,利用“乐观锁”的机制控制同一个Task只能被一个实例所运行。
判断实例的状态是根据心跳机制来进行的,因此LAF-DTX SDK中还会定期向LAF-DTX Server发送心跳信息。
该任务调度方案也已被整理成一项专利,并且已经通过了公司的审核。
5 例子及注意事项
5.1 例子
图6:使用例子
在这个例子中,@SimplePSTransaction是LAF-DTX提供的注解(PS就是primary/secondary的简写),所有加上这个注解的方法都会被按照LAF-DTX分布式事务模型进行处理。在这个例子里,sp1Service.sendCreateOrderMsg和sp2Service.cache4CreateOrder是边缘流程(需要配置在4.2中所提的配置文件中)。其实,根据前面的内容可以推论出:除了被指定为边缘流程的方法外,剩下的都会被看做主流程。
5.2 注意事项
LAF-DTX之所以能够成为轻量化的中间件,除了在设计上强调简单实用外,还有一个原因就是它充分利用了现有的一些框架和机制,从而使得自己显得很轻量。因此,在使用LAF-DTX前,需要用户确认LAF-DTX依赖的框架和机制是否已经具备(所幸这些框架和机制都是很常见的,不属于很强的条件)。这些框架和机制如下:
Spring容器环境
LAF-DTX抽象了任意边缘流程执行所涉及的内容,并进行持久化,以方便后续补偿任务调度。边缘流程执行所需的上下文环境需要由Spring容器来提供和保证。
数据库事务相关的bean
@SimplePSTransaction注解合并了Spring事务注解@Transactional,因此暗含支持Spring数据库事务的所有特性,这也是实现4.1节中的1)的关键所在。业务需要配置好相关的bean,例如对应的TransactionManager等。
边缘流程需要“幂等性”
由于可能需要多次补偿边缘流程,因此其需要满足“幂等性”。
主流程和边缘流程必须运行在一个线程中
如果边缘流程通过其他线程执行(比如线程池等),那么LAF-DTX的事务逻辑将会受到干扰,最终影响事务的一致性。其实LAF-DTX已经是异步执行边缘流程了,所以就不需要业务这样做了。
选择最单纯、无依赖的方法(比如jmq的sendMq方法)进行增强
如果选择的方法粒度过大(即包含了太多的逻辑,比如4.1节所提到的a和b逻辑),由于该方法会被抽象为Task对象而被延后异步运行,那么a或者b中的错误本来应该引发整个事务回滚,但实际上并不会发生。
要对抽象类的方法进行增强
测试中发现对抽象类的方法进行增强会引发错误,一定要对实体类的方法进行增强。
AspectJ框架
@SimplePSTransaction注解的AOP逻辑采用了AspectJ来完成,因此业务代码需要遵循AspectJ的规范,尤其需要指出的是AspectJ不支持“自调用”的情况。
长事务问题
LAF-DTX的事务范围就是加上@SimplePSTransaction注解的方法所覆盖的范围,在这个范围里包含了主流程和若干个边缘流程。需要强调的是要控制这个范围的大小,否则事务包含的内容太多,不管是提交还是回滚都会比较耗时,影响了业务的响应。在一些批量操作中,尤其要注意这个问题。