ETCD(10):事务

393 阅读2分钟

1. 事务

etcd 中的事务则是基于 CAS(Compare and Swap,即比较再交换) 方式。

其对应的语法为 If-Then-Else。etcd 允许用户在一次修改中批量执行多个操作,即这一组操作被绑定成一个原子操作,并共享同一个修订号。如下所示:

Txn().If(cond1, cond2, ...).Then(op1, op2, ...,).Else(op1, op2)

如果 If 冲突判断语句为真,对应返回值为 true,Then 中的语句将会被执行,否则执行 else 中的逻辑。

在 etcd 事务执行过程中,客户端与 etcd 服务端之间没有维护事务会话。冲突判断(If)和执行过程 Then/Else作为一个原子过程来执行 If-Then-Else,因此 etcd 事务不会发生阻塞,无论成功还是失败都会返回,当发生冲突导致执行失败时,需要应用进行重试。业务代码需要考虑这部分的重试逻辑。

2. 使用示例

演示转账的过程,发送者向接收者发起转账事务。etcd 的事务基于乐观锁来检测冲突并重试,检测冲突时使用了 ModRevision 进行校验,该字段表示某个 key 上一次被更改时,全局的版本是多少。在 etcd 中的实现代码如下所示:

import  v3 "go.etcd.io/etcd/clientv3"
func main() {
	config := v3.Config{Endpoints: []string{"localhost:2379"}, DialTimeout: 5 * time.Second}
	client, err := v3.New(config)
	if err != nil{
		fmt.Println(err)
                os.Exit(-1)
	}
        // 需现在etcd中存储数据,本案例为: sender:10000, receiver:2000,sender向receiver转账1000
	err = txnTransfer(client, "sender", "receiver", 1000)
	if err != nil{
		fmt.Println(err.Error())
	}else{
		fmt.Println("转账成功")
	}
}
func txnTransfer(etcd *v3.Client, sender, receiver string, amount uint64)error{
	for{
		if ok, err := doTxn(etcd, sender, receiver, amount); err != nil{
			return err
		}else if ok{
			return nil
		}
	}
}

func doTxn(etcd *v3.Client, sender, receiver string, amount uint64)(bool, error){
	getresp, err := etcd.Txn(context.Background()).Then(v3.OpGet(sender), v3.OpGet(receiver)).Commit()
	if err != nil{
		fmt.Println("获取sender/receiver失败")
		return false, err
	}
	fmt.Print("获取senderKV:")
	senderKV := getresp.Responses[0].GetResponseRange().Kvs[0]
	fmt.Print("成功\n获取receiverKV")
	receiverKV := getresp.Responses[1].GetResponseRange().Kvs[0]
	fmt.Print("成功\n")
	senderNum, receiverNum := toUInt64(senderKV.Value), toUInt64(receiverKV.Value)
	fmt.Println("sender余额:", senderNum)
	fmt.Println("receiver余额:", receiverNum)
	if senderNum < amount{
		return false, fmt.Errorf("资金不足")
	}
	txn := etcd.Txn(context.Background()).If(
		v3.Compare(v3.ModRevision(sender), "=", senderKV.ModRevision),
		v3.Compare(v3.ModRevision(receiver), "=", receiverKV.ModRevision))
	txn = txn.Then(
		v3.OpPut(sender, strconv.FormatUint(senderNum - amount, 10)),
		v3.OpPut(receiver, strconv.FormatUint(receiverNum + amount, 10)))
	resp, err := txn.Commit()
	if err != nil{
		return false, err
	}
	return resp.Succeeded, nil
		
}

func toUInt64(bs []byte)uint64{
	str := string(bs)
	ret, _ := strconv.ParseUint(str, 10, 64)
	return ret
}

etcd 事务的实现基于乐观锁,涉及到两次事务操作,第一次事务利用原子性来同时获取发送方和接收方的当前账户金额;第二次事务发起转账操作,冲突检测 ModRevision 是否发生变化,如果没有变化则正常提交事务。若发生了冲突,则需要进行重试。

如上过程的实现较为繁琐,除了业务逻辑,还有大量的代码用来判断冲突以及重试。因此,etcd 社区基于事务特性,实现了一个简单的事务框架 STM,构建了各个事务隔离级别类,下面我们看看基于 STM 框架如何实现 etcd 事务。

3. STM

为了简化 etcd 事务实现的过程呢,etcd clientv3 提供了 STM,即软件事务内存,帮我们自动处理这些繁琐的过程。使用 STM 的转账业务代码如下:

// 注意这里导入的包和之前的不同,但创建client的过程是一样的
import v3 "github.com/coreos/etcd/clientv3" 

func txnStmTransfer(cli *v3.Client, from, to string, amount uint) error {
	// NewSTM 创建了一个原子事务的上下文,并把我们的业务代码作为一个函数传进去
	_, err := concurrency.NewSTM(cli, func(stm concurrency.STM) error {
		// stm.Get 封装好了事务的读操作
		senderNum := toUint64(stm.Get(from))
		receiverNum := toUint64(stm.Get(to))
		if senderNum < amount {
			return fmt.Errorf("余额不足")
		}
		// stm.Put封装好了事务的写操作
		stm.Put(to, fromUint64(receiverNum + amount))
		stm.Put(from, fromUint64(senderNum - amount))
		return nil
	})
	return err
}

上述基于 STM 实现的转账业务流程,我们只要关注转账逻辑的实现即可,事务相关的其他操作由 STM 完成。

STM 的使用特别简单,只需把业务相关的代码封装成可重入的函数传给 stm,然后 STM 会处理好其余所有的细节。STM 对象在内部构造 txn 事务,把我们编写的业务函数翻译成 If-Then,自动提交事务,处理失败重试等工作,直到事务执行成功,或者出现异常,重试亦不能解决。

// 位于 clientv3/concurrency/stm.go:89
func NewSTM(c *v3.Client, apply func(STM) error, so ...stmOption) (*v3.TxnResponse, error) {
   opts := &stmOptions{ctx: c.Ctx()}
   for _, f := range so {
      f(opts)
   }
   if len(opts.prefetch) != 0 {
      f := apply
      apply = func(s STM) error {
         s.Get(opts.prefetch...)
         return f(s)
      }
   }
   return runSTM(mkSTM(c, opts), apply)
}

根据源码可以知道,NewSTM 首先创建一个 stm,然后执行 stm,代码如下所示:

func runSTM(s STM, apply func(STM) error) (*v3.TxnResponse, error) {
	outc := make(chan stmResponse, 1)
	go func() {
		defer func() {
			if r := recover(); r != nil {
				e, ok := r.(stmError)
				if !ok {
					// client apply panicked
					panic(r)
				}
				outc <- stmResponse{nil, e.err}
			}
		}()
		var out stmResponse
		for {
			s.reset()
			if out.err = apply(s); out.err != nil {
				break
			}
			if out.resp = s.commit(); out.resp != nil {
				break
			}
		}
		outc <- out
	}()
	r := <-outc
	return r.resp, r.err
}

runstm 主要是循环执行以下三个步骤:

  • 重置 stm,清空 STM 的读写缓存
  • 执行事务操作,apply 函数
  • 提交事务

etcd client 最终执行提交事务的操作:

txnresp, err := s.client.Txn(s.ctx).If(s.conflicts()...).Then(s.wset.puts()...).Commit()

4. 隔离级别

etcd 的事务可以看做是一种“微事务”,在它之上,可以构建出各种隔离级别的事务。

数据库有如下几种事务隔离级别:

  • 未提交读(Read Uncommitted):能够读取到其他事务中还未提交的数据,这可能会导致脏读的问题。
  • 读已提交(Read Committed):只能读取到已经提交的数据,即别的事务一提交,当前事务就能读取到被修改的数据,这可能导致不可重复读的问题。
  • 可重复读(Repeated Read):一个事务中,同一个读操作在事务的任意时刻都能得到同样的结果,其他事务的提交操作对本事务不会产生影响。
  • 串行化(Serializable):串行化的执行可能冲突的事务,即一个事务会阻塞其他事务。它通过牺牲并发能力来换取数据的安全,属于最高的隔离级别。

而 etcd clientv3 实现了四种事务模型,位于 clientv3/concurrency/stm.go 中,分别为 SerializableSnapshot、Serializable、RepeatableReads 和 ReadCommitted。

// 位于 clientv3/concurrency/stm.go:25
type STM interface {
	// Get 返回键的值,并将该键插入 txn 的 read set 中。 如果 Get 失败,它将以错误中止事务,没有返回。
	Get(key ...string) string
	// Put 在 write set 中增加键值对
	Put(key, val string, opts ...v3.OpOption)
	// Rev 返回 read set 中某个键指定的版本号
	Rev(key string) int64
	// Del 删除某个键
	Del(key string)

	// commit 尝试提交事务到 etcd server
	commit() *v3.TxnResponse
	reset()
}
// STM 是软件事务存储的接口。其中定义了 Get、Put、Rev、Del、commit、reset 等接口方法。
const (

	SerializableSnapshot Isolation = iota
	// 串行化
	Serializable
	// 可重复读
	RepeatableReads
	// 读提交
	ReadCommitted
)

STM 的事务级别通过 stmOption 指定,默认就是 SerializableSnapshot。下面分别介绍这几种隔离级别。

4.1 ReadCommitted

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

  • Get 操作:从 etcd 读取 keys,就像普通的 kv 操作一样。
  • If 条件:None,没有任何冲突检测。

4.2 RepeatableReads

  • Get 操作:从 etcd 读取 keys,就像普通的 kv 操作一样。第一次 Get 后,在事务中缓存,后续不再从 etcd 读取。
  • If 条件:在事务提交时,事务中 Get 的 keys 没有被改动过。

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

4.3 Serializable

  • Get 操作:事务中的第一个 Get 操作发生时,保存服务器返回的当前 revision;后续对其他 keys 的 Get 操作,指定获取 revision 版本的 value。
  • If 条件:在事务提交时,事务中 Get 的 keys 没有被改动过。

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

4.3 SerializableSnapshot

SerializableSnapshot 提供可序列化的隔离,并检查写冲突。默认就是采用这种隔离级别。

  • Get 操作:事务中的第一个 Get 操作发生时,保存服务器返回的当前 revision;后续对其他 keys 的 Get 操作,指定获取 revision 版本的 value。
  • If 条件:在事务提交时,事务中 Get 的 keys 没有被改动过,事务中要修改的 keys 也没有被改动过。

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