事务:etcd 中如何实现事务(下)?

1,935 阅读7分钟

我们在前面介绍了数据库中的事务定义,以及 etcd 中的事务实现。事务降低了客户端应用编码的复杂度,接着通过一个转账的案例来演示 etcd 基于乐观锁如何实现事务。

因此本文将会继续上一篇的内容,带着你基于 STM 进行改进的转账案例,以及介绍 etcd STM 微事务及其几种隔离机制。

使用 STM 实现转账

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

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()

该函数是根据隔离级别定义的。下面我们将了解 etcd 在 STM 的封装基础上如何实现事务及其隔离级别。

etcd 微事务

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

数据库有如下几种事务隔离级别 (Transaction Isolation Levels):

  • 未提交读(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。下面分别介绍这几种隔离级别。

ReadCommitted

读提交是指,一个事务提交之后,它做的变更才会被其他事务看到。只允许获取已经提交的数据。比如事务 A 和事务 B 同时进行,事务 A 进行 +1 操作,此时,事务 B 无法看到这个数据项在事务A操作过程中的所有中间值,只能看到最终的 10。

由于 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

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

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

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

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

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

小结

本文主要介绍了基于 STM 改进的转账案例。最后介绍了 etcd STM 微事务及其几种隔离机制:ReadCommitted、RepeatableReads、Serializable 和 SerializableSnapshot。etcd 中默认就是 SerializableSnapshot级别,属于最高的隔离级别。