Http服务间调用一致性方案

277 阅读11分钟

背景

项目组需要与第三方合作开展一系列活动,第三方采用http接口的形式对外提供支付与发奖两个服务,以供本项目组使用。在该业务场景中,活动数据由本项目组维护,而第三方服务维护用户余额与背包数据,由于与用户的利益紧密相关,因此需要设计出一个高一致性的方案来规避漏支付或是漏发奖的情况。

订单号设计

订单ID由用户状态生成,不同活动其定义格式不同,但可承诺在同一次活动中,订单ID不重复。

以一次抽奖活动为例,订单ID格式可能是:{活动名}{活动ID}{用户ID}{抽奖类型}{抽奖次数},当某一个抽奖操作失败时,用户状态(如抽奖次数)不变,订单ID不变。

第三方服务需存储每个用户当次活动所产生的订单ID以做去重。

原支付发奖流程

正常流程

正常流程.png

支付失败

在支付失败时,服务端不会走后续发奖流程,本次抽奖请求失败,用户活动状态不变,订单ID不变。用户下次重试时,生成的仍是上次的订单ID。

支付失败.png

第三方服务支付失败的情况可分为以下三种:

Case1

逻辑失败:如代币不足,此时直到用户补足代币之前,抽奖都是返回失败

Case2

系统失败:如网络超时,该订单ID在第三方服务未入库,用户状态(抽奖次数)也未更新,下次抽奖请求与正常请求无异

Case3

系统失败:如网络超时或第三方服务处理耗时过长,该订单ID在支付队列已入库,用户活动状态(如抽奖次数)未更新。此时存在支付未发奖的问题

发奖失败

该支付订单ID在第三方服务已入库,但发奖失败,故事务回滚,用户活动状态(如抽奖次数)未更新。

下次请求生成的订单ID与先前一致,此时调用支付接口,第三方服务返回 “支付订单已处理”,故可认为该笔订单已支付完成,则继续后续发奖流程,完成操作,更新活动状态。

发奖失败.png

第三方服务发奖失败的情况同样可分为以下三种:

Case1

逻辑失败:如背包已满或其他情况,此时直到用户处理之前,发奖请求都是返回失败,此时存在支付未发奖的问题

Case2

系统失败:如网络超时,第三方服务未收到发奖请求,此时存在支付未发奖的问题

Case3

系统失败:如网络超时或第三方服务处理耗时过长,但最终第三方服务已处理完发奖请求,此时由于本地事务回滚,存在活动状态未更新的问题

缺陷

在支付成功发奖失败的情况下,依赖于用户再次操作触发补发,但用户在已支付未发奖的情况下未必会主动再次操作,且一旦发生,与第三方的沟通解释成本也较高

即使用户再次操作,也无法覆盖全部活动场景,若奖励内容不固定,即奖励内容依赖于用户某些特定操作,则可能导致第二次操作触发的奖励补发内容不匹配第一次扣除的代币。

基于上述的活动形式,需要一个解决方案,完成以下两个目标

目标一:解决不一致问题:支付未发奖 & 已支付发奖但活动状态未更新

目标二:不依赖于用户二次操作触发订单状态更新

理论支持

网络是不可靠的,HTTP调用或者接受响应的时候如果出现网络闪断有可能出现了服务间状态不能互相明确的情况。需求的本质上是为保证远端数据与本地数据的一致性,将修改用户货币背包数据与修改本地活动数据视为一个事务,即分布式事务问题,根据一致性的要求,可分为两个类别:

ACID:强一致性(不可行)

业界方案:2PC&3PC

2PC:事务提交拆分成了两阶段过程,也就是准备阶段和提交阶段。准备阶段,协调者询问事务的所有参与者是否准备好提交,收到所有事务参与者回复的 Prepared 消息,就会首先在本地持久化事务状态为 Commit,然后向所有参与者发送 Commit 指令,所有参与者立即执行提交操作;否则,任意一个参与者回复了 Non-Prepared 消息,或任意一个参与者超时未回复,协调者都会将自己的事务状态持久化为“Abort”之后,向所有参与者发送 Abort 指令,参与者立即执行回滚操作。

3PC:把原本的两段式提交的准备阶段再细分为两个阶段,分别称为 CanCommit、PreCommit,把提交阶段改为 DoCommit 阶段。其中,新增的 CanCommit 是一个询问阶段,协调者让每个参与的数据库根据自身状态,评估该事务是否有可能顺利完成。

可行性分析

对于我们来说,第三方服务的事务实现,数据库选型是不确定的,在事务层面做操作不现实。

另一方面提交阶段尽管时间很短,但仍是明确存在风险的窗口期,如果此时网络忽然断开了,无法再通过网络向所有参与者发出 Commit 指令的话,就会导致部分数据(活动数据)已提交,但部分数据(余额与奖励)未提交导致数据不一致。

整个过程无论从哪一步都有槽点,本质上并没有解决问题,且网络调用次数*2,更极大提升了风险

BASE:最终一致性

业界方案一:可靠消息队列

简述

“可靠事件队列”有一种更普通的形式,被称为“最大努力一次提交”(Best-Effort 1PC),意思就是系统会把最有可能出错的业务,以本地事务的方式完成后,通过不断重试的方式(不限于消息系统)来促使同个事务的其他关联业务完成。

可行性分析

两个关键:最有可能出错的业务先行;不断重试

在本业务场景中,最有可能出错的业务在于第三方服务,即使客户端有做先行校验,但支付与发奖请求往往会因为代币不足、背包已满而失败,但第三方服务支付发奖又无法以本地事务完成。假如是活动数据先提交事务,再以不断重试(可以是定时器)的方式促使第三方服务支付发奖,对应上述失败场景,则分别需要等待到用户重登、补充完成货币或者清理背包后才能处理成功。

业界方案二:TCC(不可行)

简述

在具体实现上,TCC 的操作是一种业务侵入性较强的事务方案,要求业务处理过程必须拆分为“预留业务资源”和“确认 / 释放消费资源”两个子过程。

Try:尝试执行阶段,完成所有业务可执行性的检查(保障一致性),并且预留好事务需要用到的所有业务资源(保障隔离性)。

Confirm:确认执行阶段,不进行任何业务检查,直接使用 Try 阶段准备的资源来完成业务处理。注意,Confirm 阶段可能会重复执行,因此需要满足幂等性。

Cancel:取消执行阶段,释放 Try 阶段预留的业务资源。注意,Cancel 阶段也可能会重复执行,因此也需要满足幂等性。

可行性分析

其实TCC优点类似于2PC,都存在一次询问与资源锁定的过程,只是它的Confirm与Cancel阶段是位于业务层而不是在基础设施层面,且存在轮询来确保状态推进。套在本应用场景上,即先询问第三方服务此次支付,发奖请求是否可能成功,若可能,则第三方服务先对用户货币背包数据进行锁定,我们再提交本地事务,发起真正支付发奖请求。而在Try过程同样面临着超时状态不可知的问题,没有解决现存的本质问题,且这一过程同样给第三方带来了较大的接入负担,不可行。

业界方案三:SAGA

简述

SAGA 为每一个子事务Ti设计对应的补偿动作,我们命名为 Ci。Ti 与 Ci 必须满足以下条件:Ti 与 Ci 都具备幂等性;Ti 与 Ci 满足交换律(Commutative),即不管是先执行 Ti 还是先执行 Ci,效果都是一样的;Ci 必须能成功提交,即不考虑 Ci 本身提交失败被回滚的情况,如果出现就必须持续重试直至成功,或者要人工介入。如果 T1 到 Tn 均成功提交,那么事务就可以顺利完成。否则,我们就要采取以下两种恢复策略之一:正向恢复(Forward Recovery):如果 Ti 事务提交失败,则一直对 Ti 进行重试,直至成功为止(最大努力交付)。这种恢复方式不需要补偿,适用于事务最终都要成功的场景,比如在别人的银行账号中扣了款,就一定要给别人发货。反向恢复(Backward Recovery):如果 Ti 事务提交失败,则一直执行 Ci 对 Ti 进行补偿,直至成功为止(最大努力交付)。这里要求 Ci 必须(在持续重试后)执行成功。

可行性分析

选择正向恢复:等同于上文中可靠消息队列的方式,通过持续重试直至支付发奖成功,不允许发奖失败

选择反向恢复:这要求在数据结构上支持保留多版本数据,在支付发奖失败时,通过回滚数据保证一致性,实现成本不低。

优化方案

前提:合并支付发奖接口

在调用支付发奖接口前,已能计算出本次操作所需扣除的货币及发放的奖励,应发奖励内容的计算不依赖与支付是否成功决定,故可将两个接口合二为一

合并支付发奖.png

优点:

1、稳定性大幅提升:依托第三方本地来保证支付发奖原子性(一次RPC),避免支付成功未发奖的情况

2、用户体验优化:减少一次RTT,降低操作延迟

3、第三方服务实现成本降低:对接接口减少,支付发奖可共用一个幂等队列

缺陷:

1、依然存在支付发奖成功但活动数据未修改的情况,且依赖于用户二次操作触发订单状态更新

即下述情况:

合并支付发奖2.png

结合上文中的SAGA正反向恢复策略,可有以下变种:

方案二:正向恢复

正向恢复.png

方案三:反向恢复(推荐)

反向恢复.png

要求:

1、活动数据操作必须先于第三方支付发奖接口请求,才能在超时的情况下获得未确认执行的SQL

实现方式

1、构造用户请求上下文,记录待执行sql

线程变量:

public class RequestContextHolder {
    public static TransmittableThreadLocal<RequestContext> context = new TransmittableThreadLocal<>();
}

public class RequestContext {
     private String reqId;
     private List<ExecuteSql> sqlToBeExecuted = new ArrayList<>();  
}      

未完成订单数据结构:

public class UnfinishedOrderEntity extends AbstractPlayerActivityEntity {
    private String orderId;
    private String request;    
    private List<String> sqlsToBeExecuted = new ArrayList<>();   
    private int status = 0;
 }
@Data
@AllArgsConstructor
public class ExecuteSql {
    private MappedStatement ms;
    private Object param;    
    private String type;
 }

在数据库更新前记录待执行sql(本项目引入了mybatis作为底层持久化框架,此处是拦截器处进行记录)

RequestContextHolder.context.get().getSqlToBeExecuted().add(new ExecuteSql(ms, entity, SqlType.UPDATE_BY_ID.getMethodName()));

2.持久化未完成订单

public void submitUnfinishedOrder(PayAndAwardRequest request) throws Exception {
    List<ExecuteSql> sqlsToBeExecuted = RequestContextHolder.context.get().getSqlToBeExecuted(); 
     
    UnfinishedOrderEntity unfinishedOrderEntity = orderMapper.selectByUniqueId(new OrderId(request.getOrderId()));
    if (unfinishedOrderEntity == null) {
         UnfinishedOrderEntity unfinishedOrder = new UnfinishedOrderEntity();             
         unfinishedOrder.setOrderId(request.getOrderId());            
         unfinishedOrder.setRequest(JSON.toJSONString(request));               
         List<String> sqlList = sqlsToBeExecuted.stream().map(s -> SqlHelper.fillSql(s.getType(), s.getMs(), s.getParam())).collect(Collectors.toList());               
         unfinishedOrder.setSqlsToBeExecuted(sqlList);               
         orderMapper.insert(unfinishedOrder);         
    }
        
   

3.定时器重试订单

@Scheduled(cron = "*/10 * * * * ?")
public void retry() {
    查询未完成订单
    请求支付发奖
    if(success){
          JdbcTemplate template = ...
          order.getSqlsToBeExecuted().forEach(template::execute);
          order.finish();
    }else{
        ...    
    }
 }

总结

在“分布式事务”中,由于无法舍弃CAP理论中的A和P,我们也不得不将要求从获得强一致性,降低为获得“最终一致性”,即从刚性事务降低为柔性事务。

而柔性事务则存在可靠事件队列, TCC , SAGA等主流方案,它们都有各自的优缺点和应用场景。分布式系统中不存在放之四海皆准的万能事务解决方案,针对具体场景,选择合适的解决方案,达到一致性与可用性之间的最佳平衡,是我们作为一名设计者必须具备的技能。