事务
事务(Transaction)是数据库管理中的一个概念。指一系列的操作步骤,要么完全执行,要么完全不执行。事务用于保证数据库的完整性和一致性。
事务具有四个主要特性,通常称为 ACID 特性:
- 原子性(Atomicity):事务作为一个整体被执行,事务中的所有操作要么全部完成,要么全部不完成。如果事务中的某个操作失败,那么事务中已经执行的操作将会被回滚(撤销),数据库状态回到事务开始之前的状态。
- 一致性(Consistency):事务完成时,必须使数据库从一个一致的状态转变到另一个一致的状态。一致性的含义是数据库中的数据应满足完整性约束。
- 隔离性(Isolation):多个事务并发执行时,一个事务的执行不应该被其他并发执行的事务干扰。即事务的中间状态对其他并发执行的事务是不可见的。
- 持久性(Durability):一旦事务完成(即事务被提交),对数据库所做的更改就是永久性的,即使系统发生故障也不会丢失这些更改。
事务概述
Etcd 的事务 API 是简单的 If/Then/Else 结构,
- If 中是一系列的条件表达式。支持对 key 的 mod_revision、key 的 create_revision、key 的 version 以及 key 的 value 分别进行等于、大于、小于、不等于比较。
- 如果条件表达式全部通过检查,则执行 Then 中的一系列操作。
- 否则执行 Else 中的一系列操作。
client.Txn(ctx).If(cmp1, cmp2, ...).Then(op1, op2, ...,).Else(op1, op2, ...)
举个例子,
$ etcdctl txn -i
# 对 key 为 Alice 的 mod_revision 进行判断
# 对 key 为 Bob 的 mod_revision 进行判断
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
整个事务从 API 到执行的流程如下所示,
- 首先,client 发起一个 txn 事务请求,比如上述例子的请求。
- 经过 gRPC KV Server、Raft 模块处理后,来到 Apply 模块的处理。
- Apply 模块首先对事务中的 If 语句进行检查,也就是 ApplyCompares 操作。如果通过此操作,则执行 ApplyTxn(Then) 语句,否则执行 ApplyTxn(Else) 语句。在整个过程会持有一把大锁,来确保整个操作是原子的。
- 之后,遍历 Then 或者 Else 中的操作请求,通过 MVCC 依次执行其中的 get/put/delete 等操作请求。
ACID 特性实现概述
下面介绍 Etcd 是如何实现事务要求的 ACID 这 4 个特性的。
原子性
-
MVCC 执行写操作的时候是先将修改写入到内存中,然后再通过 boltdb 的提交接口将修改进行持久化。MVCC 写操作在执行的时候会持有 boltdb 写锁,由于负责 boltdb 提交的 goroutine 无法持有写锁,在修改的过程中是无法将写操作所做的修改持久化到存储中。
-
boltdb 的提交过程是原子的,txid 和 key-value 数据在一个提交中会同时持久化到磁盘的。
通过以上方式确保原子性,
-
如果写操作未完成就出错了,因为此时的操作都在内存中进行,不会对数据库造成影响。etcd 在重启的时候会重新执行相应日志的内容。
-
如果 boltdb 提交的过程出错,boltdb 的提交接口本身就会确保原子化。
简单来说,boltdb 的 commit 接口会先写入 B+ 树数据,最后写入 meta 数据。其中 boltdb 针对有修改的页都将分配新的页用于写入,不会覆盖旧的页。
因此,如果在写入 B+ 树中的数据出错或者 B+ 树持久化之后出错,由于 meta 数据未持久化。boltdb 中存的是旧的 meta 数据,由于 meta 数据中有记录 boltdb 中 B+ 树相关的信息,因此相当于 boltdb 存的、能访问到的还是旧的 B+ 树。
而对于 meta 数据来说,boltdb 通过两个 meta 数据页来进行保证,会交替持久化到其中 meta 数据页。如果往一个 meta 数据页中写入出错,则会往另一个 meta 数据页写入。
如果是事务执行过程中出错的话,因为事务中每个操作都会对应一个子 revision。因此,跟上述差不多,只是 etcd 在重启的时候会重新执行事务中未执行的内容。
一致性
一致性其实需要从两方面来实现,
- 一方面是依赖原子性。如果确保了原子性,起码不会出现有部分成功部分失败的现象。
- 另一方面需要业务层面进行提前判断,也就说先判断状态是否符合预期,然后根据这个判断再进行处理。
隔离性
常见的事务隔离级别有以下四种,
- 未提交读:读取到未提交的事务。
- 已提交读:在一个事务内,两次读取的结果是不一样的。
- 重复读:在一个事务内,两次读取到的结果是一样的。
- 串行化:事务之间相当于是串行化执行的,读写的时候数据都是最新的。
- 对于基于锁的并发控制数据库系统实现来说,是通过读写锁来实现。
- 对于基于 MVCC 机制实现的数据库系统中,提供了一个名为“可串行化的快照隔离”级别。相比悲观锁而言,它是一种乐观并发控制,通过快照技术实现的类似串行化的效果,事务提交时再检查是否冲突。
Etcd 针对上述四种事物隔离级别是个什么情况?
-
未提交读:Etcd 不会有这种读。
-
已提交读:Etcd 的读请求若未增加任何版本号限制,则默认都是返回最新的结果给你。
-
重复读:要想使用 Etcd 实现重复读的隔离性,可以这么操作。
- 可以参考 etcd 的事务框架 STM 实现,它在事务中维护一个读缓存,优先从读缓存中查找,不存在则从 etcd 查询并更新到缓存中,这样事务中后续读请求都可从缓存中查找,确保了可重复读。
- 采用 Etcd 的快照读,其实就相当于指定版本号。
-
串行化快照隔离:要想使用 Etcd 实现串行化的隔离性,需要客户端参与处理额外的逻辑。比如需要先添加一些检查条件,在通过检查条件之后再进行某些操作,否则进行重试。也就是说 Etcd 其实不能保证在没有客户端额外参与的情况下提供完全串行化的隔离性,是个“假串行隔离性”。
举个例子,key A 的 mod_revision 是 2,A 事务是将 keyA 的 value+200。B 事务是将 keyA 的 value+300。要想 A 事务和 B 事务能串行执行,需要 B 事务或 A 事务中添加检查条件,判断 keyA 的 mod_revision 是否为 2,为 2 再执行,否则重新获取最新的,然后再发起一个事务。
- 假设 A 事务先被执行了,在事务执行的过程中 Etcd 会独占一个 MVCC 大写锁。因此 B 事务是无法执行的。
- 当 A 用户的事务执行完成之后,B 事务执行的时候会发现 keyA 的 mod_revision 不符合。
- 那么此时需要重新获取 keyA 的 mod_revision,并重新发起一个事务,然后再加上类似的检查条件。
持久性
boltdb 定时或定量将已更改的数据进行持久化,同时持久化 txid。