提到事务,你肯定不陌生。和数据库打交道的时候,我们总是会用到事务。简单来说,事务就是要保证一组数据库操作,要么全部成功,要么全部失败。有了事务,就大大简化了业务开发的难度,使我们更容易开发出逻辑正确且高效的代码。但是,在传统关系数据库之外,NoSQL存储系统几乎都不提供事务支持。
最近,在由于项目需要,对etcd3进行了一些调研,惊喜得发现,基于etcd3可以很容易实现ACID事务(设计到实现方式,以MySQL为例)。
etcd旨在提供强一致的kv存储,作为zookeeper的一个替代品,为分布式应用提供并发协调支持,下面是etcd用来构建ACID事务的几个关键特征:
- raft
etcd使用raft协议来进行Leader选举和操作同步,意味着无论你访问任意节点,都将获得最终一致的数据视图,高度可靠。
raft使用quorum机制(多数同意原则),一个提议只要被多数节点批准,就写入raft日志文件持久化,不可撤销。
- mvvc
etcd采用的是 "btree + bbolt"(类似leveldb的kv存储引擎)两级存储结构。在内存中使用btree维护key/value索引,节点存的是bbolt中的键值k,通过这个k在bbolt查找,得到的才是用户传进去的value值。
etcd中有一个revision(修订)的概念,类似mysql的事务id,每次更新kv,revision就递增1。举个例子,假如当前etcd的revision=3,执行put("demo","abc"),revision就会变为4,更新btree["demo"] = "demo:4",更新bbolt["demo:4"] = "abc"。这样就更新了demo,也保留了demo的历史版本,比如,通过 bbolt["demo:2"] 可以访问到demo的历史版本。
- txn
etcd提供了“事务”,可以处理多个key的原子更新,这一点是非常难得的。
etcd事务的语法是"If-Then-Else",代替了常见的CAS操作,可以在一个事务中,原子地执行冲突检查,更新多个keys的值。
了解mysql的innoDB事务的同学都清楚,mysql是依赖锁实现事务的。一个事务首先要拿到它操作的数据库记录的锁,才能进行后续的操作,发生冲突时,事务会阻塞,严重时会发生死锁。在整个事务过程中,client和mysql要进行多次交互,mysql要为client维持事务资源,直到事务提交。
而etcd的实现方式有些不同,它的事务是基于cas方式实现的。在事务执行过程中,client和etcd之间没有维护事务会话,在commit事务时,它的“冲突判断(If)和执行过程Then/Else”一次性提交给etcd,etcd来作为一个原子过程来执行“If-Then-Else”。所以,etcd事务不会发生阻塞,无论成功,还是失败,都会立即返回,需要应用进行失败(发生冲突)重试。因此,这也就要求业务代码是可重试的。
etcd的事务可以看做是一种“微事务”,在它之上,可以构建出各种有意思的应用,例如,我们下面谈到的ACID事务。
下面是一个常用来解释事务的转账业务的例子,假如要从 from 向 to 转账 amount,业务代码是这样的:
func txnXfer(etcd *v3.Client, from, to string, amount uint) (error) {
// 失败重试
for {
if ok, err := doTxnXfer(etcd, from, to amount); err != nil {
return err
} else if ok {
return nil
}
}
}
func doTxnXfer(etcd *v3.Client, from, to string, amount uint) (bool, error) {
// 获取from,to账户金额
getresp, err := etcd.Txn(ctx.TODO()).Then(OpGet(from), OpGet(to)).Commit()
if err != nil {
return false, err
}
fromKV := getresp.Responses[0].GetRangeResponse().Kvs[0]
toKV := getresp.Responses[1].GetRangeResponse().Kvs[1]
fromV, toV := toUInt64(fromKV.Value), toUint64(toKV.Value)
// 验证账户余额是否充足
if fromV < amount {
return false, fmt.Errorf(“insufficient value”)
}
// 发起转账视图
txn := etcd.Txn(ctx.TODO()).If(
v3.Compare(v3.ModRevision(from), “=”, fromKV.ModRevision), // 事务提交时,from账户余额没有没有变动
v3.Compare(v3.ModRevision(to), “=”, toKV.ModRevision)) // 事务提交时,to账户余额没有变动
txn = txn.Then(
OpPut(from, fromUint64(fromV - amount)), // 更新from账户余额
OpPut(to, fromUint64(toV - amount)) // 更新to账户余额
putresp, err := txn.Commit() // 提交事务
if err != nil {
return false, err
}
return putresp.Succeeded, nil
}
可以看到,使用etcd事务,我们不仅要写转账业务代码,还要构造If条件(冲突判断条件),处理重试。有没有办法能够简化这个过程呢?答案是有。etcd的官方clientv3 sdk(go语言)提供了STM(软件事务内存),帮我们自动处理了这些繁琐的过程。
使用STM的转账业务代码如下,
func stmXfer(e *v3.Client, from, to string, amount uint) error {
return <-conc.NewSTMRepeatable(context.TODO(), e, func(s *conc.STM) error {
// 取账户余额
fromV := toUInt64(s.Get(from))
toV := toUInt64(s.Get(to))
// 验证账户余额是否充足
if fromV < amount {
return fmt.Errorf(“insufficient value”)
}
// 更新账户余额
s.Put(to, fromUInt64(toV + amount))
s.Put(from, fromUInt64(fromV - amount))
return nil
})
}
这段代码是不是很清爽?我们只要编写转账逻辑,其他事情STM都帮我们做了。你可能很好奇,这个函数是如何被执行的,其实,就是New(NewSTMRepeatable)出来的STM对象在内部构造txn事务,把我们编写的业务函数翻译成If-Then,自动提交事务,处理失败重试等工作,直到事务执行成功,或者明确的执行失败(出现异常,不能靠重试成功)。
etcd官方client实现了四种事务模型,通过分析源代码 clientv3/concurrency/stm.go,可以了解STM各种事务的语义。
- ReadCommitted
读提交是指,一个事务提交之后,它做的变更才会被其他事务看到。
由于etcd的kv操作(包括txn事务内的多个keys操作)都是原子操作,所以你不可能读到未提交的修改,ReadCommitted是etcd中的最低事务级别。
Get操作:从etcd读取keys,就像普通的kv操作一样。第一次Get后,在事务中缓存,后续不再从etcd读取。
If条件:None,没有任何冲突检测。
- RepeatableReads
可重复读是指,一个事务执行过程中看到的数据,总是跟这个事务在启动时看到的数据是一致的。
Get操作:从etcd读取keys,就像普通的kv操作一样。第一次Get后,在事务中缓存,后续不再从etcd读取。
If条件:在事务提交时,事务中Get的keys没有被改动过。
MySQL事务“可重复读”是通过在事务第一次select时建立readview,来确保事务中读到的是到这一刻为止的最新数据,忽略后面发生的更新。而这里每个key的Get是独立的(也可以说,每个key都是获取的当前值,没有readview的概念),在事务提交时,如果这些keys没有变动过,那么事务就可以提交。
- Serializable
串行化,顾名思义是对同一行记录,“写”会加“写锁”,“读”会加“读锁”。当出现读写锁冲突的时候,后访问的事务必须等前一个事务执行完成,才能继续执行。
Get操作:事务中的第一个Get操作发生时,保存服务器返回的当前revision;后续对其他keys的Get操作,指定获取revision版本的value。
If条件:在事务提交时,事务中Get的keys没有被改动过。
可见,这个约束比数据库串行化的约束要低,它没有验证事务要修改的keys是否被改动过,下面的SerializableSnapshot事务增加了这个约束。
- SerializableSnapshot
Get操作:事务中的第一个Get操作发生时,保存服务器返回的当前revision;后续对其他keys的Get操作,指定获取revision版本的value。
If条件:在事务提交时,事务中Get的keys没有被改动过,事务中要修改的keys也没有被改动过。
通过上面的分析,我们清楚了如何使用etcd的txn事务,构建符合ACID语义的事务框架。如果这些语义不能满足你的业务需求,通过扩展etcd的官方client sdk,写一个新STM事务类型即可。
有一点要强调的是,数据库事务是“锁/阻塞”模式,而etcd的STM事务是“cas/重试”模式,这是有差别的。简单的说,数据库事务不会自己重试,而STM事务在发生冲突是会多次重试,必须要保证业务代码是可重试的,且必须有明确的失败条件(例如判断账户余额是否够转账)。
参考文章:
https://github.com/etcd-io/etcd/blob/master/Documentation/learning/api.md
https://coreos.com/blog/transactional-memory-with-etcd3.html
https://yuerblog.cc/2017/12/10/principle-about-etcd-v3/