[微服务进阶场景实战] - 数据一致性

前面我们梳理了微服务的九大痛点,其中一部分目前尚无完美解决方案,另一部分则已有可行对策。从本章起,我们将逐一探讨那些"有解"的痛点。首当其冲的,就是数据一致性问题------让我们从一个实际业务场景切入。

1 业务场景:下游服务失败后上游服务如何独善其身

在微服务架构中,一个业务请求常常需要跨多个服务更新多个数据库,示意图如下:

理想情况下,业务顺利完成时,三个服务的数据应依次更新为 a2、b2、c2,此时数据处于一致状态。然而,现实往往充满意外:网络抖动、服务过载、数据库响应缓慢等都可能导致调用链中途失败。例如,若在步骤 2 失败,数据会停留在 a2、b1、c1;若在步骤 3 失败,则变成 a2、b2、c1------数据不一致就这样产生了。

在本项目启动前,由于早期改造工期紧张,开发团队无暇顾及数据一致性问题,导致线上陆续出现错误数据。业务部门通过工单反馈后,IT 团队排查发现,根源正是分布式更新过程中的不一致。

事已至此,团队不得不专门腾出精力,为数据一致性问题设计可靠方案。经过讨论,他们将问题归纳为两类:

第一类:可接受短期不一致,但必须保证最终一致性

以零售下单为例(简化版):订单需依次在商品服务扣库存、订单服务生成订单、交易服务生成交易单。假设生成交易单失败,就会出现库存已扣、订单已生、但交易单缺失的状态。此时只要系统能最终补全交易单,业务即可接受------这就是最终一致性。

第二类:必须保证实时一致性

以积分兑换折扣券为例:需要先扣积分,再发折扣券。若采用最终一致性,用户可能看到积分已扣但券未到账,进而立刻投诉。此时更好的做法是:一旦后续步骤失败,立即回滚前面所有操作,并提示用户"操作失败,请重试"。这就是实时一致性。

那么,针对这两类情况,具体如何实现呢?下面我们分别展开。

2 最终一致性方案

对对于接受最终一致性的场景,核心思路是借助消息队列(MQ)实现跨服务的数据更新"接力"。具体流程如下:

  1. 每个服务完成本地操作后,向 MQ 发送一条消息,触发下一个服务的处理。
  2. 下游服务消费消息,完成自身操作后,继续发送消息给更下游的服务。
  3. 若消费失败,消息应保留在 MQ 中,等待后续重试。

整个调用流程如图所示:

详细步骤与异常处理如下:

步骤 执行内容 失败应对策略
1 调用端调用 Service A 直接返回失败,用户数据无影响
2 Service A 将 a1 改为 a2 本地事务回滚,数据恢复
3 Service A 向 MQ 发送 Step2 消息 发送失败则触发本地回滚
4 Service A 返回成功响应给调用端 无需额外处理
5 Service B 消费 Step2 消息 MQ 自带重试机制,无需担心
6 Service B 将 b1 改为 b2 本地事务回滚,消息重新投递(重新回到步骤5)
7 Service B 向 MQ 发送 Step3 消息 依赖 MQ 生产端重试机制
8 Service B 确认消费 Step2 若失败,MQ 会重新投递给其他消费者(重新回到步骤5)
9 Service C 消费 Step3 消息 同步骤 5
10 Service C 将 c1 改为 c2 同步骤 6
11 Service C 确认消费 Step3 同步骤 8

该方案本质上是利用 MQ 的消息持久化、重试和确认机制,将分布式更新拆解为多个本地事务,通过异步消息串联起来。但还需要注意两个常见问题:

1. 幂等性必须保证

由于 MQ 的重试机制,步骤 6 和步骤 10 可能被重复执行。因此,下游服务在更新数据时,必须实现业务幂等------即同一消息多次消费,结果应与一次消费相同。

2. 代码复用与封装

如果每个业务流程都手动实现上述逻辑,开发量会很大。实际上,MQ 相关的发送、消费、确认逻辑可以抽象为公共组件。本项目最终将这些重复代码封装成通用模块,供各业务服务调用。具体封装方法较为直接,此处不再展开。

3 实时一致性方案

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

MySQL其实有一个两阶段提交的分布式事务方案MySQL XA,但是该方案存在严重的性能问题。比如,一个数据库的事务与多个数据库间的XA事务性能可能相差10倍。

另外,XA的事务处理过程会长期占用锁资源,所以项目组一开始就没有考虑这个方案。而当时比较流行的方案是使用TCC模式,下面简单介绍一下。

4 TCC模式:一分为三的补偿型事务

TCC(Try-Confirm-Cancel)是一种补偿型事务方案。它将一个完整的业务接口逻辑拆解为三个独立的操作阶段:

  1. Try :尝试执行。负责完成所有业务检查,并预留必要的资源(例如冻结积分、锁定库存)。
  2. Confirm :确认执行。真正执行业务操作,使用Try阶段预留的资源,通常保证成功
  3. Cancel :取消执行。用于释放Try阶段预留的资源,进行业务回滚。

以"积分兑换折扣券"为例,涉及"账户服务扣积分"和"营销服务发折扣券"两个操作。那么,仅"扣积分"这一个接口,就需要拆分为以下三个方法:

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

理,"发折扣券"接口也需要编写对应的三个方法。其成功调用的流程示意如图所示。

该流程中,除Cancel路径外,代表成功调用链。一旦任何环节出错,事务管理器将协调调用所有已执行服务的Cancel方法进行回滚。

TCC模式将一段业务逻辑拆成三部分,实施复杂度显著增加,并需谨慎处理以下问题:

  1. 空提交:需保证Try成功,Confirm必定成功。
  2. 空回滚:需处理Try未执行却收到Cancel调用的情况,实现正确回滚。
  3. 防悬挂:需防止因网络拥堵,Cancel先于Try到达导致资源被永久锁定。
  4. 幂等性:所有Try、Confirm、Cancel操作都必须支持重复调用。
  5. 数据隔离:事务期间数据处于中间状态(如"冻结"),需设计得当,避免对其他业务逻辑造成干扰。

可见,TCC模式实现相当繁琐 ,不仅业务代码量激增,还需精心处理上述各类边缘情况,开发和维护成本高,易出错。幸运的是,存在更优的替代方案,例如Seata框架提供的AT模式

5 Seata AT模式:基于SQL反向补偿的自动回滚

AT(Automatic Transaction)模式,即自动(分支)事务模式。其最大优点是对业务侵入极低

开发者只需做两件事:

  • 在全局事务的发起方 方法上添加 @GlobalTransactional 注解。
  • 在各个参与服务的本地方法上,继续使用普通的 @Transactional 注解。

Seata官网对这块介绍的非常详细,有兴趣的朋友可以看一下:Apache Seata。在这里简要概括一下,其核心机制在于,Seata会自动拦截并解析业务SQL,通过生成并管理反向回滚日志,实现分布式事务的自动协调。整个过程可简要概括为三个阶段:

第一阶段:执行与日志记录

  1. 解析SQL:拦截业务SQL,解析其操作类型、表、条件等。
  2. 保存前置镜像:根据解析条件查询数据,保存更新前的状态(Before Image)。
  3. 执行业务SQL:执行用户定义的更新操作。
  4. 保存后置镜像:查询更新后的数据状态(After Image)。
  5. 插入回滚日志 :将前后镜像数据及SQL信息组成回滚日志,存入 UNDO_LOG 表。
  6. 注册与锁 :向事务协调器(TC)注册分支事务,并获取相关数据的全局锁
  7. 本地提交 :提交业务数据更新和 UNDO_LOG
  8. 上报结果 :将本地事务提交结果上报给TC。

第二阶段:回滚(如需要)

若收到TC发出的回滚指令,则:

  1. 开启本地事务,查找对应的 UNDO_LOG 记录。
  2. 数据校验 :对比 UNDO_LOG 中的后镜像与当前数据。若不一致(说明数据被其他事务修改),需根据策略处理。
  3. 生成并执行回滚SQL:根据前镜像数据,生成反向SQL执行,恢复数据。
  4. 提交并上报 :提交本地事务,删除 UNDO_LOG,并向TC上报回滚结果。

第三阶段:异步清理

收到TC的提交指令后,异步、批量地删除对应的 UNDO_LOG 记录,完成资源清理。

6 为何选择Seata:一个务实的工程决策

当时,Seata虽未发布正式版,但我们项目组仍决定采用,主要基于两点现实考量:

  1. 场景可控 :需要强实时一致性的业务场景占比少、频率低 ,影响范围有限。对于高频、高并发的场景,我们会优先与业务方沟通,采用最终一致性方案,从而规避性能风险。
  2. 投入产出比高 :相较于TCC模式需要将每个业务逻辑"一拆三"并处理各种异常,AT模式仅需增加一个 @GlobalTransactional注解,开发工作量天差地别。这种显著的效率提升,使得尝试新技术带来的风险变得可以接受。这或许也是Seata能迅速流行的原因之一。

尽管AT模式存在一些小缺陷(如全局锁对性能的细微影响),但瑕不掩瑜,其易用性优势在大多数场景下非常突出。

7 小结:缓解痛点,轻装前行

通过为"最终一致性"和"实时一致性"场景分别设计并落地了基于MQ的异步补偿方案与基于Seata AT模式的自动补偿方案,我们成功解决了数据不一致的核心痛点。
关键成果在于 :这些方案并未给业务开发带来显著负担,也未影响项目正常推进,却极大降低了数据错误的发生概率。

至此,数据一致性的痛点已得到有效缓解。接下来,我们将面对微服务架构中的另一个常见难题:服务间数据依赖复杂,导致需要编写大量冗余数据获取与组装逻辑。这个问题又该如何破解?