fenixsoft / awesome-fenix

讨论如何构建一套可靠的大型分布式系统
https://icyfenix.cn
8.7k stars 1.01k forks source link

「Comment」https://icyfenix.cn/architect-perspective/general-architecture/transaction/distributed.html #102

Open fenixsoft opened 4 years ago

fenixsoft commented 4 years ago

https://icyfenix.cn/architect-perspective/general-architecture/transaction/distributed.html

yanyuyouyou commented 4 years ago

内容非常精彩,受益颇多~

有个小小的问题: 反向恢复(Backward Recovery):如果Ti事务提交失败,则一直执行Ci对Ti进行补偿,直至成功为止(最大努力交付)。这里要求Ci必须(持续重试后)执行成功。反向回复的执行模式为:T1,T2,…,Ti(失败),Ci(补偿),…,T2,T1。

我的理解这里最后应该是Tn 吧?

fenixsoft commented 4 years ago

@yanyuyouyou 内容非常精彩,受益颇多~

有个小小的问题: 反向恢复(Backward Recovery):如果Ti事务提交失败,则一直执行Ci对Ti进行补偿,直至成功为止(最大努力交付)。这里要求Ci必须(持续重试后)执行成功。反向回复的执行模式为:T1,T2,…,Ti(失败),Ci(补偿),…,T2,T1。

我的理解这里最后应该是Tn 吧?

经提醒发现这段写错了,T1,T2,…,Ti(失败),Ci(补偿),…,C2,C1才对,就是此前提交的T1-i的事务都应该被反向顺序地补偿掉。

yanyuyouyou commented 4 years ago

@fenixsoft

@yanyuyouyou 内容非常精彩,受益颇多~

有个小小的问题: 反向恢复(Backward Recovery):如果Ti事务提交失败,则一直执行Ci对Ti进行补偿,直至成功为止(最大努力交付)。这里要求Ci必须(持续重试后)执行成功。反向回复的执行模式为:T1,T2,…,Ti(失败),Ci(补偿),…,T2,T1。

我的理解这里最后应该是Tn 吧?

经提醒发现这段写错了,T1,T2,…,Ti(失败),Ci(补偿),…,C2,C1才对,就是此前提交的T1-i的事务都应该被反向顺序地补偿掉。

好的 ,明白了,非常感谢~ 之前理解有误,以为Ci作为Ti的补偿是Ti另外一种方式的表达,没了解到补偿动作是用于撤销Ti造成的结果。

lan-cyl commented 3 years ago

GTS就是SAGA的一种实现吧?
1.子事务Ti=每个数据源
2.补偿Ci=根据日志自动产生的逆向SQL

另外GTS加上全局锁后,系统吞吐量跟2PC没差别了吧?资源的释放也只能等到全局事务结束吧?正常情况倒是能减少一次网络调用

fenixsoft commented 3 years ago

@lan-cyl

GTS就是SAGA的一种实现吧?
1.子事务Ti=每个数据源
2.补偿Ci=根据日志自动产生的逆向SQL

另外GTS加上全局锁后,系统吞吐量跟2PC没差别了吧?资源的释放也只能等到全局事务结束吧?正常情况倒是能减少一次网络调用

GTS是阿里的事务框架,和SAGA完全不是可以比较的概念。

根据上下文,你是想说Seata的AT模式是SAGA的实现?文中说了,广义上,它们的思路是一致的。细节上,你可以参考一下Seata的SAGA模式AT模式的区别。

lambert-shi commented 3 years ago

周老师你好,结合本地事务篇我对以下这段话有一些疑问,希望能够指教。

完全有可能两个客户在短时间内都成功购买了同一件商品,而且他们各自购买的数量都不超过目前的库存,但他们购买的数量之和却超过了库存。如果这件事情处于刚性事务,且隔离级别足够的情况下是可以完全避免的,譬如,以上场景就需要“可重复读”(Repeatable Read)的隔离剂别,以保证后面提交的事务会因为无法获得锁而导致失败。

无法获得锁的话不是应该进行锁等待,获取到锁之后继续执行事务吗?为什么会导致后面提交的事务失败?而且只依赖“可重复读”隔离级别是否会出现丢失更新导致的超卖情况发生呢?

fenixsoft commented 3 years ago

@lambert-shi 周老师你好,结合本地事务篇我对以下这段话有一些疑问,希望能够指教。

完全有可能两个客户在短时间内都成功购买了同一件商品,而且他们各自购买的数量都不超过目前的库存,但他们购买的数量之和却超过了库存。如果这件事情处于刚性事务,且隔离级别足够的情况下是可以完全避免的,譬如,以上场景就需要“可重复读”(Repeatable Read)的隔离剂别,以保证后面提交的事务会因为无法获得锁而导致失败。

无法获得锁的话不是应该进行锁等待,获取到锁之后继续执行事务吗?为什么会导致后面提交的事务失败?而且只依赖“可重复读”隔离级别是否会出现丢失更新导致的超卖情况发生呢?

你好。 这里的“失败”不是指没有锁的事务直接被出错回滚,它当然是会被阻塞的,失败表达的就是“一直被阻塞,如果达到超时阈值之后,将会被回滚”的意思。

lambert-shi commented 3 years ago

@fenixsoft

@lambert-shi 周老师你好,结合本地事务篇我对以下这段话有一些疑问,希望能够指教。

完全有可能两个客户在短时间内都成功购买了同一件商品,而且他们各自购买的数量都不超过目前的库存,但他们购买的数量之和却超过了库存。如果这件事情处于刚性事务,且隔离级别足够的情况下是可以完全避免的,譬如,以上场景就需要“可重复读”(Repeatable Read)的隔离剂别,以保证后面提交的事务会因为无法获得锁而导致失败。

无法获得锁的话不是应该进行锁等待,获取到锁之后继续执行事务吗?为什么会导致后面提交的事务失败?而且只依赖“可重复读”隔离级别是否会出现丢失更新导致的超卖情况发生呢?

你好。 这里的“失败”不是指没有锁的事务直接被出错回滚,它当然是会被阻塞的,失败表达的就是“一直被阻塞,如果达到超时阈值之后,将会被回滚”的意思。

感谢周老师解答,那么按照周老师的场景事例,如果数据库采用的是MVCC方案,是否有可能出现以下这种超售情况

初始quantity值为10,事务: T1和事务: T2都想要将quantity减8

SELECT quantity FROM books WHERE id=1                   /* 时间顺序:1,事务: T1 */
SELECT quantity FROM books WHERE id=1                   /* 时间顺序:2,事务: T2 */

/*事务: T1 运算后将quantity改为2  因为MVCC方案中事务2的select并不会加读锁,所以这条语句可以顺利执行并commit*/
UPDATE books SET quantity=2 WHERE id=1                  /* 时间顺序:3,事务: T1 */
commit

/*事务: T2 运算后将quantity改为2 ,此时没有其他的事务了,所以这条语句可以顺利执行并commit*/
UPDATE books SET quantity=2 WHERE id=1                  /* 时间顺序:4,事务: T2 */
commit
fenixsoft commented 3 years ago

@lambert-shi

感谢周老师解答,那么按照周老师的场景事例,如果数据库采用的是MVCC方案,是否有可能出现以下这种超售情况

初始quantity值为10,事务: T1和事务: T2都想要将quantity减8

SELECT quantity FROM books WHERE id=1                 /* 时间顺序:1,事务: T1 */
SELECT quantity FROM books WHERE id=1                 /* 时间顺序:2,事务: T2 */

/*事务: T1 运算后将quantity改为2  因为MVCC方案中事务2的select并不会加读锁,所以这条语句可以顺利执行并commit*/
UPDATE books SET quantity=2 WHERE id=1                    /* 时间顺序:3,事务: T1 */
commit

/*事务: T2 运算后将quantity改为2 ,此时没有其他的事务了,所以这条语句可以顺利执行并commit*/
UPDATE books SET quantity=2 WHERE id=1                    /* 时间顺序:4,事务: T2 */
commit

这种写法当然是会出现超售的呀,相当于卖了两次8本书。 之前提到过,MVCC只解决“读-写”事务的情况,在“写-写”的场景中它是不适用的。 也正是为了解决你提到的这类情况,InnoDB之类采用MVCC的引擎,都会提供诸如“lock in share mode”的语法,让开发者在“写-写”的场景中显式加共享锁,让数据库进行当前读而非快照读。 以MySQL为例,你把代码修改为这样,它就可以保证T1的Update语句被T2的共享锁阻塞了,达到避免超售的目的了。

SELECT quantity FROM books WHERE id=1 lock in share mode;       /* 时间顺序:1,事务: T1 */
SELECT quantity FROM books WHERE id=1 lock in share mode;       /* 时间顺序:2,事务: T2 */

/*这句会被阻塞,因为有两个共享锁,无法做锁升级了*/
UPDATE books SET quantity=2 WHERE id=1                  /* 时间顺序:3,事务: T1 */
commit

UPDATE books SET quantity=2 WHERE id=1                  /* 时间顺序:4,事务: T2 */
commit
RhodesOfficial commented 3 years ago

周老师好,按照图3-6,我可以理解为分布式事务的使用场景是以web服务为入口依次调用多个单独的微服务吗? 那么可不可以多个微服务之间相互调用,采用try-catch的方式去同步控制事务呢? 我第一家公司是P2P项目,采用Dubbo调用标的服务,然后同步调用账户服务,再同步调用合同服务,任意一步出错则回滚并抛出异常,调用方的微服务抓获异常后也回滚并抛出异常,一直回滚到web服务并反馈给用户 采用最大努力交付方案后就是不需要相互调用了,标的服务本地事务成功后发送消息,账户服务收到消息处理完成后发送消息一直持续下去,可以这么理解嘛?谢谢周老师

fenixsoft commented 3 years ago

@RhodesOfficial 周老师好,按照图3-6,我可以理解为分布式事务的使用场景是以web服务为入口依次调用多个单独的微服务吗? 那么可不可以多个微服务之间相互调用,采用try-catch的方式去同步控制事务呢? 我第一家公司是P2P项目,采用Dubbo调用标的服务,然后同步调用账户服务,再同步调用合同服务,任意一步出错则回滚并抛出异常,调用方的微服务抓获异常后也回滚并抛出异常,一直回滚到web服务并反馈给用户 采用最大努力交付方案后就是不需要相互调用了,标的服务本地事务成功后发送消息,账户服务收到消息处理完成后发送消息一直持续下去,可以这么理解嘛?谢谢周老师

只讨论理论上的可行性,不考虑超时、重试、容错等工程问题。 如果每个层次,都只调用一个远程服务,即A调B,B调C,C调D……,可以放到try-catch里面做。譬如: Service A

但是上面假设的条件不具备普适性。譬如,要是A调用B、C两个服务,就无法用try-catch来处理。譬如: Service A

RhodesOfficial commented 3 years ago

@fenixsoft

@RhodesOfficial 周老师好,按照图3-6,我可以理解为分布式事务的使用场景是以web服务为入口依次调用多个单独的微服务吗? 那么可不可以多个微服务之间相互调用,采用try-catch的方式去同步控制事务呢? 我第一家公司是P2P项目,采用Dubbo调用标的服务,然后同步调用账户服务,再同步调用合同服务,任意一步出错则回滚并抛出异常,调用方的微服务抓获异常后也回滚并抛出异常,一直回滚到web服务并反馈给用户 采用最大努力交付方案后就是不需要相互调用了,标的服务本地事务成功后发送消息,账户服务收到消息处理完成后发送消息一直持续下去,可以这么理解嘛?谢谢周老师

只讨论理论上的可行性,不考虑超时、重试、容错等工程问题。 如果每个层次,都只调用一个远程服务,即A调B,B调C,C调D……,可以放到try-catch里面做。譬如: Service A

  • try-catch Service B
    • try-catch Service C
    • try-catch Service D

但是上面假设的条件不具备普适性。譬如,要是A调用B、C两个服务,就无法用try-catch来处理。譬如: Service A

  • try-catch Service B
  • try-catch Service C

感谢周老师,说的非常清晰!

ralphhuang commented 3 years ago

两年之后,麻省理工学院的 Seth Gilbert 和 Nancy Lynch 以严谨的数学推理上证明了 CAP 猜想

应该是:以严谨的数学推理证明了 CAP 猜想

nemolpsky commented 2 years ago

有个疑问想请教作者,可靠事件队列这个方案,其实是利用本地事务确保当前服务的执行和发送消息的执行是可以保证原子性,然后后续依靠定时任务来轮询消息表执行下一个操作,比如调用另外的服务,只有成功之后才会修改消息表的状态。那是不是这个方案必须要多个服务使用同一个数据库?毕竟是依靠同一张消息表的来保证后续的执行是否成功,或者是至少单独抽出来的"消息服务"需要也是需要使用和账号服务相同的数据库?

AnyUncle commented 2 years ago

@fenixsoft

@lambert-shi

感谢周老师解答,那么按照周老师的场景事例,如果数据库采用的是MVCC方案,是否有可能出现以下这种超售情况

初始quantity值为10,事务: T1和事务: T2都想要将quantity减8

SELECT quantity FROM books WHERE id=1                   /* 时间顺序:1,事务: T1 */
SELECT quantity FROM books WHERE id=1                   /* 时间顺序:2,事务: T2 */

/*事务: T1 运算后将quantity改为2  因为MVCC方案中事务2的select并不会加读锁,所以这条语句可以顺利执行并commit*/
UPDATE books SET quantity=2 WHERE id=1                  /* 时间顺序:3,事务: T1 */
commit

/*事务: T2 运算后将quantity改为2 ,此时没有其他的事务了,所以这条语句可以顺利执行并commit*/
UPDATE books SET quantity=2 WHERE id=1                  /* 时间顺序:4,事务: T2 */
commit

这种写法当然是会出现超售的呀,相当于卖了两次8本书。 之前提到过,MVCC只解决“读-写”事务的情况,在“写-写”的场景中它是不适用的。 也正是为了解决你提到的这类情况,InnoDB之类采用MVCC的引擎,都会提供诸如“lock in share mode”的语法,让开发者在“写-写”的场景中显式加共享锁,让数据库进行当前读而非快照读。 以MySQL为例,你把代码修改为这样,它就可以保证T1的Update语句被T2的共享锁阻塞了,达到避免超售的目的了。

SELECT quantity FROM books WHERE id=1 lock in share mode;     /* 时间顺序:1,事务: T1 */
SELECT quantity FROM books WHERE id=1 lock in share mode;     /* 时间顺序:2,事务: T2 */

/*这句会被阻塞,因为有两个共享锁,无法做锁升级了*/
UPDATE books SET quantity=2 WHERE id=1                    /* 时间顺序:3,事务: T1 */
commit

UPDATE books SET quantity=2 WHERE id=1                    /* 时间顺序:4,事务: T2 */
commit

还有另外一种方案,也🉑️行

SELECT quantity FROM books WHERE id=1                 /* 时间顺序:1,事务: T1 */
SELECT quantity FROM books WHERE id=1                 /* 时间顺序:2,事务: T2 */

/*事务: T1,加上quantity=10条件之后T1、T2最多有一个能成功更新库存,也能避免超卖*/
UPDATE books SET quantity=2 WHERE id=1 and quantity=10            /* 时间顺序:3,事务: T1 */
commit

/*事务: T2,加上quantity=10条件之后T1、T2最多有一个能成功更新库存,也能避免超卖*/
UPDATE books SET quantity=2 WHERE id=1 and quantity=10            /* 时间顺序:4,事务: T2 */
commit
Durianyang commented 2 years ago

@fenixsoft

@lambert-shi

感谢周老师解答,那么按照周老师的场景事例,如果数据库采用的是MVCC方案,是否有可能出现以下这种超售情况

初始quantity值为10,事务: T1和事务: T2都想要将quantity减8

SELECT quantity FROM books WHERE id=1                   /* 时间顺序:1,事务: T1 */
SELECT quantity FROM books WHERE id=1                   /* 时间顺序:2,事务: T2 */

/*事务: T1 运算后将quantity改为2  因为MVCC方案中事务2的select并不会加读锁,所以这条语句可以顺利执行并commit*/
UPDATE books SET quantity=2 WHERE id=1                  /* 时间顺序:3,事务: T1 */
commit

/*事务: T2 运算后将quantity改为2 ,此时没有其他的事务了,所以这条语句可以顺利执行并commit*/
UPDATE books SET quantity=2 WHERE id=1                  /* 时间顺序:4,事务: T2 */
commit

这种写法当然是会出现超售的呀,相当于卖了两次8本书。 之前提到过,MVCC只解决“读-写”事务的情况,在“写-写”的场景中它是不适用的。 也正是为了解决你提到的这类情况,InnoDB之类采用MVCC的引擎,都会提供诸如“lock in share mode”的语法,让开发者在“写-写”的场景中显式加共享锁,让数据库进行当前读而非快照读。 以MySQL为例,你把代码修改为这样,它就可以保证T1的Update语句被T2的共享锁阻塞了,达到避免超售的目的了。

SELECT quantity FROM books WHERE id=1 lock in share mode;     /* 时间顺序:1,事务: T1 */
SELECT quantity FROM books WHERE id=1 lock in share mode;     /* 时间顺序:2,事务: T2 */

/*这句会被阻塞,因为有两个共享锁,无法做锁升级了*/
UPDATE books SET quantity=2 WHERE id=1                    /* 时间顺序:3,事务: T1 */
commit

UPDATE books SET quantity=2 WHERE id=1                    /* 时间顺序:4,事务: T2 */
commit

我在尝试这种情况的时候,T1的update被阻塞,T2的update将会报错deadlock从而结束事务,T1阻塞的update成功执行。这种情况是正常吗?但是这样的话,在很多个事务会怎样呢。还是不理解为什么 “超卖” 处于刚性事务,且隔离级别足够的情况下是可以完全避免的

fuck1Sky commented 2 years ago

商家服务:取消业务操作(大哭一场后安慰商家谋生不易)

ZwxwZ commented 2 years ago

想问问tcc中的回滚,和saga中的补偿是一种意思吧?

prchann commented 2 years ago

@zwxisboy 想问问tcc中的回滚,和saga中的补偿是一种意思吧?

不是的。当扣除了用户的余额后,如果后续步骤出现异常,

prchann commented 2 years ago

CAP

BASE

  1. 尽最大努力交付:借助可靠事件队列并采用尽最大努力交付,BASE 的逻辑比较简单(无需提前冻结资源,也无需回滚)
  2. 优点:简单
  3. 缺点:隔离性差,必须成功(无回滚)
  4. 适合场景:隔离要求低,成功概率高,小事务(快,从而降低失败可能性)

TCC: Try-Confirm-Cancel

  1. 两个 C 阶段均是尽最大努力交付
  2. 优点:隔离性好,性能好(已预留的资源归当前事务所有,无竞争,免锁)
  3. 缺点:Try 需要预留资源,要求开发者对数据有较高控制性 -- 业务侵入性较高
  4. 适合场景:性能要求,隔离要求,数据可控

SAGA

  1. 大事务拆解成多个小事务;
    1. 小事务独立进行;
    2. 根据业务情况,可选择尽最大努力交付,也可选择补偿。
  2. 优点:补偿动作对数据可控性要求较低,易于实现
  3. 缺点:隔离性较差
  4. 适合场景:长事务

SEATA SEATA 分布式事务解决方案

补充

以上三种方案,均需要有个地方来记录事务的状态和执行进度。

renliangyu857 commented 2 years ago

周老师,消息队列的方式为什么会出现超卖呢?不都通过数据库层面的锁保证就可以了吗。TCC 只不过是有补偿机制以及通过资源预留而不是全局锁的方式提高性能

heypig commented 2 years ago

几个小意见

与 TCC 相比,SAGA 不需要为资源设计冻结状态和撤销冻结的操作,补偿操作往往要比冻结操作容易实现得多。"

这里的说法,我觉得还是要补充一下. 但需要关注到, SAGA应该还算是基于BASE思想下的,还是需要设计"软状态" (可回滚或者可补偿):


另外, 针对这个举例, 再补充一些业务信息.

以银行的转账来说. 这里举例的来的 用户--> 书店 (都是指资金账户在银行侧的情况),
补偿动作是 书店 --> 用户.

这是典型的资金流入场景(书店视角), "书店平台"对"书店账户"有控制权, 所以可以确保"书店"-->"用户"这步是可行可补偿(不存在书店银行资金被其他业务挪用)

以现在行业里解决"二清问题", 的各类方案 "收付通"/"电商通", 包括银行都是包含"冻结/解冻并扣款"这样的服务,确保对银行侧资源(账户资金)能够有一个"软状态".

才能确保在更真实的场景可控:

  1. 资源的级联使用: 如"分账"(用户-商家-分佣)
  2. 流出场景: 如"退款"场景, 商家->用户(用户余额冻结,如果要回滚,可以解冻并扣款还给场景)

这里引出一个话题想探讨下, 其实基于BASE思路的TCC/SAGA实现, 其实都不是"业务无感", 其实需要业务配合设计"软状态".

现在较多资料的看法, 认为TCC对是有"业务侵入" ; 其实这个是技术层面编程上的具体实现框架可以优化的地方.
公平一点比较TCC和SAGA的, 我个人会这样比较两种模式:

之所以,比较较真的提这个, 个人期望避免有"SAGA优于TCC"的误解趋势.


ps. 关于"全局事务"刻意和"分布式事务"区分出来这个很好. 不过取名上, 感觉其实和后面的GTS框架是重叠的(中英文字面语义)

建议考虑, 用"数据侧分布式事务 vs 应用侧分布式事务"来区分. 实际工作中, 也经验遇到有人提到"数据库已经支持分布式事务,为什么还要用分布式事务框架"

gugugubo commented 2 years ago

面向副本复制的一致性与这里面向数据库状态的一致性严格来说并不完全等?这里面向数据库状态的一致性是指什么?

xiaoyangzhang commented 2 years ago

@gugugubo 面向副本复制的一致性与这里面向数据库状态的一致性严格来说并不完全等?这里面向数据库状态的一致性是指什么?

上面写了,代表数据在任何时刻、任何分布式节点中所看到的都是符合预期的

empty-cicada commented 2 years ago

有一个疑问 ACID 和 CAP 中的 C 是否等价?文中认为都是指的强一致性,但是有也说两者没有关系的

Consistency in CAP actually means linearizability, which is a very specific (and very strong) notion of consistency. In particular it has got nothing to do with the C in ACID, even though that C also stands for “consistency”. I explain the meaning of linearizability below.

-- Please stop calling databases CP or AP

zhanp0304 commented 2 years ago

想在这里补充一下,我看的时候比较疑惑的点的说明。可靠消息队列其实是一种实现最终一致性的过程手段,并不是某一种分布式事务的经典解决思路。我理解的分布式事务又分为强一致性和最终一致性两大类。

在最终一致性大方向下的解决模型有TCC和SAGA,最大努力通知(常用于第三方支付回调,控制权在第三方),根据文中描述,其中SAGA有两种模式,一种是正向恢复,另一种是反向恢复。

其中正向恢复是使用可靠消息队列(最大努力交付)使其一直重试,直到成功。正向恢复的隔离性最差,因为在正向恢复的过程中,由于无法像TCC那样提前try预留了资源,所以正向恢复不断retry的过程中,会被其他事务的操作结果所影响,例如扣减库存,如果库存不够了,那无论如何重试,都还是没有库存可以被扣减。(需要人工介入去补货,且存在严重的“超卖”风险)

反向恢复是通过中间件(也可以是可靠消息队列)对已执行成功的事务进行【补偿】操作。其中补偿指的是对已经执行的操作进行失败后的补偿逻辑。这个"补偿"一词其实很有意思,要理解补偿的含义,才能真正区分SAGA与TCC的区别。

"补偿"举个例子,比如我打了小明一拳,然后后面发现我打错人了,这个时候有两种做法,一种叫回滚,一种叫"另类补偿"。回滚的手段是我使用了时间回溯,还原到我没有打小明的现场,或者我让小明的身体恢复到没有被打之前。而"另类补偿"指的是我让小明打回我一拳,或者我请小明吃一顿KFC当作补偿。

从上面的例子可以看到其实回滚也属于补偿的一种。在程序中,以程序的例子就是,库存被扣减了,那么补偿=回滚,就是库存重新补回被扣的数量即可。但是以银行转账为例子,就不一样了,因为用户通过银行第三方进行支付,我们没办法要求银行去给我们做金额的预留try操作,也没办法在后续系统业务执行失败后,让银行做cancel回滚逻辑。所以与第三方交互的过程中,TCC有着天然无法避免的劣势,那就是无法做try与cancel操作,故只能通过SAGA模式进行处理。

而SAGA模式之于TCC,可以通过补偿机制,在反向恢复的过程中,怎么样去补偿用户的银行卡扣款已经成功的事实呢? 答案:那就是通过"另类补偿"手段,比如给用户返还fenix系统的钱包余额,或者返还等额的红包与优惠券,又或者狠下决心,追求用户体验,那就是走一遍银行转账,把货款原路退回用户原账户中。这些手段都是补偿,而不单单是回滚能做到的,补偿是需要结合业务和用户体验进行设计的。

那么SAGA与TCC最大的区别也可以从上面的例子看到,那就是TCC具备事务的隔离性,因为有try阶段的操作逻辑,所以能保证数据在事务之间是相互隔离的。而SAGA不是相互隔离的,所以SAGA的痛点就是弱隔离性,基本没有隔离性可言,是一种牺牲隔离性的解决方案。

关于隔离性,举个例子,以库存为例。用户在提交订单后,执行库存扣减,采用SAGA模式,假如库存事务执行异常,如果采用的是正向恢复手段,那么就需要retry重试库存事务,但不一定能成功,因为很有可能在retry期间,其他事务已经把库存给扣掉了,此时无论怎么重试,都是没有库存的现状。【没有保证好在SAGA事务执行期间,此sku的库存不能被其他事务所操作,导致SAGA执行期间,库存无法正确扣减】

刚刚说了正向补偿,那么反向恢复手段,其实也是存在隔离性问题的。针对库存的例子比较特殊,结合业务来进行解释。当库存事务T3执行成功的情况下,假如sku当前现有量为10,扣减10件后,现有量变为0。此时,又有另一个事务在进行网店的库存计算,库存计算后发现仓库已经没有库存了,所以网店库存被重新设置为0。但提交订单大事务执行到T4事务阶段时,发现T4执行失败,那么按照反向恢复的流程,应该回滚T3,T2,T1。由于没有try操作,都是直接操作的现有量,而不是TCC#try能操作保留量(冻结量),所以补偿后库存重新恢复为了10件,导致的结果就是明明仓库已经没有库存可供线上网店进行售卖了,但由于SAGA没有保证隔离性,导致反向补偿阶段时,重新恢复为了10件,出现超卖现象。

但如果是TCC的try操作,就不会出现上述这种超卖现象。在TCC中,T3库存事务try阶段sku库存为:现有量10件,保留量10件,可用量0件。 此时另一个事务进行网店库存计算,由于仓库库存为0,网店库存被设置为0,sku现有量被更新为0件。此时现有量0件,保留量10件,可用量-10件。然后假如T4事务执行失败,进入cancel阶段,那么保留量会被释放为0件,此时现有量0件,保留量0件,可用量0件。不会出现超卖问题。

那么可能有朋友问,那如果T4事务执行成功呢,所有事务提交成功,那么此时是否能发货?我理解是这样的,因为对用户来说,此时下单已经成功了,他在那一刻占住了库存,后续也付款了,钱也扣了。那么订单肯定会进入"待发货"。此时就需要商家自己去补货就完事了,从业务上来说合情合理,不属于系统bug问题。但是上面SAGA由于补偿手段而导致现有量重新回滚为10件,那么就是属于技术方案的bug引起的超卖风险,是需要去权衡与考量的。

以上是我个人结合自己所在部门的产品系统中的库存业务做出的一些思考和讲解,希望对大家理解TCC与SAGA有所帮助,同时对我自己而言,也是一次非常不错的理解体验和思考过程。感谢周老师的文章!

roylion commented 1 year ago

请问SAGA正向恢复 与 可靠事件队列 的实现有区别吗

BanTanger commented 7 months ago

“BASE 分别是基本可用性(Basically Available)、柔性事务(Soft State)和最终一致性(Eventually Consistent)的缩写” 这里 Soft State 翻译错了吧,应该翻译成软状态