一、分布式事务保证一致性
1.1 两阶段提交
在分布式系统中,每个节点虽然可以知晓自己的操作时成功或者失败,却无法知道其他节点的操作的成功或失败。当一个事务跨越多个节点时,为了保持事务的ACID特性,需要引入一个作为协调者的组件来统一掌控所有节点(称作参与者)的操作结果并最终指示这些节点是否要把操作结果进行真正的提交(比如将更新后的数据写入磁盘等等)。因此,二阶段提交的算法思路可以概括为:参与者将操作成败通知协调者,再由协调者根据所有参与者的反馈情报决定各参与者是否要提交操作还是中止操作。
所谓的二阶段提交(Two Phase Commit)是指:
第一阶段:准备阶段(投票阶段)
第二阶段:提交阶段(执行阶段)
缺点:
1、同步阻塞问题。执行过程中,所有参与节点都是事务阻塞型的。当参与者占有公共资源时,其他第三方节点访问公共资源不得不处于阻塞状态。
2、单点故障。由于协调者的重要性,一旦协调者发生故障。参与者会一直阻塞下去。尤其在第二阶段,协调者发生故障,那么所有的参与者还都处于锁定事务资源的状态中,而无法继续完成事务操作。(如果是协调者挂掉,可以重新选举一个协调者,但是无法解决因为协调者宕机导致的参与者处于阻塞状态的问题)。
3、数据不一致。在二阶段提交的阶段二中,当协调者向参与者发送commit请求之后,发生了局部网络异常或者在发送commit请求过程中协调者发生了故障,这回导致只有一部分参与者接受到了commit请求。而在这部分参与者接到commit请求之后就会执行commit操作。但是其他部分未接到commit请求的机器则无法执行事务提交。于是整个分布式系统便出现了数据不一致性的现象。
4、二阶段无法解决的问题:协调者再发出commit消息之后宕机,而唯一接收到这条消息的参与者同时也宕机了。那么即使协调者通过选举协议产生了新的协调者,这条事务的状态也是不确定的,没人知道事务是否被已经提交。
1.2 传统分布式事务不是微服务中一致性的最佳选择
依据CAP理论,必须在可用性(availability)和一致性(consistency)之间做出选择。如果选择提供一致性需要付出在满足一致性之前阻塞其他并发访问的代价。这可能持续一个不确定的时间,尤其是在系统已经表现出高延迟时或者网络故障导致失去连接时。
依据目前的成功经验,可用性一般是更好的选择,但是在服务和数据库之间维护数据一致性是非常根本的需求,微服务架构中选择满足最终一致性。
当然选择了最终一致性,就要保证到最终的这段时间要在用户可接受的范围之内。
二、微服务架构实现最终一致性的三种模式
2.1 可靠事件模式
可靠事件模式属于事件驱动架构,当某件重要事情发生时,例如更新一个业务实体,微服务会向消息代理发布一个事件。消息代理会向订阅事件的微服务推送事件,当订阅这些事件的微服务接收此事件时,就可以完成自己的业务,也可能会引发更多的事件发布。
2.1.1 例子
(1)如订单服务创建一个待支付的订单,发布一个“创建订单”的事件。
(2)支付服务消费“创建订单”事件,支付完成后发布一个“支付完成”事件。
(3)订单服务消费“支付完成”事件,订单状态更新为待出库。
2.1.2 难点
这个过程可能导致出现不一致的地方在于:
- 某个微服务在更新了业务实体后发布事件却失败;
- 虽然微服务发布事件成功,但是消息代理未能正确推送事件到订阅的微服务;
- 接受事件的微服务重复消费了事件。
可靠事件模式在于保证可靠事件投递和避免重复消费。
避免重复消费
要求服务实现幂等性,如支付服务不能因为重复收到事件而多次支付。
2.1.3 异常捕获和回滚
举个例子,Bob向Smith转账100块,那我们到底是先发送消息,还是先执行扣款操作?
为了实现可靠事件投递,我们可以采用异常捕获和回滚。即先执行业务操作,再发送消息,如果消息发送失败,则回滚业务操作。
缺点:
(1)在网络不稳定的情况下,Producer发送的消息可能已经被消息中间件成功接收,但是返回超时了,这时Producer回滚了本地事务,这就会造成Bob并没有减少100块,但Smith增加了100块。
(2)在投递完成后到数据库commit操作之间如果微服务宕机也将造成数据库操作因为连接异常关闭而被回滚。最终结果还是事件被投递,数据库却被回滚。
2.1.4 本地事件表
在基于异常捕获和回滚的可靠事件方案中,本地事件表这样改进:
- 在发布事件之前要先在
本地事件表
中添加一条记录, - 如果事件发布成功立即删除记录,
- 事件恢复服务定时从事件表中恢复未发布成功的事件,重新发布,重新发布成功才删除记录的事件。
缺点:
额外的事件数据库操作也会给数据库带来额外的压力,可能成为瓶颈。
2.1.5 事务消息
下面以阿里巴巴的RocketMQ
中间件为例,分析下其设计和实现思路。
第一阶段Producer
发送Prepared
消息时,RocketMQ也不会对外发送消息;
第二阶段:执行本地事务;在业务回滚时,通过实时事件向事件系统取消事件;
第三阶段,Producer向RocketMQ发送确认消息,这时RocketMQ才会对订阅者发送消息。如果确认消息发送失败,RocketMQ会定期扫描消息集群中的事物消息,这时候发现了Prepared消息,它会向消息发送者确认,Bob的钱到底是减了还是没减呢?如果减了是回滚还是继续发送确认消息呢?如下图:
我们继续分析分析异常情况。如果Prepared消息发送失败,则本地事务就不会执行;即使Prepared消息超时但是RocketMQ收到了Prepared消息,MQ也不会对外发送消息,而是调用Producer的反查接口确认这个消息的当前状态。
总结:据笔者的了解,各大知名的电商平台和互联网公司,几乎都是采用类似的设计思路来实现“最终一致性”的。这种方式适合的业务场景广泛,而且比较可靠。不过这种方式技术实现的难度比较大。目前主流的开源MQ(ActiveMQ、RabbitMQ、Kafka)均未实现对事务消息的支持,所以需二次开发或者新造轮子。比较遗憾的是,RocketMQ事务消息部分的代码也并未开源,需要自己去实现。
做过支付宝交易接口的同学都知道,我们一般会在支付宝的回调页面和接口里,解密参数,然后调用系统中更新交易状态相关的服务,将订单更新为付款成功。同时,只有当我们回调页面中输出了success字样或者标识业务处理成功相应状态码时,支付宝才会停止回调请求。否则,支付宝会每间隔一段时间后,再向客户方发起回调请求,直到输出成功标识为止。
2.1.6 幂等性
为保证幂等性一个简单的做法是在事件中添加时间戳,微服务记录每类型的事件最后处理的时间戳,如果收到的事件的时间戳早于我们记录的,丢弃该事件。
如果事件不是在同一个服务器上发出的,那么服务器之间的时间同步是个难题,更稳妥的做法是使用一个全局递增序列号替换时间戳。
对于本身不具有幂等性的操作,主要思想是为每条事件存储执行结果,当收到一条事件时我们需要根据事件的id查询该事件是否已经执行过,如果执行过直接返回上一次的执行结果,否则调度执行事件。
2.2 补偿模式
为了描述方便,这里先定义两个概念:
- 业务异常:业务逻辑产生错误的情况,比如账户余额不足、商品库存不足等。
- 技术异常:非业务逻辑产生的异常,如网络连接异常、网络超时等。
补偿模式使用一个额外的协调服务来协调各个需要保证一致性的微服务,协调服务按顺序调用各个微服务,如果某个微服务调用异常(包括业务异常和技术异常)就取消之前所有已经调用成功的微服务。
补偿模式建议仅用于不能避免出现业务异常的情况,如果有可能应该优化业务模式,以避免要求补偿事务。如账户余额不足的业务异常可通过预先冻结金额的方式避免,商品库存不足可要求商家准备额外的库存等。我们通过一个实例来说明补偿模式,一家旅行公司提供预订行程的业务,可以通过公司的网站提前预订飞机票、火车票、酒店等。
2.2.1 例子
假设一位客户规划的行程是,(1)上海-北京6月19日9点的某某航班,(2)某某酒店住宿3晚,(3)北京-上海6月22日17点火车。在客户提交行程后,旅行公司的预订行程业务按顺序串行的调用航班预订服务、酒店预订服务、火车预订服务。最后的火车预订服务成功后整个预订业务才算完成。
如果火车票预订服务没有调用成功,那么之前预订的航班、酒店都得取消。取消之前预订的酒店、航班即为补偿过程。
2.2.2 实现原理
开发者还可以将整个行为链加密,这样只有该行为链的接收者才能够操控这个行为链。当一个行为完成后,会将完成的信息记录到一个集合(比如说,是一个队列)中,之后可以通过这个集合访问到对应的行为。当一个行为失败的实收,行为将本地清理完毕,然后将消息发送给该集合,从而路由到之前执行成功的行为,然后回滚所有的事务。
程序会生成一个典型的集合用来访问对应的行为链中的行为,会创建3个独立的进程,每一个进程都会负责一个指定的任务。分别是租车,预订酒店以及预订机票三个独立的任务。
static ActivityHost[] processes;
static void Main(string[] args)
{
var routingSlip = new RoutingSlip(new WorkItem[]
{
new WorkItem<ReserveCarActivity>(new WorkItemArguments),
new WorkItem<ReserveHotelActivity>(new WorkItemArguments),
new WorkItem<ReserveFlightActivity>(new WorkItemArguments)
});
// imagine these being completely separate processes with queues between them
processes = new ActivityHost[]
{
new ActivityHost<ReserveCarActivity>(Send),
new ActivityHost<ReserveHotelActivity>(Send),
new ActivityHost<ReserveFlightActivity>(Send)
};
// hand off to the first address
Send(routingSlip.ProgressUri, routingSlip);
}
static void Send(Uri uri, RoutingSlip routingSlip)
{
// this is effectively the network dispatch
foreach (var process in processes)
{
if (process.AcceptMessage(uri, routingSlip))
{
break;
}
}
}
其中的AcitivityHost
就是对外部服务的一个抽象,RoutingSlip
是对前面说的集合的抽象。
下面是具体的一个服务的简化实现,ReserveHotelActivity
以及ReserveFlightActivity
的实现就不在此处列出了,下面是ReserveCarActivity
的实现。其中主要包括的几个方法:
DoWork
以及Compensate
方法是Activity抽象出来的用来执行实际操作以及回滚的补偿方法。 WorkItemQueueAddress
以及CompensationQueueAddress
都是用来索引到对应服务的。参考如下代码:
class ReserveCarActivity : Activity
{
static Random rnd = new Random(2);
public override WorkLog DoWork(WorkItem workItem)
{
Console.WriteLine("Reserving car");
var car = workItem.Arguments["vehicleType"];
var reservationId = rnd.Next(100000);
Console.WriteLine("Reserved car {0}", reservationId);
return new WorkLog(this, new WorkResult
{
{ "reservationId", reservationId }
});
}
public override bool Compensate(WorkLog item, RoutingSlip routingSlip)
{
var reservationId = item.Result["reservationId"];
Console.WriteLine("Cancelled car {0}", reservationId);
return true;
}
public override Uri WorkItemQueueAddress
{
get { return new Uri("sb://./carReservations"); }
}
public override Uri CompensationQueueAddress
{
get { return new Uri("sb://./carCancellactions"); }
}
}
RoutingSlip
是对成功行为集合的抽象,用来索引到对应的服务,包含了两个队列,一个是完成的任务,一个是等待执行的任务。RoutingSlip
主要用来控制连接多个行为。如果成功就会将任务向前执行,如果失败就会向后执行 。RoutingSlip
使用队列来向前执行,使用栈来向后执行。
class RoutingSlip
{
readonly Stack<WorkLog> completedWorkLogs = new Stack<WorkLog>();
readonly Queue<WorkItem> nextWorkItem = new Queue<WorkItem>();
public RoutingSlip()
{
}
public RoutingSlip(IEnumerable<WorkItem> workItems)
{
foreach (var workItem in workItems)
{
this.nextWorkItem.Enqueue(workItem);
}
}
public bool IsCompleted
{
get { return this.nextWorkItem.Count == 0; }
}
public bool IsInProgress
{
get { return this.completedWorkLogs.Count > 0; }
}
public bool ProcessNext()
{
if (this.IsCompleted)
{
throw new InvalidOperationException();
}
var currentItem = this.nextWorkItem.Dequeue();
var activity = (Activity) Activator.CreateInstance(
currentItem.ActivityType);
try
{
var result = activity.DoWork(currentItem);
if (result != null)
{
this.completedWorkLogs.Push(result);
return true;
}
}
catch (Exception e)
{
Console.WriteLine("Exception {0}", e.Message);
}
return false;
}
public Uri ProgressUri
{
get
{
if (IsCompleted)
{
return null;
}
else
{
return
((Activity)Activator.CreateInstance(this.nextWorkItem.Peek().ActivityType)).
WorkItemQueueAddress;
}
}
}
public Uri CompensationUri
{
get
{
if (!IsInProgress)
{
return null;
}
else
{
return ((Activity) Activator.CreateInstance(
this.completedWorkLogs.Peek().ActivityType)).
CompensationQueueAddress;
}
}
}
public bool UndoLast()
{
if (!this.IsInProgress)
{
throw new InvalidOperationException();
}
var currentItem = this.completedWorkLogs.Pop();
var activity = (Activity) Activator.CreateInstance(
currentItem.ActivityType);
try
{
return activity.Compensate(currentItem, this);
}
catch (Exception e)
{
Console.WriteLine("Exception {0}", e.Message);
throw;
}
}
}
ActivityHost
会调用RoutingSlip
上面的ProcessNext
方法来解析下一个行为并正向调用DoWork()
或者反向调用Compensate()
方法。
abstract class ActivityHost
{
Action<Uri, RoutingSlip> send;
public ActivityHost(Action<Uri, RoutingSlip> send)
{
this.send = send;
}
public void ProcessForwardMessage(RoutingSlip routingSlip)
{
if (!routingSlip.IsCompleted)
{
// if the current step is successful, proceed
// otherwise go to the Unwind path
if (routingSlip.ProcessNext())
{
// recursion stands for passing context via message
// the routing slip can be fully serialized and passed
// between systems.
this.send(routingSlip.ProgressUri, routingSlip);
}
else
{
// pass message to unwind message route
this.send(routingSlip.CompensationUri, routingSlip);
}
}
}
public void ProcessBackwardMessage(RoutingSlip routingSlip)
{
if (routingSlip.IsInProgress)
{
// UndoLast can put new work on the routing slip
// and return false to go back on the forward
// path
if (routingSlip.UndoLast())
{
// recursion stands for passing context via message
// the routing slip can be fully serialized and passed
// between systems
this.send(routingSlip.CompensationUri, routingSlip);
}
else
{
this.send(routingSlip.ProgressUri, routingSlip);
}
}
}
public abstract bool AcceptMessage(Uri uri, RoutingSlip routingSlip);
}
2.3 TCC模式(Try-Confirm-Cancel)
一个完整的TCC业务由一个主业务服务和若干个从业务服务组成,主业务服务发起并完成整个业务活动,TCC模式要求从服务提供三个接口:Try、Confirm、Cancel。
(1) Try:完成所有业务检查
预留必须业务资源
(2) Confirm:真正执行业务
不作任何业务检查
只使用Try阶段预留的业务资源
Confirm操作满足幂等性
(3)Cancel:
释放Try阶段预留的业务资源
Cancel操作满足幂等性
整个TCC业务分成两个阶段完成。
第一阶段:主业务服务分别调用所有从业务的try操作,并在活动管理器中登记所有从业务服务。当所有从业务服务的try操作都调用成功或者某个从业务服务的try操作失败,进入第二阶段。
第二阶段:活动管理器根据第一阶段的执行结果来执行confirm或cancel操作。如果第一阶段所有try操作都成功,则活动管理器调用所有从业务活动的confirm操作。否则调用所有从业务服务的cancel操作。
需要注意的是第二阶段confirm或cancel操作本身也是满足最终一致性的过程,在调用confirm或cancel的时候也可能因为某种原因(比如网络)导致调用失败,所以需要活动管理支持重试的能力,同时这也就要求confirm和cancel操作具有幂等性。
2.3.1 TCC和2PC的区别
- TCC是更上层的抽象,Try阶段冻结资源(业务层面,不一定涉及本地事务),Comfirm/Cancel阶段执行事务或者取消;
- 2PC是在Prepare阶段就已经开启了各个服务的本地事务,所以性能很低。
总的来说,TCC实际上把数据库层的二阶段提交上提到了应用层来实现,对于数据库来说是一阶段提交,规避了数据库层的2PC性能低下问题。
TCC事务的缺点,主要就一个:TCC的Try、Confirm和Cancel操作功能需要业务提供,开发成本高。
三、对账是最后的终极防线
如果有些业务由于瞬时的网络故障或调用超时等问题,通过上文所讲的3种模式一般都能得到很好的解决。但是在当今云计算环境下,很多服务是依赖于外部系统的可用性情况,在一些重要的业务场景下还需要周期性的对账来保证真实的一致性。比如支付系统和银行之间每天日终是都会有对账过程。