分布式数据一致性解决方案
分布式数据一致性解决方案
在聊到一致性问题时,其实涉及两方面内容
副本数据一致性,主要时考虑同一份逻辑数据存在多个物理的数据副本的一致性
另一种是事务一致性
本文内容侧重于后者,即数据操作从一个合法的状态迁移到另一个合法的状态
问题引入
为方便描述,假设下单流程只有两个操作
- 添加订单
- 锁券
1 |
|
对于这 2 个操作,可能有 3 个操作结果
- 2 个操作都成功了
- 2 个操作都失败了
- 添加订单成功了,锁定卡券失败了
前 2 种结果最终状态都是合法的,第 3 个会导致下单了,用户的券没锁定,从而会有用户套利的风险,怎么解决呢?单体数据时代我们直接使用带有事务能力的数据库就行,比如 MySQL、Pg 等。
随着服务化的流行,我们对服务按领域进行了拆分,比如订单、促销、卡券、支付等。这样的好处是各模块进行了解耦,提高开发维护效率,但也带来了一致性的挑战。
单机数据库,我们可以直接使用数据库的事务能力达到一致性,多机呢,这其实就涉及到分布式事务,本文会提及当前的一些行业方案。
行业方案
2PC
2PC,二阶段提交,将一个事务分成了两步来提交。第一步做准备动作,第二步做提交 / 回滚动作,这两步之间的协调是交由事务协调者来管理,保证多步操作的原子性
我们来看下 2PC 是怎么解决上面的下单问题的。
引入事务协调者的角色,来协调订单系统和卡券系统,协调者对客户端提供一个完整的「使用优惠券下单」的服务,在这个服务的内部,协调者再分别调用订单和促销的相应服务。
在准备阶段,协调者给订单系统和卡券系统发送准备命令,订单系统和促销系统分别开启事务执行对应的数据库操作,但是并不提交事务。
如果两个系统都返回准备成功,进入提交阶段。协调者给两个系统发送提交命令,待收到所有响应之后,给客户端返回成功响应。
上面说的是正常情况,异常情况下呢?
在准备阶段,如果任何一步出现错误或者超时,协调者会给两个系统发送回滚事务命令。每个系统收到命令后,回滚自己的本地事务,分布式事务执行失败。
如果准备阶段成功,进入提交阶段,整个分布式事务只能成功,不能失败。但提交阶段可能还是有异常,怎么办?比如发生网络传输失败的情况,需要反复重试,直到提交成功为止。
2PC 是一种强一致的设计,它可以保证原子性和隔离性。只要全局事务完成,订单库和卡券库中的数据一定是一致的状态。缺点也很明显,整个事务的执行过程需要阻塞服务端的线程和数据库的会话,并发场景下的性能不会很高。
Seata AT
2PC 并发性能不高核心点在于准备阶段,资源准备就绪之后需要等待其他资源都就绪才能提交,这样会导致长时间的事务占用。有没有可能我们准备阶段就提交事务呢?阿里开源的分布式事务中间件 Seata 提供了一种思路解决这个问题。
AT 模式是 Seata 创新的一种非侵入式的分布式事务解决方案,Seata 在内部做了对数据库操作的代理层,我们使用 Seata AT 模式时,实际上用的是 Seata 自带的数据源代理 DataSourceProxy,Seata 在这层代理中加入了很多逻辑,比如插入回滚 undo_log 日志,检查全局锁等。
准备阶段
提交/回滚阶段
这里旨在说明 AT 回滚的思路,详细原理可参考官方文档 https://seata.apache.org/zh-cn/docs/user/mode/at
TCC
TCC 要求每个分支事务实现三个操作:预处理 Try、确认 Confirm、撤销 Cancel
操作 | 说明 |
---|---|
Try | 业务代码会预留业务所需的全部资源,比如冻结用户账户 100 元、提前扣除一个商品库存、提前创建一个没有开始交易的订单等。业务到这些资源后,后续两个阶段操作就可以无锁进行了 |
Confirm | 业务确认所需的资源都拿到后,子事务会并行执行这些业务。执行时可以不做任何锁互斥,也无需检查,直接执行 Try 阶段准备的所有资源就行 |
Cancel | 如果子事务在 Try 阶段或 Confirm 阶段多次执行重试后仍旧失败,TM 就会执行 Cancel 阶段的代码,并释放 Try 预留的资源,同时回滚 Confirm 期间的内容 |
事务协调者首先发起所有的分支事务的 Try 操作,任何一个分支事务的 Try 操作执行失败,协调者将会发起所有分支事务的 Cancel 操作;若 Try 操作全部成功,协调者将会发起所有分支事务的 Confirm 操作。其中 Confirm/Cancel 操作若执行失败,事务协调者首会进行重试
TCC 可以理解为一种 2PC 变体,适用于应用层/服务层的 2PC。
其他
除了上面的方案外,常用的还有本地消息表和事务消息,这两者适用于后续事务一般都能执行成功或者异步更新数据的场景,比如发短信、发邮件、清空购物车等,而像上面下单流程中锁券能不能成功和用户动作相关,不大适用于这种方式,出于完整性这里简单提下
本地消息表
看经典的转账问题
1 |
|
用户的 user_id = 1,从支付宝转帐1万快到余额宝分为两个步骤
1 |
|
如何保证数据一致性呢?
事务消息
举个例子,用户下完单后可以异步删除购物车中对应的商品
如果创建订单成功,发送消息失败,就会导致有订单且购物车还有对应商品的情况,所以需要保证这两个操作的原子性,这里用事务消息就很合适。
着重说明下,订单系统给消息服务器发送一个「半消息」,这个半消息不是说消息内容不完整,它包含的内容就是完整的消息内容,半消息和普通消息的唯一区别是,在事务提交之前,对于消费者来说,这个消息是不可见的。
这个实现过程中,有一个问题是没有解决的。如果在第 4 步提交事务消息时失败了怎么办?Kafka 的解决方案比较简单粗暴,直接抛出异常,让用户自行处理。我们可以在业务代码中反复重试提交,直到提交成功,或者进行回滚补偿。RocketMQ 则给出了另外一种解决方案
本地消息表的一般都能转为事务消息模式,省去创建、查消息表的成本。
补偿
由于环境、网络等问题,任何阶段服务之间交互都可能出错。正向出错时,逻辑会走到异常分支里,异常分支会进行回滚。但是,如果回滚逻辑也出现问题了怎么办呢?答案还是补偿。