etcd 系列之事务:etcd 中如何实现事务?

4,318 阅读6分钟

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

你好,我是 aoho,今天我和你分享的主题是事务:etcd 中如何实现事务?

我们在前面课时介绍了 etcd 存储、etcd-raft 模块以及 MVCC 多版本控制实现的原理。今天将会介绍 etcd 中事务的实现。

在我们的业务中,希望能够实现在无论什么样的故障场景下,一组操作要么同时完成,要么都失败。etcd 实现了在一个事务中,原子地执行冲突检查、更新多个 keys 的值。除此之外,etcd 将底层 MVCC 机制的版本信息暴露出来,基于版本信息封装出了一套基于乐观锁的事务框架 STM,并实现了不同的隔离级别。因此本课时将会来带着你了解 etcd 事务的概念、基本使用和 STM 事务的隔离级别。

什么是事务?

事务通常就是指数据库事务。事务具有 ACID 特性,即原子性、一致性、隔离性和持久性。

  • 原子性(Atomicity):事务作为一个整体被执行,包含在其中的对数据库的操作要么全部被执行,要么都不执行。
  • 一致性(Consistency):事务应确保数据库的状态从一个一致状态转变为另一个一致状态。一致状态的含义是数据库中的数据应满足完整性约束。
  • 隔离性(Isolation):多个事务并发执行时,一个事务的执行不应影响其他事务的执行。
  • 持久性(Durability):一个事务一旦提交,他对数据库的修改应该永久保存在数据库中。

常见的关系型数据库如 MySQL ,其 InnoDB 事务的实现基于锁实现数据库事务。事务操作执行时,需要获取对应数据库记录的锁,才能进行操作;如果发生冲突,事务会阻塞,某些情况下可能会死锁。在整个事务执行的过程中,客户端与 MySQL 多次交互,MySQL 为客户端维护事务资源,直至事务提交。

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

etcd 使用了不到四百行的代码实现了迷你事务,其对应的语法为 If-Then-Else。etcd 允许用户在一次修改中批量执行多个操作,即这一组操作被绑定成一个原子操作,并共享同一个修订号。写法类似 CAS,如下所示:

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

上面的实现其实很好理解,如果 If 冲突判断语句为真,对应返回值为 true,Then 中的语句将会被执行,否则执行 else 中的逻辑。

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

etcd 事务的使用示例

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

在 etcd 中的实现代码如下所示:

func txnTransfer(etcd *v3.Client, sender, receiver string, amount uint) 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 uint) (bool, error) {
	// 第一个事务,利用事务的原子性,同时获取发送和接收者的余额以及 ModRevision
	getresp, err := etcd.Txn(context.TODO()).Then(v3.OpGet(sender), v3.OpGet(receiver)).Commit()
	if err != nil {
		return false, err
	}
	senderKV := getresp.Responses[0].GetResponseRange().Kvs[0]
	receiverKV := getresp.Responses[1].GetResponseRange().Kvs[1]
	senderNum, receiverNum := toUInt64(senderKV.Value), toUInt64(receiverKV.Value)
	// 验证账户余额是否充足
	if senderNum < amount {
		return false, fmt.Errorf("资金不足")
	}
	// 发起转账事务,冲突判断 ModRevision 是否发生变化
	txn := etcd.Txn(context.TODO()).If(
		v3.Compare(v3.ModRevision(sender), "=", senderKV.ModRevision),
		v3.Compare(v3.ModRevision(receiver), "=", receiverKV.ModRevision))
	txn = txn.Then(
		v3.OpPut(sender, fromUint64(senderNum-amount)), // 更新发送者账户余额
		v3.OpPut(receiver, fromUint64(receiverNum-amount))) // 更新接收者账户余额
    resp, err := txn.Commit()         // 提交事务
	if err != nil {
		return false, err
	}
	return resp.Succeeded, nil
}

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

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

使用 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 的封装基础上如何实现事务及其隔离级别。