问题描述
在 etcd v2 的时候, etcd 提供了 CAS(Compare and swap),然而其只支持单 key,不支持多 key,因此无法满足类似转账场景的需求。严格意义上说 CAS 称不上事务,无法实现事务的各个隔离级别;
解决方案
etcd v3 为了解决多 key 的原子操作问题,提供了全新迷你事务 API,同时基于 MVCC 版本号,它可以实现各种隔离级别的事务;
事务,它就是为了简化应用层的编程模型而诞生的,etcd 实现事务 ACID 特性的;
原理简述
基础使用
基本结构如下:
client.Txn(ctx).If(cmp1, cmp2, ...).Then(op1, op2, ...,).Else(op1, op2, …)
从上面结构中你可以看到,事务 API 由 If 语句、Then 语句、Else 语句组成,这与我们平时常见的 MySQL 事务完全不一样;
它的基本原理是,在 If 语句中,你可以添加一系列的条件表达式,若条件表达式全部通过检查,则执行 Then 语句的 get/put/delete 等操作,否则执行 Else 的 get/put/delete 等操作;
那么 If 语句支持哪些检查项:
If 语句通过以上 MVCC 版本号、value 值、各种比较运算符 (等于、大于、小于、不等于),实现了灵活的比较的功能,满足你各类业务场景诉求;
整体流程
上图是 etcd 事务的执行流程,当你通过 client 发起一个 txn 转账事务操作时,通过 gRPC KV Server、Raft 模块处理后,在 Apply 模块执行此事务的时候,它首先对你的事务的 If 语句进行检查,也就是 ApplyCompares 操作,如果通过此操作,则执行 ApplyTxn/Then 语句,否则执行 ApplyTxn/Else 语句;
在执行以上操作过程中,它会根据事务是否只读、可写,通过 MVCC 层的读写事务对象,执行事务中的 get/put/delete 各操作,也就是 MVCC 对 key 的读写原理;
特性特征
WIP
应用场景
WIP
事务 ACID 特性
ACID 是衡量事务的四个特性,由原子性(Atomicity)、一致性(Consistency)、隔离性(Isolation)、持久性(Durability)组成。接下来我就为你分析 ACID 特性在 etcd 中的实现;
原子性
事务的原子性(Atomicity)是指在一个事务中,所有请求要么同时成功,要么同时失败。比如在我们的转账案例中,是绝对无法容忍 Alice 账号扣款成功,但是 Bob 账号资金到账失败的场景;
如上图转账事务流程图所示,etcd 在执行一个事务过程中,任何时间点都可能会出现节点 crash 等异常问题。我在图中给你标注了两个关键的异常时间点,它们分别是 T1 和 T2。接下来我分别为你分析一下 etcd 在这两个关键时间点异常后,是如何保证事务的原子性和持久性的;
T1 时间点是在 Alice 账号扣款 100 元完成时,Bob 账号资金还未成功增加时突然发生了 crash;
T2 时间点是在 MVCC 写事务完成转账,server 返回给 client 转账成功后,boltdb 的事务提交 goroutine,批量将事务持久化到磁盘中时发生了 crash。这时 etcd 又是如何保证原子性和持久性的呢?
持久性
持久性(Durability)是指事务一旦提交,其所做的修改会永久保存在数据库;
一致性
在软件系统中,到处可见一致性(Consistency)的表述,其实在不同场景下,它的含义是不一样的;
首先分布式系统中多副本数据一致性,它是指各个副本之间的数据是否一致,比如 Redis 的主备是异步复制的,那么它的一致性是最终一致性的;
其次是 CAP 原理中的一致性是指可线性化。核心原理是虽然整个系统是由多副本组成,但是通过线性化能力支持,对 client 而言就如一个副本,应用程序无需关心系统有多少个副本;
然后是一致性哈希,它是一种分布式系统中的数据分片算法,具备良好的分散性、平衡性;
最后是事务中的一致性,它是指事务变更前后,数据库必须满足若干恒等条件的状态约束,一致性往往是由数据库和业务程序两方面来保障的;
在 Alice 向 Bob 转账的案例中有哪些恒等状态呢?
很明显,转账系统内的各账号资金总额,在转账前后应该一致,同时各账号资产不能小于 0;
图中有两个并发的转账事务,Mike 向 Bob 转账 100 元,Alice 也向 Bob 转账 100 元,按照我们上面的事务实现,从下图可知转账前系统总资金是 600 元,转账后却只有 500 元了,因此它无法保证转账前后账号系统内的资产一致性,导致了资产凭空消失,破坏了事务的一致性;
事务一致性被破坏的根本原因是,事务中缺少对 Bob 账号资产是否发生变化的判断,这就导致账号资金被覆盖;
为了确保事务的一致性,一方面,业务程序在转账逻辑里面,需检查转账者资产大于等于转账金额。在事务提交时,通过账号资产的版本号,确保双方账号资产未被其他事务修改。若双方账号资产被其他事务修改,账号资产版本号会检查失败,这时业务可以通过获取最新的资产和版本号,发起新的转账事务流程解决;
另一方面,etcd 会通过 WAL 日志和 consistent index、boltdb 事务特性,去确保事务的原子性,因此不会有部分成功部分失败的操作,导致资金凭空消失、新增;
隔离性
ACID 中的 I 是指 Isolation,也就是事务的隔离性,它是指事务在执行过程中的可见性。常见的事务隔离级别有以下四种;
未提交读
首先是最低的事务隔离级别,未提交读。我们通过如下一个转账事务时间序列图,来分析下一个 client 能否读取到未提交事务修改的数据,是否存在脏读;
图中有两个事务,一个是用户查询 Alice 和 Bob 资产的事务,一个是我们执行 Alice 向 Bob 转账的事务;
如图中所示,若在 Alice 向 Bob 转账事务执行过程中,etcd server 收到了 client 查询 Alice 和 Bob 资产的读请求,显然此时我们无法接受 client 能读取到一个未提交的事务,因为这对应用程序而言会产生严重的 BUG。那么 etcd 是如何保证不出现这种场景呢?
我们知道 etcd 基于 boltdb 实现读写操作的:读请求由 boltdb 的读事务处理,你可以理解为快照读;写请求由 boltdb 写事务处理,etcd 定时将一批写操作提交到 boltdb 并清空 buffer;
由于 etcd 是批量提交写事务的,而读事务又是快照读,因此当 MVCC 写事务完成时,它需要更新 buffer,这样下一个读请求到达时,才能从 buffer 中获取到最新数据;
在我们的场景中,转账事务并未结束,执行 put Alice 为 100 的操作不会回写 buffer,因此避免了脏读的可能性。用户此刻从 boltdb 快照读事务中查询到的 Alice 和 Bob 资产都为 200;
从以上分析可知,etcd 并未使用悲观锁来解决脏读的问题,而是通过 MVCC 机制来实现读写不阻塞,并解决脏读的问题;
已提交读、可重复读
比未提交读隔离级别更高的是已提交读,它是指在事务中能读取到已提交数据,但是存在不可重复读的问题。已提交读,也就是说你每次读操作,若未增加任何版本号限制,默认都是当前读,etcd 会返回最新已提交的事务结果给你;
如何理解不可重复读呢?
在上面用户查询 Alice 和 Bob 事务的案例中,第一次查出来资产都是 200,第二次是 Alice 为 100,Bob 为 300,通过读已提交模式,你能及时获取到 etcd 最新已提交的事务结果,但是出现了不可重复读,两次读出来的 Alice 和 Bob 资产不一致;
你可以通过 MVCC 快照读,或者参考 etcd 的事务框架 STM 实现,它在事务中维护一个读缓存,优先从读缓存中查找,不存在则从 etcd 查询并更新到缓存中,这样事务中后续读请求都可从缓存中查找,确保了可重复读;
串行化快照隔离
串行化快照隔离是最严格的事务隔离级别,它是指在在事务刚开始时,首先获取 etcd 当前的版本号 rev,事务中后续发出的读请求都带上这个版本号 rev,告诉 etcd 你需要获取那个时间点的快照数据,etcd 的 MVCC 机制就能确保事务中能读取到同一时刻的数据;
同时,它还要确保事务提交时,你读写的数据都是最新的,未被其他人修改,也就是要增加冲突检测机制。当事务提交出现冲突的时候依赖 client 重试解决,安全地实现多 key 原子更新;
那么我们应该如何为上面一致性案例中,两个并发转账的事务,增加冲突检测机制呢?
核心就是我们前面介绍 MVCC 的版本号,我通过下面的并发转账事务流程图为你解释它是如何工作的;
如上图所示,事务 A,Alice 向 Bob 转账 100 元,事务 B,Mike 向 Bob 转账 100 元,两个事务同时发起转账操作;
一开始时,Mike 的版本号 (指 mod_revision) 是 4,Bob 版本号是 3,Alice 版本号是 2,资产各自 200。为了防止并发写事务冲突,etcd 在一个写事务开始时,会独占一个 MVCC 读写锁;
事务 A 会先去 etcd 查询当前 Alice 和 Bob 的资产版本号,用于在事务提交时做冲突检测。在事务 A 查询后,事务 B 获得 MVCC 写锁并完成转账事务,Mike 和 Bob 账号资产分别为 100,300,版本号都为 5;
事务 B 完成后,事务 A 获得写锁,开始执行事务;
为了解决并发事务冲突问题,事务 A 中增加了冲突检测,期望的 Alice 版本号应为 2,Bob 为 3。结果事务 B 的修改导致 Bob 版本号变成了 5,因此此事务会执行失败分支,再次查询 Alice 和 Bob 版本号和资产,发起新的转账事务,成功通过 MVCC 冲突检测规则 mod(“Alice”) = 2 和 mod(“Bob”) = 5 后,更新 Alice 账号资产为 100,Bob 资产为 400,完成转账操作;
通过上面介绍的快照读和 MVCC 冲突检测检测机制,etcd 就可实现串行化快照隔离能力;
转账案例应用
介绍完 etcd 事务 ACID 特性实现后,你很容易发现事务特性初体验中的案例问题了,它缺少了完整事务的冲突检测机制;
首先你可通过一个事务获取 Alice 和 Bob 账号的上资金和版本号,用以判断 Alice 是否有足够的金额转账给 Bob 和事务提交时做冲突检测。你可通过如下 etcdctl txn 命令,获取 Alice 和 Bob 账号的资产和最后一次修改时的版本号 (mod_revision):
$ etcdctl txn -i compares: // 对应 If 语句 value("Alice") = "200" // 判断 Alice 账号资金是否为 200 success requests (get, put, del): // 对应 Then 语句 put Alice 100 // Alice 账号初始资金 200 减 100 put Bob 300 // Bob 账号初始资金 200 加 100 failure requests (get, put, del): // 对应 Else 语句 get Alice get Bob SUCCESS OK OK
其次发起资金转账操作,Alice 账号减去 100,Bob 账号增加 100。为了保证转账事务的准确性、一致性,提交事务的时候需检查 Alice 和 Bob 账号最新修改版本号与读取资金时的一致 (compares 操作中增加版本号检测),以保证其他事务未修改两个账号的资金;
若 compares 操作通过检查,则执行转账操作,否则执行查询 Alice 和 Bob 账号资金操作,命令如下:
$ etcdctl txn -i compares: mod("Alice") = "2" mod("Bob") = "3" success requests (get, put, del): put Alice 100 put Bob 300 failure requests (get, put, del): get Alice get Bob SUCCESS OK OK
到这里我们就完成了一个安全的转账事务操作,从以上流程中你可以发现,自己从 0 到 1 实现一个完整的事务还是比较繁琐的,幸运的是,etcd 社区基于以上介绍的事务特性,提供了一个简单的事务框架STM,构建了各个事务隔离级别类,帮助你进一步简化应用编程复杂度。