解析 etcd 微事务

1,992 阅读5分钟

一起养成写作习惯!这是我参与「掘金日新计划 · 4 月更文挑战」的第3天,点击查看活动详情

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 事务在发生冲突是会多次重试,必须要保证业务代码是可重试的,且必须有明确的失败条件(例如判断账户余额是否够转账)。

小结

本文首先介绍了数据库中的事务定义,以及 etcd 中的事务实现。事务降低了客户端应用编码的复杂度,接着通过一个转账的案例来演示 etcd 基于乐观锁如何实现事务,之后介绍了基于 STM 改进的转账案例。最后介绍了 etcd STM 微事务及其几种隔离机制。