You cannot select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.

313 lines
21 KiB
Markdown

This file contains ambiguous Unicode characters!

This file contains ambiguous Unicode characters that may be confused with others in your current locale. If your use case is intentional and legitimate, you can safely ignore this warning. Use the Escape button to highlight these characters.

# 09 | 事务如何安全地实现多key操作
你好,我是唐聪。
在软件开发过程中我们经常会遇到需要批量执行多个key操作的业务场景比如转账案例中Alice给Bob转账100元Alice账号减少100Bob账号增加100这涉及到多个key的原子更新。
无论发生任何故障我们应用层期望的结果是要么两个操作一起成功要么两个一起失败。我们无法容忍出现一个成功一个失败的情况。那么etcd是如何解决多key原子更新问题呢
这正是我今天要和你分享的主题——事务,它就是为了**简化应用层的编程模型**而诞生的。我将通过转账案例为你剖析etcd事务实现让你了解etcd如何实现事务ACID特性的以及MVCC版本号在事务中的重要作用。希望通过本节课帮助你在业务开发中正确使用事务保证软件代码的正确性。
## 事务特性初体验及API
如何使用etcd实现Alice向Bob转账功能呢
在etcd v2的时候 etcd提供了CASCompare and swap然而其只支持单key不支持多key因此无法满足类似转账场景的需求。严格意义上说CAS称不上事务无法实现事务的各个隔离级别。
etcd v3为了解决多key的原子操作问题提供了全新迷你事务API同时基于MVCC版本号它可以实现各种隔离级别的事务。它的基本结构如下
```
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语句支持哪些检查项呢
首先是**key的最近一次修改版本号mod\_revision**简称mod。你可以通过它检查key最近一次被修改时的版本号是否符合你的预期。比如当你查询到Alice账号资金为100元时它的mod\_revision是v1当你发起转账操作时你得确保Alice账号上的100元未被挪用这就可以通过mod(“Alice”) = “v1” 条件表达式来保障转账安全性。
其次是**key的创建版本号create\_revision**简称create。你可以通过它检查key是否已存在。比如在分布式锁场景里只有分布式锁key(lock)不存在的时候你才能发起put操作创建锁这时你可以通过create(“lock”) = "0"来判断因为一个key不存在的话它的create\_revision版本号就是0。
接着是**key的修改次数version**。你可以通过它检查key的修改次数是否符合预期。比如你期望key在修改次数小于3时才能发起某些操作时可以通过version(“key”) < "3"来判断。
最后是**key的value值**。你可以通过检查key的value值是否符合预期然后发起某些操作。比如期望Alice的账号资金为200, value(“Alice”) = “200”。
If语句通过以上MVCC版本号、value值、各种比较运算符(等于、大于、小于、不等于),实现了灵活的比较的功能,满足你各类业务场景诉求。
下面我给出了一个使用etcdctl的txn事务命令基于以上介绍的特性初步实现的一个Alice向Bob转账100元的事务。
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
```
## 整体流程
![](https://static001.geekbang.org/resource/image/e4/d3/e41a4f83bda29599efcf06f6012b0bd3.png?wh=1920*852)
在和你介绍上面案例中的etcd事务原理和问题前我先给你介绍下事务的整体流程为我们后面介绍etcd事务ACID特性的实现做准备。
上图是etcd事务的执行流程当你通过client发起一个txn转账事务操作时通过gRPC KV Server、Raft模块处理后在Apply模块执行此事务的时候它首先对你的事务的If语句进行检查也就是ApplyCompares操作如果通过此操作则执行ApplyTxn/Then语句否则执行ApplyTxn/Else语句。
在执行以上操作过程中它会根据事务是否只读、可写通过MVCC层的读写事务对象执行事务中的get/put/delete各操作也就是我们上一节课介绍的MVCC对key的读写原理。
## 事务ACID特性
了解完事务的整体执行流程后那么etcd应该如何正确实现上面案例中Alice向Bob转账的事务呢别着急我们先来了解一下事务的ACID特性。在你了解了etcd事务ACID特性实现后这个转账事务案例的正确解决方案也就简单了。
ACID是衡量事务的四个特性由原子性Atomicity、一致性Consistency、隔离性Isolation、持久性Durability组成。接下来我就为你分析ACID特性在etcd中的实现。
### 原子性与持久性
事务的原子性Atomicity是指在一个事务中所有请求要么同时成功要么同时失败。比如在我们的转账案例中是绝对无法容忍Alice账号扣款成功但是Bob账号资金到账失败的场景。
持久性Durability是指事务一旦提交其所做的修改会永久保存在数据库。
软件系统在运行过程中会遇到各种各样的软硬件故障如果etcd在执行上面事务过程中刚执行完扣款命令put Alice 100就突然crash了它是如何保证转账事务的原子性与持久性的呢
![](https://static001.geekbang.org/resource/image/cf/9e/cf94ce8fc0649fe5cce45f8b7468019e.png?wh=1920*949)
如上图转账事务流程图所示etcd在执行一个事务过程中任何时间点都可能会出现节点crash等异常问题。我在图中给你标注了两个关键的异常时间点它们分别是T1和T2。接下来我分别为你分析一下etcd在这两个关键时间点异常后是如何保证事务的原子性和持久性的。
#### T1时间点
T1时间点是在Alice账号扣款100元完成时Bob账号资金还未成功增加时突然发生了crash。
从前面介绍的etcd写原理和上面流程图我们可知此时MVCC写事务持有boltdb写锁仅是将修改提交到了内存中保证幂等性、防止日志条目重复执行的一致性索引consistent index也并未更新。同时负责boltdb事务提交的goroutine因无法持有写锁也并未将事务提交到持久化存储中。
因此T1时间点发生crash异常后事务并未成功执行和持久化任意数据到磁盘上。在节点重启时etcd server会重放WAL中的已提交日志条目再次执行以上转账事务。因此不会出现Alice扣款成功、Bob到帐失败等严重Bug极大简化了业务的编程复杂度。
#### T2时间点
T2时间点是在MVCC写事务完成转账server返回给client转账成功后boltdb的事务提交goroutine批量将事务持久化到磁盘中时发生了crash。这时etcd又是如何保证原子性和持久性的呢?
我们知道一致性索引consistent index字段值是和key-value数据在一个boltdb事务里同时持久化到磁盘中的。若在boltdb事务提交过程中发生crash了简单情况是consistent index和key-value数据都更新失败。那么当节点重启etcd server重放WAL中已提交日志条目时同样会再次应用转账事务到状态机中因此事务的原子性和持久化依然能得到保证。
更复杂的情况是当boltdb提交事务的时候会不会部分数据提交成功部分数据提交失败呢这个问题我将在下一节课通过深入介绍boltdb为你解答。
了解完etcd事务的原子性和持久性后那一致性又是怎么一回事呢事务的一致性难道是指各个节点数据一致性吗
### 一致性
在软件系统中到处可见一致性Consistency的表述其实在不同场景下它的含义是不一样的。
首先分布式系统中多副本数据一致性它是指各个副本之间的数据是否一致比如Redis的主备是异步复制的那么它的一致性是最终一致性的。
其次是CAP原理中的一致性是指可线性化。核心原理是虽然整个系统是由多副本组成但是通过线性化能力支持对client而言就如一个副本应用程序无需关心系统有多少个副本。
然后是一致性哈希,它是一种分布式系统中的数据分片算法,具备良好的分散性、平衡性。
最后是事务中的一致性,它是指事务变更前后,数据库必须满足若干恒等条件的状态约束,**一致性往往是由数据库和业务程序两方面来保障的**。
**在Alice向Bob转账的案例中有哪些恒等状态呢**
很明显转账系统内的各账号资金总额在转账前后应该一致同时各账号资产不能小于0。
为了帮助你更好地理解前面转账事务实现的问题,下面我给你画了幅两个并发转账事务的流程图。
图中有两个并发的转账事务Mike向Bob转账100元Alice也向Bob转账100元按照我们上面的事务实现从下图可知转账前系统总资金是600元转账后却只有500元了因此它无法保证转账前后账号系统内的资产一致性导致了资产凭空消失破坏了事务的一致性。
![](https://static001.geekbang.org/resource/image/1f/ea/1ff951756c0ffc427e5a064e3cf8caea.png?wh=1920*1153)
事务一致性被破坏的根本原因是事务中缺少对Bob账号资产是否发生变化的判断这就导致账号资金被覆盖。
为了确保事务的一致性,一方面,业务程序在转账逻辑里面,需检查转账者资产大于等于转账金额。在事务提交时,通过账号资产的版本号,确保双方账号资产未被其他事务修改。若双方账号资产被其他事务修改,账号资产版本号会检查失败,这时业务可以通过获取最新的资产和版本号,发起新的转账事务流程解决。
另一方面etcd会通过WAL日志和consistent index、boltdb事务特性去确保事务的原子性因此不会有部分成功部分失败的操作导致资金凭空消失、新增。
介绍完事务的原子性和持久化、一致性后我们再看看etcd又是如何提供各种隔离级别的事务在转账过程中其他client能看到转账的中间状态吗(如Alice扣款成功Bob还未增加时)
### 隔离性
ACID中的I是指Isolation也就是事务的隔离性它是指事务在执行过程中的可见性。常见的事务隔离级别有以下四种。
首先是**未提交读**Read UnCommitted也就是一个client能读取到未提交的事务。比如转账事务过程中Alice账号资金扣除后Bob账号上资金还未增加这时如果其他client读取到这种中间状态它会发现系统总金额钱减少了破坏了事务一致性的约束。
其次是**已提交读**Read Committed指的是只能读取到已经提交的事务数据但是存在不可重复读的问题。比如事务开始时你读取了Alice和Bob资金这时其他事务修改Alice和Bob账号上的资金你在事务中再次读取时会读取到最新资金导致两次读取结果不一样。
接着是**可重复读**Repeated Read它是指在一个事务中同一个读操作get Alice/Bob在事务的任意时刻都能得到同样的结果其他修改事务提交后也不会影响你本事务所看到的结果。
最后是**串行化**Serializable它是最高的事务隔离级别读写相互阻塞通过牺牲并发能力、串行化来解决事务并发更新过程中的隔离问题。对于串行化我要和你特别补充一点很多人认为它都是通过读写锁来实现事务一个个串行提交的其实这只是在基于锁的并发控制数据库系统实现而已。**为了优化性能在基于MVCC机制实现的各个数据库系统中提供了一个名为“可串行化的快照隔离”级别相比悲观锁而言它是一种乐观并发控制通过快照技术实现的类似串行化的效果事务提交时能检查是否冲突。**
下面我重点和你介绍下未提交读、已提交读、可重复读、串行化快照隔离。
#### 未提交读
首先是最低的事务隔离级别未提交读。我们通过如下一个转账事务时间序列图来分析下一个client能否读取到未提交事务修改的数据是否存在脏读。
![](https://static001.geekbang.org/resource/image/6a/8d/6a526be4949a383fd5263484c706d68d.png?wh=1920*786)
图中有两个事务一个是用户查询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为100Bob为300通过读已提交模式你能及时获取到etcd最新已提交的事务结果但是出现了不可重复读两次读出来的Alice和Bob资产不一致。
那么如何实现可重复读呢?
你可以通过MVCC快照读或者参考etcd的事务框架STM实现它在事务中维护一个读缓存优先从读缓存中查找不存在则从etcd查询并更新到缓存中这样事务中后续读请求都可从缓存中查找确保了可重复读。
最后我们再来重点介绍下什么是串行化快照隔离。
#### 串行化快照隔离
串行化快照隔离是最严格的事务隔离级别它是指在在事务刚开始时首先获取etcd当前的版本号rev事务中后续发出的读请求都带上这个版本号rev告诉etcd你需要获取那个时间点的快照数据etcd的MVCC机制就能确保事务中能读取到同一时刻的数据。
**同时,它还要确保事务提交时,你读写的数据都是最新的,未被其他人修改,也就是要增加冲突检测机制。**当事务提交出现冲突的时候依赖client重试解决安全地实现多key原子更新。
那么我们应该如何为上面一致性案例中,两个并发转账的事务,增加冲突检测机制呢?
核心就是我们前面介绍MVCC的版本号我通过下面的并发转账事务流程图为你解释它是如何工作的。
![](https://static001.geekbang.org/resource/image/3b/26/3b4c7fb43e03a38aceb2a8c2d5c92226.png?wh=1920*1011)
如上图所示事务AAlice向Bob转账100元事务BMike向Bob转账100元两个事务同时发起转账操作。
一开始时Mike的版本号(指mod\_revision)是4Bob版本号是3Alice版本号是2资产各自200。为了防止并发写事务冲突etcd在一个写事务开始时会独占一个MVCC读写锁。
事务A会先去etcd查询当前Alice和Bob的资产版本号用于在事务提交时做冲突检测。在事务A查询后事务B获得MVCC写锁并完成转账事务Mike和Bob账号资产分别为100300版本号都为5。
事务B完成后事务A获得写锁开始执行事务。
为了解决并发事务冲突问题事务A中增加了冲突检测期望的Alice版本号应为2Bob为3。结果事务B的修改导致Bob版本号变成了5因此此事务会执行失败分支再次查询Alice和Bob版本号和资产发起新的转账事务成功通过MVCC冲突检测规则mod(“Alice”) = 2 和 mod(“Bob”) = 5 后更新Alice账号资产为100Bob资产为400完成转账操作。
通过上面介绍的快照读和MVCC冲突检测检测机制etcd就可实现串行化快照隔离能力。
### 转账案例应用
介绍完etcd事务ACID特性实现后你很容易发现事务特性初体验中的案例问题了它缺少了完整事务的冲突检测机制。
首先你可通过一个事务获取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账号减去100Bob账号增加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](https://github.com/etcd-io/etcd/blob/v3.4.9/clientv3/concurrency/stm.go),构建了各个事务隔离级别类,帮助你进一步简化应用编程复杂度。
## 小结
最后我们来小结下今天的内容。首先我给你介绍了事务API的基本结构它由If、Then、Else语句组成。
其中If支持多个比较规则它是用于事务提交时的冲突检测比较的对象支持key的**mod\_revision**、**create\_revision、version、value值**。随后我给你介绍了整个事务执行的基本流程Apply模块首先执行If的比较规则为真则执行Then语句否则执行Else语句。
接着通过转账案例四幅转账事务时间序列图我为你分析了事务的ACID特性剖析了在etcd中事务的ACID特性的实现。
* 原子性是指一个事务要么全部成功要么全部失败etcd基于WAL日志、consistent index、boltdb的事务能力提供支持。
* 一致性是指事务转账前后的,数据库和应用程序期望的恒等状态应该保持不变,这通过数据库和业务应用程序相互协作完成。
* 持久性是指事务提交后,数据不丢失,
* 隔离性是指事务提交过程中的可见性etcd不存在脏读基于MVCC机制、boltdb事务你可以实现可重复读、串行化快照隔离级别的事务保障并发事务场景中你的数据安全性。
## 思考题
在数据库事务中,有各种各样的概念,比如脏读、脏写、不可重复读与读倾斜、幻读与写倾斜、更新丢失、快照隔离、可串行化快照隔离? 你知道它们的含义吗?
感谢你的阅读,如果你认为这节课的内容有收获,也欢迎把它分享给你的朋友,谢谢。