一致性挑战:微服务架构下的数据一致性解决方案

CAP 原则与 BASE 定理

CAP 原则

CAP 是Consistency(一致性)、Availability(可用性)、Partition tolerance(分区容错性)的首字母组合,它是所有分布式系统在设计前设计师必须优先考虑的事情。

一致性挑战:微服务架构下的数据一致性解决方案
CAP 原则
  • 一致性 C 代表更新操作成功后,所有节点在同一时间的数据完全一致。

  • 可用性 A 代表用户访问数据时,系统是否能在正常响应时间返回预期的结果。

  • 分区容错性 P 代表分布式系统在遇到某节点或网络分区故障的时候,仍然能够对外提供满足一致性或可用性的服务。

通过实际案例进行说明。以订单系统与库存系统为例

一致性挑战:微服务架构下的数据一致性解决方案
订单与库存系统

在实际业务中订单的创建会伴随着库存的减少,在原本单体架构中,这是一个应用中的两个模块,但随着业务发展库存模块压力增加,架构师就将其剥离为新的库存系统,库存数据也存放在单独的数据库中,两个系统间通过网络进行通信,架构模式自然也转为分布式。但与此同时,加入网络通信后系统故障概率也在增加,架构师必须考虑应用“分区”后的容错性问题,这就是 CAP 中的 P 分区容错性的含义,分区容错性是任何分布式系统必须包含的因素。

那么如何后续处理呢?有两种方案:

  • 第一种,放弃 C 一致性保障 A 可用性,简称 AP。具体表现为产生通信故障后,应用会完成新增订单放弃减少库存的操作,应用立即返回响应,并在响应中说明具体处理的细节,这种方案用户会有更好的体验,但数据层面是不完整的,需要后续补偿。

  • 第二种,放弃 A 可用性保障一致性 C,简称 CP。具体表现为产生通信故障后,应用会进入阻塞状态,一直尝试与库存系统恢复通信直到完成所有数据处理。这种方案是优先保障数据完整性,但此方案用户体验极差,因为在所有操作完成前用户会一直处于等待的状态。

CAP 本身就是互斥的,只能三者选两个,对于 CA、AP、CP 都有它们自己的应用场景,要结合实际进行选择。例如,CA 因为不考虑分区容忍度,所以它所有操作需要在同一进程内完成(也就是我们常说的单体应用);AP 因为放弃数据一致性,适合数据要求不高但强调用户体验的项目,如博客、新闻资讯等;CP 反之放弃了可用性,适合数据要求很高的交易系统,如银行交易、电商的订单交易等,就算是用户长时间等待,也要保障数据的完整可靠。

以上就是 CAP 原则在实际项目中的运用,对于互联网应用来说,如果为了用户体验完全放弃数据一致性这也是不可取的,毕竟数据才是根本。那怎么解决呢?这又衍生出一个新的理论:BASE 定理。

BASE 定理

BASE 定理是Basically Available(基本可用),Soft State(软状态)和Eventually Consistent(最终一致性)三个短语的缩写。BASE 是对 CAP 中一致性和可用性权衡后的选择,其来源于对大规模互联网系统分布式实践的结论,是基于 CAP 定理逐步演化而来的,其核心思想是即使无法做到强一致性,但每个应用都可以根据自身的业务特点,采用适当的方式来使系统达到最终一致性。

在刚才案例中,订单与库存系统通信故障时,架构师选择AP方案保障了用户的响应速度,此时订单创建但库存并没有减少,这就是不一致的情况。为了解决这种问题,很多项目会写一个任务线程定时检查通信状态,当通信恢复后该任务会通知库存系统完成减库存的处理,使数据保持一致。

BASE 允许存在软状态,所谓软状态就是在上述案例中订单增加但库存没有减少时的中间状态,后续的补救措施中任务线程对库存进行补偿,此时数据最终做到了的一致,此时的状态就是最终一致性状态。BASE 定理只要求保障数据是最终一致的,因此存在中间的软状态。

保障最终一致性的措施有很多,如 TCC、任务定时检查、MQ 消息队列缓冲、ETL 做日终校对甚至人工补录都是常用策略。在这些策略中,TCC 是架构师门普遍采用的一致性实现方案,先来了解什么是 TCC。

TCC 一致性方案

TCC 是三个字母Try(尝试)、Confirm(确认)与Cancel(取消)的首字母缩写。TCC 并不是指某一种技术,而是一种数据一致性方案,它将分布式处理过程分为两个阶段:

  • Try 是第一个阶段,用于尝试并锁定资源;

  • 如果资源锁定成功,第二个阶段进行 Confirm 提交完成数据操作;

  • 如果资源锁定失败,第二个阶段进行 Cancel 将数据回滚;

假设张三购买了 10 瓶可乐总价 30 元,需要订单表增加一笔金额为 30元 的订单,库存表可乐减 10 瓶。如果是 TCC 方案,在订单表需要额外增加预增金额与状态两个字段,库存表额外增加冻结库存字段。因为 Try 阶段用于锁定资源,因此新订单的 30 元是在预增金额中,而不是直接更新订单金额字段,此时订单的状态是“初始”状态。同样的,库存表要冻结 10 瓶可乐,而不是直接将 100 更新为 90。

一致性挑战:微服务架构下的数据一致性解决方案
Try 阶段

当两个系统所有资源都锁定完毕,便进入第二阶段执行 Confirm 操作。Confirm 操作用于提交数据,提交数据的过程非常简单,将订单表预增金额移动到订单金额中,订单状态变为完成,商品库存对应减少。

一致性挑战:微服务架构下的数据一致性解决方案
二阶段 Confirm 操作

如果 Try 阶段锁定资源出现异常,比如出现“库存不足”的情况,则进入第二阶段执行 Cancel 操作撤销之前锁定的资源。订单表预增金额重置为 0,订单状态变为取消,而库存表冻结库存也重置为 0。

一致性挑战:微服务架构下的数据一致性解决方案
二阶段 Cancel 操作

以上就是 TCC 在实际项目中的执行过程,你可以发现 TCC 是在数据源做文章,通过控制表上增加额外的锁定资源字段保障数据的一致性。那 TCC 方案中有哪些注意事项呢?

首先,要把绝大多数的业务逻辑在 Try 阶段完成,在 Try 阶段做尽可能多的事情。因为 TCC 设计之初认为 Confirm 或 Cancel 是一定要成功的,因此不要二阶段包含任何业务代码或者远程通信,只通过最简单的代码释放冻结资源。就像这个案例,Confirm 或 Cancel 只是在表上执行 update 语句来释放冻结资源,这个操作成功率 99.9999%,你可以认为二阶段是可靠的。

其次,假设 Confirm 或 Cancel 执行时出现错误,那具体的 TCC 框架也会不断重试执行操作来尽量保证执行成功,这个过程中可能会多次执行 update 语句,因此要注意代码的幂等性。

TIPS:幂等性是指1次操作与多次操作的结果是一致的,例如下面的例子。

# 不具备幂等性的SQL,因为在重试过程中每执行1次,num就自增+10

update tab set num = num + 10 where id = 1; 

# 具备幂等性的SQL,无论重试多少次更新结果num都是1760

update tab set num = 1760 where id = 1;

最后,极小概率下,Confim 或 Cancel 在多次重试后宣告失败,便会出现数据最终不一致的情况,这就需要自己开发额外的数据完整性校验程序补救或者通过人工进行补录。

说到这,想必你对 TCC 方案有了直观认识,但 TCC 方案终究是一种理论设计,需要厂商实现相应的框架给予支撑。在 Java 开源领域著名的 TCC 框架有:ByteTCC、Hmily、Tcc-transaction 与 Seata。没错,前面我们学过的分布式事务中间件 Seata 也支持 TCC 模式,下面来介绍 Seata 的 TCC 模式。

Seata 的 TCC 模式

Seata TCC 的执行过程

对于 Seata 的 TCC 模式,与AT 模式高度相似。可以理解为 TCC 是 AT 模式的“手动版”,所有提交与回滚时的操作都要自己书写代码进行处理,而不能像 AT 那样自动执行反向 SQL 完成提交与回滚。

一致性挑战:微服务架构下的数据一致性解决方案
Seata TCC 模式执行流程

 将执行步骤和伪代码结合在一起讲解 Seata TCC 模式的执行。

  • 第一步,TM 端商城应用执行 MallService.sale 方法,该方法用于实现完整的业务逻辑,同时定义了全局事务的边界。在 sale 方法上注意增加 @GlobalTransactional 注解,写上后在进入 sale 方法前会自动通知 TC 开启全局事务。

@Service
public class MallService {

    @Resource
    private OrderAction orderAction;

    @Resource
    private StorageAction storageAction;

    @GlobalTransactional
    public void sale(){ //商城业务中销售商品方法
       orderAction.prepare("张三",30);
       storageAction.prepare("可乐",10);
    }
}
  • 第二步,sale 方法第一句调用了 OrderAction 的 prepare 方法。Action 是 Seata TCC 模式下的特有类,在 TM 端的 OrderAction 只是一个接口,定义了 TCC 中 Try、Commit、Cancel 对应的方法。在本例中定义的 prepare 方法就对应 TCC 的 Try 阶段用于锁定资源。我们需要在 prepare 方法外增加 @TwoPhaseBusinessAction 注解,声明 prepare 方法执行成功或失败后由哪个方法进行后续的提交或回滚,这个注解有三个参数:

  1. name 代表分支事务的注册名称;

  2. commitMethod 代表二阶段 TC 发来提交消息时执行哪个方法;

  3. rollbackMethod 代表二阶段 TC 发来回滚消息时执行哪个方法。

public interface OrderAction {
    @TwoPhaseBusinessAction(name="TccOrderAction",commitMethod = "commit" , rollbackMethod = "rollback")
    //prepare对应TCC阶段一,用于锁定资源。
    public boolean prepare(BusinessActionContext actionContext,
                           @BusinessActionContextParameter(paramName = "customer") String customer,
                           @BusinessActionContextParameter(paramName = "amount") float amount
    );
    //提交方法定义
    public boolean commit(BusinessActionContext actionContext);

    //回滚方法定义
    public boolean rollback(BusinessActionContext actionContext);
}

OrderAction 具体的实现类则要在 RM 端订单服务中进行开发,下面是 OrderActionImpl 实现类的伪代码。

@Service("orderActionImpl")
public class OrderActionImpl implements OrderAction{

    @Override
    @Transactional(propagation = Propagation.REQUIRES_NEW) //本地事务控制
    public boolean prepare(BusinessActionContext actionContext, String customer, float amount) {
        Order order = new Order();
        order.setCustomer("张三");//客户
        order.setAmount(0f); //订单金额
        order.setFrozenAmount(amount); //prepare用于锁定资源,因此金额应写入"预增金额"字段。
        order.setStatus(0); //0代表初始状态
        orderRepository.save(order);//在RM端新建Order记录
        return true; //true代表执行成功
    }

    @Override
    @Transactional(propagation = Propagation.REQUIRES_NEW)
    public boolean commit(BusinessActionContext actionContext) {
        //幂等性检查
        String orderCode = (String)actionContext.getActionContext("orderCode");
        Order condition = new Order();
        condition.setOrderCode(orderCode);
        Example<Order> sample = Example.of(condition);
        Order order = orderRepository.findOne(sample).get();
        if(order.getStatus() > 0){ //订单状态不为0,代表这笔订单已被处理过,直接跳过
            return true; 
        }
        //设置金额,释放锁定金额,订单状态改为“完成”
        order.setAmount(order.getFrozenAmount());
        order.setFrozenAmount(0f);
        order.setStatus(1);
        orderRepository.save(order);
        return true;
    }

    @Override
    @Transactional(propagation = Propagation.REQUIRES_NEW)
    public boolean rollback(BusinessActionContext actionContext) {
        //幂等性检查
        String orderCode = (String)actionContext.getActionContext("orderCode");
        Order condition = new Order();
        condition.setOrderCode(orderCode);
        Example<Order> sample = Example.of(condition);
        Order order = orderRepository.findOne(sample).get();
        if(order.getStatus() > 0){//订单状态不为0,代表这笔订单已被处理过,直接跳过
            return true; 
        }
        //执行失败,订单金额回滚设置为0,订单状态改为“取消”
        order.setAmount(0f);
        order.setFrozenAmount(0f);
        order.setStatus(2);
        orderRepository.save(order);
        return true;
    }
}

因为 TM 商城应用与 RM 订单服务之间是远程调用。当 TM 端调用 prepare 方法时,实际是在订单服务中执行,此时订单服务也会通知 TC 开启分支事务。

  • 第三步,TM 端 sale 方法执行到第二句,调用 StorageAction 的 prepare 方法,与前面一样在 TM 端持有 StorageAction 接口,该接口与 OrderAction 接口基本相同,定义如下:
public interface StorageAction {

    @TwoPhaseBusinessAction(name="TccStorageAction" ,commitMethod = "commit" , rollbackMethod = "rollback")
    public boolean prepare(BusinessActionContext context,
                           @BusinessActionContextParameter(paramName = "goodsCode") String goodsCode,
                           @BusinessActionContextParameter(paramName = "quantity") int quantity);

    public boolean commit(BusinessActionContext context);

    public boolean rollback(BusinessActionContext context);

}

接口的实现类在 RM 端库存服务中,名为 StorageActionImpl。prepare 方法用于锁定库存,commit、rollback 实现提交与回滚。RM 端 StorageActionImpl 伪代码如下。

@Service("storageActionImpl")
@Transactional(propagation = Propagation.REQUIRES_NEW)
public class StorageActionImpl implements StorageAction {

    Logger logger = LoggerFactory.getLogger(this.getClass());

    @Resource
    private StorageRepository storageRepository;

    @Override
    public boolean prepare(BusinessActionContext context, String goodsCode, int quantity) {
        //实现锁定库存逻辑
        //检查库存是否足够,不够直接抛出异常;
        //更新“锁定库存”字段,原商品库存不变。
    }

    @Override
    public boolean commit(BusinessActionContext context) {
        //实现提交逻辑
        //幂等性检查;
        //更新商品库存=原库存-锁定库存,锁定库存重置为0; 
    }

    @Override
    public boolean rollback(BusinessActionContext context) {
        //实现回滚逻辑
        //幂等性检查;
        //锁定库存重置为0; 
    }
}
  • 第四步,TM 端 sale 方法执行完毕,假如所有 Action 的 prepare 方法都正常执行,sale 会自动向 TC 发送“全局事务提交”消息。
  • 第五步,TC 收到全局事务提交消息后,再下发给每个 RM 进行分支事务提交,这个过程中 OrderActionImpl.commit 方法与 StorageActionImpl.commit 方法都会自动执行完成数据提交。

相应的,如果 TM 的 sale 方法执行失败,则会全局事务回滚自动执行 OrderActionImpl 与 StorageActionImpl 的 rollback 方法实现回滚操作。

在执行过程中,如 commit 或者 rollback 执行失败,Seata 会不断重试尽可能保证操作成功,因此要做好幂等性检查。

Seata AT 与 TCC 如何取舍

既然 Seata 已经有了 AT 模式,为何还要引入 TCC 保障事务一致性?在分布式事务 Seata AT 模式中,你是否考虑过,在复杂的企业应用中,不可能完全要求底层数据库统一使用 MySQL,甚至都不能保证所有的数据源都支持事务。

以前就遇到过类似的问题,用户上传了文件后结果系统出现异常需要全局回滚,回滚时需要把这个文件删除,此时基于 Seata AT 模式的反向 SQL 就无能为力了。而 TCC 就能良好解决此类问题,因为 TCC 过程中所有的逻辑都是程序员通过代码控制的,能很好地解决这类非事务型数据处理场景。

那 Seata AT 与 TCC 又该如何取舍呢?这要根据具体的业务场景了,如果在涉及的所有服务底层都是用 MySQL、Oracle 这样的事务型关系数据库,且业务都是对数据库的直接操作,那使用 AT 模式可以最快地实现分布式数据一致性,但是如果涉及非事务资源的操作,那 AT 模式就无能为力,必须采用 TCC 自己实现准备、提交、回滚的细节,但这也无疑对程序员的能力提出了更高的要求,因此尽量将这些任务安排给团队中技术好的核心工程师来实现。

上一篇:使用Sqoop job工具同步数据


下一篇:hive元数据管理与存储