随着软件系统从单体应用迈向微服务架构以及数据库选型去中心化、异构化的趋势,传统的ACID事务在分布式系统上能否延续,如何落地,有哪些注意事项?本文将围绕分布式事务这一技术议题,介绍FreeWheel核心业务系统在相关领域的业务需求、技术决策和线上实践。
FreeWheel 核心业务产品历经十多年的积累和迭代,伴随着数据体量和功能复杂度的上升,支撑 FreeWheel 核心业务的工程团队所采用和探索的技术也在不断演化和革新。
早期 FreeWheel 核心业务系统是一个单体应用(Monolith):在同一台服务器的同一个进程中,完成接收客户请求、处理请求、数据存储、返回响应等步骤。为了提升系统整体的可靠性,方便各个模块的独立演化,工程团队对单体应用进行了拆分部署和服务化,迈向了面向服务的架构(SOA)。随着服务的不断细分,单个服务的功能变得更加聚焦,基础服务和公用设施的组合/编排逻辑则变得更加错综复杂,有向微服务发展的趋势。依托近年来蓬勃发展的云计算平台 AWS,FreeWheel 的技术团队还在积极探索无服务(Serverless)技术。
FreeWheel 核心业务系统最早广泛使用了以 MySQL 为代表的关系型数据库(RDBMS)。后来为了满足多样化索引和查询数据的需求,引入了以 ApacheSolr 和 ElasticSearch 为代表的搜素引擎(Search Engine)。随着数据体量的增长,传统的关系型数据库已无法满足分布式存取海量数据的需求,为此又引入了以 AmazonDynamoDB 和 MongoDB 为代表的 NoSQL 数据库 。
在诸多变化背后,客户多年积累下来的使用习惯其实是难以改变的。而看上去日新月异的产品迭代需求,经过抽象不难发现一些恒定的规律和模式:
传统关系型数据库中,一批数据操作同时成功、同时失败的这类需求共性被抽象为事务性,英文缩写为 ACID:
随着系统的服务拓扑从单体应用迈向微服务时代,以及数据库数量和种类的增长,分布式系统在满足传统 ACID 标准的事务性需求上,面临着新的挑战。所谓的 CAP 三选二定理是说,任何一个分布式系统不能同时满足以下三个特性:
在 CAP 三个特性中,P 通常是分布式系统无法规避的既定事实,设计者只能在 C 和 A 之间进行取舍。大部分系统经过综合考虑,都选择了 A 而放弃 C,目标是高可用,最终一致(不过达成一致需要的时间无上限)。少部分系统坚持 C 而放弃 A,即选择强一致、低可用(单节点故障将导致服务不可用,可用率取决于故障频度和恢复时间,无上限)。
我们考虑通过引入一套分布式事务方案,达成以下各项设计目标:
如上图,A、B、C 是三个服务/数据库, 1 和 2 为并发修改同一个 key 的两个请求。由于随机网络延迟,最终落在三个服务/数据库的值不一致,A 为 2 的值,B 和 C 为 1 的值。
XA 协议通过引入一个协调者的角色,以及要求所有参与事务的数据库支持 Two-phaseCommit(2PC,两阶段提交,即先准备,后提交或回滚)来实现分布式事务。
(图片来源:https://docs.particular.net/nservicebus/azure/understanding-transactionality-in-azure)
使用 XA 实现分布式事务的优点有:
使用 XA 实现分布式事务的缺点也很明显:
XA 在设计上没有考虑到分布式系统的特点,事实上是一个强一致、低可用的设计方案,对网络分隔的容忍度较差。
Saga 原意是长篇神话故事。它实现分布式事务的思路是实现一种驱动流程机制,按顺序执行每个数据操作步骤,一旦出现失败,就倒序执行之前各步骤对应的“补偿”操作。这要求每个步骤涉及到的服务提供与正向操作接口对应的补偿操作接口。
使用 Saga 实现分布式事务的优点有:
使用 Saga 实现分布式事务的缺点有:
Saga 从流程上,还可分为两种模式:Orchestration(交响乐)和 Choreography(齐舞)。
Saga Orchestration 引入了类似 XA 中的协调者的角色,来驱动整个流程。
(图片来源:https://medium.com/trendyol-tech/saga-pattern-briefly-5b6cf22dfabc)
如上图,Order Service 发起分布式事务,Orchestrator 负责驱动分布式事务流程,PaymentService 和 Stock Service 负责提供数据操作的正向接口和补偿接口。
Saga Choreography 将流程分拆到每个步骤涉及到的服务中,由每个服务自行调用后序或前序服务。
(图片来源:https://medium.com/trendyol-tech/saga-pattern-briefly-5b6cf22dfabc)
如上图,Order Service 直接调用 PaymentService 来发起分布式事务,后者再调用 Stock Service,直到完成所有步骤;一旦某步骤出现失败,服务之间会反向调用。
ACID 事务链可以看作是 SagaChoreography 的增强版,它要求参与分布式事务的所有服务都使用支持传统 ACID 事务的数据库,然后通过将每个服务内部的数据操作和同步调用相邻服务的操作打包到一个 ACID 事务中,通过 ACID 事务的链式调用实现分布式事务。
使用 ACID 事务链实现分布式事务的优点有:
使用 ACID 事务链实现分布式事务的缺点有:
我们首先排除了 XA 方案,它无法满足系统的可用性和扩展性。其次排除了 ACID 事务链,因为它不兼容业务现有的数据库选型,未来还会引入更多不支持 ACID 事务的数据库技术。
最终决定采用 Saga 来实现高可用、低延迟、最终一致的分布式事务框架,主要原因是其设计思想非常契合于目前 FreeWheel 核心业务团队的 SOA/微服务/Serverless 实践,即通过对一些基础服务(对于 Serverless 其实是 Lambda,以下不再区分)进行组合/编排来完成各种业务需求。
在 Saga 的两个变种中,我们选择了 Orchestration 而不是 Choreography,原因是:
采用 Saga Orchestration,势必需要想办法克服它的两个缺点,即要求基础服务提供补偿接口,以及没有实现 ACID 中的 Isolation 和 Durability。
如何实现数据补偿操作呢?数据操作可分为 Insert(新建),Delete(删除)和 Update(更新)三种,而 Update 又可细分为 Full update(Replace,整体更新)和 Partial update(Patch,部分更新),它们对应的补偿操作如下:
再来看下如何实现 ACID 中的 I 和 D:
综上所述,我们需要增加一个队列+持久化的技术方案来补足 Saga 的短板,实现 ACID。结合 FreeWheel 核心业务系统现有的基础设施,我们优先考虑引入 ApacheKafka(以下简称 Kafka)。
Kafka 是一个功能丰富的队列+持久化解决方案,针对分布式事务的设计目标,我们看中的是它的这些能力:
另一方面,Kafka 作为一个强大的队列解决方案,它的众多特性给分布式事务的设计和实现带来了新的机遇和挑战。引入队列之前,从客户点击浏览器按钮,到数据落盘再到返回响应数据,主流程上的节点都是同步交互的:
如上图,实线箭头为 RPC 请求,虚线箭头为 RPC 响应(下同),数据按照序号标注的顺序从客户发起,先后经过 A、B 和 C 三个服务,所有步骤都是同步的。
引入队列之后,列两端的生产者和消费者彼此隔开,整个过程变成了同步→异步→同步:
如上图,1 和 2 之间是同步的, 2 和 3 之间是异步的,接下来的 3 到 7 又是同步的。
通过化同步为异步,系统整体的吞吐量和资源利用率可以得到进一步的提升。随之而来的问题是为了维持同步的前端数据流程,需要增加同步流程和异步流程如何衔接的设计。
同步转异步比较简单,在此不做讨论。异步转同步的时候,需要建立一种消费者所在节点和生产者所在节点进行点对点通信的机制。我们采取的方案是直接回调:生产者把回调地址打包到消息里,消费者处理完成后将处理结果发送到回调地址。
基于 Saga Orchestration 和 Kafka 的分布式事务架构如下图所示:
其中服务 A 是编排组织器,它负责驱动 SagaOrchestration 的流程, 服务 B、C、D 是三个使用了独立且异构的数据库的基础服务。
由于使用了 Saga Orchestration 而不是 Choreography,只有服务 A 能感知到分布式事务并且依赖 Kafka 和 Saga,基础服务 B、C、D 只需要多实现几个补偿接口供 A 调用,没有产生对 Kafka 和 Saga 的依赖。
服务 A 从接到用户请求,触发分布式事务,分步骤调用各个基础服务,到最终返回响应,流程如下图:
步骤详解:
一条队列消息至少包含两部分信息:元数据(Metadata)和内容(Content)。
Kafka 上的消息数据被分成 topic(主题)和 partition(分区)两个层级,由 topic、partition 和 offset(偏移量)来唯一标识一条消息。partition 是负责保证消息顺序的层次。Kafka 还支持一个消息被不同的“业务”多次消费(称为多播或扇出),为了区分不同“业务”,引入了消费者组的概念,一个消费者组在一个 partition 上共享一个消费进度(consumergroup offset)。为保证消息送达顺序,一个 partition 上的数据,同一时间、同一消费者组最多由一个消费者获得。
这给 Kafka 的使用者造成了一些实际问题:
针对以上两个问题,分布式事务的并行消费部分引入了如下改进方案:在不违背 ACID 事务性的前提下,在一个消费者进程内,对 partition(分区)根据一个子分区 ID(以下简称 id)和 TxType 进行再次分区,同一个子分区的消息串行消费,不同子分区的消息并行消费。
如上图所示:
分布式系统的高可用性,需要依赖参与其中的每个服务足够健壮。下面对分布式事务中的各种服务进行分类探讨,描述当部分服务节点出现故障时系统的可用性。
分布式事务框架随服务发布之后,经过一段时间的线上运行,基本符合设计预期。期间出现了一些问题,列举如下。
使用分布式事务的某服务在部分数据上出现超时,客户重试无效,而在另一些数据上正常返回。通过分析日志发现,这些消息的发送和处理都成功了,但是消费者回调生产者失败。进一步研究日志发现,消费者所在的节点和生产者所在的节点位于不同的集群,出现了网络分隔。查看配置,两个集群的同名服务配置了相同的 Kafkabrokers、topics 和消费者组,两个集群的消费者连到同一个 Kafka,被随机分配处理同一个 topic 下多个 partitions。
如上图所示,位于集群 C 的服务 A(生产者)和集群 D 的服务 A(消费者)使用了相同的 Kafka 配置。他们的节点虽然都能连到 Kafka,但是彼此无法直连,因此第 7 步回调失败了。之所以有些数据超时且重试无效,有些却没有问题,是因为特定数据的值会映射到特定的 partition,如果消息生产者和 partition 的消费者不在同一个集群,就会回调失败;反之如果在同一个集群则没有问题。解决方法是通过修改配置,让不同集群的服务使用不同的 Kafka。
服务 A 出现业务异常报警,内容是分布式事务的消费者接到队列消息的类型不符合预期。通过分析日志和查看代码,发现该消息类型属于服务 B,而且同样的消息已经被服务 B 的消费者处理了。查看配置发现服务 A 和 B 的分布式事务使用了同一个 Kafkatopic,通过配置不同的消费者组来区分各自的消费进度。
如上图所示,服务 A 和 B 共享了 Kafka 的 topic 和 partition,导致异常的消息来自服务 B 的生产者(步骤 1),异常报警出现在 A 的消费者(步骤 2),而且 B 的消费者也收到并处理了这条消息(步骤 3),步骤 2 和 3 之间是并行的。服务 A 的生产者在这次异常事件中没有发挥作用。解决这个问题有两种思路:一种是修改配置,取消 Kafkatopic 共享;一种是修改日志,忽略不认识的分布式事务消息类型。由于短期内在该 topic 上服务 A+B 的生产能力小于消费能力,如果取消共享的话会进一步浪费 Kafka 资源,所以暂时采用了修改日志的方式。
分布式系统的挑战之一就是在 RPC 调用关系复杂的时候难以追踪和定位问题。分布式事务由于引入异步队列,生产者和消费者有可能位于不同的节点,对服务可见性,特别是链路的追踪提出了更高的要求。通过与 FreeWheel 的链路追踪系统进行集成,工程师可以直观地看到分布式事务数据在各服务的流转情况,更好地追查和定位功能和性能上的问题,如下图所示:
此外,还可利用 Kafka 消息多播的能力,使用临时的消费者组随时浏览、回溯 topic 上的消息数据,只要不使用线上业务的消费者组,就不会妨碍数据的正常消费。
使用分布式事务的某服务发现,客户在提交特定数据的时候稳定出现 5xx 错误,重试无效。经过分析日志发现,某个基础服务对该数据返回了 4xx 的错误(业务认为数据不合理),但是经过分布式事务框架的异常捕获和处理,原始细节丢失,异常在发送给客户前被改写成了 5xx 的错误。解决办法是修改框架的异常处理机制,在消费者进程中将每个步骤遇到的原始异常信息进行汇总,打包进回调数据发送给生产者,允许业务代码做进一步的异常处理。
使用分布式事务的服务 A 发现,偶尔会出现请求成功,但是在基础服务 B 管理的数据库里创建出了多条同样的数据的情况。通过 FreeWheel 的链路追踪系统发现,服务 A 调用 B 的创建接口的时候因为超时而进行了重试,但是两次调用在服务 B 都成功了,而且该接口不具有幂等性(idempotency,即多次调用的效果等于一次调用的效果),导致同样的数据被多次创建。类似的问题在微服务实践中经常出现,解决思路有两种:一种是治标的方法,即 A 和 B 共享超时配置,A 将自己的超时设置 tA 传给 B,然后 B 按照一个比 tA 更短的超时 tB(考虑到 A 和 B 之间的网络开销)来事务性提交数据。另一种是治本的方法,也就是服务 B 的接口实现幂等性(方法可以是数据库设置唯一索引,创建数据请求要求必传唯一索引,忽略索引冲突的请求)。无论是否使用分布式事务,客户端因为网络问题重试而导致多次请求重复数据的问题,都是每个微服务面临的现状,而实现接口幂等性则是可以优先考虑的方案。
将来会在以下几个方面,对分布式事务方案进行持续优化:
立足 FreeWheel 核心业务系统的架构变迁和事务性需求,本文介绍了一种支持异构数据库、实现最终一致性的分布式事务方案以及相关的落地实践,希望能为面临类似问题的读者提供一些思路和启发。
作者介绍:杨帆,FreeWheel 高级软件工程师,技术发烧友、模范消费者。研究方向为弹性架构、云计算、数据可视化、产品设计等领域。
相关文章:
领取专属 10元无门槛券
私享最新 技术干货