ACID

事务特性初体验及 API

etcd v3 为了解决多 key 的原子操作问题,提供了全新迷你事务 API,同时基于 MVCC 版本号,它可以实现各种隔离级别的事务。它的基本结构如下:

  1. client.Txn(ctx).If(cmp1, cmp2, ...).Then(op1, op2, ...,).Else(op1, op2, …)

它的基本原理是,在 If 语句中,你可以添加一系列的条件表达式,若条件表达式全部通过检查,则执行 Then 语句的 get/put/delete 等操作,否则执行 Else 的 get/put/delete 等操作。

If 语句支持哪些检查项呢?

  1. key 的最近一次修改版本号 mod_revision,简称 mod
  2. key 的创建版本号 create_revision,简称 create
  3. key 的修改次数 version
  4. key 的 value 值

Alice 和 Bob 初始账上资金分别都为 200 元,事务首先判断 Alice 账号资金是否为 200,若是则执行转账操作,不是则返回最新资金。etcd 是如何执行这个事务的呢?这个事务实现上有哪些问题呢?

$ 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

整体流程

image.png

事务 ACID 特性

ACID 是衡量事务的四个特性,由原子性(Atomicity)、一致性(Consistency)、隔离性(Isolation)、持久性(Durability)组成。

原子性与持久性

软件系统在运行过程中会遇到各种各样的软硬件故障,如果 etcd 在执行上面事务过程中,刚执行完扣款命令(put Alice 100)就突然 crash 了,它是如何保证转账事务的原子性与持久性的呢?

image.png

T1 时间点

  1. 从前面介绍的 etcd 写原理和上面流程图我们可知,此时 MVCC 写事务持有 boltdb 写锁仅是将修改提交到了内存中,保证幂等性、防止日志条目重复执行的一致性索引 consistent index 也并未更新。同时,负责 boltdb 事务提交的 goroutine 因无法持有写锁,也并未将事务提交到持久化存储中。
  2. 因此,T1 时间点发生 crash 异常后,事务并未成功执行和持久化任意数据到磁盘上。在节点重启时,etcd server 会重放 WAL 中的已提交日志条目,再次执行以上转账事务。因此不会出现 Alice 扣款成功、Bob 到帐失败等严重 Bug,极大简化了业务的编程复杂度。

T2 时间点

我们知道一致性索引 consistent index 字段值是和 key-value 数据在一个 boltdb 事务里同时持久化到磁盘中的。若在 boltdb 事务提交过程中发生 crash 了:

  • 简单情况是 consistent index 和 key-value 数据都更新失败。那么当节点重启,etcd server 重放 WAL 中已提交日志条目时,同样会再次应用转账事务到状态机中,因此事务的原子性和持久化依然能得到保证。
  • 更复杂的情况是,当 boltdb 提交事务的时候,会不会部分数据提交成功,部分数据提交失败呢?这个问题,我将在下一节课通过深入介绍 boltdb 为你解答。

一致性

在软件系统中,到处可见一致性(Consistency)的表述,其实在不同场景下,它的含义是不一样的。

  • 分布式系统中多副本数据一致性,它是指各个副本之间的数据是否一致,比如 Redis 的主备是异步复制的,那么它的一致性是最终一致性的。
  • CAP 原理中的一致性是指可线性化。核心原理是虽然整个系统是由多副本组成,但是通过线性化能力支持,对 client 而言就如一个副本,应用程序无需关心系统有多少个副本。
  • 一致性哈希,它是一种分布式系统中的数据分片算法,具备良好的分散性、平衡性。
  • 事务中的一致性,它是指事务变更前后,数据库必须满足若干恒等条件的状态约束,一致性往往是由数据库和业务程序两方面来保障的

在 Alice 向 Bob 转账的案例中有哪些恒等状态呢?

  • 转账系统内的各账号资金总额,在转账前后应该一致,同时各账号资产不能小于 0。

两个并发转账事务的流程图:

  • 图中有两个并发的转账事务,Mike 向 Bob 转账 100 元,Alice 也向 Bob 转账 100 元,按照我们上面的事务实现,从下图可知转账前系统总资金是 600 元,转账后却只有 500 元了,因此它无法保证转账前后账号系统内的资产一致性,导致了资产凭空消失,破坏了事务的一致性。
  • 事务一致性被破坏的根本原因是,事务中缺少对 Bob 账号资产是否发生变化的判断,这就导致账号资金被覆盖。

image.png

为了确保事务的一致性,一方面,业务程序在转账逻辑里面,需检查转账者资产大于等于转账金额。在事务提交时,通过账号资产的版本号,确保双方账号资产未被其他事务修改。若双方账号资产被其他事务修改,账号资产版本号会检查失败,这时业务可以通过获取最新的资产和版本号,发起新的转账事务流程解决

根据上下文, etcd 只是提供了多版本和并发控制, 而一致性仍需程序进行判断.

另一方面,etcd 会通过 WAL 日志和 consistent index、boltdb 事务特性,去确保事务的原子性,因此不会有部分成功部分失败的操作,导致资金凭空消失、新增。

隔离性

指事务在执行过程中的可见性。

常见的事务隔离级别有以下四种:

  1. 读未提交(Read UnCommitted),也就是一个 client 能读取到未提交的事务
  2. 读已提交(Read Committed),指的是只能读取到已经提交的事务数据
  3. 可重复读(Repeated Read),它是指在一个事务中,同一个读操作 get Alice/Bob 在事务的任意时刻都能得到同样的结果,其他修改事务提交后也不会影响你本事务所看到的结果。
  4. 最后是串行化(Serializable),它是最高的事务隔离级别,读写相互阻塞,通过牺牲并发能力、串行化来解决事务并发更新过程中的隔离问题

对于串行化我要和你特别补充一点,很多人认为它都是通过读写锁,来实现事务一个个串行提交的,其实这只是在基于锁的并发控制数据库系统实现而已。为了优化性能,在基于 MVCC 机制实现的各个数据库系统中,提供了一个名为“可串行化的快照隔离”级别,相比悲观锁而言,它是一种乐观并发控制,通过快照技术实现的类似串行化的效果,事务提交时能检查是否冲突。

未提交读

image.png

etcd 如何避免读未提交?

  1. etcd 基于 boltdb 实现读写操作的,读请求由 boltdb 的读事务处理,你可以理解为快照读。写请求由 boltdb 写事务处理,etcd 定时将一批写操作提交到 boltdb 并清空 buffer。
  2. 由于 etcd 是批量提交写事务的,而读事务又是快照读,因此当 MVCC 写事务完成时,它需要更新 buffer,这样下一个读请求到达时,才能从 buffer 中获取到最新数据。(“MVCC 写事务完成”: 猜测其不代表 boltdb 写事务被提交)
  3. 在我们的场景中,转账事务并未结束,执行 put Alice 为 100 的操作不会回写 buffer,因此避免了脏读的可能性。用户此刻从 boltdb 快照读事务中查询到的 Alice 和 Bob 资产都为 200。

已提交读、可重复读

  • 已提交读: 每次都读最新的且已提交的数据
  • 可重复读: 在已提交读的基础上, 读时使用固定版本号

如何实现可重复读呢?

  • 你可以通过 MVCC 快照读,或者参考 etcd 的事务框架 STM 实现,它在事务中维护一个读缓存,优先从读缓存中查找,不存在则从 etcd 查询并更新到缓存中,这样事务中后续读请求都可从缓存中查找,确保了可重复读。

串行化快照隔离

串行化快照隔离是最严格的事务隔离级别,它是指在在事务刚开始时,首先获取 etcd 当前的版本号 rev,事务中后续发出的读请求都带上这个版本号 rev,告诉 etcd 你需要获取那个时间点的快照数据,etcd 的 MVCC 机制就能确保事务中能读取到同一时刻的数据。(类似可重复读)

同时,它还要确保事务提交时,你读写的数据都是最新的,未被其他人修改,也就是要增加冲突检测机制。当事务提交出现冲突的时候依赖 client 重试解决,安全地实现多 key 原子更新。(串行保证)

如何为上面一致性案例中,两个并发转账的事务,增加冲突检测机制呢?

  • 核心就是我们前面介绍 MVCC 的版本号,我通过下面的并发转账事务流程图为你解释它是如何工作的。

image.png

  1. 一开始时,Mike 的版本号 (指 mod_revision) 是 4,Bob 版本号是 3,Alice 版本号是 2,资产各自 200。为了防止并发写事务冲突,etcd 在一个写事务开始时,会独占一个 MVCC 大写锁
  2. 事务 A 会先去 etcd 查询当前 Alice 和 Bob 的资产版本号,用于在事务提交时做冲突检测。在事务 A 查询后,事务 B 获得 MVCC 写锁并完成转账事务,Mike 和 Bob 账号资产分别为 100,300,版本号都为 5。(为什么都是5?)
  3. 事务 B 完成后,事务 A 获得写锁,开始执行事务。
  4. 为了解决并发事务冲突问题,事务 A 中增加了冲突检测,期望的 Alice 版本号应为 2,Bob 为 3。结果事务 B 的修改导致 Bob 版本号变成了 5,因此此事务会执行失败分支,再次查询 Alice 和 Bob 版本号和资产,发起新的转账事务,成功通过 MVCC 冲突检测规则 mod(“Alice”) = 2 和 mod(“Bob”) = 5 后,更新 Alice 账号资产为 100,Bob 资产为 400,完成转账操作。

转账案例应用

首先你可通过一个事务获取 Alice 和 Bob 账号的资金和版本号,用以判断 Alice 是否有足够的金额转账给 Bob 和事务提交时做冲突检测。 你可通过如下 etcdctl txn 命令,获取 Alice 和 Bob 账号的资产和最后一次修改时的版本号 (mod_revision):

$ etcdctl txn -i -w=json
compares:


success requests (get, put, del):
get Alice
get Bob


failure requests (get, put, del):


{
 "kvs":[
      {
          "key":"QWxpY2U=",
          "create_revision":2,
          "mod_revision":2,
          "version":1,
          "value":"MjAw"
      }
  ],
    ......
  "kvs":[
      {
          "key":"Qm9i",
          "create_revision":3,
          "mod_revision":3,
          "version":1,
          "value":"MzAw"
      }
  ],
}

其次发起资金转账操作,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

etcd 社区基于以上介绍的事务特性,提供了一个简单的事务框架STM,构建了各个事务隔离级别类,帮助你进一步简化应用编程复杂度。

看完后理解了为啥 etcd 中使用事务是 if else 形式了.