今年开发过很多类似本地积分扣减、本地库存扣减,同时调用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等。