本文简单总结了etcd的事务逻辑。
1. 简单事务
etcd的事务是基于乐观锁实现的,它的事务调用方式是经典的CAS。
因为是cas,也就不支持回滚,即要么全部成功要么全部失败。
使用方法如下:
- Txn(): 开始一个事务
- If(cond1, cond2, ...): 定义事务的提交条件。这些条件通常是关于某个 key 的比较操作,例如检查 key 是否存在,或者当前的值是否等于预期的值。
- Then(op1, op2, ...): 如果所有条件都满足,那么就执行这里的操作。操作可以是 put、delete 或者 get 等。
- Else(op1, op2): 如果条件不满足,那么就执行这里的操作。
Txn().If(cond1, cond2, ...).Then(op1, op2, ...,).Else(op1, op2)
上面的操作在etcd中是原子性的,要么全部成功,要么全部失败
if 主要比较对应的版本号是否一致。
etcd数据的版本只有写事务才会增加,读事务不增加。
2. stm
为了简化 etcd 事务实现的过程,etcd v3 提供了 STM,会自动处理冲突以及重试。
// STM is an interface for software transactional memory.
type STM interface {
// Get returns the value for a key and inserts the key in the txn's read set.
// If Get fails, it aborts the transaction with an error, never returning.
Get(key ...string) string
// Put adds a value for a key to the write set.
Put(key, val string, opts ...v3.OpOption)
// Rev returns the revision of a key in the read set.
Rev(key string) int64
// Del deletes a key.
Del(key string)
// commit attempts to apply the txn's changes to the server.
commit() *v3.TxnResponse
reset()
}
stm后两个级别第一次都是是线性读,后面才是串行读。
每次读写请求都会创建事务,只有写事务会导致全局的事务版本号增加,串行读不会导致版本号增加
stm的事务隔离级别,分为四种:
- ReadCommitted: 读已提交隔离级别,不作任何检查,每次的Get请求都是串行读,也就是不用raft集群同步,直接返回结果的读,所以每次get结果版本号可能不一致。读已提交能接受读过的key发生变化。
- RepeatableReads :可重复读隔离级别,会缓存所有读过的key,提交时会检查所有的读过key版本是否发生过变化,所有的读都是串行读。可重复读不能接受读过的key发生变化。
- Serializable : 串行化隔离级别,因为第一次是线性读,会经过raft集群同步,第一个get获取到事务的版本号,后面的获取都会带上对应的版本号(带上版本号的读只有读到这个版本号之前的数据),同时会检查对应的读过key版本是否发生变化。串行化隔离级别只能读到这个事务开启之前和这个事务过程中更新的key的数据,且读到的key在事务提交时不能发生变化。
- SerializableSnapshot:串行化快照隔离级别,会同时检查所有的读key版本是否发生过变化和检查写的key的版本是不是事务开始的版本+1,即在事务期间要求写的key没有被修改过。串行化快照隔离级别只能读到这个事务开启之前和这个事务过程中更新的key的数据,读到的key在事务提交时不能发生变化,要写的key没有被更后的事务修改过。
Serializable和SerializableSnapshot 一个对读进行检查,一个对读写都进行检查。
上面的检查不通过,会进行重试,直到通过为止。
stm的读请求都是一次事务,写请求是放在一起进行提交的
func (s *stm) Get(keys ...string) string {
if wv := s.wset.get(keys...); wv != nil {
return wv.val
}
return respToValue(s.fetch(keys...))
}
func (ws writeSet) get(keys ...string) *stmPut {
for _, key := range keys {
if wv, ok := ws[key]; ok {
return &wv
}
}
return nil
}
func (s *stm) fetch(keys ...string) *v3.GetResponse {
if len(keys) == 0 {
return nil
}
ops := make([]v3.Op, len(keys))
for i, key := range keys {
if resp, ok := s.rset[key]; ok {
return resp
}
ops[i] = v3.OpGet(key, s.getOpts...)
}
txnresp, err := s.client.Txn(s.ctx).Then(ops...).Commit()
if err != nil {
panic(stmError{err})
}
s.rset.add(keys, txnresp)
return (*v3.GetResponse)(txnresp.Responses[0].GetResponseRange())
}
func (s *stm) commit() *v3.TxnResponse {
txnresp, err := s.client.Txn(s.ctx).If(s.conflicts()...).Then(s.wset.puts()...).Commit()
if err != nil {
panic(stmError{err})
}
if txnresp.Succeeded {
return txnresp
}
return nil
}
3. backend
backend 模块就是负责将buffer的数据异步同步到boltdb。
在etcd的底层,所有的写事务和线性读都会走raft集群同步,然后通过apply模块一条一条进行应用。
etcd 的事务没有回滚功能。
写请求的事务是相互阻塞,一个一个去执行的,完成后全局的事务版本号才会增加。
生成每个新的读写事务,会携带生成时的全局事务版本号。
写事务的put请求会通过backend模块写入对应writebuffer,然后定时批量同步到boltdb
writebuffer在事务提交后,需要写回到readbuffer,此时为了保护readbuffer数据,出现了读写锁。
通过读写锁,实现了读读并发。
但读写锁性能还是不太好!etcd在3.4版本进一步优化,提出了并发读事务,也就是读事务不会被写事务阻塞(上面的读写锁,在写锁期间,读事务会在写事务执行期间被阻塞),原理很简单,就是对于每个版本的readbuffer拷贝一份,每个并发读事务使用对应版本的readbuffer,也就是空间换时间,缺的内存消耗大。