基於etcd3的訪問序列化及分佈式軟事務內存

本文翻譯自Serializability and Distributed Software Transactional Memory with etcd3

新的etcd3 API引入了新的更加強大的原語,相比較於etcd2的限制,這些新的原語充分利用了系統的能力。作爲評估etcd3性能的一部分,我們花費了很大力氣來使用新的API開發分佈式的併發算法。

etcd3的訪問序列化要優於etcd2的隔離模型。當應用更新若干個相關的key時,通常需要這些更新要麼全部成功,要麼全部失敗,進而維持應用程序數據的一致性。在實際應用中,etcd3的事務操作和它的多修訂版本數據存儲給予了etcd一種用於表達原子性的方式,這種原子性基於對多次修訂的序列化。在etcd2上,每一個key的更新是獨立提交到數據存儲上的;這樣就無法做到整個提交的原子性。爲了評判etcd3的原語是否正確以及性能情況,我們實現了通用的分佈式同步控制“食譜”,並進行了基準測試。

這篇文章關注於etcd3新的最小事務接口所提供的原子性。我們將涵蓋etcd3的事務,並且演示通過事務來實現原子更新。接着,我們將通過概要介紹一個簡單的客戶端側軟事務實現來展示etcd的修訂元數據如何自然的映射到軟事務內存(STM)上。最後,我們將說明這種軟事務內存實現是分佈式共享鎖的一種高性能替代。

序列化(訪問序列化)

像etcd這類分佈式一致性系統時常要處理非常多的來自不同併發客戶端的併發請求。儘管有衆多併發的讀和寫,原子性依然能夠保證在每一次數據修訂時數據模型是一致的。有很多文獻討論過各種方式來實現,或者是說明如何在分佈式系統中做到原子性。etcd3的API支持了很多典型的模式。

對於整個key-value存儲而言,序列化給整個過程構建了一個時間軸上的點。一組序列化後的讀操作將無法觀測到自首次讀取之後的所有新的寫操作(譯者注:這裏的意思是一組序列化後的對同一個key的讀操作,首次讀取其值value之後,後續的讀都是讀到首次讀取時的value,後續有寫操作更改了這個key的值,這組序列化的讀,仍然是讀到更改之前的value);所有的讀像是從同一個快照上獲取的數據。序列化一組寫將在一個時間點上要麼發佈一個完整的所有寫操作的更新,要麼一個更新都沒有。部分更新將不會出現,從而不會破壞整個應用程序的狀態。

然我們通過一個示例來說明原子性對於避免錯亂數據的重要性。下面的代碼將從一個賬戶轉移一定數量的資金到另外一個賬戶:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31

import (
    "fmt"
    "encoding/binary"
    "github.com/coreos/etcd/clientv3"
)

func toUInt64(v []byte) uint64 { x, _ := binary.Uvarint(v); return x }
func fromUInt64(v uint64) []byte {
    b := make([]byte, binary.MaxVarintLen64);
    return b[:binary.PutUvarint(b, v)]
}

func nosyncXfer(etcd *clientv3.Client, from, to string, amount uint64) (err error) {
	var fromKV, toKV *clientv3.GetResponse
	if fromKV, err = etcd.Get(context.TODO(), from); err != nil {
		return err
	}
	if toKV, err = etcd.Get(context.TODO(), to); err != nil {
		return err
	}
	fromV, toV := toUInt64(fromKV.Kvs[0].Value), toUInt64(toKV.Kvs[0].Value)
	if fromV < amount {
		return fmt.Errorf("insufficient value")
	}
	if _, err = etcd.Put(context.TODO(), to, string(fromUInt64(toV+amount))); err != nil {
		return err
	}
	_, err = etcd.Put(context.TODO(), from, string(fromUInt64(fromV-amount)))
	return err
}

(譯者注:原文示例代碼有諸多小bug,無法編譯執行,翻譯稿根據實際接口對代碼進行了調整,可正常執行)

雖然整個示例代碼非常的直觀,但在併發訪問時,給定一個不適當的導致衝突的訪問順序,併發的處理還是會破壞整個應用的狀態。下圖顯示了一個導致衝突的時間順序,兩個併發的處理進程P1和P2分別基於公用etcd服務執行nosyncXfer。每一個方框表示了進程在收到一個消息後(用一個帶箭頭的線條表示)認爲的當前etcd的鍵值數據狀態。例如,進程P2在P1發起更新”a”和”b”之前,收到了”a”(粗體),進而導致P2多記錄了不一致的a值(紅色),並將其寫回到etcd中

大多數系統在處理示例代碼中所需的原子性時,要麼是藉助於分佈式共享鎖,要麼是基於事務工具。最終的,一些機制都會強制要求以原子的方式訪問這一組key,同時還保持容錯並避免在競爭爭用下的性能下降。對於etcd3而言,這個原子性機制就是事務。

etcd3事務

etcd3向etcd API中引入了事務用於原子性的更新一組key。一個etcd3事務是一個元語操作,他由歸屬於事務塊中的Get、Put和Delete操作構成,並在etcd存儲上受到事務保護。基於事務元語,使得構建各種複雜的併發控制算法成爲可能。例如,etcd事務能夠清晰的支持客戶端軟件事務內存(STM)

事務元語

一個etcd事務是一個編碼在etcd協議中的元語。這個元語使得客戶端能在單次數據修訂中提交對多個key的操作,整個操作單元在etcd上是序列化的。除了批量操作外,事務性也被保持;一個事務基於etcd存儲上的狀態條件來控制哪些操作將被提交。

一個etcd事務的結構如下所示:
Txn().If(cond1, cond2, ...).Then(op1, op2, ...,).Else(op1’, op2’, …)

一個事務由3個組成部分:條件塊;成功塊;失敗塊。首先是條件塊,例如上面的If(cond1, cond2, …),如果所有的條件(例如,cond1, cond2, …)都爲真,那麼整個事務被認爲成功,如果其中任何一個條件爲假,則事務被視爲失敗。成功塊,例如上面的Then(op1, op2, …),當事務被認爲成功時,將在單次數據修訂中應用其中的所有操作(例如,op1, op2, …)。失敗塊,例如上面的Else(op1’, op2’, …),當事務被視爲失敗時,將在一次版本修訂中應用其中的所有操作(例如,op1, op2, …)

條件塊是多個條件的連接組合。每一個條件針對一個key有一個比較目標(值,創建的修訂版本,修改的修訂版本或者版本)和一個比較操作(<, =, >)。nosyncXfer示例中的問題,是覆蓋了一個已經被取代(修改過的)key,這個是可以避免的,可以通過如果key的修改的修訂版本和之前獲取的修訂版本不一致,則整個更新失敗來保證。

下面是示例代碼,通過事務來保證安全的更新:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
func txnXfer(etcd *clientv3.Client, from, to string, amount uint64) error {
	for {
		if ok, err := doTxnXfer(etcd, from, to, amount); err != nil {
			return err
		} else if ok {
			return nil
		}
	}
}

func doTxnXfer(etcd *clientv3.Client, from, to string, amount uint64) (bool, error) {
	getresp, err := etcd.Txn(context.TODO()).
		Then(clientv3.OpGet(from), clientv3.OpGet(to)).
		Commit()
	if err != nil {
		return false, err
	}
	fromKV := getresp.Responses[0].GetResponseRange().Kvs[0]
	toKV := getresp.Responses[1].GetResponseRange().Kvs[0]
	fromV, toV := toUInt64(fromKV.Value), toUInt64(toKV.Value)
	if fromV < amount {
		return false, fmt.Errorf("insufficient value")
	}
	txn := etcd.Txn(context.TODO()).If(
		clientv3.Compare(clientv3.ModRevision(from), "=", fromKV.ModRevision),
		clientv3.Compare(clientv3.ModRevision(to), "=", toKV.ModRevision))
	txn = txn.Then(
		clientv3.OpPut(from, string(fromUInt64(fromV-amount))),
		clientv3.OpPut(to, string(fromUInt64(toV+amount))))
	putresp, err := txn.Commit()
	if err != nil {
		return false, err
	}
	return putresp.Succeeded, nil
}

 

這段代碼是基於原始版本的一點改進。所有的Get請求在一個事務中,在獲取fromto時,期間的寫操作不會對其產生影響。類似的,所有的Put請求也在一個事務中,進而確保fromto在最近被獲取後,沒有被修改過。下圖是一個衝突過程的演示,原本會破壞數據一致性,但現在會接收一個事務,而另一個事務會失敗並且重試

軟件事務內存(STM)

通過事務重寫的示例解決了數據一致性遭到破壞的問題,但是代碼有很多不足之處。代碼有些不夠自然;在前頭讀取數據時的手動事務提交,跟蹤修訂版本,以及顯示重試對一個樣板模式而言,都顯得太笨拙。理想情況下,安全的數據處理就像和普通的被隔離的內存數據處理一樣直觀。

在由修改修訂版本保護的事務中打包各種訪問是對軟件事務內存(STM)的硬編碼。就像被修改修訂版本保護的事務那樣,STM系統會檢測內存訪問衝突,進而恢復,安全的回滾任何修改。在一個樂觀的STM系統中,事務上下文會記錄所有的讀操作,並分別緩存讀集和寫集的所有寫操作。在提交事務時,系統會校驗任何讀衝突(參見下面的示例)。一個無衝突的事務會將所有寫操作集合寫回內存。如果有衝突,則會重試,或者終止事務。

爲了演示STM的重試過程,上面的圖重新展示了通過STM解決P1和P2衝突的過程。像之前一樣,P1在P2收到”a”和”b”之間更新了”a”和”b”。P1的更新增加了key的修改修訂版本至{a:2, b:2}當P2嘗試通過STM提交事務時,老的讀獲取的”a”的修訂版本信息(1)與當前的修訂版本信息(2)衝突,導致服務器拒絕本次提交。P2的事務接着重試,重新獲取讀信息,重新應用事務,並最終無衝突的提交事務。

下面是通過etcd3的STM客戶端重寫的示例代碼:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
import conc "github.com/coreos/etcd/clientv3/concurrency"
func stmXfer(etcd *clientv3.Client, from, to string, amount uint64) error {
	_, err := conc.NewSTMRepeatable(context.TODO(),
		etcd,
		func(s conc.STM) (err error) {
			fromV := toUInt64([]byte(s.Get(from)))
			toV := toUInt64([]byte(s.Get(to)))
			if fromV < amount {
				return fmt.Errorf("insufficient value")
			}
			s.Put(to, string(fromUInt64(toV+amount)))
			s.Put(from, string(fromUInt64(fromV-amount)))
			return nil
		})

	if err != nil {
		return err
	}

	return nil
}

 

利用STM的版本更加簡單:將一個函數交給STM運行時,由這個函數來處理細節。這樣錯誤處理更少;STM層會自動捕獲etcd錯誤並終止事務。事務示例中的重試循環也不見了,因爲STM系統會自動進行重試。作用域也更爲簡單;事務可以通過在事務函數中返回錯誤碼或者通過取消事務內存上下文的方式終止(例如, context.TODO())。最終,各種繁瑣記賬行爲將更少:比較修訂版本數據和構建事務都由STM的客戶端代碼負責完成了。

實現STM

etcd3的軟件事務內存(STM)是基於v3 API的原語實現的。爲了展示使用etcd3的STM協議的機制,我們將在70行的Go代碼上概述一個簡單的可重複讀取的樂觀STM算法。這個實現包括了一個STM的一些通用特性,例如事務讀操作集、寫操作集管理,數據訪問,提交,重試和中斷。

STM系統有很多值得期待的特性。首先,事務必須是原子的;一次提交要麼全部成功,要麼全部不成功。第二,事務必須至少具備可重複的讀隔離(完全序列化,所有讀都是在同一個修訂版本上,針對於我們的示例代碼無關緊要)。第三,他必須能保持一致性;提交能夠檢測到數據的衝突,並能進行重試,解決衝突。

事務循環

STM的處理過程由他的事務循環來控制:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
func NewSTM(ctx context.Context, c *v3.Client, apply func(*STM) error) <-chan error {
    errc := make(chan error, 1)
    go func() {
        defer func() {
            if r := recover(); r != nil {
                e, ok := r.(stmError)
                if !ok { panic(r) }
                errc <- e.err
            }
        }()
        var err error
        for {
            s := &STM{c, ctx, make(map[string]*v3.GetResponse), make(map[string]string)}
            if err = apply(s); err != nil { break }
            if s.commit() { break }
        }
    }()
    return errc
}

 

事務循環管理STM事務的整個生命週期。一個新的事務啓動一個事務循環,並且這個調用將返回一個future用於通知循環的結束。循環創建新的簿記數據結構,運行用戶提供的apply函數來訪問一些key,之後提交事務。如果STM的運行時無法訪問etcd(例如,網絡故障)或者context被取消,它將使用Go的panic/recover來取消事務。如果有衝突,循環將重複執行,通過一個新的事務來重試。

讀操作集和寫操作集

下面的結構描述了整個STM事務的上下文(context):

1
2
3
4
5
6
type STM struct {
   c *v3.Client
   ctx context.Context
   rset map[string]*v3.GetResponse
   wset map[string]string
}

 

一個STM事務上下文追蹤運行的事務的狀態。他保留了一個客戶端引用從而可通過事務中的GetPut請求來獲取數據。這些GetPut請求來自於提交階段衝突檢測過程中的讀操作集(rset)和寫操作集(wset)。用戶同樣可取消這個事務,通過對上下文執行取消操作即可。

Get和Put

STM的GetPut方法會檢測和緩存etcd上key的訪問:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
type stmError struct { err error}

func (s *STM) Get(key string) string {
    if wv, ok := s.wset[key]; ok {
       return wv
    }
    if rv, ok := s.rset[key]; ok {
        return string(rv.Kvs[0].Value)
    }
    rk, err := s.c.Get(s.ctx, key, v3.WithSerializable())
    if err != nil {
        panic(err)
    }
    s.rset[key] = rk
    return string(rk.Kvs[0].Value)
}

func (s *STM) Put(key, val string) { s.wset[key] = val }

 

GetPut跟蹤由事務管理的數據。對於Put方法,key的值存儲在寫操作集中,延遲實際的更新,直到事務提交的時候才執行。對於Get方法,key的值是基於他最新能觀察到的值:如果在寫操作集上被覆寫,則值來自寫操作集;如果已經緩存了,則來自讀操作集,或者如果兩者都沒有,則強制來自etcd。所有來自etcd的強制讀取都將更新讀操作集,從而做到可重複讀取的隔離,並且會在衝突解決階段跟蹤key的修訂版本信息。

提交

當apply函數完成後,所有修改將通過事務提交回etcd:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
func (s *STM) commit() bool {
    cs := make([]v3.Cmp, 0, len(s.rset))
    for k, rk := range s.rset {
        cs = append(cs, v3.Compare(v3.ModRevision(k), “=”, rk.Kvs[0].ModRevision))
    }
    puts := make([]v3.Op, 0, len(s.wset))
    for k, v := range s.wset {
        puts = append(puts, v3.OpPut(k, v))
    }
    txnresp, err := s.c.Txn(s.ctx).If(cs…).Then(puts…).Commit()
    if err != nil {
        panic(err)
    }
    return txnresp.Succeeded
}

 

這個提交事務是基於讀操作集和寫操作集來構建的。爲了檢測衝突,事務由所有讀操作集的修改修訂版本保護;如果有任何一個key被更新了,事務將會失敗。如果沒有檢測到衝突,事務將把寫操作集的數據寫到etcd,並最終成功。

基於etcd的STM性能

這一章評估基於etcd的STM的性能。如果STM符合預期的工作,其示例代碼的請求吞吐量應該與key的數量成正比。相反,分佈式鎖的請求吞吐量是保持穩定的。接着,通過比較可重複讀隔離策略與序列化隔離策略,我們深入的看一看STM隔離策略對吞吐量的影響

上圖顯示了對示例代碼建模的基準測試結果,使用了etcd3的基準測試工具”stm”命令。與預期相符,鎖的吞吐量保持恆定,而STM的吞吐量隨着key的增加而增加(譯者注:我認爲是因爲隨着key的數量增加,多個事務訪問不同的key,之間的衝突變的更少,“並行化”可以變得更高,從而使得STM的吞吐量提高)。read-committed訪問會破壞數據一致性,因爲他不解決衝突,與read-committed訪問相比序列化的STM僅僅增加了20%的額外開銷。與鎖相比,STM在大規模key時要快了15倍。令人驚訝的是,可重複的讀隔離,儘管是較弱的隔離和可能需要較少的重試,但與串行化STM相比,性能卻更差些。這也許是因爲串行化隔離在實現上會在重試時預獲取讀操作集,而可重複的讀隔離只在需要時纔去獲取key的值。

如上圖所示的重試機率證明了不同隔離級別的效果。在key較少時,衝突較多,此時可重複讀隔離比串行化隔離重試更少,因爲衝突策略允許更多的穿插寫。隨着key數量的增加,衝突的機率降低,可重複讀隔離的優勢則越來越少。最終,序列化的隔離策略將比可重複讀隔離具有更少的重試次數,因爲他有更快的重試邏輯,預讀取讀操作集,節省了獲取數據的週期,縮短了衝突窗口。

接下來幹什麼?

這篇文章演示瞭如何使用etcd3的事務來作爲強制序列化的一種機制。另外,etcd3上新的多修訂版本存儲和事務操作對於構建更高階抽象,例如軟件事務內存,已經足夠豐富了。同時,這些原語有很好的性能,即使使用了etcd3的格外功能,STM請求吞吐量隨着規模的擴大快速超過鎖的請求吞吐量。

雖然etcd3還相對比較新,但etcd3的事務已經在實際應用中使用了。對於容器調度者, Kubernetes的新的etcd3後端已經從事務上受益。他通過在API對象之間構建一致性的關係模型,能夠一次性的,原子地修補多個對象的屬性。對於存儲,Torus分佈式存儲系統從頭開始構建,以便使用etcd3的事務來發起一致的塊元素更新。我們很高興看到社羣將如何使用etcd3的事務。

是否有興趣嘗試使用etcd3的事務?etcd的代碼是開源的並託管在etcd的github項目主頁上。可以在etcd3文檔頁上找到更多關於etcd3的信息。在性能測試中用到的etcd3的STM客戶端源代碼也許能在v3客戶端的concurrency包中找到

 

基於etcd3的訪問序列化及分佈式軟事務內存 | lday的博客

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章