今年开发过很多类似本地积分扣减、本地库存扣减,同时调用rpc进行发券、兑换0元单等业务,对于分布式事务有了一点自己的开发经验,这里记录一下。

1、基本前提

在我看来,两个操作,想要保证分布式事务,必须有一个大前提,就是这两个操作都是幂等操作,也就是带着同一个幂等参数进行请求时,无论请求多少次,结果都和请求一次是一样的。

操作是幂等的,才可保证操作可重试,这是分布式事务的关键。无论是AT模式,还是TCC(try-commit-cancel)模式,当一次请求失败后,都需要可以支持用户或者业务的重试。

2、简单实现

最简单的实现方式莫过于将整个事务都丢进kafka消息队列中,依赖kafka的可靠性,如果任何一个流程发生异常,都进行全局回滚,任何一个流程出现未知状态,都进行不断的重试,这样来实现事务的最终一致性。

但是这种用法的缺陷是,消息队列是异步的,还需要不断的轮询获取实验结果,对于某些同步的流程,比如本地积分兑券的场景来说,会使得业务代码变得非常的恶心。

3、实战解决方案

3.1、表结构设计

我们组这边,借鉴了TCC模式,引入补偿任务的概念,设计出了一套可靠的分布式事务解决方案。

首先定义积分表,有一张任务记录表,一张积分表,他们的数据结构如下:

任务记录表:

字段名

含义

解释

uid

用户uid

biz_id

业务id

可以用于多个业务的扩展

unit_id

积分周期

可以用于多个维度,如时间,表示一周的积分,或者一次活动周期的积分

task_type

任务类型

task_id

任务幂等id

用户标示一次任务,比如每天打卡,则是uid+date

guid

事务id

用户标示一次操作,比如每天的打卡,可能失败,标示这一次打卡,uid+date+timestamp

tx_status

事务状态

标示try,commit,cancel

score

积分

积分记录表

字段名

含义

解释

uid

用户uid

biz_id

业务id

可以用于多个业务的扩展

unit_id

积分周期

可以用于多个维度,如时间,表示一周的积分,或者一次活动周期的积分

score

当前积分

3.2、主流程步骤

步骤设置为:

步骤1、try积分,即写入一条tx_status状态为try的记录,同时扣减积分,如积分不够则扣减失败;

步骤2、进行发券,需要感知三个状态,即发券成功、发券失败、发券状态未知;

步骤3、如果发券成功,将任务的tx_status状态改为commit;

步骤4、如果发券失败,将任务的tx_status状态改为cancel。

其中,每个步骤,都可能会有成功、失败、未知三种状态,我们可以来一一分析。

3.3、分流程解析

a、try成功,则正常执行下一步;

b、try失败,则直接整个事务执行失败,可以直接关闭事务;

c、try未知,此时在同步过程中无法处理,丢给补偿任务;(待定)

d、发券成功,此时正常执行下一步;

e、发券失败,此时回滚积分,执行步骤4;

f、发券未知,此时交给补偿任务;(待定)

g、commit成功,此时正常关闭事务;

h、commit失败、未知,此时交给补偿任务进行重试;(待定)

l、cancel成功,此时正常回滚事务;

m、cancel失败、未知,此时交给补偿任务进行重试;(待定)

这样,除了交予补偿任务进行重试的步骤外,其余步骤都可以拿到一个明确的执行结果了。

3.4、补偿任务

关于补偿任务,我们的执行方式是,扫描出2分钟前,任务记录表内全部处于try状态的任务记录,并执行接下来的发券-commit-cancel流程。

这样一来,所以表中,只要写入了的任务,且处于补偿状态时,就会继续执行解下来的操作,除非明确完成了发券-commit,发券失败-cancel操作外,会一直进行重试。

同时我们可以设置一个重试时间限制,重试超过了10次后,可以触发死信消息告警,让相关开发人工介入。

3.5、对账

即使做了上述的操作,但是依然还是有可能存在数据不一致的情况,此时需要有一个对账系统,每隔一段时间进行一次对账操作,比如业务记录发券了多少金额,在券侧又记录了多少发券金额,这种每隔一段时间进行一次比对,即可保证双方的数据保持同步。

4、总结

所以,关于分布式事务,实现的条件是:

1、任何一道rpc环节都必须是幂等的,否则无法支持重试;

2、需要关注每一次rpc操作的成功、失败、未知三种状态,并完整的设计出对应的处理方式;

3、本文介绍的方式还是有一些复杂,且比较局限性,只适合于所用到的场景,阿里等公司已经有了自己的分布式事务组件,如Seata等。