我们在前面介绍了数据库中的事务定义,以及 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级别,属于最高的隔离级别。