分布式一致性

文章1:

一、背景

在日常的服务场景下,只要用到缓存,就涉及到DB与缓存一致性的保障,本文主要描述基于公司目前中间件的情况,对缓存一致性的保障的选型和思考。(作者之前有幸维护过大规模的缓存系统,仅对自己之前遇到的过的一些问题做交流,本文中有一部分是主观意见思考,不见得对所有场景适用,欢迎大家一起评论区探讨)

本文描述链路:

二、一致性保障选型

  • RMQ事务消息(T - RMQ):消息中保证事务,事务消息有两种实现,一种本地事务表(MySQL的2PC),一种RMQ的XA。

    • 优点
      • 【业务友好】一次业务操作,发送的消息数量只有一条(业务表单维度),不仅可支持内部系统业务交互,也可作为业务对外底层对外输出的业务事件使用(较强的业务属性)。
      • 【避免写扩散问题】比如一些关联关系的数据会影响主体信息,用事务消息则不会出现写扩散的问题。
      • 【不受其他链路影响】缓存等链路更新,不受Kbus方案中的主从延迟影响(之前多个故障原因,是因为主从延迟,导致消息不能及时投递到缓存,导致消费场景无法及时展示实时数据,导致客诉)。
      • 【性能较好】消息链路延迟较低(适当存疑,T-RMQ-XA事务消息需要与MQ Broker交互两次,这部分理论比写入MySQL性能好。如果是走本地事务表T-RMQ(2PC),则性能弱于Kbus)
    • 缺点
      • 【开发维护成本高】事务消息接入成本高(T-RMQ接入方式),有较高的编码要求。要求业务变更足够收敛,比如出现单独封装一个更新主体的接口,但接口逻辑中没有发消息逻辑,就可能出现问题。
      • 【缓存更新不高效】缓存无法按数据维度分布,因此要将主体数据落到同一处(同一RedisKey)
        • 【无法按多维度拆分主体缓存】只能存一个主体维度的大缓存,这种容易出现大热Key问题(大热Key拖垮整个集群的情况,历史上多次发生)。(危险指数比较高)
        • 【无法按需更新多维度主体缓存】缓存无法按照 主体基础信息、主体图片等 构造缓存,消息中无法区分具体是哪个维度的消息发生了变化。(危险指数比较高)
      • 【无法利用上版本号】使用事务消息,无法利用上DB中的版本号,也就无法通过乐观的方式更新缓存。
      • 【需要做异步链路容灾】如果消息消费延迟了,则缓存不能得到及时更新,导致缓存和DB长时间不一致【针对延迟要做优化】

  • KBUS: MySQL保证事务(MySQL的2PC),只有DB更新成功了,才会将消息发出来。

    • 优点
      • 【日常维护成本低】研发无需感知事务逻辑,研发日常只要关注跟DB的交互即可。
        • 公司及业界大部分都是用监听Binlog更新缓存,作为最终一致性方案,方案较为成熟。
      • 【缓存更新高效】主体任意维度数据更新,则更新对应维度的缓存即可,缓存定义随表走。
      • 【可以利用上版本号】这里也有开发成本,目前redis不支持这种基于版本号的更新,需要定制化开发。
    • 缺点
      • 【业务不友好】Kbus消息无法直接被外部直接使用,一是没办法做数据层防腐(变更字段下游要跟着搞),二是存储层的数据比较原子偏底层的,而业务方可能要的是一个聚合状态的业务信息。
      • 【有写扩散问题】一次主体编辑,可能发送几条关联关系的变更消息,关联关系缓存则要更新几百次,此处要单独优化【增加定制化优化成本】。
      • 【受其他链路影响】如果出现主从延迟,则数据延迟时间较长,可能造成故障(尤其是数据库从库结是链式串联的方式,影响尤为恶劣)【针对延迟要做优化】
      • 【需要做异步链路容灾】如果消息消费延迟了,或者主从延迟了,则缓存不能得到及时更新,导致缓存和DB长时间不一致【针对延迟要做优化】
  • 选型总结

    • T-RMQ 对业务友好,但对缓存链路不够友好。Kbus是比较成熟的链路,维护成本相对较低。
    • 建议使用Kbus链路作为DB&缓存的一致性保障

三、使用kbus保障数据一致性可能会遇到哪些问题?

在高吞吐的情况下(量级不大的时候,怎么搞都难出问题,量级较大的情况下,所有细节都要考虑):

  1. 【有序 != 性能】kbus实现的顺序消费,无法保障极高的写TPS,如果出现数据较为倾斜的情况,单个线程将成为瓶颈。
    1. 分析:线上无数次发生因为依赖全局顺序导致数据延迟进而产生线上故障,当出现数据倾斜的时候,数据消费延迟,导致缓存与DB不一致。因此强烈不建议使用顺序消息&亲缘线程池来保障全局消息消费顺序。
  2. 【缓存=>脏写】不依赖binlog的顺序消息,会出现缓存脏写,同一个缓存变更消息,如果落到缓存中的顺序发生了错乱,导致缓存是脏的。
    1. 场景一:直接利用binlog消息内容写入缓存,并发场景下,会有以下问题:
      1. kbus消息消费到不同consumer,导致老的kbus消息最后执行的,导致脏写缓存。
      2. 同一个kbus consumer java 服务消费到两条消息,在多线程的场景下,线程顺序执行错误,导致老数据覆盖新数据。
    2. 场景二:如果不依赖binlog的顺序消息,且想拿到准确数据,因此需要反查主库,但反查主库也会带来问题
  3. 【DB=>脏读】
    1. 问题场景如下
      1. 场景一:收到binlog后,反查主库拿数据查到旧数据。这块大部分同事都有疑问,答案在这:AccessProxy访问控制: 可选方式的身份认证
      2. 场景二:程序中两个线程同时查主库,一个查到新数据,一个查到旧的,如果旧的线程最后执行,也会导致缓存脏写。
  4. 【最严重问题】【主从延迟】
    1. 问题场景:
      1. 主从延迟会导致DB与数据库数据长时间不一致,延迟一个小时,则DB和缓存就有一个小时的延迟。
      2. 影响:出过多次故障,因为缓存&DB数据不一致导致线上大量客诉。

四、缓存更新模型选择

常见模型:

  • 模型一:"先删除缓存,后操作DB" or "先操作DB,后删除缓存":  此方案不建议超高并发的C端场景下使用,比如直播间等场景,此方案会导致服务的RT抖动以及影响服务的稳定性。
  • 模型二:"先更新缓存,后更新DB":这种方案无法保障DB&缓存数据的一致性,如果使用分布式强事务组件,方案太重,不合适。
  • 模型三:"先更新DB,后更新缓存":这种方案是可以通过事务消息或者binlog方案更新缓存,可以保证缓存和DB的一致性。

缓存场景业界对比:

  • 美团-外卖-商品:模型三 + 双异步链路(binlog & mq双链路),日常仅开启 binlog消费,如果延迟了开启MQ , 实时消息会造成双倍DB容量, 日常不开启, 手动容灾,在出现故障时手动开启实时消息消费。
  • 阿里-淘宝-商品:模型一 + 同步、异步双链路,同步链路删除,异步链路check,问题在于删除数据,双链路、双倍DB容量、大促需要缓存预热。
  • 快手-电商-商品:【比较领先】方案三加强版,先写DB,然后通过异步链路保证数据的一致性,通过前置过期的方式,作为缓存延迟的容灾方案。【单链路】【成本低】【无需预热】【自动容灾】

五、推荐的解决方案

目标:既要保障DB&缓存数据最终一致性,又要保障高吞吐,还要保障主从延迟发生自动降级。

一致性保障详细流程:

策略

  1. 数据库表中增加版本号,每个更新操作版本号+1(乐观)。
    1. 收益:收到binlog后,相同记录可以通过版本号区分先后。
  2. Redis命令定制化改造,支持乐观更新。需求:Redis定制化命令需求
    1. 收益:缓存更新依赖binlog中的版本对比,不再出现老版本覆盖新版本的问题。
  3. 主动失效,更新DB之前先前置设置比较短的过期时间。
    1. 收益:消除主从延迟的风险,实现主从延迟自动降级(20s过期,缓存miss会重新走load db的流程)。

总结 : 通过以上策略,保障了缓存&DB的一致性,解决了高吞吐、数据准确、自动容灾等一系列问题。

:以上不适合写后即查的场景,但适用于大部分缓存场景。

文章2:

前言

缓存是目前互联网项目中最常用到的技术解决方案,通过缓存,我们可以做到

  1. 减少接口RT,减少查询数据库的次数,降低数据库压力
  2. 实现各种各样复杂的业务逻辑,比如排行榜、风控、黑名单过滤等
  3. ......

而无论是本地缓存,还是Redis缓存,还是memcached缓存,无论是哪一种缓存落地方案,其数据存储都一定和MySQL数据库属于异地异质存储,而在这种情况下,由于众所周知的分布式问题,我们无法保证数据库和缓存一定会同时更新成功或者失败,此时就会出现数据一致性问题,于是就衍生出了多种缓存同步数据库数据的方案,这些方案在一致性、性能上都有自己的优缺点,我们只有真正了解这些方案之后,才能在复杂的业务场景中落地适合我们业务场景的技术方案。

一、 缓存读写策略

在介绍数据库与缓存一致性方案之前,我们先来看一下经典的三种缓存读写策略,这三种策略是出现在计算机系统中的三大基本策略,而数据库与缓存一致性方案,也是基于这三种策略的思想,进行设计的。

Cache Aside Pattern(旁路缓存模式)

Cache Aside Pattern 是我们平时使用比较多的一个缓存读写模式,比较适合 读请求比较多 的场景。

Cache Aside Pattern 中服务端需要同时维系 db 和 cache,并且是以 db 的结果为准。

下面我们来看一下这个策略模式下的缓存读写步骤。

对于写:

  1. 先更新db(这样可以确保db的数据一定正确)
  2. 再修改缓存

对于读:

  1. 从cache中读取数据,读取到就直接返回
  2. 如果读取不到的话,就从db中读取数据返回,再把数据写入到cache中

Read/Write Through Pattern(读写穿透)

Read/Write Through Pattern 中服务端把 cache 视为主要数据存储,从中读取数据并将数据写入其中。cache 服务负责将此数据读取和写入 db,从而减轻了应用程序的职责。

这种缓存读写策略在平时在开发过程中比较少见。抛去性能方面的影响,大概率是因为我们经常使用的分布式缓存并没有提供 cache 将数据写入 db 的功能。

写(Write Through):

  1. 先查 cache,cache 中不存在,直接更新 db。
  2. cache 中存在,则先更新 cache,然后 cache 服务自己更新 db( 同步更新 cache 和 db )。

读(Read Through):

  1. 从 cache 中读取数据,读取到就直接返回 。
  2. 读取不到的话,先从 db 加载,写入到 cache 后返回响应。

Read-Through Pattern 实际只是在 Cache-Aside Pattern 之上进行了封装。在 Cache-Aside Pattern 下,发生读请求的时候,如果 cache 中不存在对应的数据,是由客户端自己负责把数据写入 cache,而 Read Through Pattern 则是 cache 服务自己来写入缓存的,这对客户端是透明的。

Write Behind Pattern/Write Back(异步缓存写入/写回)

Write Behind Pattern 和 Read/Write Through Pattern 很相似,两者都是由 cache 服务来负责 cache 和 db 的读写。

但是,两个又有很大的不同: Read/Write Through 是同步更新 cache 和 db,而 Write Behind 则是只更新缓存,不直接更新 db,而是改为异步批量的方式来更新 db。

很明显,这种方式对数据一致性带来了更大的挑战,比如 cache 数据可能还没异步更新 db 的话,cache 服务可能就就挂掉了。

这种策略在我们平时开发过程中也比较少见,但是不代表它的应用场景少,比如操作系统中的Page cache刷脏页的策略,比如操作系统中CPU缓存一致性策略,比如消息队列中消息的异步写入磁盘、MySQL 的 Innodb Buffer Pool 机制都用到了这种策略。

Write Behind Pattern 下 db 的 写性能非常高 ,非常适合一些数据经常变化又对数据一致性要求没那么高的场景,比如浏览量、点赞量

下面针对这三种策略,介绍一些更详细的方案。

二、缓存一致性方案

CAP

在介绍缓存一致性方案之前,先介绍一下一个分布式理论的著名定理,并且给大家先留下一个问题。

理论计算机科学中,CAP定理(CAP theorem),又被称作布鲁尔定理(Brewer's theorem),它指出对于一个分布式计算系统来说,不可能同时满足以下三点:[1][2]

  • 一致性(Consistency) (等同于所有节点访问同一份最新的数据副本)

  • 可用性(Availability)(每次请求都能获取到非错的响应——但是不保证获取的数据为最新数据)

  • 分区容错性(Partition tolerance)(以实际效果而言,分区相当于对通信的时限要求。系统如果不能在时限内达成数据一致性,就意味着发生了分区的情况,必须就当前操作在C和A之间做出选择[3]。)

根据定理,分布式系统只能满足三项中的两项而不可能满足全部三项[4]。理解CAP理论的最简单方式是想象两个节点分处分区两侧。允许至少一个节点更新状态会导致数据不一致,即丧失了C性质。如果为了保证数据一致性,将分区一侧的节点设置为不可用,那么又丧失了A性质。除非两个节点可以互相通信,才能既保证C又保证A,这又会导致丧失P性质。

而对于我们的缓存架构来说,分区容错性是我们必须保证的,那么,这是否代表,一致性和可用性无法同时保呢?

让我们带着这个疑惑,来看一下缓存一致性方案。

2.1 Cache Aside Pattern

更新DB,再更新缓存

该方案就是对于旁路缓存这种策略,在写的时候,先更新DB,再更新缓存,那么此时会存在什么问题呢。

举个例子,比如「请求 A 」和「请求 B 」两个请求,同时更新「同一条」数据,则可能出现这样的顺序:

A 请求先将数据库的数据更新为 1,然后在更新缓存前,请求 B 将数据库的数据更新为 2,紧接着也把缓存更新为 2,然后 A 请求更新缓存为 1。

此时,数据库中的数据是 2,而缓存中的数据却是 1, 出现了缓存和数据库中的数据不一致的现象 。

这种情况用专业点的话来说,这不就是 脏写 吗

更新DB,再删除缓存

既然更新缓存会出现脏写问题,那么我们直接不写缓存,直接将缓存删除,在请求的时候在去重建缓存,这样就解决了之前的脏写问题,那么这样就完全没问题了吗?我们来看看下面这种情况:

图片

最终,缓存中是 20(旧值),在数据库中是 21(新值),缓存和数据库数据不一致。这不就是 脏读 问题吗

从上面的理论上分析,先更新数据库,再删除缓存也是会出现数据不一致性的问题, 但是在实际中,这个问题出现的概率并不高 。

因为数据库的读取操作往往比写入操作更快 ,所以在实际中很难出现请求 B 已经更新了数据库并且删除了缓存,请求 A 才更新完缓存的情况。

而一旦请求 A 早于请求 B 删除缓存之前更新了缓存,那么接下来的请求就会因为缓存不命中而从数据库中重新读取数据,所以不会出现这种不一致的情况。

总结一下就是这张情况需要两个前提条件:

  1. 缓存刚好失效
  2. 数据库的读取比写入慢,导致读到的脏数据最后被写入了缓存当中

所以, 「先更新数据库 + 再删除缓存」的方案,是可以一定程度的保证数据一致性的 。

但是为了万无一失,我们还可以给缓存加上 过期时间 来保证数据的最终一致性。

那么此时还会有其他问题吗?要注意,以上两种情况的本质其实是 多个请求之间的指令乱序 问题,这种方案确实可以解决这个问题,可是一个请求涉及到数据库操作,又涉及到缓存操作,对于这种分布式操作,我们无法控制其是否一定成功了。那么如果在 修改修改数据库成功之后,删除缓存失败了 ,那么此时的数据一致性又无法保证了。

如果想保证删除缓存成功,那么我们可以选择 重试机制 来删除缓存,但是我们重试几次呢?1次?三次?五次?重试次数如果太少,可能依旧会发送失败,重试次数如果太多,可能会阻塞用户请求,导致接口RT升高,无论哪种情况,都会对我们的系统造成负向影响,那我们有没有一种方法,可以保证删除缓存成功呢?

答案就是异步删除,在异步删除的时候,保证删除成功,这样就解决了重试这种方案所带来的问题。

更新DB,异步删除缓存

我们在删除缓存的时候,使用异步的方式去删除缓存,而异步的方案又有好几种:

  1. 线程池
  2. 消息队列
  3. 消费数据库Binlog日志

由于前两种方案,对于业务代码的 侵入 比较严重,所有涉及到数据库更新的地方,都需要关注对应缓存数据的更新,所以此处主要介绍第三种方案,也是业界比较流行的方案。

具体的写流程如下(此处用开源的binlog消费工具Canal举例):

图片

  1. 修改数据库
  2. Canal监听到binlog日志变更,投递到MQ
  3. MQ消费者消费MQ,删除相应的缓存

由于MQ的特性,我们基本可以保证此处的删除缓存的操作能成功,至此,缓存和数据库的一致性得以保证。

思考

对于以上三种 Cache Aside Pattern策略的方案实现,我们再进行一些思考:

1.为什么要删除缓存而不是更新缓存?

防止并发时,指令乱序导致的脏写问题

2.可以不删除缓存解决脏写问题吗?

当然可以,常见的方案就是大名鼎鼎的分布式锁,使用分布式锁来防止指令乱序,保证指令串行执行即可,但是分布式锁使用复杂且性能较低,一般不推荐

3.删除缓存会存在什么问题吗?

由于每次都会删除缓存,所以一个缓存在删除后第一次被请求到的时候,一定是空的,此时存在两个问题:

  1. 对于 hotkey ,会存在缓存的热点问题,存在缓存击穿问题
  2. 由于一些请求会请求数据库来重建缓存,所以 缓存命中率会降低 ,而缓存命中率可以衡量缓存对用户请求的有效性
4.异步会带来什么问题?

异步存在延迟,会导致一段时间内数据库和缓存的数据不一致

2.2 Read/Write Through Pattern

这种策略和旁路缓存的区别不大,主要是由cache服务自己来写入缓存,所以这里不在介绍相关方案。

2.3 Write Behind Pattern

先更新缓存,异步更新DB

前面有过介绍,该策略和以上介绍过的方案有很大的不同,主要区别就在于, Write Behind 则是只更新缓存,不直接更新 db,而是改为异步批量的方式来更新 db。

下面我们来看一下这个策略模式下的缓存读写步骤。

对于写:

  1. 先更新缓存
  2. 异步修改数据库(比如通过线程池批量更新数据库,通过MQ异步更新数据库)

对于读:

  1. 从cache中读取数据,读取到就直接返回
  2. 读取不到的话,先从 db 加载,写入到 cache 后返回响应。

该方案的特点在于,由于不会同步更新缓存和数据库,只会更新缓存,然后异步更新数据库,所以他能够支撑的写请求很高,可以突破数据库的瓶颈,并且,由于先更新缓存,缓存的数据的有效性比较强,不会存在用户更新完数据,缓存过一会才更新的情况。但是由于数据库是异步修改的,所以在一些情况下会丢失部分数据(比如服务重启,缓存崩溃,MQ丢消息等)。

方案对比

方案存在的问题优点适用场景
更新数据库,再更新缓存并发导致的脏写问题缓存命中率比较高对数据一致性要求不高,且对缓存命中率有要求
更新数据库,再更新缓存(加锁版)由于使用了分布式锁,性能比较低可以保证强一致性,且缓存命中率比较高要求强一致性,写请求很少的情况
更新数据库,再删除缓存缓存命中率降低,少数情况下可能出现并发问题性能比较好,可以保证最终一致性多数场景
更新DB,异步删除缓存数据更新存在延迟,无法保证强一致性可以保证最终一致性,且性能较高多数场景
更新缓存,异步更新DB会丢失部分数据,一致性不太好写性能很高写请求很多,可以接受丢失部分数据

三、一些实践

下面介绍一些互联网公司在缓存数据库一致性方案上的实现,根据不同的业务场景,业务量级,采用不同的实现方案,可以最大程度的提升我们系统的稳定性。

快手缓存方案

下面介绍一下快手比较流行的缓存方案,目前的缓存方案是,会将DB里对应数据的缓存写入Memcached中。而对于缓存和数据库数据一致性的保证,则使用的是上面提到的Cache Aside Pattern策略的变种,即先更新DB,再异步消费binlong修改数据库数据。

img

读流程:

  1. 查询缓存,如果查到了,直接返回
  2. 如果没查到,调用cacheSetter回源缓存,然后再读取缓存

写流程:

  1. 更新DB,用户写请求流程结束
  2. kbus服务监听binlog变更,调用cacheSetter
  3. cacheSetter从DB查询最新的数据,然后更新缓存

CacheSetter是一个特殊的RPC服务,他主要负责缓存的回源逻辑,并且内部保证针对同一ID,保证同一时间,只有单一线程更新缓存,这样就避免了如果同时有多个请求调用cacheSetter,并发修改导致的脏写问题,从而保证了最终一致性。但是由于消费binlog数据存在延迟,所以该方案不能保证实时的数据一致性。

CacheSetter如何保证更新的时候数据一致性?

正是由于cacheSetter内部帮我们保证了更新时候的数据一致性,我们在这种架构下才不用考虑缓存更新的脏写问题

CacheSetter主要从两个方面来做的处理:

  1. 客户端路由保证同一个id只会路由到同一个CacheSetter实例上。
  2. CacheSetter 使用 CountDownLatch 保证同一个id的并发load请求,只会访问一次DB。

更多CacheSetter相关内容,可以阅读笔者之前写的这篇文章 聊一聊CacheSetter的实现原理

某内容APP评论点赞缓存方案

下面介绍一下某内容APP的评论点赞缓存方案,该业务场景的特殊性在于:评论点赞量的读写请求并发量都很高,且允许丢失一些用户的点赞数量,考虑到这几点,采用Write Behind的方案来设计缓存数据库一致性方案。

读流程:

  1. 查询缓存,如果查到了,就直接返回
  2. 如果没查到,就从数据库里查出数据回源缓存,然后返回缓存数据

写流程:

  1. 更新缓存里的评论点赞量,如果缓存里没有,就从数据库里回源到缓存里去
  2. 更新缓存中保存的Set,该Set保存了所有更新过点赞数量的评论ID
  3. 定时任务执行,获取Set,通过Set中的ID,查询缓存中的评论点赞量,将缓存中的评论点赞量刷入数据库

该方案还存在一定的问题,由于定时任务每次执行的时候,会对缓存带来很多无用的查询,会增加缓存服务的压力,如果有1000W条帖子更新了点赞量,那么就会查询缓存1000W次,所以此处还可以优化一下。

由于点赞是一个数量累加的操作,如果我们想更新到数据库里,其实不需要获取到最新的数据,只需要在本地记录一下,然后每个机器在将累加值加到DB中即可,并且由于数据库update操作会进行加锁,此处也就避免了并发操作的问题。

让我们看看优化后的流程

img

读流程:

  1. 查询缓存,如果查到了,就直接返回
  2. 如果没查到,就从数据库里查出数据回源缓存,然后返回缓存数据

写流程:

  1. 更新缓存里的评论点赞量,如果缓存里没有,就从数据库里回源到缓存里去
  2. 加读锁,将点赞的累加量记录到本地缓存中的Map<Long, AtomicInteger>中去
  3. 加写锁,定时任务执行,将本地缓存中的数据批量发送到MQ中,清空Map
  4. MQ消费消息,将数据写入到数据库中

让我们来看一下这套架构中的一些疑问

1.为什么要加读写锁?

如果不加读写锁的话,那么在读取map数据的时候,用户也在同时修改map的数据,在读取 - 更新完成这一段时间,会丢失用户的请求。所以通过读写锁,使得读取map数据更新到DB这一流程和用户修改Map数据的流程互斥。

2.为什么要用原子类来记录点赞量?

防止并发问题,也可以用Concurrenthashmap,但是用原子类,符合场景的同时并发度更高,性能更好。

3.为什么更新DB的时候还要用MQ?

因为读取map数据的时候需要加写锁,这时候会阻塞住读锁(也就是用户请求),这里如果直接更新DB,延迟太大,如果使用MQ,通过异步的方式更新,对用户的阻塞更小。

四、总结与思考

本文从缓存读写策略入手,首先介绍了三个计算机系统中经典的策略,然后根据这三种策略,介绍了具体不同的几种缓存数据库一致性方案,并且针对每种方案的使用场景进行了分析,在性能,一致性上都进行了相应的分析。我们可以发现,缓存数据库一致性问题本质上有两个原因:

  1. 指令乱序问题(多个请求之间的原子性无法保证)
  2. 分布式无法保证一定成功(单个请求的多次操作无法保证原子性)

通过不同的方案来解决这两个问题,就构成了不同的一致性方案。

再回到文章开头的那个问题,C和A能不能同时保证?

分析完所有的方案之后,我们可以发现,当保证了强一致性的时候,性能势必会降低,可用性也就降低了,当保证了性能也就是可用性的时候,多多少少会牺牲一点一致性,也许是非实时一致性,也许是顺序一致性,也许是最终一致性...

文章的最后,笔者通过两个互联网领域的缓存实践,通过实际案例和方案相结合的方式来说明缓存数据库一致性方案,希望能给读者带来一些思考。

总而言之,没有任何方案是我们解决问题时的银弹,我们需要根据业务的实际场景,以及当前产品的项目架构来选择最适合我们业务的技术方案。

文章3:

一、概述

本文对我手缓存架构设计中频繁出现的CacheSetter,进行一点简单的剖析,希望通过本文,能够帮助刚进入我手的校招生和实习生同学们熟悉公司缓存架构,也希望能帮助已经对CacheSetter比较熟悉的大佬们,加深一点对它的了解,在日常开发中不再是黑盒开发。

1.1 什么是CacheSetter

CacheSetter本质上是一个RPC服务,提供了将 数据从数据库加载到缓存 的能力。

CacheSetter 保证针对同一id,同一时间,只有单一线程,在更新缓存,因此可以 避免并发问题 ,可以保证了缓存的 最终一致性 。    

CacheSetter框架内部通过 模版方法 这种设计模式,已经替我们封装好了一些关于缓存重建的load和reload流程,我们只需要在相关抽象方法中实现我们自己的 缓存构建 的业务逻辑,就能很快速的构建一个CacheSetter服务。

1.2 为什么要用CacheSetter

使用CacheSetter的好处主要有两点:

  1. 提供一套一致性和可用性都还不错的 缓存数据库一致性方案
  2. 在我手 高并发 的业务场景下解决 缓存击穿和缓存雪崩 的问题

1.3 怎么使用CacheSetter

  1. 依赖CacheSetter项目
<dependency>
  <groupId>kuaishou</groupId>
  <artifactId>kuaishou-cache-setter</artifactId>
</dependency>
  1. 实现CacheSetter RPC服务端接口
public class FlowCacheSetterImpl extends BaseCacheSetterImpl {

    @Override
    protected void loadToCache(Collection<Long> needToLoadIds) {
        //实现方法,从db读取数据并写入缓存
    }

    @Override
    protected Set<Long> notInCache(Collection<Long> ids) {
        //实现方法,返回在缓存中不存在的id集合。这部分id会被cachesetter服务加载到缓存中
    }
}
  1. 使用CacheSetter提供的一致性hash客户端加载/更新缓存
// 准备待加载缓存的id集合
List<Long> ids = buildIds();
//NewCommonCacheSetterUtils是cachesetter提供的client工具。
//通过该工具执行的方法会按照一致性hash把请求分发到各个cachesetter服务实例
NewCommonCacheSetterUtils.load(ids, Any::pack, CacheRpcConfig);
  1. 读取缓存数据

加载完缓存数据之后,就可以读取了,我们可以直接使用redis/memcached客户端去读取,也可以使用我手内部提供的客户端封装类去读取:

private final LongSortedSetIndexEx tubeSubscribeSortedSetIndexEx = new LongSortedSetIndexEx(
            String::valueOf, (keys, multiThread) -> NewCacheSetterUtils.load(keys,
            TubeCacheSetter.tubeSubscribeCacheSetter, TUBE_SUBSCRIBE_CACHE_SETTER_TIME_OUT.get()),
            TubeNewJedisCluster.tubeSubscribe);

使用该封装类的优点在于,当查询不到数据的时候,会自动帮我们执行传入的NewCacheSetterUtils.load方法,我们就可以不需要操心缓存的回源操作,这种操作类似于write through策略,无需程序员关注缓存写回的操作。

二、架构设计

2.1 缓存和数据库一致性设计

我手常见的使用CacheSetter时的缓存数据库一致性流程图

2.2 如何保证并发请求时一个key只回源一次DB

我们前面提到过CacheSetter 保证针对同一key,同一时间,只有单一线程,在更新缓存,因此可以 避免并发问题 ,可以保证了缓存的 最终一致性 。那么它是怎么做到的呢?

CacheSetter主要从两个方面来做的处理:

  1. 保证同一个key只会路由到同一个CacheSetter实例上。
  2. CacheSetter保证同一个id的并发load请求,只会访问一次DB。
2.2.1 同一个Key路由到同一个CacheSetter实例

客户端使用一致性Hash算法保证同一个id路由到同一个CacheSetter实例。

调用CacheSetter grpc服务时,在grpc客户端做一致性哈希,使得相同id的请求被打到同一个CacheSetter实例上。com.kuaishou.cache.setter.NewCommonCacheSetterUtils方法已经做好了封装,使用该方法即可。

NewCommonCacheSetterUtils使用grpc客户端的bulkCall,其底层使用了一致性哈希策略,相关的代码在com.kuaishou.framework.rpc.client.n.strategy.impl.ConsistentHashLBStrategy中,采用的是ketama哈希算法,感兴趣的同学可以自己看下。

对一致性Hash有疑惑的同学,可以阅读以下文章学习:

分布式算法 - 一致性Hash算法 | Java 全栈知识体系

CodingLabs - 一致性哈希算法及其在分布式系统中的应用

一致性哈希算法之Ketama算法 - 简书

2.2.2 同一台实例的同一个key的并发load请求只访问一次DB

当一台实例上一个key已经在进行load的操作的时候,如果这时候又有这个key的请求要load cache,此时这次请求的这个key将不会在进行load操作。

CacheSetter会为某个key分配一个 CountDownLatch ,当某个key请求CacheSetter时,会先检查下当前是否为这个key分配了CountDownLatch,如果已经分配了,说明这个key已经在loading了,就不再执行load操作了,但是会通过CountDownLatch.await,在这个key load完成之后再返回;如果没有分配,就创建一个新的CountDownLatch,并将id和该CountDownLatch保存到map,等到load操作执行完,会执行CountDownLatch.countDown操作,并从map中移除该key和对应的CountDownLatch。

CountDownLatch可以够使 一个线程等待其他线程完成各自的工作后再执行 ,在创建CountDownLatch对象时会传入一个整型数 N,每次调用 CountDownLatch 的countDown方法会对 N 减一, 直到 N 减到 0 的时候,所有调用await方法的线程继续执行 。在CacheSetter这个场景中,CountDownLatch创建时传入的N是1,表示其余线程要等待load线程执行完,再继续往下执行。

三、源码解读

talk is cheap ,我们先来看一下源码,再来根据源码进行并发请求时的流程分析。

一些重要的成员变量

//这个锁是为了锁让loadingIds和reloadingIds的两个一致,
//一个id不应该同时在loadingIds和reloadingIds里
private final Object lock = new Object();
//该注解代表该字段只能在线程持有this.lock这个锁时被访问。
@GuardedBy("this.lock")
// key : id , value : CountDownLatch
private final Map<Long, CountDownLatch> loadingIdLatchMap = new HashMap<>();
@GuardedBy("this.lock")
private final Map<Long, CountDownLatch> reloadingIdLatchMap = new HashMap<>();

load方法

public void load(IdsParams request, StreamObserver<SimpleResult> responseObserver) {
    /**
     * load的逻辑是:
     * 如果有同时reload和load的,就忽略,只load没在同时reload和load的
     * 但是要等所有reload/load都完成才继续
     */
    CountDownLatch countDownLatch = null;
    try {
        //1.需要进行load的idList
        List<Long> needToLoadIds = new LinkedList<>(request.getIdsList());
        //用来保存正在loading和reloading的CountDownLatch
        Collection<CountDownLatch> reloadingLatches;
        Collection<CountDownLatch> loadingLatches;
        //2.在操作 CountDownLatch Map的时候 获取锁,防止并发修改
        synchronized (lock) {
             //3.排除当前在reloading的id并拿到其对应的Latch
             TwoTuple<List<Long>, Collection<CountDownLatch>> reloadingInfo = excludeLoading(
                    needToLoadIds, reloadingIdLatchMap);
             reloadingLatches = reloadingInfo.getSecond();
             //4.排除当前在loading的id并拿到其对应的Latch
             TwoTuple<List<Long>, Collection<CountDownLatch>> loadingInfo = excludeLoading(
                     needToLoadIds, loadingIdLatchMap);
             loadingLatches = loadingInfo.getSecond();
             //此时needToLoadIds里只有需要load的id
             //修改完成之后就保护为不可变的
             needToLoadIds = Collections.unmodifiableList(needToLoadIds);
             //5.保存需要loading的id对应的latch到loadingIdLatchMap中
             //id 和 countDownLatch 的关系是N :1
             countDownLatch = saveLatch(needToLoadIds, loadingIdLatchMap, OP_LOADING);
         }
         //6.由于对loadingIdLatchMap的操作已经结束,释放锁
         try { 
             //7.判断需要load的ids是否已经在缓存中了,notInCache由各业务自己实现
             Set<Long> realNeedToLoadIds = notInCache(needToLoadIds);
             if (!realNeedToLoadIds.isEmpty()) {
                 //8.执行loadToCache方法,也是由各业务自己实现
                 loadToCache(realNeedToLoadIds);
             }
         } finally {
             //到这里这批需要load的ids就完成了
             //9.在loadingIdLatchMap中删除这批id和countDownLatch的映射,这一步是加锁的,所有操作loadingIdLatchMap都需要加锁
             release(needToLoadIds, loadingIdLatchMap, countDownLatch, OP_LOADING);
             //10.需要等前面被excludeLoading的id对应的countDownLatch执行countDown方法到0
             blocking(loadingLatches, OP_LOADING);
             //11.同理
             blocking(reloadingLatches, OP_LOADING);
         }
     } finally {
         //12.保证所有创建的latch都被countdown,执行该方法后,调用了countDownLatch.await方法的线程在count=0的时候会被唤醒
         if (countDownLatch != null) {
             countDownLatch.countDown();
         }
     }
 }

下面是load方法的流程图

1.分配CountDownLatch

当请求过来之后,首先要做的就是为每个id分配一个CountDownLatch对象,对于同一个请求里的id,可以共用一个对象,因为这批id是同时被加载到缓存里的,所以可以复用。

分配后的id和countDownLatch对象会被保存到一个map中,每个请求过来时会先根据id是否在map中,将id分为inLoading和needLoad的两部分,needLoad的id会执行load操作,inLoading id的会在needLoad的id加载完之后执行等待操作。

整个分配的过程是串行的,基于synchronized来实现。

2.notInCache

判断id是否在Cache中,如果已经在cache中就不需要再load数据了。

3.loadToCache

加载数据到缓存里,一般是读取DB数据。

4.await

对于inLoading的id,需要等待对应的load线程执行完再返回,否则可能造成CacheSetter返回了,但是实际上数据并没有加载到缓存里。通过调用CountDownLatch#await方法来实现。

5.countDown

数据加载完毕,释放CountDownLatch,让其他等待的线程继续往下执行。

reload方法

private void reload(Collection<Long> requestIds, boolean isAsync) {
    /**
     * reload的逻辑:
     * 如果有load的,要等load完了再reload。
     * 没在load的,执行reload。
     * reload时,同时在reload的不执行,但是要等其执行完
     */
    CountDownLatch firstReloadLatch = null;
    CountDownLatch secondReloadLatch = null;
    try {
        List<Long> finalNeedToLoadIds;
        Collection<CountDownLatch> loadingLatches;
        Collection<CountDownLatch> reloadingLatches;
        List<Long> loadingIds; //当前在loading中的id
        synchronized (lock) {
            //1.需要load的ids
            List<Long> needToLoadIds = new LinkedList<>(requestIds);
            //2.排除当前在loading的id并拿到其对应的Latch
            TwoTuple<List<Long>, Collection<CountDownLatch>> loadingInfo = excludeLoading(
                    needToLoadIds, loadingIdLatchMap);
            loadingIds = loadingInfo.getFirst();
            loadingLatches = loadingInfo.getSecond();

            //3.排除当前在reloading的id并拿到其对应的Latch
            TwoTuple<List<Long>, Collection<CountDownLatch>> reloadingInfo = excludeLoading(
                    needToLoadIds, reloadingIdLatchMap);
            reloadingLatches = reloadingInfo.getSecond();
            //4.修改完成之后就保护为不可变的
            finalNeedToLoadIds = Collections.unmodifiableList(needToLoadIds);
            //5.保存排除后剩余的id对应的latch
            firstReloadLatch = saveLatch(finalNeedToLoadIds, reloadingIdLatchMap, OP_RELOADING);
        }
        //6.加载排除后剩余的id
        doReload(finalNeedToLoadIds, reloadingLatches, firstReloadLatch, isAsync, OP_RELOADING);
        //7.当前在loading中的那些id,等loading结束再reload
        if (!loadingIds.isEmpty()) {
            //8.await
            blocking(loadingLatches, OP_AFTER_LOAD_RELOAD);
            List<Long> final2ndReloadIds;
            Collection<CountDownLatch> secondReloadingLatches;
            //9.之前在loading中的那些id,现在等loading完成之后需要reload,
            // 由于可能存在多个线程await然后被唤醒了,所以此时执行时,可能有其它的线程正在reload其中的某几个id,所以要重新过滤一遍
            synchronized (lock) {
                TwoTuple<List<Long>, Collection<CountDownLatch>> secondReloadingInfo = excludeLoading(
                        loadingIds, reloadingIdLatchMap);
                secondReloadingLatches = secondReloadingInfo.getSecond();
                final2ndReloadIds = Collections.unmodifiableList(loadingIds);
                secondReloadLatch = saveLatch(final2ndReloadIds, reloadingIdLatchMap,
                        OP_AFTER_LOAD_RELOAD);
            }
            //10.reload之前由于正在reloading或者loading而没有reload的ids
            doReload(final2ndReloadIds, secondReloadingLatches, secondReloadLatch, isAsync,
                    OP_AFTER_LOAD_RELOAD);
        }

    } catch (Throwable e) {
        logger.error("Ops.", e);
    } finally {
        //11.保证创建的latch无论如何都会被释放掉,防泄漏和死锁
        if (firstReloadLatch != null) {
            firstReloadLatch.countDown();
        }
        if (secondReloadLatch != null) {
            secondReloadLatch.countDown();
        }
    }
}

private void doReload(Collection<Long> needToLoadIds,
        Collection<CountDownLatch> reloadingLatches, CountDownLatch latch, boolean isAsync,
        String opName) {
    try {
        //执行loadToCache方法
        if (!needToLoadIds.isEmpty()) {
            loadToCache(needToLoadIds);
        }
        //如果需要延迟再次Reload,则加入到延迟队列中,等待一分钟后再次执行(默认是true)
        if (isDelayReloadAgain().getAsBoolean()) {
            delayReloadQueue.put(new DelayReloadTask(needToLoadIds, reloadDelay));
        }
    } finally {
        //在reloadingIdLatchMap删除已经reload的id的映射
        release(needToLoadIds, reloadingIdLatchMap, latch, opName);
        //等待别的reload线程执行完
        blocking(reloadingLatches, opName);
    }
}

总结一下load和reload的异同点

相同点:

  1. 保证同一个id只会同时在load和reload的过程并发执行一个流程。这两个流程互斥且流程中的同一id互斥
  2. 需要等到这一次请求所有的ids load/reload结束才会结束完成

不同点:

  1. load只会load没有在同时load/reload的ids,然后等待别的线程load/reload完成;而reload会等待别的线程load/reload结束之后,再次对这批没有reload的ids进行reload,不过不一定是当前线程执行了reload方法,也可能是别的reload线程,所以reload方法会保证所有请求传入的ids进行一次reload流程,但并不保证是当前线程。

  2. load不会load在缓存中存在的ids,通过notInCache方法控制;而reload则不会进行这一层处理

  3. reload在执行loadToCache之后,执行完后会延迟一分钟再执行一次,来尽量保证一致性,如果不需要延迟的reload,可以重写 isDelayReloadAgain 方法,返回false。

四、思考

针对源码中的设计,我们来进行一些思考。

1.为什么使用CountDownLatch而不用其他的并发工具类

我们先来分析一下这里的场景,我们的目的是:让多个执行load流程的线程,对同一id,只允许一个线程执行load,而其他线程都需要等待loading线程执行完毕。

再来看一下CountDownLatch的使用场景:CountDownLatch允许一个或多个线程等待其他线程完成操作。我们可以发现,刚好符合这里的场景。

而其他的并发工具类

CyclicBarrier :让一组线程到达一个屏障时被阻塞,直到所有线程到达屏障的时候,屏障才会开门,所有被屏障拦截的线程才会继续运行。而这里不是这个场景

Semaphore :用来控制同时访问特定资源的线程数量,它通过协调各个线程,以保证合理的使用公共资源。

他和 CountDownLatch 除了在操作上的区别以外,还有一个区别就是Semaphore在释放锁的时候, 只会有一个线程获取锁执行 ,对于并发线程很多的情况,会有很多线程阻塞浪费CPU资源,也会导致RPC请求响应过慢;而CountDownLatch当N = 0 时,所有await的线程都会执行。

因此,这里使用CountDownLatch最为合适。

2.为什么要用Map + Synchronized来实现并发修改latchMap

还是一样,我们先来分析一下这里的场景,针对latchMap的操作包括:

  1. 根据Key获取Entry
  2. 添加新的KV对
  3. 删除旧的KV对

那么为什么不使用ConcurrentHashMap呢?

我们来回想一下ConcurrentHashMap在JDK1.8以后的实现,它只会在hash到了同一个hash桶的时候使用Lock和Synchronized来保证并发修改,它的锁粒度其实是每个Hash桶。而此处我们的操作,是针对不同的Hash桶的,我们需要的锁粒度应该是整个map。

综上:由于锁粒度的问题,我们不能使用ConcurrentHashMap 。

那么为什么不使用Lock锁呢?

我的理解是:

  1. 此处的并发场景较为简单,仅仅需要线程间串行并行即可,无需线程间通信,使用Synchronized足够了
  2. 此处的并发度不高,除了极端的hotkey导致的缓存击穿的时候有比较大的并发以外,其他时间并发度比较小,而Synchronized在优化过后性能还不错

3.为什么reload默认延迟一分钟再次执行

为了保证极端情况下最终一致性。那么比如什么极端情况呢?

  1. loadToCache中读DB读的是从库,导致数据不一致
  2. loadToCache方法更新缓存失败,出现了网络问题或者是一些异常,由于CacheSetter并没有失败重试的逻辑,而是直接用Try包裹,所以这里再次执行来进行兜底处理

文章4:

分布式锁和事务,他们分别是为了解决什么问题?他们同时出现,又会带来什么问题?

名词释义:

事务:

  • 事务:数据库事务是为了保证一堆操作的原子性,要么同时成功,要么同时失败,事务隔离级别分为以下四种,我们常用的是3,可重复读
    • Read Uncommitted(读未提交);
    • Read Committed(读已提交);
    • Repeatable Read(可重复读取);
    • Serializable(可串行化)

分布式锁:

  • 分布式锁:分布式锁是为了保证并发场景下部分操作串行化,本质上是一个悲观锁,先将可能会有并发问题的资源上锁,等操作完资源后再释放;

业务场景:

加团场景:我们有两个核心数据模型,group(团队),groupMember(团队成员),核心诉求是团队规模是有上限的,我们需要保证并发场景下也不会突破团队规模上限

步骤分解:

  1. 根据groupId查询团队成员数量
  2. 判断团队成员数量是否超过团队规模上限
  3. 往groupMember表中插入一个新成员

原始代码:

以下是该场景的部分伪代码,大家可以看下以下代码有什么问题~

// 此处事务是为了保证核心流程doJoin()和doPush()的原子性
@Transactional(rollbackFor = Exception.class)
 public void join(ACTIVITY activityUserBO) {
             // 当前用户维度,保证用户串行
             lockUser();
             try {
                 // 部分数据通过缓存进行预校验,包括用户的资质,团队上限之类的,尽量提前retrun
                    preCheckByCache();
                    // 团维度加锁
                    lockGroup();
                    try {
                    // 强走数据库校验,保证校验的绝对准确
                        checkByDb();
                        // 加团,实际执行为,往groupMember中插入一条数据
                        doJoin();
                        // 额外的DB操作
                        doPush();
                    } finally {
                    // 释放团锁
                        releaseGroupLock();
                    }
             } finally {
                     // 释放用户锁
            releaseUserLock();
        }
 }

好,给大家5分钟的时间总结以上代码~看看总结的和我的是否一致~有问题欢迎讨论

代码分析:

优点:

  • 锁的粒度把控比较到位

      1. 人维度属于一个比较小的维度,先给人维度加锁,减少团锁的竞争
      1. 团锁代码力度尽可能的小,在保证业务逻辑的前提下,尽可能的减少锁的占用时间,能大大提高项目的并发性能
  • 提前通过缓存校验,尽可能提前return,减少服务器压力
    问题:
    其实以上代码问题还是比较多的,它真的能做到上述提到的核心业务诉求,保证团队规模不超上限么?

  • 加锁和释放都在事务内,哪怕是checkByDb,只要事务没有提交,并发的其他线程就无法读到真正的团队成员数量!(数据库隔离级别为:读已提交)

  • 特殊场景:数据库读写分离的场景下,读写是有延迟的,checkByDb真的能读到主库刚刚插入的数据么?(非读写分离场景可忽略此条)

优化第一版:

根据以上问题,我们有了第一版优化方案~

  1. 事务提交后再释放锁

    public void releaseLockAfterTransacation(String lock) {
    TransactionSynchronizationManager.registerSynchronization(new TransactionSynchronization() {
                @Override
                public void afterCompletion(int status) {
                    releaseLock(lock);
                }
            });
    }
  2. 强制读主库

public Long chekByMainDb(...) {
        KsMasterVisitedManager.setMasterVisited();
        try {
            doCheck();
        } finally {
            KsMasterVisitedManager.clear();
        }
    }

经过优化后的伪代码:

@Transactional(rollbackFor = Exception.class)
 public void join(ACTIVITY activityUserBO) {
             // 当前用户维度,保证用户串行
             lockUser();
             try {
                 // 部分数据通过缓存进行预校验,包括用户的资质,团队上限之类的,尽量提前retrun
                    preCheckByCache();
                    // 团维度加锁
                    lockGroup();
                    try {
                    // 强走数据库校验,保证校验的绝对准确,**强制走主库**
                        chekByMainDb();
                        // 加团,实际执行为,往groupMember中插入一条数据
                        doJoin();
                        // 额外的DB操作
                        doPush();
                    } finally {
                    // 释放团锁**事务提交后再释放**
                        releaseLockAfterTransacation(group);
                    }
             } finally {
                     // 释放用户锁,**事务提交后再释放**
            releaseLockAfterTransacation(user);
        }
 }

优化后的代码问题分析

看起来没问题了,并发测试发现还是不行,到底是哪里出了问题?
问题又来到了事务隔离级别,我们来看下,可重复读的完整释义:
1. 仅能读到其他事务已经commit的数据
2. 在同一个事务内,第一次查询之后会生成一个读快照,后续相同查询都会走快照

那么进行代码分析,上述我们会有两个校验操作,preCheckByCache() -> chekByMainDb,请看流程图:

file

最终方案

解决方案:
方案1: 事务往下移,在锁团后再开启事务
缺点:如果用户锁后还有要求数据原子性的操作要求,那么无法满足
方案2: 修改数据库隔离级别(改为不可重复读)
缺点:这是个非常危险的操作!一般不推荐
方案3: chekByMainDb() 这个操作里做文章
3.1 单开一个传播型为新开事务OR没有事务的事务,单独执行查询,绕开快照
3.2 select for update

文章5:

分布式事务定义:一个业务流程跨越多个分布式系统或服务的事务处理,需要确保在多个参与者之间的数据一致性和原子性。

一、分布式事务产生的背景

数据库的水平拆分

随着业务数据规模的增大,单库单表逐渐成为瓶颈,所以要对数据库进行了水平拆分,将原单库单表拆分成数据库分片。

分库分表之后,原来在一个数据库上就能完成的写操作,可能就会跨多个数据库,这就产生了跨数据库事务问题。

业务服务化拆分

随着业务访问量和复杂程度的增长,单系统架构逐渐成为业务发展瓶颈,需要将单业务系统拆分成多个业务系统,实现各系统解耦,各业务系统可以更专注自身业务,有利于业务的发展和系统容量的伸缩。

当拆分为多个服务后,一个完整的业务流程往往需要调用多个服务,需要保证多个服务间的数据一致性和原子性。

二、分布式事务理论基础

2.1 分布式事务模式

包括四种常见模式:

  • AT 模式:无侵入的分布式事务解决方案,适用于不希望对业务进行改造的场景,几乎零开发成本。
  • TCC 模式:高性能分布式事务解决方案,适用于核心系统等对性能有很高要求的场景。
  • Saga 模式:长事务解决方案,适用于业务流程长且需要保证事务最终一致性的业务系统。
  • XA 模式:传统强一致性解决方案,性能较低,很少使用。

2.2 分布式事务类型

分布式事务实现方案,从类型上分为刚性事务和柔性事务。

刚性事务

  • 满足 CAP 的 CP,要求数据强一致。强一致性,原生支持回滚/隔离性。
  • 通常无需业务改造,像本地式事务一样,具备数据强一致性,原生支持回滚/隔离性。
  • 同步阻塞,低并发,适合短事务,不适合大型网站分布式场景。
  • XA 模式属于刚性事务。

柔性事务

  • 满足 BASE 理论,或者说满足 CAP 的 AP。不要求强一致性,要求最终一致性,允许有中间状态。
  • 通常需要业务改造,实现补偿接口,实现资源锁定。
  • 高并发,适合长事务。
  • 柔性事务分为:补偿型 和 通知型
  • 补偿型:AT 模式、TCC 模式、Saga 模式
  • 通知型可参考《可靠消息最终一致性
  • MQ 事务消息方案
  • 本地消息表方案

为什么需要补偿型分布式事务?
· 基于消息实现的事务并不能解决所有的业务场景。
· 例如电商下单支付场景:某笔订单完成时,同时扣掉用户的现金。这里事务发起方是管理订单库的服务,但对整个事务是否提交并不能只由订单服务决定,因为还要确保用户有足够的钱,才能完成这笔交易,而这个信息在管理现金的服务里。

2.3 分布式事务协议

四种分布式事务模式的共同点都是 「两阶段」

下边介绍一下分布式事务的理论基础:两阶段提交 2PC 和 三阶段提交 3PC。这两个机制具有普适性——为协议一样的存在。不过由于 3PC 非常难实现,所以目前绝大多数分布式解决方案都是以两阶段提交协议为基础的。

2.3.1 两阶段提交 2PC

两阶段提交协议:事务管理器分两个阶段来协调资源管理器,第一阶段准备资源,也就是预留事务所需的资源,如果每个资源管理器都资源预留成功,则进行第二阶段资源提交,否则协调资源管理器回滚资源。

2PC 核心原理
  • 定义了两种角色:事务参与者 和 事务协调者
  • 将过程划分成两个阶段:
  • 第一阶段:准备资源,即所有参与者预留事务所需资源,也叫预提交。协调者收到所有参与者的预提交才会进入第二阶段。若在协调者的超时时间内,有任意参与者的预提交 preCommit 未发送或未到达,都会结束事务。
  • 第二阶段:提交或回滚。所有参数者预提交完成后,由协调者决定最终事务是成功(commit)还是失败(rollback)。

2PC优点

参与者的事务提交和回滚可以直接利用数据库的实现,无需自己实现,不会侵入业务逻辑。

2PC缺点

同步阻塞,影响性能

  • 执行过程中,所有参与节点都是等待协调者返回是否可提交,等待过程中参与者是同步阻塞的,这会导致系统并发能力下降。
  • 另外当参与者占用了公共资源,其他第三方节点想同时访问公共资源时,不得不处于阻塞状态。

容错风险:

  • 协调者是非常关,若发生故障,参与者会一直阻塞。当然可以通过备机+日志等方式缓解。
  • 参与者发生故障,会导致协调者等待。一般需要设置合理的超时时间,超时后整个事务失败。

一致性问题:

  • 极端情况下,当协调者发出 commit 消息之后宕机,而唯一接收到这条消息的参与者也同时宕机。即使协调者通过选举协议产生了新的协调者,此前的事务状态也是不确定的,没人知道事务是否被已经提交。

2.3.2 三阶段提交3PC

3PC 基于 2PC 有两项优化:

  • 改进超时机制:同时在协调者和参与者中都引入超时机制。
  • 划分为三阶段:把2PC的准备阶段再次一分为二,分为 CanCommit、PreCommit、DoCommit 三个阶段。

CanCommit阶段

3PC 的 CanCommit 阶段与 2PC 的准备阶段类似。

  1. 事务询问:协调者向参与者发送 CanCommit 请求。然后开始等待参与者响应。
  2. 响应反馈:参与者接到 CanCommit 请求后,如果自身认为可以执行事务,则返回 Yes,并进入预备状态。否则返回 No。

PreCommit阶段

协调者根据反馈情况,判断后续操作。

若所有参与者都返回 Yes,发起 PreCommit 操作:

  1. 发送预提交请求:协调者向参与者发送 PreCommit,并进入 Prepared 阶段。
  2. 事务预提交:参与者接收到 PreCommit,会执行事务预提交,记录 undo 和 redo 事务日志。
  3. 响应反馈:若参与者成功执行了事务,则返回 ACK ,同时开始等待最终指令。

若任意参与者返回 No,或者协调者等待超时,则发起事务中断。

  1. 发送中断请求:协调者向所有参与者发送 abort 请求。
  2. 中断事务:参与者收到 abort 请求后,或参与者等待超时后,执行事务中断。

doCommit阶段

该阶段进行真正的事务提交,分为两种情况。

执行提交

  1. 发送提交请求:协调接收到所有参与者发送的 ACK 响应,从预提交状态进入到提交状态。并向所有参与者发送doCommit请求。
  2. 事务提交:参与者接收到 doCommit 请求之后,执行正式的事务提交。并在完成事务提交之后释放所有事务资源。 (此处有个特殊逻辑:在参与者超时未收到来自协调者的信息之后,会默认执行commit,避免长期阻塞
  3. 响应反馈:事务提交完之后,向协调者发送 ACK。
  4. 完成事务:协调者接收到所有参与者的 ACK 之后,完成事务。 (若超时未收到所有的 ACK,会反复重试。3PC 有个乐观假设:因为 preCommit 阶段已经进行了预提交,所以大概率参与者能执行成功)

中断事务:协调者未接收到所有参与者发送的ACK响应(可能是等待超时了),会执行中断事务。

  1. 发送中断请求:协调者向所有参与者发送 abort 请求
  2. 事务回滚:参与者接收到 abort 请求之后,利用之前记录的 undo 日志执行回滚操作,并在完成回滚之后释放所有的事务资源。
  3. 反馈结果:参与者完成事务回滚之后,向协调者发送 ACK
  4. 中断事务:协调者接收到参与者反馈的 ACK 消息之后,执行事务中断。

3PC优劣势分析

3PC 相比 2PC的优点

  • 减少阻塞:3PC更加乐观,在参与者超时未收到来自协调者的信息之后,会默认执行commit。不会一直持有事务资源并处于阻塞状态。避免了参与者在长时间无法与协调者节点通讯(协调者挂掉了或出现网络分区)的情况下,无法释放资源的问题。

为什么参与者敢直接commit ?这样做有什么影响?

  • 原因:3PC 更加乐观,这是基于概率来决定的。因为增加了 CanCommit 阶段,若所有参与者都响应了 Yes 并进入到了 Prepare 阶段,意味大家都同意提交事务,有理由相信成功提交的概率很大。
  • 影响:会导致数据不一致性问题,如果由于网络原因,协调者发送的 rollback 命令没有及时被参与者接收到,参与者在等待超时之后执行了commit操作。这样会导致与其他参与者之前存在数据不一致情况。

三、Seata 分布式事务框架

Seata (Simpe Extensible Autonomous Transaction Architecture)

一款针对分布式架构下产生的数据一致性问题而诞生的分布式事务产品,基于2PC协议和BASE理论的最终一致性来达成事务。

阿里 2019 年开源,Seata 是什么? | Apache Seata

3.1 Seata 架构

  • 微服务:业务服务
  • Seata Server:一个中心化的、单独部署的事务管理中间件。负责分布式事务实现中的Transaction Coordinator
  • Nacos
  • 用于微服务到 Seata Server 的通信,微服务通过 Nacos 汇报分支事务状态,并接收 Seata 的 Commit/Rollback 决议
  • Seata 支持多种注册类型:file 、nacos 、eureka、redis、zk、consul、etcd3等
  • MySQL:用于记录分布式事务中分支事务 和 全局事务的执行状态
  • server端:三张表:global_table、branch_table 和 lock_table
  • client 端:需要在业务库中创建 undo_log 表,用于 Seata AT 模式下自动记录数据快照

3.2 Seata 框架

Seata 框架有三个角色,TC、TM 和 RM

  • TC(Transaction Coordinator),即Seata Server,中心化的事务协调者,负责协调全局事务的提交和回滚,并维护全局和分支事务的状态。
  • TM(Transaction Manager),它是事务管理器,主要作用是发起一个全局事务,对全局事务的提交和回滚做出决议。
  • RM(Resource Manager),它是资源管理器,向 TC 注册分支事务并上报事务状态,同时负责对当前分支事务进行提交和回滚。每一个分支事务都是全局事务的参与者,这些分支事务的所属应用扮演了 RM 的角色。

Seata 支持四种分布式事务模式:AT、TCC、SAGA、AX。

下面会展开介绍下 AT 和 TCC 事务模式,对 XA 和 Saga 大家感兴趣可自行了解。

3.3 AT 模式

自动补偿型事务模式,Automatic Transaction。

对业务无侵入:需要改动任何业务代码,只需要一个注解和少量的配置信息,就可以实现分布式事务。Seata 框架会自动生成事务的二阶段提交和回滚操作。

3.3.1 原理

基于2PC协议进行了演变:

  • 一阶段:执行核心业务逻辑(即代码中的 CRUD 操作)。Seata 会根据 DB 操作自动生成相应的回滚日志,并将回滚日志添加到 RM 对应的 undo_log 表中。执行业务代码和添加回滚日志这两步都是在同一个本地事务中提交的。这一步完成后会释放本地锁和连接资源。

Seata 会拦截“业务 SQL”,首先解析 SQL 语义,找到“业务 SQL”要更新的业务数据,在业务数据被更新前,将其保存成“before image”,然后执行“业务 SQL”更新业务数据,在业务数据更新之后,再将其保存成“after image”,最后生成行锁。以上操作全部在一个数据库事务内完成,这样保证了一阶段操作的原子性。
  • 二阶段
  • 二阶段是以异步化的方式来执行的。
  • 如果全局事务的最终决议是 Commit,则更新分支事务状态并清空回滚日志(只需将一阶段保存的快照数据和行锁删掉,完成数据清理即可)。

  • 如果最终决议是 Rollback,则根据 undo_log 中的回滚日志进行 rollback 操作(反向补偿)。

Seata AT 方案的核心是回滚日志 undo_log表。通过 undo_log 表将一阶段和二阶段拆分成两个独立的本地事务来执行。

Seata AT 执行效率比 2PC 高,主要原因有两个:

  • 一是核心业务逻辑可以在一阶段得到快速提交,DB 资源被快速释放;
  • 二是全局事务的 Commit 和 Rollback 是异步执行。
CREATE TABLE IF NOT EXISTS `undo_log`
(
  `id` BIGINT(20) NOT NULL AUTO_INCREMENT COMMENT 'increment id',
  `branch_id` BIGINT(20) NOT NULL COMMENT 'branch transaction id',
  `xid` VARCHAR(100) NOT NULL COMMENT 'global transaction id',
  `context` VARCHAR(128) NOT NULL COMMENT 'undo_log context, such as se',
  `rollback_info` LONGBLOB NOT NULL COMMENT 'rollback info', -- 存放回滚信息,包括before image 和 after image`log_status` INT(11) NOT NULL COMMENT '0:normal status, 1:defense status',`log_created` DATETIME NOT NULL COMMENT 'create datetime',`log_modified` DATETIME NOT NULL COMMENT 'modify datetime',
	PRIMARY KEY (`id`),
	UNIQUE KEY `ux_undo_log` (`xid`, `branch_id`)
) ENGINE = InnoDB
AUTO_INCREMENT = 1
DEFAULT CHARSET = utf8 COMMENT ='AT transaction mode undo table';

3.3.2 实现案例

删除优惠券模板案例,Customer 和 Template 两个服务。Customer 服务是整个业务流程的起点,它先调用 Template 服务注销券模板,然后再调用本地事务注销了由该模板生成的优惠券。

详细流程
  • 首先,Customer服务发起事务,生成 XID
  • Customer 服务扮演了 TM 角色,它会向 TC 注册并发起一个全局事务。
  • 全局事务会生成一个 XID,它是全局唯一的 ID 标识,所有分支事务都会和这个 XID 进行绑定。绑定方式:
  • XID 在服务内部,传播机制是基于ThreadLocal 构建的,即 XID 在当前线程的上下文中进行透传
  • XID 在跨服务时,依赖 seata-all 组件内置的各个适配器(如 Interceptor 和 Filter)将 XID 传递给下游服务。
  • 然后,Customer 服务调用 Template 服务,Template 服务执行分支事务
  • Customer 服务调用 Template 服务的模板注销接口,Template 的 RM 会开启分支事务并注册到 TC。
  • 在执行分支事务的过程中,RM 还会生成回滚日志并提交到 undo_log 表中。
  • RM 还需要获取到两个特殊的 Lock:Local Lock(本地锁)和 Global Lock(全局锁)
Lock 信息存放在 lock_table 这张表里,它会记录待修改的资源 ID 以及它的全局事务和分支事务 ID 等信息。无论是一阶段提交还是二阶段回滚,RM 都需要获取待修改记录的本地锁,然后才会去执行 CRUD 操作。而在 RM 提交一阶段事务之前,它还会尝试获取Global Lock(全局锁),目的是防止多个分布式事务对同一条记录进行修改。假设有两个不同的分布式事务想要修改记录 A,那么只有同时获取到 Local Lock 和 Global Lock 的事务才能正常提交一阶段事务。
本地锁会随一阶段事务的提交 / 回滚而释放,而全局锁只有等到全局事务提交 / 回滚之后才会被释放。在一阶段中,如果某一个事务在一定的尝试次数后仍然无法获取全局锁,它会知难而退,执行本地事务回滚操作。而如果在二阶段回滚的时候,RM 无法获取本地锁,它会原地打转不停重试,直到成功获取本地锁并完成重试。
更多关于锁原理的介绍,请参考:Seata 官方对于 AT 模式的介绍  Seata 是什么? | Apache Seata
  • 接下来,Customer 服务执行分支事务,并做出二阶段决议
  • Template 服务调用成功后,Customer 服务开始执行自己的本地事务,流程都大同小异。
  • Customer 服务作为TM 端,会根据业务的执行情况,最终做出二阶段决议,Commit 或Rollback。
  • 最后,进入二阶段进行 Commit 或 Rollback
  • TC 向各个分支下达了二阶段决议。如果最终决议是 Commit,那么各个 RM 会执行一段异步操作,删除 undo_log;如果最终决议是 Rollback,那么 RM 端会根据undo_log 中记录的回滚日志做反向补偿。

编码实战
工程中引入 Seata

1. pom.xml 中引入 Jar 包

<!-- Seata -->
<dependency><groupId>com.alibaba.cloud</groupId><artifactId>spring-cloud-starter-alibaba-seata</artifactId>
</dependency>

2. application.yml 中添加 Seata 相关配置项

# 指定了事务分组;
spring: 
  cloud:
    alibaba:
      seata:
      	tx-service-group: seata-server-group 
        
# 定义连接 Seata Server 的方式
seata: 
  application-id: customer-servregistry:type: nacosnacos:  # 指定 nacos 地址,用于发现 Seata Serverapplication: seata-server server-addr: localhost:8848namespace: devgroup: myGroupcluster: defaultservice:vgroup-mapping:
      seata-server-group: default

3. 声明数据源代理 DataSourceProxy

这一步实现无感知的编程体验的秘诀,为了能够在分支事务开启和提交等关键节点上做一番手脚(比如向 Seata 注册分支事务、生成 undo_log 等),需要用 Seata 特有的数据源代理“接管”原有的业务数据源。

@Configuration
public class SeataConfiguration {

    @Bean@ConfigurationProperties(prefix = "spring.datasource")
    public DruidDataSource druidDataSource() {
        return new DruidDataSource();  // 此处为业务正常的数据源
    }

    @Bean("dataSource")
    @Primarypublic DataSource dataSourceDelegation(DruidDataSource druidDataSource) {
        return new DataSourceProxy(druidDataSource);  // DataSourceProxy 是由 Seata 框架提供的一个数据源代理类,用于接管业务数据源,提供自动化注册分支事务、生成 undo_log 等操作。
    }
}
Customer 服务

在 CouponCustomerController.java 中开启全局分布式事务,调用 Template 服务并删除模板下的所有优惠券。

public class CouponCustomerController {
  
    @Autowireprivate TemplateService templateService;
  
    @Autowireprivate CouponDao couponDao;

    @Override@Transactional  // TR,开启分支事务,需要声明 @Transactionalpublic void deleteCouponTemplate(Long templateId) {
        templateService.deleteTemplate(templateId);  // 调用 Template Rest 服务的 deleteTemplate 接口。seata-all组件内置的各个适配器(如 Interceptor 和 Filter)会将 XID 传递给下游 Template 服务。
        couponDao.deleteCouponInBatch(templateId, CouponStatus.INACTIVE); // 执行本地事务

        throw new RuntimeException("AT分布式事务挂球了");  // 此处模拟事务挂了。
    }
}
Template 服务

CouponTemplateService.java 执行券模板删除

@Override
@Transactional // TR,开启分支事务,需要声明 @Transactional
public void deleteTemplate(Long id) {
  int rows = templateDao.makeCouponUnavailable(id);
  if (rows == 0) {
    throw new IllegalArgumentException("Template Not Found: " + id);
  }
}
执行结果

观察Template 服务日志,可以看到事务被回滚了。

rm handle branch rollback process:本地资源管理器开始执行回滚流程。
Branch Rollbacking:分支事务正在回滚。
Branch Rollbacked result: PhaseTwo_Rollbacked:分支事务回滚完成。
AT 使用建议

尽管 AT 模式已经非常简单了,但实际场景中非必要不要使用分布式事务。

  • 分布式事务会增加架构复杂度(增加了一个 failure point)。需要考虑 Seata Server 不可用的情况,制定降级预案保证业务正常运转。在大促等环节的压测端,要对 Seata Server 的高可用做好充足功课。
  • 如果业务场景比较简单,可以使用传统的事务型消息 + 日志补偿 + 跑批补偿方式(RocketMQ事务消息实现最终一致性

3.4 TCC 模式

3.4.1 原理

TCC 模式需要用户根据自己的业务场景实现 Try、Confirm 和 Cancel 三个操作

  • Try:预定操作资源。在执行业务逻辑之前,先把要操作的资源占上。
  • Confirm:执行主要业务逻辑。类似于事务的 Commit 操作。在这个阶段中,可以对 Try 阶段锁定的资源进行各种 CRUD 操作。如果 Confirm阶段被成功执行,就宣告当前分支事务提交成功。
  • Cancel:事务回滚。类似于事务的 Rollback 操作。在这个阶段没有 AT 方案中的 undo_log 做自动回滚,需要通过业务代码,对 Confirm 阶段执行的操作进行主动回滚。

3.4.2 编码实战

以 Template 服务为例,先注册 TCC 接口:

@LocalTCC // @LocalTCC注解,用于修饰实现了 TCC 二阶段提交的本地 TCC 接口
public interface CouponTemplateServiceTCC extends CouponTemplateService {
    
    // @TwoPhaseBusinessAction 注解,用于标识当前方法使用 TCC 模式管理事务提交。@TwoPhaseBusinessAction(  
        name = "deleteTemplateTCC",  // try 阶段要执行的方法
        commitMethod = "deleteTemplateCommit",  // confirm 阶段要执行的方法
        rollbackMethod = "deleteTemplateCancel" // cancel 阶段要执行的方法
    )
    void deleteTemplateTCC(@BusinessActionContextParameter(paramName = "id") Long id); // 通过@BusinessActionContextParameter注解,将 id 参数传入BusinessActionContextvoid deleteTemplateCommit(BusinessActionContext context);void deleteTemplateCancel(BusinessActionContext context);  // BusinessActionContext会由框架在事务上下文中进行传递,可以通过它传递查询参数。
}
第一阶段 Prepare 逻辑

在券模板数据库表种,增加 locked 字段。

alter table coupon_template  add locked tinyint(1) default 0 null;

并在 CouponTemplate model 中增加 locked 属性。

@Column(name = "locked", nullable = false)
private Boolean locked;

Try 阶段借助 locked 字段实现券模板的锁定。

public class CouponTemplateServiceTCCImpl implements CouponTemplateServiceTCC {

    @Override@Transactional 
    public void deleteTemplateTCC(Long id) {
        CouponTemplate filter = CouponTemplate.builder()
            .available(true) 
            .locked(false)  // 借助 locked 字段实现券模板的锁定。只有当 locked=false 时才能被筛选出来进行删除。
            .id(id)
            .build();
      
        CouponTemplate template = templateDao.findAll(Example.of(filter))
            .stream().findFirst()
            .orElseThrow(() -> new RuntimeException("Template Not Found"));
        template.setLocked(true);  // 查到了符合删除条件的记录,通过讲 locked 置为 true 进行锁定。
        templateDao.save(template);
    }
  	……
}
第二阶段 Commit 逻辑

执行到了 Commit ,代表各个分支事务已经成功执行了 Try。

@Override
@Transactional
public void deleteTemplateCommit(BusinessActionContext context) {
    Long id = Long.parseLong(context.getActionContext("id").toString()); // 从 context 中可以获取到查询参数。
    CouponTemplate template = templateDao.findById(id).get(); // 此处大胆的读取指定 ID 的券模板,没有做空校验,是因为执行到了 Commit 阶段,代表 Try 阶段已经正常锁定了资源,所以不再需要进行校验。template.setLocked(false);  // 此处要降 Try 阶段锁定的资源解除掉。locked=falsetemplate.setAvailable(false); // 执行业务逻辑,即删除优惠券。available=false
    templateDao.save(template);  
    log.info("TCC committed");
}
第二阶段 Rollback 逻辑

如果在 Try 或者 Confirm 阶段发生了异常,就会触发 TCC 全局事务回滚,Seata Server 会将 Rollback 指令发送给每一个分支事务。

@Override
@Transactional
public void deleteTemplateCancel(BusinessActionContext context) {
    Long id = Long.parseLong(context.getActionContext("id").toString());
    Optional<CouponTemplate> templateOption = templateDao.findById(id);
    if (templateOption.isPresent()) { // 为了防止空回滚,需要判断 Template 是否存在。(下边重点介绍)
        CouponTemplate template = templateOption.get();
        template.setLocked(false); // 通过将 locked 设置为 false 的方式对资源进行解锁
        templateDao.save(template);
    }
    log.info("TCC cancel");
}

3.4.3 TCC 的实践经验

使用中,需要注意以下事项:

  • 业务模型分两阶段设计
  • 并发优化
  • 允许空回滚
  • 防悬挂控制
  • 幂等控制

业务模型两阶段设计

用户接入 TCC ,最重要的是考虑如何将自己的业务模型拆成两阶段来实现。

在删除券模板的案例中,通过增加 locked 状态将业务逻辑分成两个阶段,但在实际业务中,往往资源锁定逻辑会非常复杂。

以“扣钱”场景为例:

  • 接入 TCC 前,对 A 账户的扣钱,只需一条 SQL 便能完成扣款。update 账户表 set 余额 = 余额 - 30 where 账户 = A
  • 接入 TCC 后,要考虑如何拆成两阶段,实现成三个方法。保证一阶段 Try 成功,则二阶段 Confirm 一定成功。 这里引入冻结金额。
  • 一阶段 Try 方法,资源的检查和预留。
  • 先检查 A 账户余额是否充足,再冻结要扣款的 30 元(预留资源),此阶段不会发生真正的扣款。
  • 二阶段 Confirm 方法,执行真正业务的提交。
  • 发生真正的扣款,把 A 账户中已经冻结的 30 元钱扣掉,余额变成 70 元 。
  • 二阶段 Cancle 方法,预留资源的释放。
  • 释放 Try 操作冻结的 30 元,使账号 A 的回到初始状态,100 元全部可用。

并发优化

在实现 TCC 时,应当考虑并发性问题,将锁的粒度降到最低,以最大限度的提高分布式事务的并发性。

例如:A  账户上有 100 元,事务 T1 要扣除其中的 30 元,事务 T2 也要扣除 30 元,出现并发。

解决办法

  • 在一阶段 Try 操作中,分布式事务 T1 和分布式事务 T2 分别冻结所需资金,相互之间无干扰;这样在分布式事务的二阶段,无论 T1 是提交还是回滚,都不会对 T2 产生影响,这样 T1 和 T2 在同一笔业务数据上并行执行。

允许空回滚

什么是空回滚

  • 在没有执行 Try 方法的情况下,TC 下发了回滚指令并执行了 Cancel 逻辑。(还没来得及预留资源,却要求释放

出现原因

  • 某个分支事务的一阶段 Try 方法因为网络不可用发生了 Timeout,或者 Try 阶段执行失败,这时候 TM 端会判定全局事务回滚,TC端向各个分支事务发送 Cancel 指令,这就产生了一次空回滚。

有什么影响:

  • 若未允许空回滚,因为 Cancel 阶段没有资源可释放会无法正常执行,这样 TC 会不断重试 Cancel 指令,陷入死循环。

解决办法

  • 在 Cancel 阶段,先判断一阶段 Try 有没有执行成功。示例程序中的判断方式为判断资源是否被locked,再执行释放操作。如果资源未被锁定或者压根不存在,可以认为 Try 阶段没有执行成功,这时Cancel 阶段直接返回成功即可。
  • 更为完善做法是,引入独立的事务控制表,在 Try 阶段中将 XID 和分支事务 ID 等操作信息落表保存(注:可以将修改数据与保存操作记录放入同一本地事务,保证数据一致性),如果 Cancel 阶段查不到事务控制记录,那么就说明 Try 阶段未被执行。同理,Cancel 阶段执行成功后,也可以在事务控制表中记录回滚状态,这样做是为了防止另一个TCC 的坑,“倒悬”。
CREATE TABLE `account_transaction` (
    `tx_id` varchar(100) NOT NULL COMMENT '事务Txld',
    `action_id` varchar(100) NOT NULL COMMENT '分支事务ld',
    `gmt_create` datetime NOT NULL COMMENT '创建时间',
    `gmt_modified` datetime NOT NULL COMMENT '修改时间',
    `user_id` varchar(100) NOT NULL COMMENT '账户UID',
    `amount` varchar(100) NOT NULL COMMENT '变动金额',
    `type` varchar(100) NOT NULL COMMENT '变动类型',
    `state` smallint(4) NOT NULL COMMENT '分支事务状态:1.初始化;2.已提交;3.已回滚',
    UNIQUE KEY (`tx_id`, `action_id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8 COMMENT='业务流水表,用于事务控制表';

防倒悬控制

什么是倒悬

  • Cancel 比 Try 接口先执行(都要结束了,你才来)。

出现原因

  • 如果 Try 方法因为网络问题卡在了网关层,导致锁定资源超时,这时 Cancel 执行了空回滚。若回滚之后,原先超时的 Try 方法经过网关层的重试,又被后台服务接收到了,这就产生倒悬,即一阶段 Try 在二阶段 Cancel 之后被触发。

有什么影响

  • 在悬挂的情况下,整个事务已经被全局回滚,如果再执行 Try 操作,当前资源将被长期锁定,这就造成了一种类似死锁的局面。

解决办法:

  • 可以利用独立事务控制表记录二阶段执行状态,并在 Try 阶段中检查该状态,如果二阶段回滚完毕,那么就直接跳过一阶段 Try。
一句话总结: 要允许空回滚,还要拒绝空回滚后的 Try 操作。

幂等控制

Try、Commit、Cancl 三个方法均需要保证幂等性。

什么是幂等性

  • 对同一个系统,使用同样的条件,一次请求和重复的多次请求对系统资源的影响是一致的。

为什么需要幂等

  • 因为网络抖动或拥堵可能会超时,事务管理器会对资源进行重试操作,所以很可能一个业务操作会被重复调用,为了不因为重复调用而多次占用资源,需要对服务设计时进行幂等控制,也可以使用独立的事务控制表进行判重来实现幂等。

TCC 使用建议
  • 相对于 AT 模式,TCC 模式对业务代码有一定的侵入性,但是 TCC 模式无 AT 模式的全局行锁,TCC 性能会比 AT 模式高很多。
  • 不建议轻易上 TCC 方案,因为它非常考验开发者对业务的理解深度,需要把串行的业务逻辑拆分成 Try-Confirm-Cancel 三个不同的阶段执行,如何设计资源锁定流程、如果不同资源间有关联性又怎么锁定、回滚的反向补偿逻辑等等,需要对业务流程的每一个步骤了如指掌。

3.5 模式总结

Seata 支持多种事务模式,用户可针对不同的业务场景,选择不同模式,快速高效的建立安全的事务保障。

  • XA&AT模式: 无业务入侵、即插即用
  • TCC模式:不与具体的服务框架耦合、与底层 RPC 协议无关、与底层存储介质无关
  • SAGA模式:高度自定义、最终一致性、高性能

w文章6:

一、背景

锁是操作系统的原语,是为了确保在多个 CPU、多个线程的环境中,在某一个时间点,只有一个线程可以进入临界区,从而保证在临界区操作数据的一致性。

对于分布式场景,我们希望多个实例在某个时间点可以同步,只能有一个实例运行。

可以看出来,锁的定义本质上没有任何的改变。只不过锁控制的对象从一个进程内部的多个线程,变成了分布式场景下的多个进程;同时,临界区的资源也从进程内多个线程共享的资源,变成了分布式系统内部共享的中心存储上的资源。

实际上,不管什么锁,都是借助一种资源,而锁控制的对象都可以访问这种资源。从而使并发的各种对象在某个时间点可以同步起来。

1. 简述操作系统的锁

(1). 进程内部的锁

操作系统中用于让同一进程中多个线程同步的锁。这种锁,一般在计算机中本质上就是一块内存空间。比如当这个空间被赋值为 1 的时候表示加锁了,被赋值为 0 的时候表示解锁了。仅此而已,多个线程抢一个锁,就是抢着要把这块内存赋值为 1。在一个多核环境里,内存空间是共享的。每个核上各跑一个线程,如何保证一次只有一个线程成功抢到锁呢?这就需要硬件的支持。

比如 x86 的 XCHG 或 CMPXCHG,就能完成 CAS 操作(compare-and-swap),此时用这样的硬件指令就能实现 spin lock(自旋锁)。还有多核遵守的缓存一致性协议,比如 MSEI。一旦一个核执行某个指令,在结束这个指令之前,其他核都不能动。也即让多个核同步起来了。

(2). 跨进程的锁

也即同一台机器上,多个进程之间的锁。比如信号量。本质上也是内存中一个整数,使用不同的数值表示不同的状态,比如用 0 表示空闲状态。加锁时,判断锁是否空闲,如果空闲,修改加锁状态为 1,并且返回成功,如果已经是加锁状态,则返回失败。在解锁时,则将解锁状态修改为空闲状态 0。整个加锁和解锁的过程,操作系统保证他的原子性。

这里的内存就可以选择操作系统的共享内存。多个进程都可以访问到共享内存,以此来进行加锁和解锁。

二、分布式锁的设计

如上的赘述,简而言之,就是多个并发运行的实体在同一个中心资源的控制下,在某个时间点,实现了同步的操作。

那么对于分布式锁,面对的是跨进程、跨机器的这种场景。也是同样的思路,通过一个状态来表示枷锁和解锁,只不过要让所有需要锁的服务,都能访问到状态存放的位置。在分布式系统中,一个非常自然的方案就是,将锁的状态信息存放在一个存储服务中,也称为锁服务。需要锁的服务在通过网络来访问他,来修改状态信息,最后进行加锁和解锁。

1. 分布式锁的特性

因此我们实现一个完备的分布式锁,需要满足以下几个特性。

(1). 互斥

第一个特性就是互斥,即保证不同线程、不同进程、不同节点的互斥访问。

(2). 超时机制

第二个特性就是超时机制,超时机制是为了防止死锁。在分布式系统中,因为锁服务和请求锁的服务分散在不同的机器上,他们之间是通过网络来通信的,所以我们需要用超时机制,来避免获得锁的节点故障或者网络异常,导致他持有的锁不能归坏、死锁的问题。

还有一种情况是,持有锁的节点需要处理的临界区代码非常耗时。一般情况下,可以通过另一个线程不断延长超时时间,避免出现锁操作还没有处理完,锁就被超时释放,之后其他节点获得锁,导致锁的互斥失败这种情况。

对于超时机制,我们可以在每一次成功获得锁的时候,为锁设置一个超时时间,获得锁的节点与锁服务保持心跳,锁服务每一次收到心跳,就延长锁的超时时间。这样就可以解决锁不能归还和死锁的问题了。

(3). 完备的锁接口

即存在阻塞接口 Lock 和非阻塞接口 tryLock。

(4). 可重入性

当前持有锁的节点可以再次成功获取到锁。

我们只需要在锁服务处理加锁请求的时候,记录好唯一标识。后续的加锁请求,如果是相同的节点,就直接返回成功;否则按照正常加锁流程处理。

(5). 公平性

在竞争锁的时候,保证各个节点的公平性。比如按照先来后到的顺序,将锁颁发给等待时间最长的一个加锁节点。

实现的时候,对于被阻塞的加锁请求,我们只要先记录好他们的顺序,在锁被释放后,按照顺序颁发即可。

2. 分布式锁的挑战

对于分布式服务,我们经常会面临正确性、高可用、高性能这三点的权衡问题。

其中正确性是比较重要的,因为分布式服务会存在部分失败异步网络存在的情况。

(1). 对于线程锁

对于线程锁,即一个进程中用于同步所有线程的锁。这种场景中,不会出现部分失败的情况。因为他崩溃的时候,虽然没有去做解锁操作,但是整个进程都会崩溃,不会出现死锁的情况。

我们在设计锁的时候,对于死锁的考虑,不包括业务逻辑层面出现的死锁。因为这个与锁本身的正确性没有关系。因为锁自身的设计导致的死锁需要我们来解决。

同样的,对于线程锁,解锁操作是进程内部的函数调用,这个过程是同步的。不论是硬件或者其他方面的原因,只要发起解锁操作就一定会成功,如果出现失败的情况,程序会 core,整个进程或者机器都会挂掉。

所以,我们看到对于线程锁,因为整体失败和同步通信,所以线程锁有绝对的正确性。

(2). 对于进程锁

对于同一台机器上多进程之间的锁。一般这种锁是存放在共享内存中。所以进程和锁之间的通信,依然是同步的函数调用。不会出现解锁后信息丢失,导致死锁的问题。

但是,进程获取锁后崩溃,导致死锁的情况,在进程锁中也存在。这个就是部分失败导致的。不过操作系统提供了一些机制,可以让我们判断一个进程是否存活,比如,父进程在获得进程挂掉的信号后,可以去查看当前挂掉的进程是否持有锁,如果持有就进行释放,这可以当作是进程崩溃后清理工作的一部分。

所以,我们看到对于进程锁,虽然有部分失败,但也有有效的解决方案;同时也是同步通信的。因此进程锁也有绝对的正确性。

(3). 对于分布式锁

对于分布式锁,部分失败和异步网络这两个问题是同时存在的。

如果一个进程获得了锁,但是这个进程与锁服务之间的网络出现了问题,导致无法通信。导致这个进程一直持有锁,就会导致死锁的发生。

(4). 分布式锁的问题探究

设置超时时间

如上的这个问题,一般情况下,锁服务在进程加锁成功后,会设置一个超时时间,超时后锁会自动释放。这样即使这个进程占有锁并且挂掉了,也不会导致死锁。

但是又出现一个新的问题,如果临界区的加锁时长大于锁的超时时间。进程持有锁超时后,将锁再颁发给其他进程,就会导致一把锁被两个进程同时持有的情况。使锁的互斥语义遭到破坏。

同时,临界区的加锁时长,不仅仅包括执行临界区代码的时间,还有通过网络获取锁的时延,以及进程的暂停(比如GC)等。其中“通过网络获取锁的时延” 在一个异步网络环境中是不确定的,他的时间可以非常小,也可以非常大,甚至因为网络隔离变得无穷大都有可能。

那么,想要仅仅通过设置一个锁的超时时间,来解决可能出现的死锁问题,是远远不够的。

探讨分布式锁的准确性

对于获取锁而产生的时延,我们加上心跳和网络超时机制。这样,我们就可以将获取锁的时间控制住,让其小于锁本身的超时时间。但是,也会产生新的问题,比如,锁服务给客户端颁发了锁,但是因为响应超时,客户端没有及时收到响应,以为自己没有获取到锁。

详尽一点的例子,客户端发送网络请求到锁服务。锁服务收到请求后,更改其内部状态,然后发送网络响应给客户端,由于网络是异步的,write 方法返回成功,只代表成功写入了缓冲区,而不代表这些数据真正到达了对端。而写入缓存区后,此时网络中端,这个响应可能客户端无法收到。但锁服务此时却意识不到这个问题。

这就在一定程度上,影响了锁的互斥语义的正确性,并且在某些场景下,影响系统的可用性。互斥语义是一定能保证同一时刻有一个客户端能获取锁的,但是现在的情况是所有的客户端都不能获取到锁。

如果我们获取锁之后,是为了写一个共享存储,那么我们可以在获取锁的时候,锁服务生成一个全局递增的版本号,在写数据的时候,需要带上版本号。共享存储在写入数据的时候,会检查版本号,如果版本号回退了,就说明当前锁的互斥语义出现了问题,那么就拒绝当前请求的写入;如果版本号相同或者增加了,就写入数据和当前操作的版本号。

这种方式,我们只能说,出现这种错误时,我们可以保证不发生错误,保证分布式锁正确的语义。但是就是不能解决这个问题,比如出现如上的问题之后,我们只能等待锁服务自身超时后,锁自动释放后,才能继续服务。

我们再来思考,这个方案其实是将问题转移了,如果一个存储系统能通过版本号,来检测写入冲突,那么他已经支持多版本并发控制(MVCC)了,这本身就是乐观锁的实现原理了。那么我们相当于是用共享存储自身的乐观锁,来解决分布式锁在异常情况下,互斥语义失败的问题。

因此,对于在共享存储中写入数据,如果不能容忍分布式锁互斥语义失败的情况,那么就不应该借助分布式锁从外部来实现,而是应该在共享存储内部来解决。

分布式锁的权衡

如上的探讨,分布式锁因为有部分失败和异步网络的问题,没有办法保证 100% 的正确性。所以对于需要 100% 正确性的场景,尽量避免使用分布式锁。我们可以将分布式锁定位于:可以容忍非常小概率互斥语义失效场景下的锁服务。

通常,一个分布式锁服务,他的正确性要求越高,性能可能就会越低。并且,可用性是设计分布式锁的非常关键的目标。

因此,对于锁服务的高可用性、高性能、正确性,我们需要针对具体的场景,选择均衡的方式。

三、分布式锁的实现

常见的分布式锁的方案如下:

  • 基于数据库实现分布式锁,指关系型数据库
  • 基于 Zookeeper 实现的分布式锁
  • 基于缓存实现的分布式锁,如 redis、etcd 等

1. 基于数据库的分布式锁

基于数据库的分布式锁实现,也有两种方式。一种是基于数据库表,另一种是基于数据库的排他锁

(1). 基于数据库表的增删

首先创建一张表,包括这些字段:方法名、时间戳、主机+线程信息等。其中方法名是唯一性约束。

加锁:当需要锁住某个方法时,往该表中插入一条相关的记录。因为方法名是唯一性约束,如果有多个请求同时希望锁住这个方法,提交到数据库时,数据库会保证只有一个操作可以成功。那么我们就认为操作成功的那个对象获得了该方法的锁。

解锁:删除对应方法的那一行。

这种实现方式会产生多个问题?

  • 单点故障问题,这个分布式锁依赖数据库的可用性,如果这个单点数据库挂掉,会导致分布式锁也不可用?

    解决:为了高可用,可以应用主从数据库,从数据库作为备机,一旦主库挂掉,可以快速将应用服务切换到从库上。但应用主从数据库,又会出现主库加锁成功,锁还没有同步到从库,而此时主库宕机了,这个加锁成功的状态就消失了,分布式锁基本的互斥性质都无法满足了

  • 死锁问题,这个分布式锁没有失效时间,如果已获取共享资源访问权限的进程突然挂掉、或者解锁操作失败,就会导致这把分布式锁一直在数据库中,其他线程无法再获得到锁?

    解决:可以使用一个定时任务,每隔一定时间把数据库中的超时锁清理一遍。这只是一个死锁补救措施,并不能解决问题。因为我们无法准确知道那个锁超时了,那个锁没有超时。

  • 这个分布式锁是非阻塞的,因为往数据库插入一行时,如果失败会直接返回报错。没有获得锁的线程并不会进入排队队列,也就是这把锁没有公平性?

    解决:如果需要满足业务层面的阻塞,可以循环插入,直到插入成功。但是公平性这里貌似没有想到比较好的办法

  • 这把锁是非可重入的,同一个线程在没有释放锁之前无法再次获得锁

    解决:为了实现分布式锁的可重入,可以将主机+线程信息作为标识,作为数据库表的一个字段,就可以在下次获取锁的时候先查询数据库,如果标识是存在的,则直接把锁分配给该线程,实现可重入。

  • 这把锁没有完备的锁接口,比如 Lock 和 tryLock

    解决:只能在业务层面实现,阻塞式的 Lock 原语可以通过循环插入数据库实现;非阻塞式的 tryLock 原语直接插入数据库实现。

总体来看,这种方式实现的分布式锁,实现非常简单,但是问题很多,其中:单点故障问题、死锁问题没有比较好的解决方案。

(2). 基于数据库的排他锁

Mysql 的 InnoDB 引擎中的排他锁也可以实现分布式锁。如下伪代码

void lock() {
    connection.setAutoCommit(false);
    for (int i = 0;i < 3; i++) {
        try {
            select * from lock_table where lock_name = xxx for update;
            if (result != null) {
                // 此时获取到排他锁
                return;
            }
        } catch(Exception e) {
            // 出现异常,代表没有获取到锁
        }
        // 返回值为 null 或者出现异常都代表没有获取到锁
        sleep(1000);
    }
    // 重试结束,加锁失败
    throw new LockException();
}

void unlock() {
    connection.commit();
}

在查询语句的后面加上 "for update",数据库会在查询过程中给数据库表增加排他锁,当某条记录被加上排他锁之后,其他线程就无法在该行记录上增加排他锁。这就是妥妥的悲观锁。

"for update" 语句会在执行成功后立即返回,在执行失败时一直处于阻塞状态,直到成功。当锁定之后,服务如果宕机,数据库会将锁释放,这种情况不会造成死锁。

要想满足分布式锁的完备性,实现 tryLock,目前没有比较好的做法。并且由于阻塞式的,只能依赖数据库来解决公平性。

值得注意的问题如下

  • 需要注意的是,InnoDB 引擎在加锁的时候,只有通过索引进行检索的时候才会使用行级锁,否则会使用表级锁。这里我们希望使用行级锁,就要给对应字段添加索引,而且一定需要是唯一索引。
  • 还有问题就是,mysql 可能会对查询语句做优化,即使在条件中使用了索引,但是是否使用索引来检索数据则是由 Mysql 通过判断不同的执行代价来决定的,如果 Mysql 认为全表扫描效率更高,比如对一些很小的表,他就不会使用索引,这种情况下 InnoDB 引擎将使用表锁,而不是行锁。如果使用表锁,那么这把锁可能影响的范围会更大。
  • 还有关于数据库连接的问题,使用排他锁实现的分布式锁,如果这个排他锁长时间不提交,就会占用数据库连接。如果数据库连接过多,数据库连接池的压力就会很大,会影响到数据库的正常运行。
(3). 基于 “数据库版本标识” 实现乐观锁

数据库实现乐观锁的方式就是记录数据版本。当读取数据时,将版本标识的值一同读出来,数据每更新一次,同时对版本标识进行更新。当我们提交更新的时候,判断数据库表对应记录的当前版本信息与第一次取出来的版本标识进行比对。如果数据库表当前版本号与第一次取出来的版本标识值相等,则予以更新,否则认为是过期数据。

实现数据版本可以用版本号;也可以用时间戳。

这就是基于数据库实现的乐观锁。

(4). 小总结

数据库实现分布式锁,主要是借助 Mysql 的 InnoDB 引擎。

  • 一般来说,操作数据库比较容易理解
  • 但同时,也是借助数据库的特性来达到目的,会有各种各样的问题,在解决问题的过程中会使整个方案变得越来越复杂
  • 同时使用数据库的行级锁,不一定靠谱,尤其是表比较小的时候
  • 操作数据库有一定的开销,性能问题需要考虑,数据库实现的分布式锁只适用于并发量低、对性能要求低的场景

2. 基于 zookeeper 的分布式锁

zookeeper 是一种提供 “分布式服务协调” 的中心化服务。他有如下几个特性。

  • 节点:Zookeeper 提供一个多层级的节点命名空间(节点称为 Znode),每个节点都用一个以斜杠(/) 分隔的路径来表示,而且每个节点都有父节点(根节点除外),类似于文件系统。

    节点类型可以分为持久节点(PERSISTENT)、临时节点(EPHEMERAL),每个节点还能被标记为有序性(SEQUENTIAL),一旦节点被标记为有序性,那么整个节点就具有顺序自增的特点。一般我们可以组合这几类节点来创建我们所需要的节点,例如,创建一个持久节点作为父节点,在父节点下面创建临时节点,并标记该临时节点为有序性。

    • 持久节点(PERSISTENT):这是默认的节点类型,一直存在于 Zookeeper 中
    • 持久顺序节点(PERSISTEN、SEQUENTIAL):在创建节点时,Zookeeper 根据节点创建的时间顺序对节点进行编号命名
    • 临时节点(EPHEMERAL):当客户端与 Zookeeper 连接时临时创建的节点。与持久节点不同,当客户端与 Zookeeper 断开链接后,该进程创建的临时节点就会被删除
    • 临时顺序节点(EPHEMERAL、SEQUENTIAL):就是按照时间顺序编号的临时节点
  • Watch 机制:Zookeeper 还提供了另外一个重要的特性,Watcher(事件监听器)。Zookeeper 允许用户在指定节点上注册一些 Watcher,并且在一些特定事件触发的时候,Zookeeper 服务端会将事件通知给用户。

(1). 实现方式

我们就可以利用这些特性来实现分布式锁。

  1. 首先,我们需要建立一个父节点,节点类型为持久节点,每当需要访问共享资源时,就会在父节点下建立相应的顺序子节点,节点类型为临时节点,且标记为有序性,并且以 “临时节点名称+父节点名称+顺序号” 组成特定的名字
  2. 在建立子节点后,对父节点下面的所有以临时节点名称开头的子节点进行排序,判断刚刚建立的子节点顺序号是否是最小的节点,如果是最小节点,则获得锁。如果不是最小节点,则阻塞等待锁,并且获得该节点的上一顺序节点,为其注册监听事件,等待节点对应的操作获得锁。
  3. 当调用完共享资源后,删除该节点,关闭 zookeeper,进而可以触发监听事件,释放该锁。

null

也就是说,每个线程抢占锁之前,先尝试创建自己的 ZNode。同样的,释放锁的时候,就需要删除创建的 ZNode。创建成功后,如果不是排好最小的节点,就处于等待通知的状态。等谁的通知呢?不需要其他人,只需要等前一个 ZNode 的通知就好了。前一个 ZNode 删除的时候,会触发 ZNode 事件,当前节点能监听到删除事件,就是轮到自己占有锁的时候。第一个通知第二个、第二个通知第三个,依次向后通知。

Zookeeper 的节点监听机制,实现了这种信息传递。具体的方法是,每一个等通知的 ZNode 节点,只需要监听排号在自己前面的那个,而且是紧挨在自己前面的那个节点,就能收到其删除事件了。只要上一个节点被删除了,就进行再一次判断,看看自己是不是序号最小的那个节点,如果是,则就可以获取锁。

Zookeeper 能保证由于网络异常或者其他原因,集群中占有锁的客户端失联时,锁能够被有效释放。一旦占有 ZNode 锁的客户端与 Zookeeper 集群服务器失去联系,这个临时 ZNode 也将自动删除。排在他后面的那个节点,也能收到删除事件,从而获取锁。因此在创建取号节点时,一定要创建临时 ZNode 节点。

(2). 羊群效应

羊群效应,Zookeeper 的这种首尾相接,后面监听前面的方式,可以避免羊群效应。所谓羊群效应就是一个节点挂掉,所有节点都去监听,然后作出反应,这样就会给服务器带来巨大压力,所以有了临时顺序节点,当一个节点挂掉,只有他后面的那一个节点才做出反应。

如上的实现是严格按照顺序访问的并发锁。一般我们还可以直接引用 Curator 框架来实现 Zookeeper 分布式锁。

(3). 问题剖析

到这里,我们感觉使用 Zookeeper 实现的分布式很完美?其实不然。

思考这样的一个问题,客户端在 Zookeeper 上创建临时节点后,Zookeeper 是如何保证让这个客户端一直持有锁呢?

原因在于,Zookeeper 和客户端之间维护了一个 Session,这个 Session 会依赖客户端的 “定时心跳” 来维持连接。如果 Zookeeper 长时间收不到客户端的心跳,就认为这个 Session 过期了,也会把这个临时节点删除。那么问题来了?我们来讨论一下 GC 问题、网络延迟异常场景下对 Zookeeper 的锁有什么影响:

  • 客户端 A 创建临时节点,加锁成功
  • 客户端 A 发生长时间 GC,或者网络发生异常延迟;导致无法给 Zookeeper 发送心跳,Zookeeper 把临时节点删除了
  • 客户端 B 创建临时节点,加锁成功了
  • 客户端 A 长时间的 GC 结束了,或者网络恢复正常了;他仍然会认为自己持有锁(产生冲突了)

可见,即使是 Zookeeper,也无法保证进程 GC、网络延迟异常场景下的安全性。

当然,Zookeeper 实现的分布式锁,对比数据库的实现,有很多优点,也有一些问题。

  • Zookeeper 是集群实现,可以避免单点问题,且能保证每次操作都可以有效的释放锁。因为一旦应用服务挂掉了,临时节点会因为 session 连接断开而自动删除掉。
  • 公平性,因为每个实例在 zookeeper 中都会按照先后顺序注册临时顺序节点,节点最小的才能获取锁。因此保证了公平性。
  • 也可以避免死锁问题,因为如果连接中断,Zookeeper 上的临时顺序节点就会被自动释放掉。
  • 但是由于频繁的创建和删除节点,加上大量的 Watch 事件,对 Zookeeper 集群来说,压力非常大。且从性能上来说,与缓存实现的分布式锁还是有一定差距。

3. 基于缓存的分布式锁

缓存可以是 redis、memcache 等等。这里主要以 redis 来说明。基于 redis 实现的分布式锁是最复杂的,但性能也是最好的。

基于 redis 实现分布式锁主要有两大类,一类是基于单机,另一类是基于 redis 多机。

(1). 基于 redis 单机实现的分布式锁

前提条件是 redis 是单机,也就是存在单点问题。

(a). 使用 setnx+expire 指令
SETNX lock_resource_id lock_value   # 加锁
EXPIRE lock_resource_id 10
// 业务逻辑
DEL lock_resource_id                # 解锁

setnx 指令,只有 key 不存在的情况下,才将 key 的值设置为 value;若 key 已经存在,则 setnx 命令不做任何操作。

expire 指令,给 key 设置一个过期时间,以保证 key 即使没有被显式释放,在获取锁达到一定时间后也要自动释放,防止资源被长时间独占。

这种实现中,由于 setnx 和 expire 这两个操作是非原子的,如果一个线程在执行 setnx 成功后,发生异常,expire 没有执行。就会导致这把锁一直被独占,可能无法释放。

这种方式实现的分布式锁是有问题的。

(b). 使用 set 扩展指令
SET lock_resource_id lock_value NX EX 10    # 加锁
// 业务逻辑
DEL lock_resource_id                        # 解锁

在这个 set 指令中:

  • NX 表示只有当 lock_resource_id 对应的 key 值不存在的时候才能 set 成功。保证了互斥的效果
  • EX 10 表示锁的过期时间为 10 秒

存在问题:

  • 如果 “业务逻辑执行时间+网络请求时间+GC时间” 过长,导致锁被提前释放。这样不能保证锁的互斥性质
  • 并且如果出现了上面的场景,线程 A 加锁之后,由于业务逻辑执行时间较长,导致锁过期被释放;线程 B 此时获取到了锁,然后线程 A 执行完业务逻辑后通过 DEL 释放了锁。相当于释放了不属于自己的锁。
(c). Redisson 的分布式锁

针对锁被提前释放的问题,开源框架 Redisson 有解决方案。

link:8. Distributed locks and synchronizers · redisson/redisson Wiki · GitHub

利用锁的可重入特性,让获得锁的线程开启一个定时器的守护线程,每隔 expireTime/3 时间去执行一次,去检查该线程的锁是否存在,如果存在则对锁的过期时间重新设置为 expireTime,也就是利用守护线程对锁进行续期,防止锁由于过期而被提前释放。

null

到这里,关于 redis 的分布式锁存在的问题,貌似都有解决。

  • 死锁:给锁设置过期时间
  • 临界区执行时间大于锁的过期时间,导致锁提前过期:增加守护线程,定时给锁续期
  • 锁被别人释放:在锁中写入唯一标识,释放锁时先检查标识,再释放
(2). redis 的主从同步对分布式锁的影响

以上关于 redis 分布式锁的分析都是局限在一个 redis 节点上。而我们在使用 redis 时,一般会采用 "主从集群+哨兵" 的模式部署,当主机宕机时,哨兵可以实现 "故障自动切换",把从库提升为主库,继续提供服务,保证可用性。

redis 中的主从复制是异步的,主机获取到锁后,在没有完成数据同步的情况下发生故障转移,从机被提升为主机,而此时并没有持有锁。那么其他客户端上的线程可能获取到锁,因此会丧失锁的安全性。整个过程如下:

  • 客户端 A 在主机上获取锁,并且成功了
  • 主机出现故障,并且由于异步的主从复制,这把锁对应的 key 没有同步到从机
  • 从机被哨兵提升为主机,此时这个 redis 节点上没有这把锁对应的 key
  • 客户端 B 请求新的主机,于是获取到了对应同一个 key 的锁
  • 出现多个客户端同时持有同一个 key 的锁,不满足锁的互斥性

于是,在 redis 的分布式环境中,redis 的作者 antirez 提供了 Redlock 的算法来实现一个分布式锁。

(3). 基于 redis 多机实现的分布锁 Redlock

link:Distributed Locks with Redis | Redis

Redlock 的方案基于两个前提:

  • 不再需要部署 从库 和 哨兵 实例,只部署 主库
  • 主库需要部署多个,官方推荐至少 5 个实例

也就是说,有 N(N >= 5)个 redis 节点,这些节点完全互相独立,不存在主从复制或者其他集群协调机制,他们之间没有任何关系,都是一个个孤立的实例。

获取锁的过程,客户端应执行如下操作:

  • 获取当前时间,单位为毫秒,记为 T1
  • 客户端依次向这 5 个 redis 实例发起加锁请求,并且每个请求应该设置超时时间(网络请求和响应),超时时间要远小于锁的有效时间。如果某一个实例加锁失败(包括网络超时、锁被其他客户端持有等各种异常情况),就立即向下一个 redis 实例申请加锁。
  • 如果客户端从大于 3 个(N/2+1)以上的 redis 实例加锁成功,则再次获取时间,记为 T2。如果 T2 - T1 < 锁的过期时间,此时认为客户端加锁成功,否则认为加锁失败。
  • 如果加锁成功,则去操作共享资源实现业务逻辑;如果加锁失败,则向全部 redis 节点发起释放锁请求(使用 redis lua 脚本,保证原子性)。

获取锁的过程中,有三个重点:

  • 客户端在多个 redis 实例上申请加锁
  • 大多数 redis 节点加锁成功,并且大多数 redis 节点加锁的总耗时小于锁的过期时间,才算客户端获取锁成功
  • 如果加锁失败,要向全部的 redis 节点发起释放锁请求

释放锁的过程中,客户端应执行如下操作:

  • 客户端向所有 redis 节点发起释放锁的操作,包括加锁失败的 redis 节点。

除此之外,为了避免 redis 节点发生崩溃重启后造成锁丢失,从而影响锁的安全性,redis 的作者 antirez 还提出了延时重启的概念,即一个节点崩溃后不要立即重启,而是等待一段时间后再进行重启,这段时间应该大于锁的有效时间。

接下来,我们来解释下这个流程中的一些问题。

  1. 在多个实例上加锁?并且大多数实例加锁成功,才算成功?

    多个 redis 实例一起组成了一个分布式系统,在多个实例上加锁,本质上是为了“容错”,允许出现部分实例宕机或不可用,剩余足够的实例加锁成功,整体锁服务依旧可用。

    因为在分布式系统中,总会出现“异常节点”,所以需要考虑异常节点达到多少个,也不会影响整个系统的正确性。也就是说,允许存在故障节点,只要大多数节点正常,那么整个系统依旧是可以提供正确服务的。

    这也是 “拜占庭将军” 问题。

  2. 加锁成功后,还要计算加锁的耗时?

    因为操作 redis 的多个节点,并且异步网络的情况比较复杂,存在延迟、丢包、超时等情况,网络请求越多,异常发生的概率就越大。所以即使大多数节点加锁成功,但如果加锁的累计耗时已经超过了锁的过期时间。那么有些节点上的锁可能已经失效了,这个锁本身就没有意义了。

  3. 为什么释放锁的时候,要操作所有节点,包括加锁失败的节点?

    可能存在某个节点加锁成功后,但是返回客户端的响应包丢失了。因为异步网络是有可能出现:客户端向服务器通信是成功的,但反方向却是有问题的。这种情况,对于客户端来说,加锁是失败的;但对于 redis 节点来说,加锁是成功的。因此释放锁的时候,客户端也应该对当时获取锁失败的那些 redis 节点同样发起请求

(4). 关于 Redlock 的争论

Redis 作者 Antirez 提出的 Redlock 方案后,马上受到英国剑桥大学、业界著名的分布式系统专家 Martin 的质疑。于是两人关于分布式锁做了一些争论。我们来学习一下。

(a). 分布式专家 martin 对于 Redlock 的质疑

在他的文章中,主要阐述了 4 个论点。

第一个论点:分布式锁的偏好

Martin 表示使用分布式锁有两种偏好

  1. 效率:使用分布式锁的互斥能力,避免多次做重复的工作(例如一些“昂贵”的计算任务)。这种情况要求即使锁失效,也不会带来「恶性」的后果。例如多发了 1 次邮件等无伤大雅的场景。
  2. 正确性:使用锁用来防止并发进程互相干扰。如果锁失效,会造成多个进程同时操作同一条数据,产生的后果是数据严重错误、永久性不一致、数据丢失等恶性问题。

Martin 认为,如果你是为了效率,那么使用单机版 Redis 就可以了,即使偶尔发生锁失效(宕机、主从切换),都不会产生严重的后果。而使用 Redlock 太重了,没必要。

而如果你是为了正确性,Martin 认为 Redlock 根本达不到安全性的要求,也依旧存在锁失效的问题!

第二个论点:锁在分布式系统中会遇到问题

Martin 表示,一个分布式系统,存在着各种异常情况,这些异常场景主要包括三大块,这也是分布式系统会遇到的三座大山:NPC

  • N:Network Delay,网络延迟
  • P:Process Pause,进程暂停
  • C:Clock Drift,时钟漂移

Martin 用一个进程暂停的例子,指出了 Redlock 安全性问题:

  1. 客户端 1 请求锁定节点 A、B、C、D、E
  2. 客户端 1 拿到锁后,进入进程暂停(时间比较久)
  3. 所有 Redis 节点上的锁都过期了
  4. 客户端 2 获取到了 A、B、C、D、E 上的锁
  5. 客户端 1 GC 结束,认为成功获取锁
  6. 客户端 2 也认为获取到了锁,发生「冲突」

null

Martin 认为,进程暂停可能发生在程序的任意时刻,而且执行时间是不可控的。

注:当然,即使没有进程暂停,在发生网络延迟、时钟漂移时,也都有可能导致 Redlock 出现此类问题,这里 Martin 只是拿进程暂停举例

第三个论点:假设时钟正确是不合理的

Relock 有一个隐含条件是所有的主机时间都是正确的,如果时间不正确就会出问题,例如

  1. 客户端 1 获取到节点 A、B、C 上的锁
  2. 节点 C 上的时钟「向前跳跃」,导致锁到期
  3. 客户端 2 获取节点 C、D、E 上的锁
  4. 客户端 1 和 2 现在都相信它们持有了锁(冲突)

Martin 认为 Redlock 必须「强依赖」多个节点的时钟是保持同步的,一旦有节点时钟发生错误,那这个算法模型就失效了。而机器的时钟发生错误,是很有可能发生的,比如:

  • 系统管理员「手动修改」了机器时钟
  • 机器时钟在同步 NTP 时间时,发生了大的「跳跃」

总之,Martin 认为,Redlock 的算法是建立在「同步模型」基础上的,有大量资料研究表明,同步模型的假设,在分布式系统中是有问题的。在混乱的分布式系统的中,你不能假设系统时钟就是对的,所以,你必须非常小心你的假设。

第四个论点:提出 fencing token 的方案,保证正确性

相对应的,Martin 提出一种被叫作 fencing token 的方案,保证分布式锁的正确性。

这个模型流程如下:

  1. 客户端在获取锁时,锁服务可以提供一个「递增」的 token
  2. 客户端拿着这个 token 去操作共享资源
  3. 共享资源可以根据 token 拒绝「后来者」的请求

null

这样一来,无论 NPC 哪种异常情况发生,都可以保证分布式锁的安全性,因为它是建立在「异步模型」上的。

而 Redlock 无法提供类似 fencing token 的方案,所以它无法保证安全性。

他还表示,一个好的分布式锁,无论 NPC 怎么发生,可以不在规定时间内给出结果,但并不会给出一个错误的结果。也就是只会影响到锁的「性能」(或称之为活性),而不会影响它的「正确性」。

Martin 的结论

  1. Redlock 不伦不类:对于偏好效率来讲,Redlock 比较重,没必要这么做,而对于偏好正确性来说,Redlock 是不够安全的。
  2. 时钟假设不合理:该算法对系统时钟做出了危险的假设(假设多个节点机器时钟都是一致的),如果不满足这些假设,锁就会失效。
  3. 无法保证正确性:Redlock 不能提供类似 fencing token 的方案,所以解决不了正确性的问题。为了正确性,请使用有「共识系统」的软件,例如 Zookeeper。

以上就是 Martin 反对使用 Redlock 的观点,有理有据。接下来我们来看 Redis 作者 Antirez 是如何反驳的。

(b). Redis 作者 Antirez 的反驳

在 Antirez 的反驳文章中,有三个重点

第一个重点:时钟问题

首先,Redis 作者一眼就看穿了对方提出的最为核心的问题:时钟问题。

为什么 Redis 作者优先解释时钟问题?因为在后面的反驳过程中,需要依赖这个基础做进一步解释。

Redis 作者表示,Redlock 并不需要完全一致的时钟,只需要大体一致就可以了,允许有「误差」,只要误差不要超过锁的租期即可,这种对于时钟的精度要求并不是很高,而且这也符合现实环境。

对于对方提到的「时钟修改」问题,Redis 作者反驳到:

  • 手动修改时钟:不要这么做就好了,否则你直接修改 Raft 日志,那 Raft 也会无法工作...
  • 时钟跳跃:通过「恰当的运维」,保证机器时钟不会大幅度跳跃(每次通过微小的调整来完成),实际上这是可以做到的

第二个重点:解释网络延迟、进程暂停问题

Redis 作者对于对方提出的,网络延迟、进程暂停可能导致 Redlock 失效的问题,也做了反驳。

我们重新回顾一下,Martin 提出的问题假设:

  1. 客户端 1 请求锁定节点 A、B、C、D、E
  2. 客户端 1 的拿到锁后,进入进程暂停(时间比较久)
  3. 所有 Redis 节点上的锁都过期了
  4. 客户端 2 获取到了 A、B、C、D、E 上的锁
  5. 客户端 1 GC 结束,认为成功获取锁
  6. 客户端 2 也认为获取到了锁,发生「冲突」

null

Redis 作者反驳到,这个假设其实是有问题的,Redlock 是可以保证锁安全的。还记得前面介绍 Redlock 流程的那 5 步吗?让我们来复习一下。

  1. 客户端先获取「当前时间戳 T1」
  2. 客户端依次向这 5 个 Redis 实例发起加锁请求(用前面讲到的 SET 命令),并设置超时时间(毫秒级),如果某一个实例加锁失败(包括网络超时、锁被其它人持有等各种异常情况),就立即向下一个 Redis 实例申请加锁
  3. 如果客户端从 >=3 个(大多数)以上 Redis 实例加锁成功,则再次获取「当前时间戳 T2」,如果锁的租期 > T2 - T1 ,此时,认为客户端加锁成功,否则认为加锁失败
  4. 加锁成功,去操作共享资源
  5. 加锁失败或操作结束,向「全部节点」发起释放锁请求(使用 Lua 脚本释放锁)

注意,重点是 1-3,在步骤 3,加锁成功后为什么要重新获取「当前时间戳 T2」?还用 T2 - T1 的时间,与锁的过期时间做比较?

Redis 作者强调:如果在 1-3 发生了网络延迟、进程暂停等耗时长的异常情况,那在第 3 步 T2 - T1,是可以检测出来的,如果超出了锁设置的过期时间,那这时就认为加锁会失败,之后释放所有节点的锁就好了!

Redis 作者继续论述,如果对方认为,发生网络延迟、进程暂停是在步骤 3 之后,也就是客户端确认拿到了锁,去操作共享资源的途中发生了问题,导致锁失效,那这不止是 Redlock 的问题,任何其它锁服务例如 Zookeeper,都有类似的问题,这不在讨论范畴内。

所以 Redis 作者的结论是:

  • 客户端在拿到锁之前,无论经历什么耗时长问题,Redlock 都能够在第 3 步检测出来
  • 客户端在拿到锁之后,发生 NPC,那 Redlock、Zookeeper 都无能为力

所以,Redis 作者认为 Redlock 在保证时钟正确的基础上,是可以保证正确性的。

第三个重点:质疑 fencing token 机制

Redis 作者对于对方提出的 fecing token 机制,也提出了质疑,主要分为 2 个问题

第一,这个方案必须要求要操作的「共享资源服务器」有拒绝「旧 token」的能力。

假设共享资源服务器是 MySQL,我们要操作 MySQL,从锁服务拿到一个递增数字的 token,然后客户端要带着这个 token 去改 MySQL 的某一行,这就需要利用 MySQL 的「事物隔离性」来做。

// 两个客户端必须利用事物和隔离性达到目的
// 注意 token 的判断条件
UPDATE table T SET val = $new_val WHERE id = $id AND current_token < $token

但如果操作的不是 MySQL 而是向磁盘上写一个文件,或发起一个 HTTP 请求,那这个方案就无能为力了,这对要操作的资源服务器,提出了更高的要求。

再者,既然资源服务器都有了「互斥」能力,那还要分布式锁干什么?

所以,Redis 作者认为这个方案是站不住脚的。

第二,退一步讲,即使 Redlock 没有提供 fecing token 的能力,但 Redlock 已经提供了随机值(UUID),利用这个随机值,也可以达到与 fecing token 同样的效果。

  1. 客户端使用 Redlock 拿到锁
  2. 客户端在操作共享资源之前,先把这个锁的 VALUE,在要操作的共享资源上做标记
  3. 客户端处理业务逻辑,最后,在修改共享资源时,判断这个标记是否与之前一样,一样才修改(类似 CAS 的思路)

还是以 MySQL 为例,这个实现如下

  1. 客户端使用 Redlock 拿到锁
  2. 客户端要修改 MySQL 表中的某一行数据之前,先把锁的 VALUE 更新到这一行的某个字段中(这里假设为 current_token 字段)
  3. 客户端处理业务逻辑
  4. 客户端修改 MySQL 的这一行数据,把 VALUE 当做 WHERE 条件,再修改

UPDATE table T SET val = $new_val WHERE id = $id AND current_token = $redlock_value

可见,这种方案通过依赖 MySQL 的事物机制,也达到对方提到的 fecing token 一样的效果。

对于上述通过 Redlock “实现” fecing token 的设计,网友提出了一个问题:两个客户端通过这种方案,先「标记」再「检查 + 修改」共享资源,那这两个客户端的操作顺序无法保证啊?而用 Martin 提到的 fecing token,因为这个 token 是单调递增的数字,资源服务器可以拒绝小的 token 请求,保证了操作的「顺序性」!

Redis 作者对这问题做了不同的解释,比较有意思,他认为:分布式锁的本质,是为了「互斥」,只要能保证两个客户端在并发时,一个成功,一个失败就好了,不需要关心「顺序性」**。

前面 Martin 的质疑中,一直很关心这个顺序性问题,但 Redis 的作者的看法却不同。

综上,Redis 作者的结论:

  1. 作者同意对方关于「时钟跳跃」对 Redlock 的影响,但认为时钟跳跃是可以避免的,取决于基础设施和运维。
  2. Redlock 在设计时,充分考虑了 NPC 问题,在 Redlock 步骤 3 之前出现 NPC,可以保证锁的正确性,但在步骤 3 之后发生 NPC,不止是 Redlock 有问题,其它分布式锁服务同样也有问题,所以不在讨论范畴内。
(5). 要不要用 Redlock?

Redlock 只有建立在「时钟正确」的前提下,才能正常工作,如果你可以保证这个前提,那么可以拿来使用。

但保证时钟正确,并不是简单

  • 第一,从硬件角度来说,时钟发生偏移是时有发生,无法避免。例如,CPU 温度、机器负载、芯片材料都是有可能导致时钟发生偏移的。
  • 第二,人为错误也是很难完全避免,运维暴力修改时钟,进而影响了系统的正确性

所以,对于 Redlock,个人看法,尽量不用它,而且他的性能不如单机版 Redis,部署成本也高。

4. 性能优化

分布锁能够做的优化不多。

一个思路是优化 redis 本身的性能,比如说启用单独的 redis 集群,这可以有效防止别的业务操作 redis,影响加锁和释放锁的性能。

另外一个思路是减少分布式锁的竞争。在高并发环境中,可以考虑使用 Singleflight 模式来优化分布式锁。

SingleFlight 最初是 Go 开发组提供的一个扩展并发原语。它的作用是,在处理多个 goroutine 同时调用同一个函数的时候,只让一个 goroutine 去调用这个函数,等到这个 goroutine 返回结果的时候,再把结果返回给这几个同时调用的 goroutine,这样可以减少并发调用的数量。

区分一下 Go 语言中 sync.Once 的用法。Sync.Once 会保证永远只执行一次,而 SingleFlight 是每次调用都重新执行,并且在多个请求同时调用的时候只有一个执行。它们两个面对的场景是不同的,sync.Once 主要是用在单次初始化场景中,而 SingleFlight 主要用在合并并发请求的场景中,尤其是缓存场景。

在分布式锁中应用 Singleflight 模式是为了确保对同一个锁一个实例只有一个线程去获取分布式锁。也就是说,针对同一把锁,如果一个实例中有多个线程也去参与获取这个分布式锁了,那么每个实例内部先选出一个线程去获取锁。

假设有 2 个实例,每个实例上各有 10 个线程要去获得 key1 上的分布式锁。在不使用 Singleflight 模式的情况下,总共有 20 个线程会去竞争分布式锁。但是在使用 Singleflight 模式之后,最终只有 2 个线程去竞争分布式锁。竞争越激烈,这种方案的效果越好。如果没什么并发的话,那么就基本没什么效果。

还有一种更加激进的优化方案。

在实例拿到分布式锁之后,释放锁之前先看看本地有没有别的线程也需要同一把分布式锁。如果有,就直接转交给本地的线程,进一步减少加锁和释放锁的开销。这种优化手段同样是在竞争越激烈的场景,效果越好。

5. 去分布式锁

分布式锁不论如何优化,都有性能损耗。所以不用分布式锁是最好的优化手段。

比如在一些场景中,是可以考虑去掉分布式锁的。

一个思路是用数据库乐观锁来取代分布式锁。比如说一些场景是加了分布式锁之后执行一些计算,最后更新数据库。在这种场景下,完全可以抛弃分布式锁,直接计算,最后计算完成之后,利用乐观锁来更新数据库。缺点就是没有分布式锁的话,可能会有多个线程在计算。但是问题不大,因为只要最终更新数据库控制住了并发,就没关系。

另一个思路是利用一致性哈希负载均衡算法。在使用这种算法的时候,同一个业务的请求肯定发到同一个节点上。这时候就没必要使用分布式锁了,本地直接加锁,或者用 Singleflight 模式就可以

四、总结

我们从不同的维度来比较选择不同的组件

维度
理解的容易程序(从易到难)数据库 > 缓存 > Zookeeper
实现的复杂性(从低到高)Zookeeper < 缓存 < 数据库
性能(从高到低)缓存 > Zookeeper > 数据库
可靠性(从高到低)Zookeeper > 缓存 > 数据库

首先,我们说,一个分布式锁,无论是基于 redis 还是 zookeeper,或者 etcd 等等,在极端情况下,都无法保证 100% 安全,都存在失效的可能。如果我们的业务数据是非常敏感的,在使用分布式锁时,一定要注意这个问题,不能假设分布式锁 100% 安全。这是前提条件。

不得已,必须要使用分布式锁,那我们需要根据自己的业务场景,来选择对应的存储组件来实现分布式锁。

  • 基于数据库实现的分布式锁存在单点故障和死锁问题,仅仅利用数据库技术去解决单点故障和死锁问题,是非常复杂的。
  • ZooKeeper 已定义相关的功能组件,并且 ZooKeeper 分布式锁的可靠性最高,有封装好的框架,很容易实现分布式锁的功能,但是性能相对来说比较低。
  • 如果要处于性能考虑,使用 redis 实现分布式锁性能较高。而且 Redlock 确实能够提供更安全的分布式锁,但也是有代价的,需要更多的 redis 节点。在实际业务中,一般使用基于单点的 redis 实现分布式锁就可以满足绝大部分的需求,偶尔出现数据不一样的情况,也可以人工介入解决。

最后,分布式系统设计是实现复杂性和收益的平衡,既要尽可能的安全可靠,也要避免过度设计。我们知道,任何分布式锁都无法完全保证正确性,因此使用分布式锁时建议:

  • 在上层使用分布式锁完成「互斥」目的,虽然极端情况下锁会失效,但它可以最大程度把并发请求阻挡在最上层,减轻操作资源层的压力。
  • 但对于要求数据绝对正确的业务,在资源层一定要做好「兜底」,可以借鉴 fencing token 的方案来做,即在资源层通过版本号的方式来更新数据,避免并发冲突。这样,发生极端情况时,也不会对系统造成影响

关于分布式锁的讨论在这里就结束了,我想以 Martin 在对于 Redlock 争论过后,写下的感悟来结尾:

前人已经为我们创造出了许多伟大的成果:站在巨人的肩膀上,我们可以才得以构建更好的软件。无论如何,通过争论和检查它们是否经得起别人的详细审查,这是学习过程的一部分。但目标应该是获取知识,而不是为了说服别人,让别人相信你是对的。有时候,那只是意味着停下来,好好地想一想。

文章7:

分布式事务定义:一个业务流程跨越多个分布式系统或服务的事务处理,需要确保在多个参与者之间的数据一致性和原子性。

一、分布式事务产生的背景

数据库的水平拆分

随着业务数据规模的增大,单库单表逐渐成为瓶颈,所以要对数据库进行了水平拆分,将原单库单表拆分成数据库分片。

分库分表之后,原来在一个数据库上就能完成的写操作,可能就会跨多个数据库,这就产生了跨数据库事务问题。

业务服务化拆分

随着业务访问量和复杂程度的增长,单系统架构逐渐成为业务发展瓶颈,需要将单业务系统拆分成多个业务系统,实现各系统解耦,各业务系统可以更专注自身业务,有利于业务的发展和系统容量的伸缩。

当拆分为多个服务后,一个完整的业务流程往往需要调用多个服务,需要保证多个服务间的数据一致性和原子性。

二、分布式事务理论基础

2.1 分布式事务模式

包括四种常见模式:

  • AT 模式:无侵入的分布式事务解决方案,适用于不希望对业务进行改造的场景,几乎零开发成本。
  • TCC 模式:高性能分布式事务解决方案,适用于核心系统等对性能有很高要求的场景。
  • Saga 模式:长事务解决方案,适用于业务流程长且需要保证事务最终一致性的业务系统。
  • XA 模式:传统强一致性解决方案,性能较低,很少使用。

2.2 分布式事务类型

分布式事务实现方案,从类型上分为刚性事务和柔性事务。

刚性事务

  • 满足 CAP 的 CP,要求数据强一致。强一致性,原生支持回滚/隔离性。
  • 通常无需业务改造,像本地式事务一样,具备数据强一致性,原生支持回滚/隔离性。
  • 同步阻塞,低并发,适合短事务,不适合大型网站分布式场景。
  • XA 模式属于刚性事务。

柔性事务

  • 满足 BASE 理论,或者说满足 CAP 的 AP。不要求强一致性,要求最终一致性,允许有中间状态。
  • 通常需要业务改造,实现补偿接口,实现资源锁定。
  • 高并发,适合长事务。
  • 柔性事务分为:补偿型 和 通知型
  • 补偿型:AT 模式、TCC 模式、Saga 模式
  • 通知型可参考《可靠消息最终一致性
  • MQ 事务消息方案
  • 本地消息表方案

为什么需要补偿型分布式事务?
· 基于消息实现的事务并不能解决所有的业务场景。
· 例如电商下单支付场景:某笔订单完成时,同时扣掉用户的现金。这里事务发起方是管理订单库的服务,但对整个事务是否提交并不能只由订单服务决定,因为还要确保用户有足够的钱,才能完成这笔交易,而这个信息在管理现金的服务里。

2.3 分布式事务协议

四种分布式事务模式的共同点都是 「两阶段」

下边介绍一下分布式事务的理论基础:两阶段提交 2PC 和 三阶段提交 3PC。这两个机制具有普适性——为协议一样的存在。不过由于 3PC 非常难实现,所以目前绝大多数分布式解决方案都是以两阶段提交协议为基础的。

2.3.1 两阶段提交 2PC

两阶段提交协议:事务管理器分两个阶段来协调资源管理器,第一阶段准备资源,也就是预留事务所需的资源,如果每个资源管理器都资源预留成功,则进行第二阶段资源提交,否则协调资源管理器回滚资源。

2PC 核心原理
  • 定义了两种角色:事务参与者 和 事务协调者
  • 将过程划分成两个阶段:
  • 第一阶段:准备资源,即所有参与者预留事务所需资源,也叫预提交。协调者收到所有参与者的预提交才会进入第二阶段。若在协调者的超时时间内,有任意参与者的预提交 preCommit 未发送或未到达,都会结束事务。
  • 第二阶段:提交或回滚。所有参数者预提交完成后,由协调者决定最终事务是成功(commit)还是失败(rollback)。

2PC优点

参与者的事务提交和回滚可以直接利用数据库的实现,无需自己实现,不会侵入业务逻辑。

2PC缺点

同步阻塞,影响性能

  • 执行过程中,所有参与节点都是等待协调者返回是否可提交,等待过程中参与者是同步阻塞的,这会导致系统并发能力下降。
  • 另外当参与者占用了公共资源,其他第三方节点想同时访问公共资源时,不得不处于阻塞状态。

容错风险:

  • 协调者是非常关,若发生故障,参与者会一直阻塞。当然可以通过备机+日志等方式缓解。
  • 参与者发生故障,会导致协调者等待。一般需要设置合理的超时时间,超时后整个事务失败。

一致性问题:

  • 极端情况下,当协调者发出 commit 消息之后宕机,而唯一接收到这条消息的参与者也同时宕机。即使协调者通过选举协议产生了新的协调者,此前的事务状态也是不确定的,没人知道事务是否被已经提交。

2.3.2 三阶段提交3PC

3PC 基于 2PC 有两项优化:

  • 改进超时机制:同时在协调者和参与者中都引入超时机制。
  • 划分为三阶段:把2PC的准备阶段再次一分为二,分为 CanCommit、PreCommit、DoCommit 三个阶段。

CanCommit阶段

3PC 的 CanCommit 阶段与 2PC 的准备阶段类似。

  1. 事务询问:协调者向参与者发送 CanCommit 请求。然后开始等待参与者响应。
  2. 响应反馈:参与者接到 CanCommit 请求后,如果自身认为可以执行事务,则返回 Yes,并进入预备状态。否则返回 No。

PreCommit阶段

协调者根据反馈情况,判断后续操作。

若所有参与者都返回 Yes,发起 PreCommit 操作:

  1. 发送预提交请求:协调者向参与者发送 PreCommit,并进入 Prepared 阶段。
  2. 事务预提交:参与者接收到 PreCommit,会执行事务预提交,记录 undo 和 redo 事务日志。
  3. 响应反馈:若参与者成功执行了事务,则返回 ACK ,同时开始等待最终指令。

若任意参与者返回 No,或者协调者等待超时,则发起事务中断。

  1. 发送中断请求:协调者向所有参与者发送 abort 请求。
  2. 中断事务:参与者收到 abort 请求后,或参与者等待超时后,执行事务中断。

doCommit阶段

该阶段进行真正的事务提交,分为两种情况。

执行提交

  1. 发送提交请求:协调接收到所有参与者发送的 ACK 响应,从预提交状态进入到提交状态。并向所有参与者发送doCommit请求。
  2. 事务提交:参与者接收到 doCommit 请求之后,执行正式的事务提交。并在完成事务提交之后释放所有事务资源。 (此处有个特殊逻辑:在参与者超时未收到来自协调者的信息之后,会默认执行commit,避免长期阻塞
  3. 响应反馈:事务提交完之后,向协调者发送 ACK。
  4. 完成事务:协调者接收到所有参与者的 ACK 之后,完成事务。 (若超时未收到所有的 ACK,会反复重试。3PC 有个乐观假设:因为 preCommit 阶段已经进行了预提交,所以大概率参与者能执行成功)

中断事务:协调者未接收到所有参与者发送的ACK响应(可能是等待超时了),会执行中断事务。

  1. 发送中断请求:协调者向所有参与者发送 abort 请求
  2. 事务回滚:参与者接收到 abort 请求之后,利用之前记录的 undo 日志执行回滚操作,并在完成回滚之后释放所有的事务资源。
  3. 反馈结果:参与者完成事务回滚之后,向协调者发送 ACK
  4. 中断事务:协调者接收到参与者反馈的 ACK 消息之后,执行事务中断。

3PC优劣势分析

3PC 相比 2PC的优点

  • 减少阻塞:3PC更加乐观,在参与者超时未收到来自协调者的信息之后,会默认执行commit。不会一直持有事务资源并处于阻塞状态。避免了参与者在长时间无法与协调者节点通讯(协调者挂掉了或出现网络分区)的情况下,无法释放资源的问题。

为什么参与者敢直接commit ?这样做有什么影响?

  • 原因:3PC 更加乐观,这是基于概率来决定的。因为增加了 CanCommit 阶段,若所有参与者都响应了 Yes 并进入到了 Prepare 阶段,意味大家都同意提交事务,有理由相信成功提交的概率很大。
  • 影响:会导致数据不一致性问题,如果由于网络原因,协调者发送的 rollback 命令没有及时被参与者接收到,参与者在等待超时之后执行了commit操作。这样会导致与其他参与者之前存在数据不一致情况。

三、Seata 分布式事务框架

Seata (Simpe Extensible Autonomous Transaction Architecture)

一款针对分布式架构下产生的数据一致性问题而诞生的分布式事务产品,基于2PC协议和BASE理论的最终一致性来达成事务。

阿里 2019 年开源,Seata 是什么? | Apache Seata

3.1 Seata 架构

  • 微服务:业务服务
  • Seata Server:一个中心化的、单独部署的事务管理中间件。负责分布式事务实现中的Transaction Coordinator
  • Nacos
  • 用于微服务到 Seata Server 的通信,微服务通过 Nacos 汇报分支事务状态,并接收 Seata 的 Commit/Rollback 决议
  • Seata 支持多种注册类型:file 、nacos 、eureka、redis、zk、consul、etcd3等
  • MySQL:用于记录分布式事务中分支事务 和 全局事务的执行状态
  • server端:三张表:global_table、branch_table 和 lock_table
  • client 端:需要在业务库中创建 undo_log 表,用于 Seata AT 模式下自动记录数据快照

3.2 Seata 框架

Seata 框架有三个角色,TC、TM 和 RM

  • TC(Transaction Coordinator),即Seata Server,中心化的事务协调者,负责协调全局事务的提交和回滚,并维护全局和分支事务的状态。
  • TM(Transaction Manager),它是事务管理器,主要作用是发起一个全局事务,对全局事务的提交和回滚做出决议。
  • RM(Resource Manager),它是资源管理器,向 TC 注册分支事务并上报事务状态,同时负责对当前分支事务进行提交和回滚。每一个分支事务都是全局事务的参与者,这些分支事务的所属应用扮演了 RM 的角色。

Seata 支持四种分布式事务模式:AT、TCC、SAGA、AX。

下面会展开介绍下 AT 和 TCC 事务模式,对 XA 和 Saga 大家感兴趣可自行了解。

3.3 AT 模式

自动补偿型事务模式,Automatic Transaction。

对业务无侵入:需要改动任何业务代码,只需要一个注解和少量的配置信息,就可以实现分布式事务。Seata 框架会自动生成事务的二阶段提交和回滚操作。

3.3.1 原理

基于2PC协议进行了演变:

  • 一阶段:执行核心业务逻辑(即代码中的 CRUD 操作)。Seata 会根据 DB 操作自动生成相应的回滚日志,并将回滚日志添加到 RM 对应的 undo_log 表中。执行业务代码和添加回滚日志这两步都是在同一个本地事务中提交的。这一步完成后会释放本地锁和连接资源。

Seata 会拦截“业务 SQL”,首先解析 SQL 语义,找到“业务 SQL”要更新的业务数据,在业务数据被更新前,将其保存成“before image”,然后执行“业务 SQL”更新业务数据,在业务数据更新之后,再将其保存成“after image”,最后生成行锁。以上操作全部在一个数据库事务内完成,这样保证了一阶段操作的原子性。
  • 二阶段
  • 二阶段是以异步化的方式来执行的。
  • 如果全局事务的最终决议是 Commit,则更新分支事务状态并清空回滚日志(只需将一阶段保存的快照数据和行锁删掉,完成数据清理即可)。

  • 如果最终决议是 Rollback,则根据 undo_log 中的回滚日志进行 rollback 操作(反向补偿)。

Seata AT 方案的核心是回滚日志 undo_log表。通过 undo_log 表将一阶段和二阶段拆分成两个独立的本地事务来执行。

Seata AT 执行效率比 2PC 高,主要原因有两个:

  • 一是核心业务逻辑可以在一阶段得到快速提交,DB 资源被快速释放;
  • 二是全局事务的 Commit 和 Rollback 是异步执行。
CREATE TABLE IF NOT EXISTS `undo_log`
(
  `id` BIGINT(20) NOT NULL AUTO_INCREMENT COMMENT 'increment id',
  `branch_id` BIGINT(20) NOT NULL COMMENT 'branch transaction id',
  `xid` VARCHAR(100) NOT NULL COMMENT 'global transaction id',
  `context` VARCHAR(128) NOT NULL COMMENT 'undo_log context, such as se',
  `rollback_info` LONGBLOB NOT NULL COMMENT 'rollback info', -- 存放回滚信息,包括before image 和 after image`log_status` INT(11) NOT NULL COMMENT '0:normal status, 1:defense status',`log_created` DATETIME NOT NULL COMMENT 'create datetime',`log_modified` DATETIME NOT NULL COMMENT 'modify datetime',
	PRIMARY KEY (`id`),
	UNIQUE KEY `ux_undo_log` (`xid`, `branch_id`)
) ENGINE = InnoDB
AUTO_INCREMENT = 1
DEFAULT CHARSET = utf8 COMMENT ='AT transaction mode undo table';

3.3.2 实现案例

删除优惠券模板案例,Customer 和 Template 两个服务。Customer 服务是整个业务流程的起点,它先调用 Template 服务注销券模板,然后再调用本地事务注销了由该模板生成的优惠券。

详细流程
  • 首先,Customer服务发起事务,生成 XID
  • Customer 服务扮演了 TM 角色,它会向 TC 注册并发起一个全局事务。
  • 全局事务会生成一个 XID,它是全局唯一的 ID 标识,所有分支事务都会和这个 XID 进行绑定。绑定方式:
  • XID 在服务内部,传播机制是基于ThreadLocal 构建的,即 XID 在当前线程的上下文中进行透传
  • XID 在跨服务时,依赖 seata-all 组件内置的各个适配器(如 Interceptor 和 Filter)将 XID 传递给下游服务。
  • 然后,Customer 服务调用 Template 服务,Template 服务执行分支事务
  • Customer 服务调用 Template 服务的模板注销接口,Template 的 RM 会开启分支事务并注册到 TC。
  • 在执行分支事务的过程中,RM 还会生成回滚日志并提交到 undo_log 表中。
  • RM 还需要获取到两个特殊的 Lock:Local Lock(本地锁)和 Global Lock(全局锁)
Lock 信息存放在 lock_table 这张表里,它会记录待修改的资源 ID 以及它的全局事务和分支事务 ID 等信息。无论是一阶段提交还是二阶段回滚,RM 都需要获取待修改记录的本地锁,然后才会去执行 CRUD 操作。而在 RM 提交一阶段事务之前,它还会尝试获取Global Lock(全局锁),目的是防止多个分布式事务对同一条记录进行修改。假设有两个不同的分布式事务想要修改记录 A,那么只有同时获取到 Local Lock 和 Global Lock 的事务才能正常提交一阶段事务。
本地锁会随一阶段事务的提交 / 回滚而释放,而全局锁只有等到全局事务提交 / 回滚之后才会被释放。在一阶段中,如果某一个事务在一定的尝试次数后仍然无法获取全局锁,它会知难而退,执行本地事务回滚操作。而如果在二阶段回滚的时候,RM 无法获取本地锁,它会原地打转不停重试,直到成功获取本地锁并完成重试。
更多关于锁原理的介绍,请参考:Seata 官方对于 AT 模式的介绍  Seata 是什么? | Apache Seata
  • 接下来,Customer 服务执行分支事务,并做出二阶段决议
  • Template 服务调用成功后,Customer 服务开始执行自己的本地事务,流程都大同小异。
  • Customer 服务作为TM 端,会根据业务的执行情况,最终做出二阶段决议,Commit 或Rollback。
  • 最后,进入二阶段进行 Commit 或 Rollback
  • TC 向各个分支下达了二阶段决议。如果最终决议是 Commit,那么各个 RM 会执行一段异步操作,删除 undo_log;如果最终决议是 Rollback,那么 RM 端会根据undo_log 中记录的回滚日志做反向补偿。

编码实战
工程中引入 Seata

1. pom.xml 中引入 Jar 包

<!-- Seata -->
<dependency><groupId>com.alibaba.cloud</groupId><artifactId>spring-cloud-starter-alibaba-seata</artifactId>
</dependency>

2. application.yml 中添加 Seata 相关配置项

# 指定了事务分组;
spring: 
  cloud:
    alibaba:
      seata:
      	tx-service-group: seata-server-group 
        
# 定义连接 Seata Server 的方式
seata: 
  application-id: customer-servregistry:type: nacosnacos:  # 指定 nacos 地址,用于发现 Seata Serverapplication: seata-server server-addr: localhost:8848namespace: devgroup: myGroupcluster: defaultservice:vgroup-mapping:
      seata-server-group: default

3. 声明数据源代理 DataSourceProxy

这一步实现无感知的编程体验的秘诀,为了能够在分支事务开启和提交等关键节点上做一番手脚(比如向 Seata 注册分支事务、生成 undo_log 等),需要用 Seata 特有的数据源代理“接管”原有的业务数据源。

@Configuration
public class SeataConfiguration {

    @Bean@ConfigurationProperties(prefix = "spring.datasource")
    public DruidDataSource druidDataSource() {
        return new DruidDataSource();  // 此处为业务正常的数据源
    }

    @Bean("dataSource")
    @Primarypublic DataSource dataSourceDelegation(DruidDataSource druidDataSource) {
        return new DataSourceProxy(druidDataSource);  // DataSourceProxy 是由 Seata 框架提供的一个数据源代理类,用于接管业务数据源,提供自动化注册分支事务、生成 undo_log 等操作。
    }
}
Customer 服务

在 CouponCustomerController.java 中开启全局分布式事务,调用 Template 服务并删除模板下的所有优惠券。

public class CouponCustomerController {
  
    @Autowireprivate TemplateService templateService;
  
    @Autowireprivate CouponDao couponDao;

    @Override@Transactional  // TR,开启分支事务,需要声明 @Transactionalpublic void deleteCouponTemplate(Long templateId) {
        templateService.deleteTemplate(templateId);  // 调用 Template Rest 服务的 deleteTemplate 接口。seata-all组件内置的各个适配器(如 Interceptor 和 Filter)会将 XID 传递给下游 Template 服务。
        couponDao.deleteCouponInBatch(templateId, CouponStatus.INACTIVE); // 执行本地事务

        throw new RuntimeException("AT分布式事务挂球了");  // 此处模拟事务挂了。
    }
}
Template 服务

CouponTemplateService.java 执行券模板删除

@Override
@Transactional // TR,开启分支事务,需要声明 @Transactional
public void deleteTemplate(Long id) {
  int rows = templateDao.makeCouponUnavailable(id);
  if (rows == 0) {
    throw new IllegalArgumentException("Template Not Found: " + id);
  }
}
执行结果

观察Template 服务日志,可以看到事务被回滚了。

rm handle branch rollback process:本地资源管理器开始执行回滚流程。
Branch Rollbacking:分支事务正在回滚。
Branch Rollbacked result: PhaseTwo_Rollbacked:分支事务回滚完成。
AT 使用建议

尽管 AT 模式已经非常简单了,但实际场景中非必要不要使用分布式事务。

  • 分布式事务会增加架构复杂度(增加了一个 failure point)。需要考虑 Seata Server 不可用的情况,制定降级预案保证业务正常运转。在大促等环节的压测端,要对 Seata Server 的高可用做好充足功课。
  • 如果业务场景比较简单,可以使用传统的事务型消息 + 日志补偿 + 跑批补偿方式(RocketMQ事务消息实现最终一致性

3.4 TCC 模式

3.4.1 原理

TCC 模式需要用户根据自己的业务场景实现 Try、Confirm 和 Cancel 三个操作

  • Try:预定操作资源。在执行业务逻辑之前,先把要操作的资源占上。
  • Confirm:执行主要业务逻辑。类似于事务的 Commit 操作。在这个阶段中,可以对 Try 阶段锁定的资源进行各种 CRUD 操作。如果 Confirm阶段被成功执行,就宣告当前分支事务提交成功。
  • Cancel:事务回滚。类似于事务的 Rollback 操作。在这个阶段没有 AT 方案中的 undo_log 做自动回滚,需要通过业务代码,对 Confirm 阶段执行的操作进行主动回滚。

3.4.2 编码实战

以 Template 服务为例,先注册 TCC 接口:

@LocalTCC // @LocalTCC注解,用于修饰实现了 TCC 二阶段提交的本地 TCC 接口
public interface CouponTemplateServiceTCC extends CouponTemplateService {
    
    // @TwoPhaseBusinessAction 注解,用于标识当前方法使用 TCC 模式管理事务提交。@TwoPhaseBusinessAction(  
        name = "deleteTemplateTCC",  // try 阶段要执行的方法
        commitMethod = "deleteTemplateCommit",  // confirm 阶段要执行的方法
        rollbackMethod = "deleteTemplateCancel" // cancel 阶段要执行的方法
    )
    void deleteTemplateTCC(@BusinessActionContextParameter(paramName = "id") Long id); // 通过@BusinessActionContextParameter注解,将 id 参数传入BusinessActionContextvoid deleteTemplateCommit(BusinessActionContext context);void deleteTemplateCancel(BusinessActionContext context);  // BusinessActionContext会由框架在事务上下文中进行传递,可以通过它传递查询参数。
}
第一阶段 Prepare 逻辑

在券模板数据库表种,增加 locked 字段。

alter table coupon_template  add locked tinyint(1) default 0 null;

并在 CouponTemplate model 中增加 locked 属性。

@Column(name = "locked", nullable = false)
private Boolean locked;

Try 阶段借助 locked 字段实现券模板的锁定。

public class CouponTemplateServiceTCCImpl implements CouponTemplateServiceTCC {

    @Override@Transactional 
    public void deleteTemplateTCC(Long id) {
        CouponTemplate filter = CouponTemplate.builder()
            .available(true) 
            .locked(false)  // 借助 locked 字段实现券模板的锁定。只有当 locked=false 时才能被筛选出来进行删除。
            .id(id)
            .build();
      
        CouponTemplate template = templateDao.findAll(Example.of(filter))
            .stream().findFirst()
            .orElseThrow(() -> new RuntimeException("Template Not Found"));
        template.setLocked(true);  // 查到了符合删除条件的记录,通过讲 locked 置为 true 进行锁定。
        templateDao.save(template);
    }
  	……
}
第二阶段 Commit 逻辑

执行到了 Commit ,代表各个分支事务已经成功执行了 Try。

@Override
@Transactional
public void deleteTemplateCommit(BusinessActionContext context) {
    Long id = Long.parseLong(context.getActionContext("id").toString()); // 从 context 中可以获取到查询参数。
    CouponTemplate template = templateDao.findById(id).get(); // 此处大胆的读取指定 ID 的券模板,没有做空校验,是因为执行到了 Commit 阶段,代表 Try 阶段已经正常锁定了资源,所以不再需要进行校验。template.setLocked(false);  // 此处要降 Try 阶段锁定的资源解除掉。locked=falsetemplate.setAvailable(false); // 执行业务逻辑,即删除优惠券。available=false
    templateDao.save(template);  
    log.info("TCC committed");
}
第二阶段 Rollback 逻辑

如果在 Try 或者 Confirm 阶段发生了异常,就会触发 TCC 全局事务回滚,Seata Server 会将 Rollback 指令发送给每一个分支事务。

@Override
@Transactional
public void deleteTemplateCancel(BusinessActionContext context) {
    Long id = Long.parseLong(context.getActionContext("id").toString());
    Optional<CouponTemplate> templateOption = templateDao.findById(id);
    if (templateOption.isPresent()) { // 为了防止空回滚,需要判断 Template 是否存在。(下边重点介绍)
        CouponTemplate template = templateOption.get();
        template.setLocked(false); // 通过将 locked 设置为 false 的方式对资源进行解锁
        templateDao.save(template);
    }
    log.info("TCC cancel");
}

3.4.3 TCC 的实践经验

使用中,需要注意以下事项:

  • 业务模型分两阶段设计
  • 并发优化
  • 允许空回滚
  • 防悬挂控制
  • 幂等控制

业务模型两阶段设计

用户接入 TCC ,最重要的是考虑如何将自己的业务模型拆成两阶段来实现。

在删除券模板的案例中,通过增加 locked 状态将业务逻辑分成两个阶段,但在实际业务中,往往资源锁定逻辑会非常复杂。

以“扣钱”场景为例:

  • 接入 TCC 前,对 A 账户的扣钱,只需一条 SQL 便能完成扣款。update 账户表 set 余额 = 余额 - 30 where 账户 = A
  • 接入 TCC 后,要考虑如何拆成两阶段,实现成三个方法。保证一阶段 Try 成功,则二阶段 Confirm 一定成功。 这里引入冻结金额。
  • 一阶段 Try 方法,资源的检查和预留。
  • 先检查 A 账户余额是否充足,再冻结要扣款的 30 元(预留资源),此阶段不会发生真正的扣款。
  • 二阶段 Confirm 方法,执行真正业务的提交。
  • 发生真正的扣款,把 A 账户中已经冻结的 30 元钱扣掉,余额变成 70 元 。
  • 二阶段 Cancle 方法,预留资源的释放。
  • 释放 Try 操作冻结的 30 元,使账号 A 的回到初始状态,100 元全部可用。

并发优化

在实现 TCC 时,应当考虑并发性问题,将锁的粒度降到最低,以最大限度的提高分布式事务的并发性。

例如:A  账户上有 100 元,事务 T1 要扣除其中的 30 元,事务 T2 也要扣除 30 元,出现并发。

解决办法

  • 在一阶段 Try 操作中,分布式事务 T1 和分布式事务 T2 分别冻结所需资金,相互之间无干扰;这样在分布式事务的二阶段,无论 T1 是提交还是回滚,都不会对 T2 产生影响,这样 T1 和 T2 在同一笔业务数据上并行执行。

允许空回滚

什么是空回滚

  • 在没有执行 Try 方法的情况下,TC 下发了回滚指令并执行了 Cancel 逻辑。(还没来得及预留资源,却要求释放

出现原因

  • 某个分支事务的一阶段 Try 方法因为网络不可用发生了 Timeout,或者 Try 阶段执行失败,这时候 TM 端会判定全局事务回滚,TC端向各个分支事务发送 Cancel 指令,这就产生了一次空回滚。

有什么影响:

  • 若未允许空回滚,因为 Cancel 阶段没有资源可释放会无法正常执行,这样 TC 会不断重试 Cancel 指令,陷入死循环。

解决办法

  • 在 Cancel 阶段,先判断一阶段 Try 有没有执行成功。示例程序中的判断方式为判断资源是否被locked,再执行释放操作。如果资源未被锁定或者压根不存在,可以认为 Try 阶段没有执行成功,这时Cancel 阶段直接返回成功即可。
  • 更为完善做法是,引入独立的事务控制表,在 Try 阶段中将 XID 和分支事务 ID 等操作信息落表保存(注:可以将修改数据与保存操作记录放入同一本地事务,保证数据一致性),如果 Cancel 阶段查不到事务控制记录,那么就说明 Try 阶段未被执行。同理,Cancel 阶段执行成功后,也可以在事务控制表中记录回滚状态,这样做是为了防止另一个TCC 的坑,“倒悬”。
CREATE TABLE `account_transaction` (
    `tx_id` varchar(100) NOT NULL COMMENT '事务Txld',
    `action_id` varchar(100) NOT NULL COMMENT '分支事务ld',
    `gmt_create` datetime NOT NULL COMMENT '创建时间',
    `gmt_modified` datetime NOT NULL COMMENT '修改时间',
    `user_id` varchar(100) NOT NULL COMMENT '账户UID',
    `amount` varchar(100) NOT NULL COMMENT '变动金额',
    `type` varchar(100) NOT NULL COMMENT '变动类型',
    `state` smallint(4) NOT NULL COMMENT '分支事务状态:1.初始化;2.已提交;3.已回滚',
    UNIQUE KEY (`tx_id`, `action_id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8 COMMENT='业务流水表,用于事务控制表';

防倒悬控制

什么是倒悬

  • Cancel 比 Try 接口先执行(都要结束了,你才来)。

出现原因

  • 如果 Try 方法因为网络问题卡在了网关层,导致锁定资源超时,这时 Cancel 执行了空回滚。若回滚之后,原先超时的 Try 方法经过网关层的重试,又被后台服务接收到了,这就产生倒悬,即一阶段 Try 在二阶段 Cancel 之后被触发。

有什么影响

  • 在悬挂的情况下,整个事务已经被全局回滚,如果再执行 Try 操作,当前资源将被长期锁定,这就造成了一种类似死锁的局面。

解决办法:

  • 可以利用独立事务控制表记录二阶段执行状态,并在 Try 阶段中检查该状态,如果二阶段回滚完毕,那么就直接跳过一阶段 Try。
一句话总结: 要允许空回滚,还要拒绝空回滚后的 Try 操作。

幂等控制

Try、Commit、Cancl 三个方法均需要保证幂等性。

什么是幂等性

  • 对同一个系统,使用同样的条件,一次请求和重复的多次请求对系统资源的影响是一致的。

为什么需要幂等

  • 因为网络抖动或拥堵可能会超时,事务管理器会对资源进行重试操作,所以很可能一个业务操作会被重复调用,为了不因为重复调用而多次占用资源,需要对服务设计时进行幂等控制,也可以使用独立的事务控制表进行判重来实现幂等。

TCC 使用建议
  • 相对于 AT 模式,TCC 模式对业务代码有一定的侵入性,但是 TCC 模式无 AT 模式的全局行锁,TCC 性能会比 AT 模式高很多。
  • 不建议轻易上 TCC 方案,因为它非常考验开发者对业务的理解深度,需要把串行的业务逻辑拆分成 Try-Confirm-Cancel 三个不同的阶段执行,如何设计资源锁定流程、如果不同资源间有关联性又怎么锁定、回滚的反向补偿逻辑等等,需要对业务流程的每一个步骤了如指掌。

3.5 模式总结

Seata 支持多种事务模式,用户可针对不同的业务场景,选择不同模式,快速高效的建立安全的事务保障。

  • XA&AT模式: 无业务入侵、即插即用
  • TCC模式:不与具体的服务框架耦合、与底层 RPC 协议无关、与底层存储介质无关
  • SAGA模式:高度自定义、最终一致性、高性能

文章8:

基于数据库(MySQL)的方案,一般分为3类:基于表记录、乐观锁和悲观锁。

1、基于表记录

要实现分布式锁,最简单的方式可能就是直接创建一张锁表,然后通过操作该表中的数据来实现了。当我们想要获得锁的时候,就可以在该表中增加一条记录,想要释放锁的时候就删除这条记录。

例如:创建表结构如下:

CREATE TABLE `database_lock` (
	`id` BIGINT NOT NULL AUTO_INCREMENT,
	`resource` int NOT NULL COMMENT '锁定的资源',
	`description` varchar(1024) NOT NULL DEFAULT "" COMMENT '描述',
	PRIMARY KEY (`id`),
	UNIQUE KEY `uiq_idx_resource` (`resource`) 
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='数据库分布式锁表';

要获得锁时,可以插入一条数据:

INSERT INTO database_lock(resource, description) VALUES (1, 'lock');

由于resource字段是个唯一索引,当多个线程竞争同一个资源时,只会有一个插入成功,插入成功的线程获得锁成功。

当需要释放锁时,删除这条记录即可。

2、乐观锁

顾名思义,系统认为数据的更新在大多数情况下是不会产生冲突的,只在数据库更新操作提交的时候才对数据作冲突检测。如果检测的结果出现了与预期数据不一致的情况,则返回失败信息。

数据库乐观锁大多数是基于数据版本(version)的记录机制实现的。

何谓数据版本号?

即为数据增加一个版本标识,在基于数据库表的版本解决方案中,一般是通过为数据库表添加一个 “version”字段来实现读取出数据时,将此版本号一同读出,之后更新时,对此版本号加1。在更新过程中,会对版本号进行比较,如果是一致的,没有发生改变,则会成功执行本次操作;如果版本号不一致,则会更新失败。例如:

UPDATE product 
SET stock= stock-1,version=version+1 
WHERE product_id=12345 AND version=${oldVersion}

也可以通过在where条件里对比修改的字段的旧值,例如:

UPDATE product SET stock=stock-1 WHERE stock>1 and product_id=12345;

或者,也可以是用:

UPDATE product SET stock=10 WHERE stock=11 and product_id=12345;

3、悲观锁

利用数据库的排他锁,在事务体里通过 select .... for update; 来实现:

SELECT * FROM product WHERE id = 112345 FOR UPDATE;

这样,MySQL innodb引擎下,RR隔离级别下,会自动为改行添加行锁,甚至间隙锁,达到独占的效果。

  • 25
    点赞
  • 10
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
### 回答1: MATLAB分布式一致性算法是一种用于分布式系统的算法,可以确保在网络中的不同节点之间共享的数据始终保持一致。这个算法包含了很多技巧和策略,以提高性能和效率。 MATLAB分布式一致性算法的基本思想是节点之间交换信息,确保所有节点的数据都可以始终保持一致。每个节点在接收到其他节点的数据时,都会将这些数据与自己的数据进行比较,并且根据一定的规则和策略更新自己的数据。这样可以确保所有节点中的数据始终保持一致,从而避免由于数据不一致导致的问题。 与其他分布式算法相比,MATLAB分布式一致性算法具有许多优点。首先,它具有高效和快速的通信机制,可以快速传输数据。其次,它具有灵活的规则和策略,可以根据需要进行配置和调整。再次,它可以适应各种网络拓扑结构,并扩展到大规模的分布式系统中。 总的来说,MATLAB分布式一致性算法是一种非常有用的算法,可以确保在分布式系统中共享的数据始终保持一致。它能够提高系统的性能和可靠性,减少错误和故障的发生。它在很多应用场景中都有广泛的应用,例如云计算、大数据处理等。 ### 回答2: MATLAB的分布式一致性算法是指用于解决分布式系统中数据一致性问题的算法。在分布式系统中,不同节点的事务可能导致数据不一致的问题,因此需要采取相应的措施保证系统的一致性。MATLAB的分布式一致性算法包括基于锁的算法和基于副本的算法。 基于锁的算法是指在分布式系统中引入锁机制,通过对数据的访问加锁和解锁,实现对数据的一致性保证。这种算法在实现上比较简单,但是锁机制本身会对系统性能产生影响。 基于副本的算法则是将数据副本分布在不同节点上,通过多份数据的同步和协同来保证数据的一致性。这种算法一般需要配合分布式协议来实现数据的同步和协同,相对来说更适合大规模的分布式系统,但是相对来说实现难度较高。 总的来说,MATLAB的分布式一致性算法提供了有效的解决方案,能够帮助分布式系统有效地保证数据的一致性。在实际应用中,可以根据不同的需求选择合适的算法来满足具体的业务需求。

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值