众所周知,etcd 的数据模型是建立在 MVCC 基础上的(如果你不知,那一定是没看过我的这篇博客🙃)。在当前的实现中1,etcd 不仅提供了原子性的事务操作,还对外暴露了实现这些原子操作的 MVCC 版本信息。要知道,在大部分使用了 MVCC 机制的数据库中,你很难找到底层 MVCC 的影子,因为上层的数据库都把它屏蔽掉了,用户只能使用数据库封装好的一些能力。而在 etcd 中,最底层的版本信息是公开的,这就给我们提供了很多种可能性。比如在官方的客户端 clientv3 中,有一个 concurrency 包,这里面封装好的能力就足以覆盖 etcd 常见的几种使用场景:
- mutex.go 一个分布式锁的实现
- election.go 一个分布式选举的实现,多用来实现 Active-Standby 的高可用模式
- stm.go 一个在客户端实现的事务框架,能将多条业务语句打包成一个原子操作
其中的 stm (software transactional memory) 实现的最有意思,它将原子性的事务实现得有模有样,还允许指定不同的隔离级别,就像在使用那些重量级的数据库一样。这让人不禁好奇,在拥有 MVCC 原语的基础上,它是怎样用 380 行代码实现出重量级的隔离效果的?
预备知识
在进入代码细节之前,我们先要了解常见的几种隔离级别是什么,以及 etcd 暴露出来的版本信息有哪些?
事务隔离级别
数据库的几种事务隔离级别 (Transaction Isolation Levels)一直是面试的热点,几乎逢面必问(不要问我怎么知道的)。当然这种理论知识稍微理解一下也不难记住:
- 未提交读(Read Uncommitted):能够读取到其他事务中还未提交的数据,这可能会导致脏读的问题。
- 读已提交(Read Committed):只能读取到已经提交的数据,即别的事务一提交,当前事务就能读取到被修改的数据,这可能导致不可重复读的问题。
- 可重复读(Repeated Read):一个事务中,同一个读操作在事务的任意时刻都能得到同样的结果,其他事务的提交操作对本事务不会产生影响。
- 串行化(Serializable):串行化的执行可能冲突的事务,即一个事务会阻塞其他事务。它通过牺牲并发能力来换取数据的安全,属于最高的隔离级别。
etcd 中的版本
MVCC 全称是多版本并发控制,它保留每一次的修改,并用版本号来追踪这些修改。所以相应的 etcd 里面也有很多版本的概念,我们先来理清楚它们之间的区别2:
Revision
是一个全局的状态,每一次有更改操作(Put, Delete, Txn)时,Revision 的值都会增加。它的作用有点像一个逻辑时钟,全局递增不重复。我们可以通过 Response.Header.Revision 获取到当前的值。CreateRevision
仅作用于单个 key,它记录的是这个 key 被创建时的全局时钟(Revision)。一般通过 KeyValue.CreateRevision 来获取。ModRevision
也是单个 key 才有的概念,它表示这个 key 上一次被更改时,全局的版本是多少。也是通过 KeyValue.ModRevision 获取到。Version
仅仅是个计数器,与全局的 Revision 无关,代表了当前的 key 从创建到现在被更改了多少次。
这里的概念有点像 Linux 文件系统里的各种时间。整个系统的时钟是一直往前走的(Revision),我们在08:01创建了一个文件my.file
(CreateRevision),08:07分又更改了它(ModRevision)。系统会记录下这几个时间点:
|
|
有了这个 Revision 之后,我们就像有了时光机一样,可以获取到系统在过去某个时间点的状态。比如GET
函数就支持传入一个WithRev(revision)
参数,来得到指定的 key 在指定的 revision 时的值。
etcd 中的微事务
etcd v3 中引入了一个微事务的概念(mini-transaction),允许用户在一次修改中批量执行多个操作,这意味着这一组操作被绑定成一个原子操作,并共享同一个修订号。它的写法有点类似一个 CAS :
|
|
如果If
语句中的条件全部为真,则整个事务的返回值为true
,并且Then
中的操作会被执行,否则返回false
并执行Else
中的操作。
使用姿势
在并发编程领域,我们经常用银行转账的例子来说明原子操作的重要性。为了确保并发安全,要么使用一个悲观锁来避免冲突,要么就用乐观锁来检测冲突并重试。现在在 etcd 的场景下,有了mini-transaction
和ModRevision
的加持,我们很容易就想到用它们来实现一把乐观锁:
根据这个思路写出来的代码:
|
|
可以看到,这部分代码除了转账的业务逻辑外,大部分都是实现乐观锁的样板代码,包括两次事务操作、冲突检测、冲突发生时的重试等,写起来比较繁琐。幸好 stm.go 把这部分逻辑进行了封装,抽象出了一个公共的事务处理框架,我们只需把业务相关的代码传给 stm,它会做事务管理,并适时调用我们的业务代码。现在来看看用 stm 重构后的代码:
|
|
眼花缭乱的ModRevision
不见了,事务处理也不见了,整段代码变得清晰明了了很多。所以 stm 的使用特别简单——我们只需把业务相关的代码封装成可重入的函数传给 stm,然后 stm 会处理好其余所有的细节,cool~
内部实现
先来看看传给业务程序的concurrency.STM
长什么样:
|
|
原来它是一个接口,提供了对某个 key 的 CURD 操作。而实现了 STM 接口的总共有两个类:stm 和 stmSerializable。具体选择哪个实现,则是由我们指定的隔离级别决定的:
|
|
可以看到目前共支持了4种隔离级别,并且不同的隔离级别除了使用不同的 STM 实现外,他们的conflicts
函数也不一样,这个我们待会再讲。先挑一个典型的RepeatableReads
来看看它的实现:
RepeatableReads
内部的状态:
|
|
这里的两个字段readSet
和writeSet
,底层类型是一个map
,用来缓存在当前事务中进行过的读写操作。conflicts
是在上面的工厂函数mkSTM
中赋值的,用来在事务提交的时候做冲突检测。
读写
事务中的读写调用的是stm.Get
和stm.Put
函数。简化后的逻辑大概是这样的:
|
|
对于读请求,先以本地最新的更新为准,如果之前从没处理过这个 key ,则到 etcd 中查询,并缓存下来,这样就可以做到可重复读 (RepeatableReads)。对于写操作,都是先写进本地缓存,直到事务提交时才真正写到 etcd 里。
提交
当我们的业务代码调用结束后,stm 就来尝试提交事务了:
|
|
提交事务就是利用 etcd 的Txn
,把writeSet
中的数据写出去,这里要注意的是If
中的冲突检测,就是我们在构建的时候指定的conflicts
函数:
|
|
看来conflicts
函数中真正干活的是readSet.cmps()
:
|
|
它的逻辑就是构造一堆比较操作符,确保readSet
中的数据,在读取进缓存之后再也没有被更改过,即 ModRevision 没有变。
所以梳理一下,RepeatableReads
实现的两个关键点:
- 用
readSet
缓存已经读过的数据,这样下次再读取同样数据的时候才能得到同样的结果,这确保了可重复读。 - 用
readSet
数据的ModRevision
做冲突检测,确保本事务读到的数据都是最新的。
ReadCommitted
ReadCommitted
跟RepeatableReads
的实现类似,唯一不同的是它的冲突检测函数:
|
|
可以看到,其实ReadCommitted
啥冲突也不检测,它只是确保自己读到的是别人已经提交的数据,其他什么保障也没有啊?!
Serializable
Serializable
使用的是stmSerializable
类,它与上面两个使用的stm
一个主要不同点在于,它第一次读完了会把当时的版本号给记录下来,下次再向 etcd 发出读请求的时候会带上这个版本号,表示我就要当时那个版本的数据,这确保了该事务所有的读操作读到的都是同一时刻的内容。这就相当于在事务开始时,对 etcd 做了一个快照,这样它读取到的数据就不会受到其他事务的影响,从而达到事务串行化(Serializable)执行的效果。
|
|
可以看到,在第一个读操作完成后,我们就设置了两个OpOption
给后续的读操作使用。第一个 Option 指定了要读的版本号,第二个 Option 是告诉 etcd server 直接把它当前所知道的结果返回,而不需要通过 raft 协议跟别人确认一下它目前的结果是不是最新的,这提升了读取时的性能同时也能满足该场景下的需求(默认不加这个参数时是线性 linearizable 一致性的读取策略,可靠但是慢)。
SerializableSnapshot
SerializableSnapshot
和Serializable
也是类似,不同的是它的隔离级别更严格些:
|
|
即,它不仅要确保我读取过的数据是最新的,也要确保我要写入的数据也没有被别人更改过,这是最高的隔离级别,也是 stm 的默认隔离级别。在传统数据库中,要实现这样一个隔离级别,一般通过读写锁来限制竞态冲突,而这里则是通过极其严格的冲突检测,稍微有点不一样的地方它就认为发生冲突了,需要 stm 去重试。
区别汇总
综上所述,这四种隔离级别在实现上主要有两点区别:读取数据的版本和冲突检测的方法。如果我们将事务刚开始时的版本号称为FIRST_REV
,将读操作真正发生时候的版本号称为CURRENT_REV
,将 key 的 ModRevision 简称为MOD_REV
,那它们的区别可以用一张表总结出来:
Isolation Level | Read Revision | Conflict Detection |
---|---|---|
RepeatableReads | CURRENT_REV | readSet: MOD_REV == CURRENT_REV |
ReadCommitted | CURRENT_REV | nil |
Serializable | FIRST_REV | readSet: MOD_REV == FIRST_REV |
SerializableSnapshot | FIRST_REV | readSet: MOD_REV == FIRST_REV writeSet: MOD_REV < FIRST_REV |
而对于 STM 接口的两个实现stm
和stmSerializable
,它们的区别在于,读取操作是否会受到其他事务的影响。如下图所示:
总结
本文以 etcd 为例,介绍了事务隔离机制的实现。当然,这里的实现跟传统关系型数据库(MySQL、PostgreSQL)的实现还是有一定区别的。关系型数据库一般通过给一个 row 增加两个字段(transaction-created, transaction-deleted)来做数据可见性的隔离,再辅以悲观锁来做并发控制,而 etcd 里的实现类似于事务型内存,在一个事务的上下文里先缓存业务代码的所有读、写请求,在真正提交的时候根据不同的隔离级别做冲突检测,从而决定是否重试。可以看到,底层暴露出来的 MVCC 原语具有极强的表达能力,我们在客户端就可以做到原本在服务端才能做到的事情。
另外,就 etcd 常见的使用场景官方的 SDK 中都有现成的实现(参见 concurrency, contrib/recipes),希望大家不要再重复造轮子了,官方的轮子又大又圆,而自己造的轮子往往都是 ctrl+c、ctrl+v 来的代码,到处是坑。多读读源代码,能让你少趟很多坑。