随着软件系统从单体应用迈向微服务架构以及数据库选型去中心化、异构化的趋势,传统的 ACID 事务在分布式系统上能否延续,如何落地,有哪些注意事项?本文将围绕分布式事务这一技术议题,介绍 FreeWheel 核心业务系统在相关领域的业务需求、技术决策和线上实践。
分布式事务的挑战
技术演进
FreeWheel 核心业务产品历经十多年的积累和迭代,伴随着数据体量和功能复杂度的上升,支撑 FreeWheel 核心业务的工程团队所采用和探索的技术也在不断演化和革新。
系统拓扑方面:
早期 FreeWheel 核心业务系统是一个单体应用(Monolith):在同一台服务器的同一个进程中,完成接收客户请求、处理请求、数据存储、返回响应等步骤。为了提升系统整体的可靠性,方便各个模块的独立演化,工程团队对单体应用进行了拆分部署和服务化,迈向了面向服务的架构(SOA)。随着服务的不断细分,单个服务的功能变得更加聚焦,基础服务和公用设施的组合/编排逻辑则变得更加错综复杂,有向微服务发展的趋势。依托近年来蓬勃发展的云计算平台 AWS,FreeWheel 的技术团队还在积极探索无服务(Serverless)技术。
数据存储方面:
FreeWheel 核心业务系统最早广泛使用了以 MySQL 为代表的关系型数据库(RDBMS)。后来为了满足多样化索引和查询数据的需求,引入了以 ApacheSolr 和 ElasticSearch 为代表的搜素引擎(Search Engine)。随着数据体量的增长,传统的关系型数据库已无法满足分布式存取海量数据的需求,为此又引入了以 AmazonDynamoDB 和 MongoDB 为代表的 NoSQL 数据库 。
事务类需求
在诸多变化背后,客户多年积累下来的使用习惯其实是难以改变的。而看上去日新月异的产品迭代需求,经过抽象不难发现一些恒定的规律和模式:
同步和有序的数据变更:客户习惯于在集中的入口(UI / API)提交一组数据变更请求,希望在尽可能短的时间内,得到返回结果(成功或失败);接下来做何种操作,提交什么数据,取决于之前步骤的执行结果。
批量修改,统一结果:一次请求如果对应多条数据变更操作(增加、删除、修改数据),不管这些操作发生在哪些服务、落到哪个数据库,最好要么都成功,要么都失败。
传统关系型数据库中,一批数据操作同时成功、同时失败的这类需求共性被抽象为事务性,英文缩写为 ACID:
A (Atomicity, 原子性):一组数据操作如果其中某步操作失败,之前的操作也要回滚,不允许出现部分成功部分失败的情况。
C(Consistency,一致性):数据操作符合某种业务约束。这个概念来源于财务对账领域,拓展到数据库设计上的含义比较模糊,众说纷纭。甚至有资料说 C 是为了凑成 ACID 这个缩写而添加的。
I(Isolation,隔离性):对并发的数据操作有一定的隔离性。Isolation 是分等级的, 最差的情况是毫无隔离、互相干扰;最好的情况是并发操作等效于一系列串行操作(Serializable,可串行化)。Isolation 等级越高,数据库需要的资源越多,存取数据的性能(如吞吐量、延迟)越差。
D(Durability,持久性):到达数据库的请求不会“轻易”丢失。通常数据库设计文档会对“轻易”做具体的定义,比如在磁盘坏道,机器停电重启等条件下不会丢数据。
随着系统的服务拓扑从单体应用迈向微服务时代,以及数据库数量和种类的增长,分布式系统在满足传统 ACID 标准的事务性需求上,面临着新的挑战。所谓的 CAP 三选二定理是说,任何一个分布式系统不能同时满足以下三个特性:
C(Consistency,强一致性):分布式系统的任何节点对同一个 key 的读写请求,得到的结果完全一致。也叫线性一致性。
A(Availability,可用性):每次请求都能得到及时和正常的响应,但不保证数据是最新的。
P(Partition tolerance,承受网络分隔):分布式系统在节点之间无法连通或者连接超时的前提下还能维持运转。
在 CAP 三个特性中,P 通常是分布式系统无法规避的既定事实,设计者只能在 C 和 A 之间进行取舍。大部分系统经过综合考虑,都选择了 A 而放弃 C,目标是高可用,最终一致(不过达成一致需要的时间无上限)。少部分系统坚持 C 而放弃 A,即选择强一致、低可用(单节点故障将导致服务不可用,可用率取决于故障频度和恢复时间,无上限)。
技术选型与方案设计
设计目标
我们考虑通过引入一套分布式事务方案,达成以下各项设计目标:
事务性提交:即 ACID 中的 Atomicity。业务根据需要,可以定义一组数据操作,即分布式事务,这组操作无论发生在哪个服务和数据库,要么同时成功,要么同时失败。事务中只要任何一个操作出现失败, 之前的操作都需要回滚。
系统高可用:当部分服务的部分节点出现故障时,系统整体仍然可用。通过支持服务快速扩容和缩容,实现系统整体的高吞吐量,尽可能缩短数据达成一致性的延迟。框架本身消耗的资源低,引入的额外延迟小。
数据最终一致性:并发操作同一条数据的请求到达各个服务和数据库的次序保持一致,不出现丢失、乱序。 举一个顺序不一致的例子:
如上图,A、B、C 是三个服务/数据库, 1 和 2 为并发修改同一个 key 的两个请求。由于随机网络延迟,最终落在三个服务/数据库的值不一致,A 为 2 的值,B 和 C 为 1 的值。
支持服务独立演化和部署:除了支持使用 RPC 和给定协议进行通信之外,不对服务的实现方式做过多要求和假设。
支持服务使用异构的数据存储技术:使用不同的数据存储技术(关系型数据库、NoSQL、搜索引擎等),是 FreeWheel 核心业务系统的各个服务的现状和努力方向。
架构侵入性低,易于采用:不改动或少改动现有系统的代码和部署,尽量只通过新增代码以及服务部署,来实现分布式事务的运行环境和具体业务流程。框架和业务的分工明确,框架代码维持 100%测试覆盖率, 业务代码 100%可测试,测试成本低。保持系统高可见性和可预测性,尽可能为快速故障定位和恢复提供便利。
支持同步和异步流程:提供一种机制,将 UI/API 和后端入口服务之间的同步交互流程,与可能出现的后端服务之间的异步流程衔接起来。
支持事务步骤依赖:事务里面某个步骤的数据操作是否执行、如何执行,取决于前面的步骤的操作结果。
技术选型
XA 协议和多阶段提交
XA 协议通过引入一个协调者的角色,以及要求所有参与事务的数据库支持 Two-phaseCommit(2PC,两阶段提交,即先准备,后提交或回滚)来实现分布式事务。
(图片来源:https://docs.particular.net/nservicebus/azure/understanding-transactionality-in-azure)
使用 XA 实现分布式事务的优点有:
强一致性:实现了数据在多个数据库上的强一致提交。
业务侵入性小:完全靠数据库本身的支持实现分布式事务,不需要改动业务逻辑。
使用 XA 实现分布式事务的缺点也很明显:
单点故障:协调者或者任意一个 XA 数据库都是能引起故障的单点(Single point of failure)。
低性能:支持 XA 特性的数据库在设计上有大量的阻塞和资源占位操作, 数据体量和吞吐量扩展性差。
数据库选型限制:对于服务的数据库选型引入了支持 XA 协议这个限制。
XA 在设计上没有考虑到分布式系统的特点,事实上是一个强一致、低可用的设计方案,对网络分隔的容忍度较差。
Saga
Saga 原意是长篇神话故事。它实现分布式事务的思路是实现一种驱动流程机制,按顺序执行每个数据操作步骤,一旦出现失败,就倒序执行之前各步骤对应的“补偿”操作。这要求每个步骤涉及到的服务提供与正向操作接口对应的补偿操作接口。
使用 Saga 实现分布式事务的优点有:
微服务架构:通过对一些基础服务进行组合/编排来完成各种业务需求。
数据库兼容性高:对每个服务使用何种数据库技术没有任何要求,服务甚至可以不使用数据库。
使用 Saga 实现分布式事务的缺点有:
要求服务提供补偿接口:增加了开发和维护的成本。
不符合 ACID:没有涉及 Isolation 和 Durability。
Saga 从流程上,还可分为两种模式:Orchestration(交响乐)和 Choreography(齐舞)。
Saga Orchestration
Saga Orchestration 引入了类似 XA 中的协调者的角色,来驱动整个流程。
(图片来源:https://medium.com/trendyol-tech/saga-pattern-briefly-5b6cf22dfabc)
如上图,Order Service 发起分布式事务,Orchestrator 负责驱动分布式事务流程,PaymentService 和 Stock Service 负责提供数据操作的正向接口和补偿接口。
Saga Choreography
Saga Choreography 将流程分拆到每个步骤涉及到的服务中,由每个服务自行调用后序或前序服务。
(图片来源:https://medium.com/trendyol-tech/saga-pattern-briefly-5b6cf22dfabc)
如上图,Order Service 直接调用 PaymentService 来发起分布式事务,后者再调用 Stock Service,直到完成所有步骤;一旦某步骤出现失败,服务之间会反向调用。
ACID 事务链
ACID 事务链可以看作是 SagaChoreography 的增强版,它要求参与分布式事务的所有服务都使用支持传统 ACID 事务的数据库,然后通过将每个服务内部的数据操作和同步调用相邻服务的操作打包到一个 ACID 事务中,通过 ACID 事务的链式调用实现分布式事务。
使用 ACID 事务链实现分布式事务的优点有:
符合 ACID:每个步骤都是传统 ACID 事务,整体也符合 ACID 事务性
不需要服务提供补偿接口:由支持 ACID 事务的数据库进行回滚操作
使用 ACID 事务链实现分布式事务的缺点有:
数据库选型限制:对于服务的数据库选型引入了支持传统 ACID 事务这个限制。
服务耦合过多:服务之间的依赖是链式拓扑,不方便调整步骤顺序;随着使用分布式事务的各种业务流程的增加,很容易产生服务之间的循环依赖,给部署造成困难。
选择 Saga Orchestration
我们首先排除了 XA 方案,它无法满足系统的可用性和扩展性。其次排除了 ACID 事务链,因为它不兼容业务现有的数据库选型,未来还会引入更多不支持 ACID 事务的数据库技术。
最终决定采用 Saga 来实现高可用、低延迟、最终一致的分布式事务框架,主要原因是其设计思想非常契合于目前 FreeWheel 核心业务团队的 SOA/微服务/Serverless 实践,即通过对一些基础服务(对于 Serverless 其实是 Lambda,以下不再区分)进行组合/编排来完成各种业务需求。
在 Saga 的两个变种中,我们选择了 Orchestration 而不是 Choreography,原因是:
服务解耦:Orchestration 天然地将事务本身的驱动逻辑和众多基础服务解耦,而 Choreography 在不引入队列的前提下,容易出现服务间循环依赖的问题。
服务分层:Orchestration 天然地将服务分成了组合/编排器和基础服务两个调用层级,有利于业务逻辑的扩展和重用。
数据解耦:对于某个步骤依赖前序多个步骤结果的业务场景,后者需要前序所有服务透传其他服务的数据,而 Orchestration 不需要。
采用 Saga Orchestration,势必需要想办法克服它的两个缺点,即要求基础服务提供补偿接口,以及没有实现 ACID 中的 Isolation 和 Durability。
如何实现数据补偿操作呢?数据操作可分为 Insert(新建),Delete(删除)和 Update(更新)三种,而 Update 又可细分为 Full update(Replace,整体更新)和 Partial update(Patch,部分更新),它们对应的补偿操作如下:
Insert:补偿操作是 Delete,参数为数据的 ID,要求在 Insert 操作之后记录下数据的 ID。
Delete:补偿操作是 Insert,参数为完整的数据,要求在进行 Delete 操作前记下当前完整的数据。
Full update:补偿操作是另一个 Full update,参数为完整的数据,要求在进行原 Full update 操作前记下当前完整的数据。
Partial update:补偿操作是 Partial / Full update,参数为改动前的部分数据或者完整数据,要求在进行原 Partial update 操作前记下当前部分或完整的数据。
再来看下如何实现 ACID 中的 I 和 D:
Isolation:其实是并发控制的问题,即如何处理对同一条数据(同一个 key)的并发操作。MySQL 给出的解决方案是多版本并发控制(MVCC),然而不是所有的数据库都支持这一特性。控制并发的另一条思路是消除并发,化并为串,一般通过抢占锁或者使用队列来实现。考虑到等待锁而产生的性能损耗以及锁顺序不一致导致的互锁问题,优先考虑使用队列。
Durability:指成功提交到系统的事务不能中途丢失,即实现数据持久化。需要考虑的故障包括数据存储节点的故障和数据处理节点的故障。
综上所述,我们需要增加一个队列+持久化的技术方案来补足 Saga 的短板,实现 ACID。结合 FreeWheel 核心业务系统现有的基础设施,我们优先考虑引入 ApacheKafka(以下简称 Kafka)。
引入 Kafka
Kafka 是一个功能丰富的队列+持久化解决方案,针对分布式事务的设计目标,我们看中的是它的这些能力:
消息保序:引入队列来化并为串,解决并发写入数据的 Isolation 问题。
消息送达保证:支持“至少一次”(at least once)的消息送达保证,具有冗余备份和故障恢复能力,有助于解决 ACID 的 Durability 问题。
性能优秀:各种资料表明,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 从接到用户请求,触发分布式事务,分步骤调用各个基础服务,到最终返回响应,流程如下图:
步骤详解:
1-2: 服务 A 的某个节点在接到用户请求后,首先担当生产者的角色,将用户请求和回调地址包装成消息发送到 Kafka,然后处理该用户请求的处理单元阻塞等待。
3-5: 同一个服务 A 的某个节点的消费者从 Kafka 接到消息,开始驱动 Saga Orchestration 的流程,按照业务定义的顺序和逻辑依次调用服务 B 和 C 的接口。
6-7: Saga 流程结束后,消费者向 Kafka 发送消费进度确认操作(ackMessage,也就是更新 consumer group offset),然后将结果(成功还是失败,做了哪些改动)通过 RPC 回调地址发送给生产者。
8: 生产者从回调地址接到数据后,找到对应的用户请求处理单元,解除阻塞,最后将结果封装成用户响应。
队列消息协议设计
一条队列消息至少包含两部分信息:元数据(Metadata)和内容(Content)。
元数据:由分布式事务框架读取和写入,使用 JSON 格式,字段格式固定,业务代码只能读取,不能写入。元数据中最重要的字段是分布式事务消息的类型(以下简称 TxType)。生产者通过强类型来指定消息的 TxType;消费者进程中的分布式事务框架会根据 TxType 进行事件分流(event sourcing),调用对应业务逻辑进行消费。
内容:由业务代码读取和写入,格式随意,框架不做解析,只要长度不超过 Kafka topic 的限制即可(默认 1MB)。
Kafka 并行消费模型的改进
Kafka 上的消息数据被分成 topic(主题)和 partition(分区)两个层级,由 topic、partition 和 offset(偏移量)来唯一标识一条消息。partition 是负责保证消息顺序的层次。Kafka 还支持一个消息被不同的“业务”多次消费(称为多播或扇出),为了区分不同“业务”,引入了消费者组的概念,一个消费者组在一个 partition 上共享一个消费进度(consumergroup offset)。为保证消息送达顺序,一个 partition 上的数据,同一时间、同一消费者组最多由一个消费者获得。
这给 Kafka 的使用者造成了一些实际问题:
高估 partition 导致资源浪费:为了不丢消息,给定 topic 上的 partition 数量只能增加,不能减少。这要求某个 topic 在上线之前预估其生产能力和消费能力,然后按照生产能力的上限和消费能力的下限,敲定一个 partition 数量的上限来部署。上线后如果发现 topic 上的生产能力高于消费能力,必须先扩充 partition,再提升消费能力(最直接的途径是增加消费者数量)。相反如果发现 topic 上的生产能力低于消费能力(可能是消息的生产速率低于预期或者波动明显,也可能是单个消费者的消费能力通过优化得到提升),由于 partition 数量无法回缩,就会造成 Kafka 的资源浪费。现实情况是,partition 数量经常被高估,kafka topic 的处理能力经常被浪费。也正是因为如此,业务开发工程师才会设计 topic 和 partition 的各种复用机制。
partition 不足以区分哪些消息需要串行消费,哪些可以并行:Kafka 的默认的消息分区策略是通过对消息的 Key 字段计算 hash 值,分配到特定的 partition。但是某个消费者组对一个 partition 上的消息,有可能并不需要全部串行消费。比如某个服务认为消息 A、B 和 C 虽然都被划分到了 partition 0,但是只有 A 和 C 之间存在次序关系(比如更新的是同一条数据),B 可以与 A、C 并行消费。如果能有一种机制,允许根据业务定义哪些消息需要串行消费,剩下的消息则可以并行消费,就能在不改变 partition 数量的基础上提升消费并行度和处理能力,降低代码对 partition 数量的依赖程度。
针对以上两个问题,分布式事务的并行消费部分引入了如下改进方案:在不违背 ACID 事务性的前提下,在一个消费者进程内,对 partition(分区)根据一个子分区 ID(以下简称 id)和 TxType 进行再次分区,同一个子分区的消息串行消费,不同子分区的消息并行消费。
如上图所示:
消息 id 默认复用 Kafka 消息的 Key 字段的值,支持产品工程师自定义消息的 id,但是其区分度不能小于消息的 Topic + Partition 的区分度。
消费者进程接到消息后,分布式事务框架会先解析消息的元数据,得到消息的 TxType 和 id。
消息会按照 TxType+id 进行再次分区,由框架自动分配并发送到一个内存队列(先进先出)和处理单元,交给业务代码进行实际消费。
不同 TxType+id 的消息会被分配到不同的内存队列/处理单元,处理单元之间互不阻塞,并行(或并发)执行,并行(并发)度可以调整。
由于 partition 被再次划分,定义在消费组和 partition 上的消费进度需要增加一步聚合处理,确保在 Kafka 发送 ack 的时候,给定偏移量之前的消息都已处理完毕。
可以配置内存队列/处理单元的最大长度和最大并行度,并且在空闲一段时间后会进行资源回收,避免内存堆积。
落地实践
部署细节
以代码库方式发布:不引入独立的服务,将 Saga 和 Kafka 相关的逻辑抽取成公共代码库按版本发布,随着位于组合编排器层的服务一起部署和升级。
生产者和消费者以 1:1 共存于同一个进程:需要发起和管理分布式事务的服务,每个节点都会启动一个生产者和一个消费者,并且借助现有的集群部署工具(Amazon EKS),保证该服务的所有节点都可以互相连通,并且可以连接 Kafka。这种部署方式允许我们从消费者节点直接回调生产者节点,无需引入额外的消息总线或其他数据共享机制。后续可以根据需要,将生产者和消费者部署在不同的服务上,只要它们的节点之间可以相互连通。
支持 Kafka 和 Go channel 两种队列模式:Kafka 队列模式符合 ACID 的定义,Go channel 队列模式只能保证 ACID 中的 A,不能保证 I 和 D。开发和单元测试阶段可以使用 Go channel 模式,服务集成测试和线上部署时一般使用 Kafka 模式。线上 Kafka 服务整体不可用时,发起分布式事务的服务可降级为 Go channel 模式。
共享 Kafka topic 和 partition:多个服务或流程可以共享 Kafka 的 topic 和 partition,使用消费者组来进行区分消费进度,使用 TxType 来做事件分流。
系统可用性分析
分布式系统的高可用性,需要依赖参与其中的每个服务足够健壮。下面对分布式事务中的各种服务进行分类探讨,描述当部分服务节点出现故障时系统的可用性。
生产者故障:生产者随某个组织/编排器服务部署,节点冗余。假如生产者所在服务的部分节点故障,对于该节点上发出队列消息、尚未收到回调的所有事务,客户将看到请求失败或超时,重试导流到正常节点后可以成功提交。
消费者故障:消费者和生产者一样,随同组织/编排器服务部署,节点冗余。假如消费者所在的部分节点故障,对于该节点上接到队列消息、尚未发送回调和的所有事务,客户将看到请求超时。Kafka 在配置的消费者会话超时(默认是 10 秒,可以按消费者定制)之后,会标记该消费者下线,然后对 topic 和 partition 进行负载调整,按一定算法尽可能平均地分配给当前消费者组剩余的在线成员,负载调整的耗时一般在秒级。从消费者所在节点故障开始,到 Kafka 负载调整结束,这段时间里发生故障的消费者负责的 topic 和 partition 上的消息都无法处理。客户将看到部分请求出现超时错误。如果提交的数据和生成的队列消息的 partition 有直接映射关系的话,这段时间内同一份数据重试也会失败。
基础服务故障:给定的分布式事务会依赖多个基础服务,每个服务独立部署,节点冗余。假如某基础服务部分节点故障,分布式事务的相应请求会在相应的步骤会出现部分失败,前序步骤依次执行补偿接口。客户看到的超时或者业务定制的失败信息,并且重试有可能成功。业务可以引入服务熔断机制,来避免消息堆积。
消息队列故障:Kafka 本身具备主从复制、节点冗余和数据分区来实现的高可用性,在此不做深入讨论。
线上问题及处理
分布式事务框架随服务发布之后,经过一段时间的线上运行,基本符合设计预期。期间出现了一些问题,列举如下。
生产者和消费者的连通性问题
使用分布式事务的某服务在部分数据上出现超时,客户重试无效,而在另一些数据上正常返回。通过分析日志发现,这些消息的发送和处理都成功了,但是消费者回调生产者失败。进一步研究日志发现,消费者所在的节点和生产者所在的节点位于不同的集群,出现了网络分隔。查看配置,两个集群的同名服务配置了相同的 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 核心业务系统的架构变迁和事务性需求,本文介绍了一种支持异构数据库、实现最终一致性的分布式事务方案以及相关的落地实践,希望能为面临类似问题的读者提供一些思路和启发。
参考资料
数据密集型应用的设计: https://www.oreilly.com/library/view/designing-data-intensive-applications/9781491903063/
Kafka 介绍:https://kafka.apache.org/intro
作者介绍:杨帆,FreeWheel 高级软件工程师,技术发烧友、模范消费者。研究方向为弹性架构、云计算、数据可视化、产品设计等领域。
相关文章:
评论 3 条评论