多 key 原子更新的需求:在实际应用中,经常需要对多个 key 进行原子操作,例如转账场景中同时更新两个账户的余额。这种情况下,要求所有操作要么全部成功,要么全部失败,以保证数据的一致性。
etcd v2 的限制:CAS(Compare and Swap)仅支持单 key 操作,无法满足多 key 原子更新的需求。
基本结构:
client.Txn(ctx).If(cmp1, cmp2, ...).Then(op1, op2, ...,).Else(op1, op2, …)
If 语句:添加一系列条件表达式,如果所有条件都满足,则执行 Then 语句;否则执行 Else 语句。
Then 语句:当 If 语句中的条件全部通过时,执行的操作(如 get、put、delete 等)。
Else 语句:当 If 语句中的任何一个条件不满足时,执行的操作。
检查项:
mod_revision:检查 key 最近一次被修改时的版本号是否符合预期。
create_revision:检查 key 是否已存在。
version:检查 key 的修改次数是否符合预期。
value:检查 key 的值是否符合预期。
下面我们使用 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
在介绍 etcd 事务原理及其在转账案例中的应用之前,我们首先需要了解 etcd 事务的整体执行流程。当你通过客户端发起一个 txn 转账事务操作时,该请求会经过 gRPC KV Server 和 Raft 模块处理,在 Apply 模块中执行此事务。具体步骤如下:
ApplyCompares:首先对事务的 If 条件进行检查。
ApplyTxn/Then 或 ApplyTxn/Else:如果 If 条件全部满足,则执行 Then 语句中的操作;否则执行 Else 语句中的操作。
MVCC 层的读写事务对象:根据事务是否只读或可写,使用 MVCC 层的相应机制执行 get、put、delete 等操作。

ACID 是衡量事务的四个特性:原子性(Atomicity)、一致性(Consistency)、隔离性(Isolation)和持久性(Durability)。
原子性:保证事务中的所有操作要么同时成功,要么同时失败。例如,在转账场景中,Alice 账户扣款成功的同时 Bob 账户资金必须增加,不允许出现部分成功的情况。
持久性:一旦事务提交,所做的修改将永久保存在数据库中。即使在事务执行过程中发生崩溃,etcd 也会通过 WAL 日志和 boltdb 的事务特性来保证数据的持久性和原子性。
在 etcd 中,即使在事务执行过程中发生崩溃(如下图的 T1 和 T2 时间点),WAL 日志和 boltdb 的事务特性确保了事务的原子性和持久性。
T1 时间点:当 Alice 账户扣款完成但 Bob 账户还未增加资金时发生崩溃,由于此时事务尚未提交到磁盘,重启后 etcd 将重放 WAL 日志条目,再次尝试执行事务。
T2 时间点:在事务完成并返回给客户端成功后但在 boltdb 提交事务期间发生崩溃。由于一致索引 consistent index 和 key-value 数据在同一事务中提交,若提交失败,etcd 将重放 WAL 日志条目以恢复事务状态。

在软件系统中,到处可见一致性(Consistency)的表述,其实在不同场景下,它的含义是不一样的。
分布式系统中多副本数据一致性:
指各个副本之间的数据是否一致。
例如,Redis 的主备采用异步复制,因此其一致性是最终一致性。
CAP 原理中的一致性(可线性化):
核心原理是尽管系统由多个副本组成,但通过线性化支持,对客户端而言就像操作单个副本一样,无需关心系统的副本数量。
一致性哈希:
分布式系统中的一种数据分片算法,具有良好的分散性和平衡性。
事务中的一致性:
指事务变更前后,数据库必须满足若干恒等条件的状态约束。
数据库和业务程序共同保障一致性。
在 Alice 向 Bob 转账的案例中,转账前后的恒等状态包括:
系统内各账号资金总额保持不变。
各账号资产不能小于0。
如果两个并发转账事务(Mike 向 Bob 转账100元,Alice 也向 Bob 转账100元)按照上述实现,从下图可知转账前系统总资金是 600 元,转账后却只有 500 元了,因此它无法保证转账前后账号系统内的资产一致性,导致了资产凭空消失,破坏了事务的一致性。
该案例中事务一致性被破坏的根本原因是,事务中缺少对 Bob 账号资产是否发生变化的判断,这就导致账号资金被覆盖。

问题根源:
缺少对 Bob 账号资产变化的判断,导致账号资金被覆盖。
解决方案:
业务程序层面:
在转账逻辑中检查转账者资产是否大于等于转账金额。
在事务提交时,通过账号资产的版本号确保双方账号资产未被其他事务修改。如果版本号检查失败,可通过获取最新资产和版本号发起新的转账流程。
etcd 层面:
使用 WAL 日志、consistent index 和 boltdb 事务特性来确保事务的原子性,避免部分成功或失败的情况,从而防止资金凭空消失或新增。
定义:事务的隔离性指事务执行过程中的可见性,即一个事务对数据的修改是否对其它事务可见。
常见的事务隔离级别:
未提交读(Read UnCommitted):允许读取未提交的数据,可能导致脏读。
已提交读(Read Committed):只能读取已经提交的数据,但可能会出现不可重复读的情况。
可重复读(Repeated Read):保证在同一事务中多次读取同样的数据结果一致。
串行化(Serializable):最高的事务隔离级别,通过牺牲并发性能来确保事务的完全隔离。对于串行化有一点需要特别补充,很多人认为它都是通过读写锁,来实现事务一个个串行提交的,其实这只是在基于锁的并发控制数据库系统实现而已。为了优化性能,在基于 MVCC 机制实现的各个数据库系统中,提供了一个名为“可串行化的快照隔离”级别,相比悲观锁而言,它是一种乐观并发控制,通过快照技术实现的类似串行化的效果,事务提交时能检查是否冲突。
最低的事务隔离级别,允许一个客户端读取到未提交的事务数据。
这种情况下可能会导致“脏读”,即读取到其他事务未提交的数据。
为了避免这种情况,etcd 通过 MVCC 快照读机制来避免脏读问题,因为读事务是基于快照的,不会看到未提交的更改。

如上图中有两个事务,一个是用户查询 Alice 和 Bob 资产的事务,一个是我们执行 Alice 向 Bob 转账的事务。
如图中所示,若在 Alice 向 Bob 转账事务执行过程中,etcd server 收到了 client 查询 Alice 和 Bob 资产的读请求,显然此时我们无法接受 client 能读取到一个未提交的事务,因为这对应用程序而言会产生严重的 BUG。
在前面我们学习到 etcd 基于 boltdb 实现读写操作的,读请求由 boltdb 的读事务处理,你可以理解为快照读。写请求由 boltdb 写事务处理,etcd 定时将一批写操作提交到 boltdb 并清空 buffer。由于 etcd 是批量提交写事务的,而读事务又是快照读,因此当 MVCC 写事务完成时,它需要更新 buffer,这样下一个读请求到达时,才能从 buffer 中获取到最新数据。
但是在我们的场景中,转账事务并未结束,执行 put Alice 为 100 的操作不会回写 buffer,因此避免了脏读的可能性。用户此刻从 boltdb 快照读事务中查询到的 Alice 和 Bob 资产都为 200。
在这种隔离级别下,只能读取到已经提交的事务数据,但仍可能存在“不可重复读”的问题。
这意味着在一个事务内多次读取同一数据时,可能会得到不同的结果。
对于已提交读,每次读操作默认都是当前读,etcd 会返回最新已提交的事务结果。
在这个隔离级别中,同一个事务内的多次读操作总是能获取到相同的结果,不受其他事务提交的影响。
这通常通过快照读来实现,确保事务内部读取的数据是一致的。
这是最严格的事务隔离级别,提供类似串行化的事务执行效果,但不通过锁机制实现,而是利用快照技术。
在事务开始时获取当前的版本号(revision),后续的所有读操作都基于这个版本号,确保事务中读取到的数据是一致的。
此外,还需要增加冲突检测机制,确保事务提交时没有其他事务修改了相关的数据。

如上图所示,事务 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 特性实现后,我们很容易发现开头的案例问题了,它缺少了完整事务的冲突检测机制。
为了确保转账事务的安全性和正确性,改进后的实现步骤如下:
获取账户信息及版本号:使用 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"
}
],
}
执行转账操作并进行冲突检测:
在 compares 中添加对 Alice 和 Bob 账户最新修改版本号的检查,以确保在事务提交时没有其他事务修改过这两个账户的资金。
如果条件满足,则执行转账操作;否则重新查询账户信息并重试。
即在确认 Alice 有足够的余额并且没有其他事务修改过这两个账户的资金后,执行转账操作。
$ 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,构建了各个事务隔离级别类,帮助你进一步简化应用编程复杂度。
基本结构:事务API由If、Then、Else语句组成。
If语句:用于定义事务提交时的冲突检测规则,支持比较key的mod_revision(最近修改版本号)、create_revision(创建版本号)、version(修改次数)以及value值。
Then语句:如果If语句中的所有条件都满足,则执行Then语句中的操作(例如get、put、delete等)。
Else语句:如果If语句中的任何一个条件不满足,则执行Else语句中的操作。
Apply模块的工作流程:
首先执行If语句中的比较规则。
如果条件为真,则执行Then语句;否则执行Else语句。
这个过程基于MVCC(多版本并发控制)机制和boltdb事务能力来保证事务的正确执行。
原子性(Atomicity):确保事务中的所有操作要么全部成功,要么全部失败。etcd通过WAL日志、consistent index和boltdb的事务能力来支持这一点。
一致性(Consistency):指事务前后数据库和应用程序期望的状态应该保持不变。这需要数据库与业务程序共同协作完成,确保转账前后的账户总金额一致且各账户余额非负。
持久性(Durability):一旦事务提交,所做的修改将永久保存。etcd使用WAL日志确保即使系统崩溃后也能恢复数据。
隔离性(Isolation):描述了事务执行过程中的可见性级别。etcd避免了脏读问题,并基于MVCC机制实现了可重复读和串行化快照隔离级别,确保在并发事务场景下的数据安全性和一致性。
并发事物处理:通过具体转账案例的时间序列图,我们展示了如何在并发环境下保证数据的一致性和完整性。特别是通过版本号检查来防止并发写操作导致的数据覆盖或丢失更新问题。
在数据库事务中,有各种各样的概念,比如脏读、脏写、不可重复读与读倾斜、幻读与写倾斜、更新丢失、快照隔离、可串行化快照隔离? 你知道它们的含义吗?
答案:
脏读(Dirty Read)
含义:一个事务能够读取到另一个未提交事务所做的更改。
问题:如果未提交的事务最终回滚,那么脏读的数据就是无效的。
脏写(Dirty Write)
含义:一个事务覆盖了另一个未提交事务所做的更改。
问题:可能导致数据不一致或丢失更新的情况。
不可重复读(Non-repeatable Read)与读倾斜(Read Skew)
不可重复读:在同一事务内两次读取同一行数据时,可能会得到不同的结果,因为在这两次读取之间有其他事务修改并提交了该行的数据。
读倾斜:一种特殊情况下的不可重复读,通常发生在分布式系统中,当一个事务读取多个相关但分散的数据项时,由于数据分布在不同节点上,导致读取的数据不是基于同一时间点的一致快照。
问题:影响一致性,特别是在需要多次读取相同数据以进行比较或计算的情况下。
幻读(Phantom Read)与写倾斜(Write Skew)
幻读:在一个事务中执行相同的查询两次,可能会发现第一次查询不存在的数据在第二次查询中出现了,这是因为另一个事务在此期间插入了新行。
写倾斜:当两个事务读取相同的数据集,并根据这些数据做出决策,但它们之间的交互导致了一个不符合预期的结果。
问题:幻读主要影响的是数据完整性和业务逻辑;写倾斜则可能导致数据不一致。
更新丢失(Lost Update)
含义:当两个事务同时读取和修改同一数据项时,其中一个事务的更新可能被另一个事务覆盖,而没有合并这两个事务所做的改变。
问题:直接导致数据丢失。
快照隔离(Snapshot Isolation)
含义:事务看到的是数据在事务开始时刻的一个快照版本,不会受到其他并发事务的影响。
优点:解决了脏读、不可重复读和幻读的问题,提供了较高的并发度。
缺点:不能完全避免写倾斜。
可串行化快照隔离(Serializable Snapshot Isolation, SSI)
含义:结合了快照隔离的优点,并通过额外的协议来检测和防止写倾斜和其他并发异常,确保事务的执行效果等同于某种顺序执行的效果。
优点:提供了与严格的可串行化相当的隔离保证,但通常具有更高的性能。
实现方式:通过检查事务间的依赖关系,在冲突发生前阻止潜在的并发问题。
本文内容摘抄自极客时间专栏 etcd 实战课