数据一致性(一) - 接口调用一致性

4,835 阅读5分钟

github

场景

客户端调用A服务的接口,A服务接口中又调用了B服务。 如果A服务和B服务都执行成功,则成功,并且二者事务都应提交; 如果A服务或B服务任意一个失败,则失败,且二者事务都不执行或回滚。 因为网络请求的不可靠性,如果A调用B失败,可能:1. B没有接收到网络请求;2. B收到后执行失败; 3. B执行成功,请求返回时异常。 4. B调用超时,B可能执行成功也可能失败。因此当A调用B时,可能出现不一致。

接口调用几种方式

方式一:feign直接调用

A服务接口:

try {
    业务代码A
    feign调用B服务接口
    事务提交
} catch (Exception e) {
    事务回滚
}

B服务接口:

try {
    业务代码B
    事务提交
    //此时接口状态码2XX
} catch (Exception e) {
    事务回滚
    //此时接口状态码非2XX
}

一致性分析:

  1. feign调用B服务接口成功,状态码为2XX。则B服务事务已经提交,A进行事务提交。 若A事务提交成功,则一致; 若A事务提交失败但此时B中事务已经提交,则不一致。
  2. B没有接收到网络请求。B未被执行,feign调用抛出异常,A事务不进行提交,进入回滚,数据一致。
  3. B执行成功,请求返回时异常。B事务已经提交,feign调用B服务接口异常,A事务回滚,数据不一致。
  4. B调用超时。可能为B没有接收到网络请求,也可能B执行成功,请求返回时异常,也可能B收到请求响应缓慢。一致性状态不确定,都有可能。

方式二:基于可靠消息

服务A业务代码执行完后发送消息到消息队列,如rabbitmq,并用ack等方式确保发送成功; B服务接收消费成功后,手动确认消息,kafka则用手动提交位移的方式。

A服务生产者:

try {
    业务代码A
    ack = rabbitmqProducer.sendAndGetAck
    if !ack {
        //发送失败可以设置自动重试,不重试就抛出异常
        throws new RabbitmqSendException
    }
    事务提交
} catch (Exception e) {
    //事务回滚
}

B服务消息队列消费端一:

//
try {
    业务代码B
    channel.basicAck手动确认//kafka则手动提交位移
    事务提交
    //此时接口状态码2XX
} catch (Exception e) {
    事务回滚
    //此时接口状态码非2XX
}

B服务消息队列消费端二:

//
try {
    业务代码B
    事务提交
    //此时接口状态码2XX
} catch (Exception e) {
    事务回滚
    //此时接口状态码非2XX
}
channel.basicAck手动确认//kafka则手动提交位移

一致性分析:

  1. A服务发送消息到消息队列成功,却提交事务失败,出现数据不一致。
  2. B消费端一:手动确认后,消息从消息队列删除,B事务提交失败,出现数据不一致。
  3. B消费端二:B事务提交成功,手动确认失败,可能会重复收到该条消息,出现不一致。 此时可在消息中添加uuid,服务B收到消息后根据uuid进行一次去重再处理等方式来实现幂等性。

方式三:预执行+确认+回查,类似TCC

A服务需要插入一个表transaction_record记录调用状态,提供给B服务回调。

A服务业务接口:

String uuid = generateUUID()//生成一个uuid
try{
    feign.preCreate(uuid,...)//feign调用B预执行,比如B服务为创建订单服务,预创建一个订单,但状态为待确认
    业务代码A
    将uuid插入transaction_record表中
    事务提交
}catch (Exception e) {
    事务回滚
    feign.cancel(uuid)//feign调用B取消,比如B服务为创建订单服务,设置该订单状态为取消
}

feign.confirm(uuid)//feign调用B确认,比如B服务为创建订单服务,设置该订单状态为确认,此时订单可用 

A服务服务回查接口,提供给B服务回查状态:

get /v1/transaction/{uuid}

从transaction_record表中查询,有则返回确认,没有则返回取消

B服务需要提供预执行、确认、取消接口。若预执行后迟迟没有执行确认或取消,则B向A回查,根据结果确认或取消。

一致性分析:

  1. A中preCreate执行异常。应抛出异常,不再执行业务代码和事件表插入uuid,去执行cancel。若B执行则状态也为未确认,不影响一致性; 若cancel也执行失败,比如此时B挂掉,B重启后应去调用服务A的回查接口,确定状态。状态一致。
  2. 业务代码A执行失败,事务回滚,feign调用B取消。若取消成功,则状态一致;若取消失败,应抛出异常,confirm不再执行。状态一致。
  3. 业务代码A执行成功,事务提交失败,执行事务回滚。若回滚失败,抛出异常,不再执行cancel和confirm,B会执行超时回查确定状态;若回滚成功,则执行取消。状态一致。
  4. A事务提交成功,确认失败(比如A执行确认时,服务A或者B刚好挂掉)。则服务B超时回查,发现uuid存在,修改状态为确认。状态一致。
  5. 预处理完成后,去执行业务代码,若业务代码执行缓慢,B服务认为超时,则服务B超时回查,若A的事务还未提交,A的回查接口返回取消, 则B被取消,A却提交了事务,此时出现事务状态的不一致。此时可以通过设置B稍微大的超时时间来调整,可以让服务A在预处理的feign调用时传入期待的超时时间。

总结

以上是接口间调用的几种方式,这里只提供一种大概的思路,应用时可以自己优化,同步发送也可修改为异步+重试等方式。 若一致性要求可采用方式三,uuid+插入表也可采用其他的方式实现。为了提高一致性,接口调用也要尽量是幂等的,可通过业务逻辑的幂等性或 uuid实现。