ACID
事务特性初体验及 API
etcd v3 为了解决多 key 的原子操作问题,提供了全新迷你事务 API,同时基于 MVCC 版本号,它可以实现各种隔离级别的事务。它的基本结构如下:
client.Txn(ctx).If(cmp1, cmp2, ...).Then(op1, op2, ...,).Else(op1, op2, …)
它的基本原理是,在 If 语句中,你可以添加一系列的条件表达式,若条件表达式全部通过检查,则执行 Then 语句的 get/put/delete 等操作,否则执行 Else 的 get/put/delete 等操作。
If 语句支持哪些检查项呢?
- key 的最近一次修改版本号 mod_revision,简称 mod
- key 的创建版本号 create_revision,简称 create
- key 的修改次数 version
- 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
整体流程
事务 ACID 特性
ACID 是衡量事务的四个特性,由原子性(Atomicity)、一致性(Consistency)、隔离性(Isolation)、持久性(Durability)组成。
原子性与持久性
软件系统在运行过程中会遇到各种各样的软硬件故障,如果 etcd 在执行上面事务过程中,刚执行完扣款命令(put Alice 100)就突然 crash 了,它是如何保证转账事务的原子性与持久性的呢?
T1 时间点
- 从前面介绍的 etcd 写原理和上面流程图我们可知,此时 MVCC 写事务持有 boltdb 写锁,仅是将修改提交到了内存中,保证幂等性、防止日志条目重复执行的一致性索引 consistent index 也并未更新。同时,负责 boltdb 事务提交的 goroutine 因无法持有写锁,也并未将事务提交到持久化存储中。
- 因此,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 账号资产是否发生变化的判断,这就导致账号资金被覆盖。
为了确保事务的一致性,一方面,业务程序在转账逻辑里面,需检查转账者资产大于等于转账金额。在事务提交时,通过账号资产的版本号,确保双方账号资产未被其他事务修改。若双方账号资产被其他事务修改,账号资产版本号会检查失败,这时业务可以通过获取最新的资产和版本号,发起新的转账事务流程解决。
根据上下文, etcd 只是提供了多版本和并发控制, 而一致性仍需程序进行判断.
另一方面,etcd 会通过 WAL 日志和 consistent index、boltdb 事务特性,去确保事务的原子性,因此不会有部分成功部分失败的操作,导致资金凭空消失、新增。
隔离性
指事务在执行过程中的可见性。
常见的事务隔离级别有以下四种:
- 读未提交(Read UnCommitted),也就是一个 client 能读取到未提交的事务
- 读已提交(Read Committed),指的是只能读取到已经提交的事务数据
- 可重复读(Repeated Read),它是指在一个事务中,同一个读操作 get Alice/Bob 在事务的任意时刻都能得到同样的结果,其他修改事务提交后也不会影响你本事务所看到的结果。
- 最后是串行化(Serializable),它是最高的事务隔离级别,读写相互阻塞,通过牺牲并发能力、串行化来解决事务并发更新过程中的隔离问题
对于串行化我要和你特别补充一点,很多人认为它都是通过读写锁,来实现事务一个个串行提交的,其实这只是在基于锁的并发控制数据库系统实现而已。为了优化性能,在基于 MVCC 机制实现的各个数据库系统中,提供了一个名为“可串行化的快照隔离”级别,相比悲观锁而言,它是一种乐观并发控制,通过快照技术实现的类似串行化的效果,事务提交时能检查是否冲突。
未提交读
etcd 如何避免读未提交?
- etcd 基于 boltdb 实现读写操作的,读请求由 boltdb 的读事务处理,你可以理解为快照读。写请求由 boltdb 写事务处理,etcd 定时将一批写操作提交到 boltdb 并清空 buffer。
- 由于 etcd 是批量提交写事务的,而读事务又是快照读,因此当 MVCC 写事务完成时,它需要更新 buffer,这样下一个读请求到达时,才能从 buffer 中获取到最新数据。(“MVCC 写事务完成”: 猜测其不代表 boltdb 写事务被提交)
- 在我们的场景中,转账事务并未结束,执行 put Alice 为 100 的操作不会回写 buffer,因此避免了脏读的可能性。用户此刻从 boltdb 快照读事务中查询到的 Alice 和 Bob 资产都为 200。
已提交读、可重复读
- 已提交读: 每次都读最新的且已提交的数据
- 可重复读: 在已提交读的基础上, 读时使用固定版本号
如何实现可重复读呢?
- 你可以通过 MVCC 快照读,或者参考 etcd 的事务框架 STM 实现,它在事务中维护一个读缓存,优先从读缓存中查找,不存在则从 etcd 查询并更新到缓存中,这样事务中后续读请求都可从缓存中查找,确保了可重复读。
串行化快照隔离
串行化快照隔离是最严格的事务隔离级别,它是指在在事务刚开始时,首先获取 etcd 当前的版本号 rev,事务中后续发出的读请求都带上这个版本号 rev,告诉 etcd 你需要获取那个时间点的快照数据,etcd 的 MVCC 机制就能确保事务中能读取到同一时刻的数据。(类似可重复读)
同时,它还要确保事务提交时,你读写的数据都是最新的,未被其他人修改,也就是要增加冲突检测机制。当事务提交出现冲突的时候依赖 client 重试解决,安全地实现多 key 原子更新。(串行保证)
如何为上面一致性案例中,两个并发转账的事务,增加冲突检测机制呢?
- 核心就是我们前面介绍 MVCC 的版本号,我通过下面的并发转账事务流程图为你解释它是如何工作的。
- 一开始时,Mike 的版本号 (指 mod_revision) 是 4,Bob 版本号是 3,Alice 版本号是 2,资产各自 200。为了防止并发写事务冲突,etcd 在一个写事务开始时,会独占一个 MVCC 大写锁。
- 事务 A 会先去 etcd 查询当前 Alice 和 Bob 的资产版本号,用于在事务提交时做冲突检测。在事务 A 查询后,事务 B 获得 MVCC 写锁并完成转账事务,Mike 和 Bob 账号资产分别为 100,300,版本号都为 5。(为什么都是5?)
- 事务 B 完成后,事务 A 获得写锁,开始执行事务。
- 为了解决并发事务冲突问题,事务 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 形式了.