分布式服务成为主流的当下,由分布式孕育出的问题的解决方案也变得至关重要。
Seata框架正是用来解决一个关键的分布式问题:
分布式事务问题
既然要透析原理,那么分析清楚框架想要解决的问题能帮我们找到开发这个框架的根本落脚点。
所以我们不妨先详细分析分布式事务问题的来龙去脉。
本地事务
在spring boot单体应用中,所有模块都在同一个应用服务中,显然也使用同一个数据源,那么spring的本地事务支持就能帮我们较完美的解决事务问题。
分布式事务
微服务中,将各个功能模块拆分成多个单体服务,部署在不同的机器上,不仅提高整个微服务系统能承受的访问量瓶颈,也能将各个模块解耦,大大提高版本迭代的开发效率。
但这里相比于单体服务有一个非常关键的变化:
各个服务可能分布在不同的机器上,数据源一般也会分别建库,甚至分布在不同的数据源。
所以孕育出了新的问题:
我用一个简单的例子来演示这种分布式事务问题:
假设在电商场景中,一个订单涉及到多个服务的操作,如下:
订单服务(Order Service):处理订单相关的信息,如订单状态、支付状态等。
库存服务(Inventory Service):扣减商品库存,避免超卖。
支付服务(Payment Service):完成订单支付,确保订单款项到账。
当一个用户下单时,以上三个服务需要协同工作来完成整个订单流程。具体过程如下:
Order Service 创建一个订单,记录订单状态为“待支付”。
Inventory Service 查询当前商品库存,检查库存是否充足。
如果库存充足,则 Inventory Service 扣减相应的库存。
Payment Service 完成订单支付,将订单状态更新为“已支付”。
如果任意一个服务执行失败,则需要回滚所有服务的操作。
假设在执行过程中,Payment Service 发生了错误,导致订单状态无法更新为“已支付”。这时候Order Service的订单需要删除、Inventory Service的库存需要回滚,以保证整个事务的一致。但是,spring本地事务只能控制同一机器同一jvm的逻辑,所以,这就产生了分布式事务问题,而解决方案Seata框架也就诞生了。
本地事务的关键在于无法将异常通知到前置服务 Order Service和Inventory Service,所以我们很自然的想到,引入一个独立的通信媒介,这个媒介收集每一步的执行状态,在异常时通知所有服务。
这个媒介就是seata中的 TC (Transaction Coordinator) - 事务协调者
从另一个层面说是Seata Server,单独部署的Seata服务端。
既然有了这个媒介,我们就能提出一种简单的解决方案。
1.所有服务执行完成后,都将执行状态发送给TC,未收到TC的整体事务回复前不提交
2.TC收集到所有执行状态后,全部成功的话发送提交通知给所有服务。只要其中有一个异常,则所有事务全部回滚
这就是经典的两阶段协议2PC。
但是这里面有几个问题:
在所有参与者(各个服务)提交资源,为防止其他事务修改我们未提交的资源,我们只能锁住未提交的资源,比如商品库存,而这在电商系统中是致命的。
TC需要等待所有参与者的响应,这意味着在所有参与者都响应之前,TC将一直被阻塞。这将导致系统的吞吐量降低,延迟增加。
为了解决这个进阶性能问题,我们看看Seata是怎么做的。
Seata在TC和参与者(Seata中称为RM)引入了新的角色
TM (Transaction Manager) - 事务管理器
初看Seata文档时我总是会将TM和TC的职责混淆,但联系我上面的a问题,TM的职责和存在意义就非常清晰:
TM是用来处理 开始全局事务、提交或回滚全局 等具体操作的一个角色,并不参与事务的协调判断。
而Seata AT模式中的回滚与提交与2PC不同,这也是他能一定程度避免性能问题的关键。
在这里,需要引入Seata中的三种事务的概念,这于整个事务流程的理解不可或缺。
本地事务
单体服务内的事务,是分支事务的子事务
分支事务
单个分布式事务,包含多个服务的本地事务,回滚与提交由TM来完成
全局事务
多个分支事务
单个分支事务的话TC类似于二阶段协调者的作用,但是在涉及多个分布式事务并发进行时存在资源竞争的情况时,TC就体现了他的独特之处。
官方文档中的图解很便于理解这些事务和对应的管理者角色的关系。
那么,有了这些铺垫之后,我们结合各个角色的独特职责来详细剖析一个AT模式下的典型事务流程。
在 Spring Boot 的启动类上添加 @EnableAutoDataSourceProxy 注解,开启 Seata 对数据源的代理。如下所示:
@SpringBootApplication
@EnableAutoDataSourceProxy
public class DemoApplication {
public static void main(String[] args) {
SpringApplication.run(DemoApplication.class, args);
}
}
在需要进行分布式事务的方法上添加 @GlobalTransactional 注解,表示这个方法是一个分布式事务。如下所示:
@Service
public class UserServiceImpl implements UserService {
@Autowired
private UserMapper userMapper;
@GlobalTransactional
public void transfer(String fromAccount, String toAccount, BigDecimal amount) {
// ...
}
}
从一个@GlobalTransactional注解修饰的全局事务的逻辑开始,这个注解的处理逻辑会向TM注册全局事务,TM再向TC注册分支事务1,开启全局事务,生成XID(全局事务唯一ID)
本地事务执行完成,提交本地事务,释放本地资源(相比于二阶段性能提升的原因),向TM提交本地事务执行状态,TM将XID传递给其他服务(Seata 会在 RPC 请求中增加一个拦截器,用来在各个服务间传递XID)。
如果此时,有另一个分支事务2正好也在请求分支事务1操作的资源,那么事务2在注册分支事务时会发现该资源对应的XID已被分支事务1持有,事务2会阻塞等待事务1释放该资源(也可以看做等待全局锁的释放)。
分支事务1下所有本地事务都执行成功,TM向TC提交分支事务。释放全局事务锁定的资源,分支事务2拿到全局ID,执行对应的事务。
若分支事务1下存在某一个本地事务执行失败,则分支事务1将执行回滚,回滚的数据为之前的数据,这里就会出现一种情况:
由于分支事务1提交本地事务时释放了本地资源,故该本地资源是可以被其他分支事务的本地资源获取到的,当事务1执行回滚时,如果想要回滚的数据恰好被分支事务2的本地事务持有时,分支事务1的回滚操作将会阻塞,那么阻塞到什么时候呢?
Seata设计为了解决这个问题,一般需要配置分支事务获取全局锁的等待超时时间,也就是说分支事务2在等待分支事务1释放全局锁时,等待超时的话就会释放本地资源,所以本地事务1的回滚操作最差情况也只是等待一个分支事务2超时的时间,最终得以执行回滚。
将上面的流程结合官方文档的图解更便于理解。
可以发现,对于系统的全局来说,不同的事务是能够读到其他分支事务未提交的数据的,故从这点来说,Seata AT模式是全局 “读未提交”的隔离级别,官方文档有对将全局隔离级别升级为“读已提交”的描述,讲解的比较清晰,这里就不在赘述。
seata官网地址:http://seata.io/