使用etcd,是如何实现ACID事务的?

提到事务,你肯定不陌生。和数据库打交道的时候,我们总是会用到事务。简单来说,事务就是要保证一组数据库操作,要么全部成功,要么全部失败。有了事务,就大大简化了业务开发的难度,使我们更容易开发出逻辑正确且高效的代码。但是,在传统关系数据库之外,NoSQL存储系统几乎都不提供事务支持。

最近,在由于项目需要,对etcd3进行了一些调研,惊喜得发现,基于etcd3可以很容易实现ACID事务(设计到实现方式,以MySQL为例)。

etcd旨在提供强一致的kv存储,作为zookeeper的一个替代品,为分布式应用提供并发协调支持,下面是etcd用来构建ACID事务的几个关键特征:

  • raft

etcd使用raft协议来进行Leader选举和操作同步,意味着无论你访问任意节点,都将获得最终一致的数据视图,高度可靠。

raft使用quorum机制(多数同意原则),一个提议只要被多数节点批准,就写入raft日志文件持久化,不可撤销。

  • mvvc

etcd采用的是 "btree + bbolt"(类似leveldb的kv存储引擎)两级存储结构。在内存中使用btree维护key/value索引,节点存的是bbolt中的键值k,通过这个k在bbolt查找,得到的才是用户传进去的value值。

etcd中有一个revision(修订)的概念,类似mysql的事务id,每次更新kv,revision就递增1。举个例子,假如当前etcd的revision=3,执行put("demo","abc"),revision就会变为4,更新btree["demo"] = "demo:4",更新bbolt["demo:4"] = "abc"。这样就更新了demo,也保留了demo的历史版本,比如,通过 bbolt["demo:2"] 可以访问到demo的历史版本。

  • txn

etcd提供了“事务”,可以处理多个key的原子更新,这一点是非常难得的。

etcd事务的语法是"If-Then-Else",代替了常见的CAS操作,可以在一个事务中,原子地执行冲突检查,更新多个keys的值。

了解mysql的innoDB事务的同学都清楚,mysql是依赖锁实现事务的。一个事务首先要拿到它操作的数据库记录的锁,才能进行后续的操作,发生冲突时,事务会阻塞,严重时会发生死锁。在整个事务过程中,client和mysql要进行多次交互,mysql要为client维持事务资源,直到事务提交。

而etcd的实现方式有些不同,它的事务是基于cas方式实现的。在事务执行过程中,client和etcd之间没有维护事务会话,在commit事务时,它的“冲突判断(If)和执行过程Then/Else”一次性提交给etcd,etcd来作为一个原子过程来执行“If-Then-Else”。所以,etcd事务不会发生阻塞,无论成功,还是失败,都会立即返回,需要应用进行失败(发生冲突)重试。因此,这也就要求业务代码是可重试的。

etcd的事务可以看做是一种“微事务”,在它之上,可以构建出各种有意思的应用,例如,我们下面谈到的ACID事务。

下面是一个常用来解释事务的转账业务的例子,假如要从 from 向 to 转账 amount,业务代码是这样的:

func txnXfer(etcd *v3.Client, from, to string, amount uint) (error) {
      // 失败重试
      for {
        if ok, err := doTxnXfer(etcd, from, to amount); err != nil {
            return err
        } else if ok {
            return nil
        }
      }
    }

    func doTxnXfer(etcd *v3.Client, from, to string, amount uint) (bool, error) {
      // 获取from,to账户金额
      getresp, err := etcd.Txn(ctx.TODO()).Then(OpGet(from), OpGet(to)).Commit()
      if err != nil {
         return false, err
      }
      fromKV := getresp.Responses[0].GetRangeResponse().Kvs[0]
      toKV := getresp.Responses[1].GetRangeResponse().Kvs[1]
      fromV, toV := toUInt64(fromKV.Value), toUint64(toKV.Value)
      // 验证账户余额是否充足
      if fromV < amount {
          return false, fmt.Errorf(“insufficient value”)
      }
      // 发起转账视图
      txn := etcd.Txn(ctx.TODO()).If(
          v3.Compare(v3.ModRevision(from), “=”, fromKV.ModRevision),  // 事务提交时,from账户余额没有没有变动
          v3.Compare(v3.ModRevision(to), “=”, toKV.ModRevision))      // 事务提交时,to账户余额没有变动
      txn = txn.Then(
          OpPut(from, fromUint64(fromV - amount)),  // 更新from账户余额
          OpPut(to, fromUint64(toV - amount))       // 更新to账户余额
      putresp, err := txn.Commit()   // 提交事务
      if err != nil {
          return false, err
      }
      return putresp.Succeeded, nil
    }

可以看到,使用etcd事务,我们不仅要写转账业务代码,还要构造If条件(冲突判断条件),处理重试。有没有办法能够简化这个过程呢?答案是有。etcd的官方clientv3 sdk(go语言)提供了STM(软件事务内存),帮我们自动处理了这些繁琐的过程。

使用STM的转账业务代码如下,

func stmXfer(e *v3.Client, from, to string, amount uint) error {
      return <-conc.NewSTMRepeatable(context.TODO(), e, func(s *conc.STM) error {
        // 取账户余额
        fromV := toUInt64(s.Get(from))
        toV := toUInt64(s.Get(to))
        // 验证账户余额是否充足 
        if fromV < amount {
            return fmt.Errorf(“insufficient value”)
        }
        // 更新账户余额
        s.Put(to, fromUInt64(toV + amount))
        s.Put(from, fromUInt64(fromV - amount))
        return nil
      })
    }

这段代码是不是很清爽?我们只要编写转账逻辑,其他事情STM都帮我们做了。你可能很好奇,这个函数是如何被执行的,其实,就是New(NewSTMRepeatable)出来的STM对象在内部构造txn事务,把我们编写的业务函数翻译成If-Then,自动提交事务,处理失败重试等工作,直到事务执行成功,或者明确的执行失败(出现异常,不能靠重试成功)。

etcd官方client实现了四种事务模型,通过分析源代码 clientv3/concurrency/stm.go,可以了解STM各种事务的语义。

  • ReadCommitted

读提交是指,一个事务提交之后,它做的变更才会被其他事务看到。

由于etcd的kv操作(包括txn事务内的多个keys操作)都是原子操作,所以你不可能读到未提交的修改,ReadCommitted是etcd中的最低事务级别。

Get操作:从etcd读取keys,就像普通的kv操作一样。第一次Get后,在事务中缓存,后续不再从etcd读取。

If条件:None,没有任何冲突检测。

  • RepeatableReads

可重复读是指,一个事务执行过程中看到的数据,总是跟这个事务在启动时看到的数据是一致的。

Get操作:从etcd读取keys,就像普通的kv操作一样。第一次Get后,在事务中缓存,后续不再从etcd读取。

If条件:在事务提交时,事务中Get的keys没有被改动过。

MySQL事务“可重复读”是通过在事务第一次select时建立readview,来确保事务中读到的是到这一刻为止的最新数据,忽略后面发生的更新。而这里每个key的Get是独立的(也可以说,每个key都是获取的当前值,没有readview的概念),在事务提交时,如果这些keys没有变动过,那么事务就可以提交。

  • Serializable

串行化,顾名思义是对同一行记录,“写”会加“写锁”,“读”会加“读锁”。当出现读写锁冲突的时候,后访问的事务必须等前一个事务执行完成,才能继续执行。

Get操作:事务中的第一个Get操作发生时,保存服务器返回的当前revision;后续对其他keys的Get操作,指定获取revision版本的value。

If条件:在事务提交时,事务中Get的keys没有被改动过。

可见,这个约束比数据库串行化的约束要低,它没有验证事务要修改的keys是否被改动过,下面的SerializableSnapshot事务增加了这个约束。

  • SerializableSnapshot

Get操作:事务中的第一个Get操作发生时,保存服务器返回的当前revision;后续对其他keys的Get操作,指定获取revision版本的value。

If条件:在事务提交时,事务中Get的keys没有被改动过,事务中要修改的keys也没有被改动过。

通过上面的分析,我们清楚了如何使用etcd的txn事务,构建符合ACID语义的事务框架。如果这些语义不能满足你的业务需求,通过扩展etcd的官方client sdk,写一个新STM事务类型即可。

有一点要强调的是,数据库事务是“锁/阻塞”模式,而etcd的STM事务是“cas/重试”模式,这是有差别的。简单的说,数据库事务不会自己重试,而STM事务在发生冲突是会多次重试,必须要保证业务代码是可重试的,且必须有明确的失败条件(例如判断账户余额是否够转账)。

参考文章:
https://github.com/etcd-io/etcd/blob/master/Documentation/learning/api.md
https://coreos.com/blog/transactional-memory-with-etcd3.html
https://yuerblog.cc/2017/12/10/principle-about-etcd-v3/

你可能感兴趣的:(使用etcd,是如何实现ACID事务的?)