分布式场景实战第六节 微服务数据治理方案

564 篇文章 138 订阅

16 数据一致性:下游服务失败上游服务如何独善其身?

前面三讲我们聊了微服务的 9 个痛点,有些痛点没有好的解决方案,而有些痛点刚好有一些对策,后面的课程我们就来讲解某些痛点对应的解决方案。

这一讲我们先解决数据一致性的问题,来看一个实际的业务场景。

业务场景(架构经历十二)

14 讲中我们讲过,使用微服务时,很多时候我们往往需要跨多个服务去更新多个数据库的数据,类似下图所示的架构。

Drawing 0.png

图 1

如图 1 所示,如果业务正常运转,3 个服务的数据应该变为 a2、b2、c2,此时数据才一致。但是如果出现网络抖动、服务超负荷或者数据库超负荷等情况,整个处理链条有可能在步骤二失败,这时数据就会变成 a2、b1、c1,当然也有可能在步骤三失败,最终数据就会变成 a2、b2、c1,这样数据就对不上了,即数据不一致。

在以往的架构经历中,因为项目非常赶,所以我们完全没有精力处理数据一致性的问题,最终业务系统会出现很多错误数据。然后业务部门会发工单告知数据有问题,经过一番检查后,我们发现是分布式更新的原因导致了数据不一致。

此时,我们不得不抽出时间针对数据一致性问题给出一个完美解决方案。于是,整个部门人员坐一起商量,并把数据一致性的问题归类为以下 2 种场景。

第一种场景:实时数据不一致不要紧,保证数据最终一致性就行

因为一些服务出现错误,导致图 1 的步骤三失败,此时处理完请求后,数据就变成了 a2、 b2、c1,不过不要紧,我们只需保证最终数据是 a2、b2、 c2 就行。

在我以往的一个项目中,业务场景是这样的(示例有所简化):零售下单时,一般需要实现在商品服务中扣除商品的库存、在订单服务中生成一个订单、在交易服务中生成一个交易单这三个步骤。 假设交易单生成失败,就会出现库存扣除了、订单生成了、交易单没生成的情况,此时我们只需保证最终交易单成功生成就行,这就是最终一致性。

第二种场景:必须保证实时一致性

如果图 1 中的步骤二和步骤三成功了,数据就会变成 b2、c2,但是如果步骤三失败,那么步骤一和步骤二会立即回滚,保证数据变回 a1、b1。

在以往的一个项目中,业务场景类似这样:使用积分换折扣券时,需要实现扣除用户积分、生成一张折扣券给用户这 2 个步骤。如果我们还是使用最终一致性方案的话,有可能出现用户积分扣除了而折扣券还未生成的情况,此时用户进入账户一看,积分没了也没有折扣券,立马就会投诉。

此时怎么办呢?我们直接将前面的步骤回滚,并告知用户处理失败请继续重试就行,这就是实时一致性。

针对以上两种具体的场景,其具体解决方案是什么呢?下面我们一起来看看。

最终一致性方案

对于数据要求最终一致性的场景,实现思路是这样的:

  1. 每个步骤完成后,生产一条消息给 MQ,告知下一步处理接下来的数据;

  2. 消费者收到这条消息后,将数据处理完成后,与步骤一一样触发下一步;

  3. 消费者收到这条消息后,如果数据处理失败,这条消息应该保留,直到消费者下次重试。

为了方便你理解这部分内容,我梳理了一个大概的流程图,如下图所示:

Drawing 1.png

图 2

关于图 2,详细的实现逻辑如下:

  1. 调用端调用 Service A;

  2. Service A 将数据库中的 a1 改为 a2;

  3. Service A 生成一条步骤 2(姑且命名为 Step2)的消息给到 MQ;

  4. Service A 返回成功给调用端;

  5. Service B 监听 Step2 的消息,拿到一条消息。

  6. Service B 将数据库中的 b1 改为 b2;

  7. Service B 生成一条步骤 3(姑且命名为 Step3)的消息给到 MQ;

  8. Service B 将 Step2 的消息设置为已消费;

  9. Service C 监听 Step3 的消息,拿到一条消息;

  10. Service C 将数据库中的 c1 改为 c2;

  11. Service C 将 Step3 的消息设置为已消费。

接下来我们考虑下,如果每个步骤失败了该怎么办?

1. 调用端调用 Service A。

解决方案:如果这步失败,直接返回失败给用户,用户数据不受影响。

2. Service A 将数据库中的 a1 改为 a2。

解决方案:如果这步失败,利用本地事务数据直接回滚就行,用户数据不受影响。

3. Service A 生成一条步骤 2(姑且命名为 Step2)的消息给到 MQ。

解决方案:如果这步失败,利用本地事务数据将步骤 2 直接回滚就行,用户数据不受影响

4. Service A 返回成功给调用端。

解决方案:如果这步失败,不做处理。

5. Service B 监听 Step2 的消息,拿到一条消息。

解决方案:如果这步失败,MQ 有对应机制,我们无须担心。

6. Service B 将数据库中的 b1 改为 b2。

解决方案:如果这步失败,利用本地事务直接将数据回滚,再利用消息重试的特性重新回到步骤 5 。

7. Service B 生成一条步骤 3(姑且命名为 Step3)的消息给到 MQ。

解决方案:如果这步失败,MQ 有生产消息失败重试机制。要是出现极端情况,服务器会直接挂掉,因为 Step2 的消息还没消费,MQ 会有重试机制,然后找另一个消费者重新从步骤 5 执行。

8. Service B 将 Step2 的消息设置为已消费。

解决方案:如果这步失败,MQ 会有重试机制,找另一个消费者重新从步骤 5 执行。

9. Service C 监听 Step3 的消息,拿到一条消息。

解决方案:如果这步失败,参考步骤 5 的解决方案。

10. Service C 将数据库中的 c1 改为 c2。

解决方案:如果这步失败,参考步骤 6 的解决方案。

11. Service C 将 Step3 的消息设置为已消费。

解决方案:如果这步失败,参考步骤 8 的解决方案。

以上就是最终一致性的解决方案,如果你仔细思考了该方案,就会与当初的我一样存在以下 2 点疑问。

  1. 因为我们利用了 MQ 的重试机制,就有可能出现步骤 6 跟步骤 10 重复执行的情况,此时该怎么办?比如上面流程中的步骤 8 失败了,需要从步骤 5 重新执行,这时就会出现步骤 6 执行 2 遍的情况。为此,在下游(步骤 6 和 步骤 10)更新数据时,我们需要保证业务代码的幂等性(关于幂等性,我们在 01 讲提过)。

  2. 如果每个业务流程都需要这样处理,岂不是需要额外写很多代码?那我们是否可以将类似处理流程的重复代码抽取出来?答案是可以的,这里使用的 MQ 相关逻辑在其他业务流程中也通用,最终我们就是将这些代码进行了抽取并封装。关于重复代码抽取的方法比较简单,这里就不赘述了。

实时一致性方案

实时一致性,其实就是我们常说的分布式事务。

MySQL 其实有一个两阶段提交的分布式事务方案(MySQL XA),但是该方案存在严重的性能问题。比如,一个数据库的事务与多个数据库间的 XA 事务性能可能相差 10 倍。另外,在 XA 的事务处理过程中它会长期占用锁资源,所以一开始我们并不考虑这个方案。

那时,市面上比较流行的方案是使用 TCC 模式,下面我们简单介绍一下。

TCC 模式

在 TCC 模式中,我们会把原来的一个接口分为 Try 接口、Confirm 接口、Cancel 接口。

  • Try 接口用来检查数据、预留业务资源。

  • Confirm 接口用来确认实际业务操作、更新业务资源。

  • Cancel 接口是指释放 Try 接口中预留的资源。

比如积分兑换折扣券的例子中需要调用账户服务减积分、营销服务加折扣券这两个服务,那么针对账户服务减积分这个接口,我们需要写 3 个方法,如下代码所示:

public boolean prepareMinus(BusinessActionContext businessActionContext, final String accountNo, final double amount) {    
   //校验账户积分余额    //冻结积分金额}public boolean Confirm(BusinessActionContext businessActionContext) {    
   //扣除账户积分余额    //释放账户 冻结积分金额
   }public boolean Cancel(BusinessActionContext businessActionContext) {    
    //回滚所有数据变更
}

同样,针对营销服务加折扣券这个接口,我们也需要写3个方法,而后调用的大体步骤如下:

Drawing 2.png

图 3

图 3 中绿色代表成功的调用路径,如果中间出错,就会先调用相关服务的回退方法,再进行手工回退。原本我们只需要在每个服务中写一段业务代码就行,现在需要拆成 3 段来写,而且还涉及以下 5 点注意事项:

  1. 我们需要保证每个服务的 Try 方法执行成功后,Confirm 方法在业务逻辑上能够执行成功;

  2. 可能会出现 Try 方法执行失败而 Cancel 被触发的情况,此时我们需要保证正确回滚;

  3. 可能因为网络拥堵出现 Try 方法的调用被堵塞的情况,此时事务控制器判断 Try 失败并触发了 Cancel 方法,后来 Try 方法的调用请求到了服务这里,此时我们应该拒绝 Try 请求逻辑;

  4. 所有的 Try、Confirm、Cancel 都需要确保幂等性;

  5. 整个事务期间的数据库数据处于一个临时的状态,其他请求需要访问这些数据时,我们需要考虑如何正确被其他请求使用,而这种使用包括读取和并发的修改。

所以 TCC 模式是一个很麻烦的方案,除了每个业务代码的工作量 X3 之外,出错的概率也高,因为我们需要通过相应逻辑保证上面的注意事项都被处理。

后来,我们刚好看到了一篇介绍 Seata 的文章,了解到 AT 模式也能解决这个问题。

Seata 中 AT 模式的自动回滚

对于使用 Seata 的人来说操作比较简单,只需要在触发整个事务的业务发起方的方法中加入@GlobalTransactional 标注,且使用普通的 @Transactional 包装好分布式事务中相关服务的相关方法即可。

在 Seata 内在机制中,AT 模式的自动回滚往往需要执行以下步骤:

一阶段

  1. 解析每个服务方法执行的 SQL,记录 SQL 的类型(Update、Insert 或 Delete),修改表并更新 SQL 条件等信息;

  2. 根据前面的条件信息生成查询语句,并记录修改前的数据镜像;

  3. 执行业务的 SQL;

  4. 记录修改后的数据镜像;

  5. 插入回滚日志:把前后镜像数据及业务 SQL 相关的信息组成一条回滚日志记录,插入 UNDO_LOG 表中;

  6. 提交前,向 TC 注册分支,并申请相关修改数据行的全局锁 ;

  7. 本地事务提交:业务数据的更新与前面步骤生成的 UNDO LOG 一并提交;

  8. 将本地事务提交的结果上报给事务控制器。

二阶段-回滚

收到事务控制器的分支回滚请求后,我们会开启一个本地事务,并执行如下操作:

  1. 查找相应的 UNDO LOG 记录;

  2. 数据校验:拿 UNDO LOG 中的后镜像数据与当前数据进行对比,如果存在不同,说明数据被当前全局事务之外的动作做了修改,此时我们需要根据配置策略进行处理;

  3. 根据 UNDO LOG 中的前镜像和业务 SQL 的相关信息生成回滚语句并执行;

  4. 提交本地事务,并把本地事务的执行结果(即分支事务回滚的结果)上报事务控制器。

二阶段-提交

  1. 收到事务控制器的分支提交请求后,我们会将请求放入一个异步任务队列中,并马上返回提交成功的结果给事务控制器。

  2. 异步任务阶段的分支提交请求将异步地、批量地删除相应 UNDO LOG 记录。

以上就是 Seata 的 AT 模式的简单介绍。

尝试 Seata

当时, Seata 虽然还没有更新到 1.0,且官方也不推荐线上使用,但是最终我们还是使用了它,原因如下:

  1. 因为实时一致性的场景很少,而且发生频率低,因此并不会大规模使用,对我们来说影响面在可控范围内。如果实时一致性的场景发生频率高,并发量就高,业务人员对性能要求也高,此时我们就会与业务商量,采用最终一致性的方案。

  2. Seata AT 模式与 TCC 模式相比,它只是增加了一个 @GlobalTransactional 的工作量,因此两者的工作量实在差太多了,所以我们愿意冒这个险,这也是 Seata 发展很快的原因。

后面,我们就在线上环境使用了 Seata。虽然它有点小毛病,但是瑕不掩瑜。

总结与预告

最终一致性与实时一致性的解决方案设计完后,不仅没有给业务开发人员带来额外工作量,也没有影响日常推进业务项目的进度,还大大减少了数据不一致的出现概率,因此数据不一致的痛点算是大大缓解了。

不过该方案存在一点不足,因为某个服务需要依赖其他服务的数据,使得我们需要额外写很多业务逻辑,关于此问题的解决方案我们已在 14 讲中详细说明,你可以前往回顾。

16 讲中讲解的方案,肯定还存在一些遗漏的问题没有考虑,如果你有更好的方案,欢迎在留言区进行互动。另外,如果你觉得本专栏有价值,欢迎分享给更多的好友看到哦。


17 数据同步:如何解决微服务之间的数据依赖问题?

16 讲中我们讲解了数据一致性的解决方案,这一讲我们来聊聊服务之间的数据依赖问题,还是先从具体的业务场景说起。

业务场景(架构经历十三)

在我们曾经设计的一个供应链系统中,存在商品、销售订单、采购这三个服务,它们的主数据的部分结构如下所示:

商品

Drawing 1.png

订单和子订单

Drawing 2.png

采购单和子订单

Drawing 4.png

在设计这个供应链系统时,我们需要满足以下两个需求:

  • 根据商品的型号/分类/生成年份/编码等查找订单;

  • 根据商品的型号/分类/生成年份/编码等查找采购订单。

初期我们的方案是这样设计的:严格按照的微服务划分原则将商品相关的职责存放在商品系统中。因此,在查询订单与采购单时,如果查询字段包含商品字段,我们需要按照如下顺序进行查询:

  • 先根据商品字段调用商品的服务,然后返回匹配的商品信息;

  • 在订单或采购单中,通过 IN 语句匹配商品 ID,再关联查询对应的单据。

为了方便你理解这个过程,我画了一张订单查询流程图,如下图所示:

Drawing 0.png

初期方案设计完后,很快我们就遇到了一系列问题:

  • 随着商品数量的增多,匹配的商品越来越多,于是订单服务中包含 IN 语句的查询效率越来越慢;

  • 商品作为一个核心服务,依赖它的服务越来越多,同时随着商品数据量的增长,商品服务已不堪重负,响应速度也变慢,还存在请求超时的情况;

  • 由于商品服务超时,相关服务处理请求经常失败。

结果就是业务方每次查询订单或采购单时,只要带上了商品这个关键字,查询效率就会很慢而且老是失败。于是,我们重新想了一个新方案——数据冗余,下面我们一起来看下。

数据冗余的方案

数据冗余的方案说白了就是在订单、采购单中保存一些商品字段信息。

为了便于你理解,下面我们借着上方的实际业务场景具体说明下,请注意观察两者之间的区别。

商品

Drawing 7.png

订单和子订单

Drawing 8.png

采购单和子订单

Drawing 9.png

调整架构方案后,每次查询时,我们就可以不再依赖商品服务了。

但是,如果商品进行了更新,我们如何同步冗余的数据呢?在此我分享 2 种解决办法。

  • 每次更新商品时,先调用订单与采购服务,再更新商品的冗余数据。

  • 每次更新商品时,先发布一条消息,订单与采购服务各自订阅这条消息后,再各自更新商品的冗余数据。

学到这是不是感觉很眼熟?没错,这就是 16 讲中提到的数据一致性问题。那么这 2 种解决办法会出现哪些问题呢?

如果商品服务每次更新商品都需要调用订单与采购服务,然后再更新冗余数据,则会出现以下两种问题。

  • 数据一致性问题: 如果订单与采购的冗余数据更新失败了,整个操作都需要回滚。这时商品服务的开发人员肯定不乐意,因为冗余数据不是商品服务的核心需求,不能因为边缘流程阻断了自身的核心流程。

  • 依赖问题: 从职责来说,商品服务应该只关注商品本身,但是现在商品还需要调用订单与采购服务。而且,依赖商品这个核心服务的服务实在是太多了,也就导致后续商品服务每次更新商品时,都需要调用更新订单冗余数据、更新采购冗余数据、更新门店库存冗余数据、更新运营冗余数据等一大堆服务。那么商品到底是下游服务还是上游服务?还能不能安心当底层核心服务?

因此,第一个解决办法直接被我们否决了,即我们采取的第二个解决办法——通过消息发布订阅的方案,因为它存在如下 2 点优势。

  • 商品无须调用其他服务,它只需要关注自身逻辑即可,顶多多生成一条消息送到 MQ。

  • 如果订单、采购等服务的更新冗余数据失败了,我们使用消息重试机制就可以了,最终能保证数据的一致性。

此时,我们的架构方案如下图所示:

Drawing 1.png

这个方案看起来已经挺完美了,而且市面上基本也是这么做的,不过该方案存在如下几个问题。

1. 在这个方案中,仅仅保存冗余数据还远远不够,我们还需要将商品分类与生产批号的清单进行关联查询。也就是说,每个服务不只是订阅商品变更这一种消息,还需要订阅商品分类、商品生产批号变更等消息。下面请注意查看订单表结构的红色加粗部分内容。

Drawing 11.png

以上我只是列举了一部分的结构,事实上,商品表中还有很多字段存在冗余,比如保修类型、包换类型等。为了更新这些冗余数据,采购服务与订单服务往往需要订阅近十种消息,因此,我们基本上需要把商品的一小半逻辑复制过来。

2. 每个依赖的服务需要重复实现冗余数据更新同步的逻辑。前面我们讲了采购、订单及其他服务都需要依赖商品数据,因此每个服务需要将冗余数据的订阅、更新逻辑做一遍,最终重复的代码就会很多。

3. MQ 消息类型太多了:联调时最麻烦的是 MQ 之间的联动,如果是接口联调还好说,因为调用哪个服务器的接口相对可控而且比较好追溯;如果是消息联调就比较麻烦,因为我们常常不知道某条消息被哪台服务节点消费了,为了让特定的服务器消费特定的消息,我们就需要临时改动双方的代码。不过联调完成后,我们经常忘了改回原代码。

为此,我们不希望针对冗余数据这种非核心需求出现如此多的问题,最终决定使用一个特别的同步冗余数据方案,接下来我们进一步说明。

解耦业务逻辑的数据同步方案

解耦业务逻辑的数据同步方案的设计思路是这样的:

  • 将商品及商品相关的一些表(比如分类表、生产批号表、保修类型、包换类型等)实时同步到需要依赖使用它们的服务的数据库,并且保持表结构不变;

  • 在查询采购、订单等服务时,直接关联同步过来的商品相关表;

  • 不允许采购、订单等服务修改商品相关表。

此时,整个方案的架构如下图所示:

Drawing 2.png

以上方案就能轻松解决如下两个问题:

  • 商品无须依赖其他服务,如果其他服务的冗余数据同步失败,它也不需要回滚自身的流程;

  • 采购、订单等服务无须关注冗余数据的同步。

不过,该方案的“缺点”是增加了订单、采购等数据库的存储空间(因为增加了商品相关表)。

仔细计算后,我们发现之前数据冗余的方案中每个订单都需要保存一份商品的冗余数据,假设订单总数是 N,商品总数是 M,而 N 一般远远大于 M。因此,在之前数据冗余的方案中,N 条订单就会产生 N 条商品的冗余数据。相比之下,解耦业务逻辑的数据同步方案更省空间,因为只增加了 M 条商品的数据。

此时问题又来了,如何实时同步相关表的数据呢?我们直接找一个现成的开源中间件就可以了,不过它需要满足支持实时同步、支持增量同步、不用写业务逻辑、支持 MySQL 之间同步、活跃度高这五点要求。

根据这五点要求,我们在市面上找了一圈,发现了 Canal、Debezium、DataX、Databus、Flinkx、Bifrost 这几款开源中间件,它们之间的区别如下表所示:

Drawing 13.png

从对比表中来看,比较贴近我们需求的开源中间件是 Bifrost,原因如下:

  1. 它的界面管理不错;

  2. 它的架构比较简单,出现问题后,我们可以自行调查,之后就算作者不维护了也可以自我维护,相对比较可控。

  3. 作者更新活跃;

  4. 自带监控报警功能。

因此,最终我们使用了 Bifrost 开源中间件,此时整个方案的架构如下图所示:

Drawing 3.png

上线效果

整个架构方案上线后,商品数据的同步还算比较稳定,此时商品服务的开发人员只需要关注自身逻辑,无须再关注使用数据的人。如果需要关联使用商品数据的订单,采购服务的开发人员也无须关注商品数据的同步问题,只需要在查询时加上关联语句即可,实现了双赢。

然而,唯一让我们担心的是 Bifrost 不支持集群,没法保障高可用性。不过,到目前为止,它还没有出现宕机的情况,反而是那些部署多台节点负载均衡的后台服务常常会出现宕机。

最终,我们总算解决了服务之间数据依赖的问题。

总结与预告

这一讲我们讲解了服务之间的数据依赖问题,并给出了对应解决方案。18 讲我们将讲解服务之间逻辑或流程上的依赖问题,希望你坚持学习哦~

这一讲中讲解的方案,其实不是一个很大众的方案,所以肯定还存在一些遗漏的问题没有考虑,如果你有更好的方案,欢迎在留言区与我互动、交流。

另外,喜欢本专栏的同学,欢迎分享给更多的好友看到哦。


  • 0
    点赞
  • 4
    收藏
    觉得还不错? 一键收藏
  • 打赏
    打赏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

办公模板库 素材蛙

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值