Percolator模型及其在TiKV中的實現

vivo網際網路技術發表於2021-09-22

一、背景

Percolator是Google在2010年發表的論文《Large-scale Incremental Processing Using Distributed Transactions and Notifications》中提出的一種分散式事務解決方案。在論文中該方案是用來解決搜尋引擎的增量索引問題的。

Percolator支援ACID語義,並實現了Snapshot Isolation的事務隔離級別,所以可以將其看作是一種通用的分散式事務解決方案。Percolator基於google自己的Bigtable來實現的,其本質上是一個二階段提交協議,利用了Bigtable的行事務。

二、架構

Percolator 包含三個元件:

  • Client:Client 是整個協議的控制中心,是兩階段提交的協調者(Coordinator);

  • TSO:一個全域性的授時服務,提供全域性唯一且遞增的時間戳 (timetamp);

  • Bigtable:實際持久化資料的分散式儲存;

2.1. Client

二階段提交演算法中有兩種角色,協調者和參入者。在Percolator中,Client充當協調者的角色,負責發起和提交事務。

2.2. Timestamp Oracle (TSO)

Percolator依賴於TSO提供一個全域性唯一且遞增的時間戳,來實現Snapshot Isolation。在事務的開始和提交的時候,Client都需要從TSO拿到一個時間戳。

2.3 Bigtable

Bigtable從資料模型上可以理解為一個multi-demensional有序Map,鍵值對形式如下:

(row:string, column:string,timestamp:int64)->string

key由三元組 (row, column, timestamp) 組成,value可以是認為byte陣列。

在Bigtable中,一行 (row) 可以包含多個 (column),Bigtable提供了單行的跨多列的事務能力,Percolator利用這個特性來保證對同一個row的多個column的操作是原子性的。Percolator的後設資料儲存在特殊的column中,如下:

(圖片來自:https://research.google

我們主要需要關注三個column,c:lock , c:write , c:data :

  • c:lock ,在事務Prewrite的時候,會在此column中插入一條記錄

  • **c:write **,在事務commit的時候,會在此column中插入一條記錄

  • **c:data **,儲存資料本身

2.4 Snapshot Isolation

  • 事務中所有的讀操作都會讀到一個 consistent snapshot 的資料,等同於Repeated Read隔離級別;

  • 兩個併發事務同時對同一個cell寫入時,只會有一個事務能夠提交成功;

  • 當一個事務提交時,如果發現本事務更新的一些資料,被其他比其start time大的事務修改之後,則roll back事務,否則commit事務;

  • 存在write skew問題,兩個事務讀寫的資料集有重疊,但是寫入的資料集沒有重疊,這種情況下,兩個事務都可以成功commit,但是相互都沒有看見對方寫入的新資料,這達不到serializable的隔離級別。但是snpashot isolation相對serializable有更好的讀效能,因為讀操作只需要讀快照資料即可,不需要加鎖。

三、 事務處理

3.1 寫入邏輯

Percolator使用兩階段提交演算法(2PC)來提交事務,這兩個階段分別為 Prewrite 和 Commit。

在Prewrite階段:

1)從TSO中獲取一個timestamp,將其作為事務的start_ts;

2)對事務中需要寫入的每行資料,都會在lock列中寫入事務的start_ts,並在data列中寫入新的資料並附帶start_ts,例如上面的14:"value2"。這些locks中會有一個被選作為primary lock,其他locks叫做secondary locks。每個secondary lock都包含一個指向primary lock的指標。

1. 如果需要寫入的資料中已經有一個比start_ts 更大的新版本資料,那麼當前的事務需要rollback;

2. 如果需要插入lock的行資料中已經存在一個lock,那麼當前事務需要rollback。

在Commit階段:

1)從TSO中獲取一個timestamp,將其作為事務的commit_ts;

2)將primary lock刪除,同時在write列中寫入commit_ts,這兩個操作需要是原子的。如果primary lock不存在了,那麼commit失敗;

3)對所有的secondary locks重複上述步驟。

下面看一個具體的例子,還是一個經典的銀行賬號轉賬的例子,從賬號Bob中轉賬7 dollar到賬號Joe中:

1、在事務開始之前,兩個賬號Bob和Joe分別有10 dollars和2 dollars。

(圖片來自:https://research.google

2、在Prewrite階段,往Bob的lock列中寫入一個lock (7: I am primary),這個lock為primary lock,同時在data列中寫入資料 7:$3。

(圖片來自:https://research.google

3、在Prewrite階段,繼續寫入secondary locks。往Joe的lock列中寫入lock (7:primary@Bob.bal),這個lock指向之前寫入的primary lock,同時在data列中寫入資料 7:$9。

(圖片來自:https://research.google

4、在commit階段,先清除掉primary lock,並在 write 列中使用新的timestamp (也就是commit_ts) 寫入一條新的記錄,同時清除 lock 列中的資料。

(圖片來自:https://research.google

5、在commit階段,清除掉secondary locks,同時在 write 列中以新的timestamp寫入新的記錄。

(圖片來自:https://research.google

3.2 讀取邏輯

1)獲取一個時間戳ts。

2)檢查當前我們要讀取的資料是否存在一個時間戳在[0, ts]範圍內的鎖。

  • 如果存在一個時間戳在[0, ts]範圍的鎖,那麼意味著當前的資料被一個比當前事務更早啟動的事務鎖定了,但是當前這個事務還沒有提交。因為當前無法判斷這個鎖定資料的事務是否會被提交,所以當前的讀請求不能被滿足,只能等待鎖被釋放之後,再繼續讀取資料。

  • 如果沒有鎖,或者鎖的時間戳大於ts,那麼讀請求可以被滿足。

3)從write列中獲取在[0, ts]範圍內的最大 commit_ts 的記錄,然後依此獲取到對應的start_ts。

4)根據上一步獲取的start_ts,從data列獲取對應的記錄。

3.3 處理Client Crash場景

Percolator的事務協調者在Client端,而Client是可能出現crash的情況的。如果Client在提交過程中出現異常,那麼事務之前寫入的鎖會被留下來。如果這些鎖沒有被及時清理,會導致後續的事務無限制阻塞在鎖上。

Percolator採用 lazy 的方式來清理鎖,當事務 A 遇到一個事務 B 留下來的鎖時,事務 A 如果確定事務 B 已經失敗了,則會將事務 B 留下來的鎖給清理掉。但是事務 A 很難百分百確定判斷事務 B 真的失敗了,那就可能導致事務 A 正在清理事務 B 留下來的鎖,而事務 B 其實還沒有失敗,且正在進行事務提交。

為了避免出現此異常,Percolator事務模型在每個事務寫入的鎖中選取一個作為Primary lock,作為清理操作和事務提交的同步點。在清理操作和事務提交時都會修改primary lock的狀態,因為修改鎖的操作是在Bigtable的行事務下進行的,所有清理操作和事務提交中只有一個會成功,這就避免了前面提到的併發場景下可能出現的異常。

根據primary lock的狀態就可以確定事務是否已經成功commit:

如果Primary Lock不存在,且 write 列中已經寫入了 commit_ts,那麼表示事務已經成功commit;

如果Primary Lock還存在,那說明事務還沒有進入到commit階段,也就是事務還未成功commit。

事務 A 在提交過程中遇到事務 B 留下的鎖記錄時需要根據事務 B 的Primary Lock的狀態來進行操作。

如果事務 B 的Primary Lock不存在,且 write 列中有 commit_ts 了,那麼事務

A 需要將事務 B 的鎖記錄 roll-forward。roll-forward操作是rollback操作的反向操作,也就是將鎖記錄清除,並在 write 列中寫入 commit_ts。

如果事務 B 的Primary Lock存在,那麼事務 A 可以確定事務 B 還沒有成功commit,此時事務 A 可以選擇將事務 B 留下鎖記錄清除掉,在清除掉之前,需要將事務 B 的Primary Lock先清理掉。

如果事務 B 的Primary Lock不存在,且 write 列中也沒有 commit_ts 資訊,那麼說明事務 B 已經被 rollback 了,此時也只需要將事務 B 留下的鎖清理掉即可。

雖然上面的操作邏輯不會出現不一致的情況,但是由於事務 A 可能將存活著的事務 B 的Primary Lock清理掉,導致事務 B 被rollback,這會影響到系統的整體效能。

為了解決這個問題,Percolator使用了Chubby lockservice來儲存每個正在進行事務提交的Client的存活狀態,這樣就可以確定Client是否真的已經掛掉了。只有在Client真的掛掉了之後,衝突事務才會真的清除掉Primary Lock以及衝突鎖記錄。但是還可能出現Client存活,但是其實其已經Stuck住了,沒有進行事務提交的動作。這時如果不清理掉其留下的鎖記錄,會導致其他衝突事務無法成功提交。

為了處理這種場景,每個存活狀態中還儲存了一個wall time,如果判斷wall time太舊之後,則進行衝突鎖記錄的處理。長事務則需要每隔一定的時間去更新這個wall time,保證其事務不會因此被rollback掉。

最終的事務衝突邏輯如下:

如果事務 B 的Primary Lock不存在,且 write 列中有 commit_ts 了,那麼事務 A 需要將事務 B 的鎖記錄 roll-forward。roll-forward操作是rollback操作的反向操作,也就是將鎖記錄清除,並在 write 列中寫入 commit_ts。

如果事務 B 的Primary Lock不存在,且 write 列中也沒有 commit_ts 資訊,那麼說明事務 B 已經被 rollback 了,此時也只需要將事務 B 留下的鎖清理掉即可。

如果事務 B 的Primary Lock存在,且TTL已經過期,那麼此時事務 A 可以選擇將事務 B 留下鎖記錄清除掉,在清除掉之前,需要將事務 B 的Primary Lock先清理掉。

如果事務 B 的Primary Lock存在,且TTL還未過期,那麼此時事務 A 需要等待事務 B 的commit或者rollback後繼續處理。

四、在TiKV中的實現及優化

4.1 Percolator在TiKV中的實現

TiKV底層的儲存引擎使用的是RocksDB。RocksDB提供atomic write batch,可以實現Percolator對行事務的要求。

RocksDB提供一種叫做 Column Family(CF) 的功能,一個RocksDB例項可以有多個CFs,每個CF是一個隔離的key命令空間,並且擁有自己的LSM-tree。但是同一個RocksDB例項中的多個CFs共用一個WAL,這樣可以保證寫多個CFs是原子的

在TiKV中,一個RocksDB例項中有三個CFs:CF_DEFAULT、CF_LOCK、CF_WRITE,分別對應著Percolator的data列、lock列和write列。

我們還需要針對每個key儲存多個版本的資料,怎麼表示版本資訊呢?在TiKV中,我們只是簡單地將key和timestamp結合成一個internal key來儲存在RocksDB中。下面是每個CF的內容:

  • F_DEFAULT: (key,start_ts) -> value

  • CF_LOCK: key -> lock_info

  • CF_WRITE: (key,commit_ts) -> write_info

將key和timestamp結合在一起地方法如下:

  • 將user key編碼為 memcomparable 的形式;

  • 對timestamp按位取反,然後編碼成big-endian的形式;

  • 將編碼後的timestamp新增到編碼後的key之後。

例如,key key1和時間戳 3 將被編碼成 "key1\\x00\\x00\\x00\\x00\\xfb\\xff\\xff\\xff\\xff\\xff\\xff\\xff\\xfe"。這樣同一個Key的不同版本在rocksdb中是相鄰的,且版本比較大的資料在舊版本資料的前面。

TiKV中對Percolator的實現與論文中稍有差別。在TiKV中,CF_WRITE中有4中不同的型別的資料:

  • **Put **,CF_DEFAULT中有一條對應的資料,寫入操作是一個Put操作;

  • Delete ,表示寫入操作是一個Delete操作;

  • Rollback ,當回滾一個事務的時候,我們不是簡單地刪除CF_LOCK中的記錄,而是在CF_WRITE中插入一條Rollback的記錄。

  • Lock

4.2 Percolator在TiKV中的優化

4.2.1 Parallel Prewrite

對於一個事務來說,我們不以one by one的形式去做Prewrite。當我們有多個TiKV節點時,我們會在多個節點上並行地執行Prewrite。

在TiKV的實現中,當提交一個事務時,事務中涉及的Keys會被分成多個batches,每個batch在Prewrite階段會並行地執行。不需要關注primary key是否第一個Prewrite成功

如果在事務在Prewrite階段發生了衝突,事務會被回滾。在執行回滾時,我們是在CF_WRITE中插入一條Rollback記錄,而不是Percolator論文中描述的刪除對應地鎖記錄。這條Rollback記錄表示對應的事務已經rollback了,當一個後續的Prewrite請求到來時,這個Prewrite不會成功。這種情況可能在網路異常的時候會出現。如果我們讓Prewrite請求成功,正確性還是可以保證,但是這個key會被鎖定,直到鎖記錄過期之後,其他事務才可以再次鎖定此key。

4.2.2 Short Value in Write Column

當我們訪問一個value時,我們需要先從CF_WRITE中找到key對應最新版本start_ts,然後從CF_DEFAULT中找到具體的記錄。如果一個value比較小的話,那麼查詢RocksDB兩次開銷相對來說有點大。

在具體實現中,為了避免short values兩次查詢RocksDB,做了一個優化。如果value比較小,在Prewrite階段,我們不會將value放到CF_DEFAULT中,而是將其放在CF_LOCK中。然後在commit階段,這個value會從CF_LOCK移動到CF_WRITE中。然後我們在訪問這個short value時,就只需要訪問CF_WRITE就可以了,減少了一次RocksDB查詢。

4.2.3 Point Read Without Timestamp

對於每個事務,我們需要先分配一個start_ts,然後保證事務只能看到在start_ts之前提交的資料。但是如果一個事務只讀取一個key的資料,我們是否有必要為其分配一個start_ts呢?答案是否定的,我們只需要讀取這個key的最新資料就可以了。

4.2.4 Calculated Commit Timestamp

為了保證Snapshot Isolation,我們需要保證所有的transactional reads是repeatable的。commit_ts應該足夠大,保證不會出現一個事務在一次valid read之前被提交,否則就沒發保證repeatable read。例如:

Txn1 gets start_ts 100

Txn2 gets start_ts 200

Txn2 reads key "k1" and gets value "1"

Txn1 prewrites "k1" with value "2"

Txn1 commits with commit_ts 101

Tnx2 reads key "k1" and gets value "2"

Tnx2讀取了兩次"k1",但是得到了不一樣的結果。如果commit_ts從PD上分配的,那麼肯定不存在此問題,因為如果Txn2的第一次read操作發生在Txn1的Prewrite之前,Txn1的commit_ts肯定發生在完成Prewrite之後,那麼Txn2的commit_ts肯定大於Txn1的start_ts。

但是,commit_ts也不能無限大。如果commit_ts大於實際時間的話,那麼事務提交的資料新的事務可能讀取步到。如果不向PD詢問,我們是不能確定一個時間戳是否超過當前的實際時間的。

為了保證Snapshot Isolation的語義以及資料的完整性,commit_ts的有效範圍應該是:

max(start_ts,max_read_ts_of_written_keys)<commit_ts<=now

commit_ts的計算方法為:

commit_ts=max(start_ts,region_1_max_read_ts,region_2_max_read_ts,...)+

其中region_N_max_read_ts為region N上所有讀操作的最大時間戳,region N為事務所涉及的所有region。

4.2.5 Single Region 1PC

對於非分散式資料庫來說,保證事務的ACID屬性是比較容易地。但是對於分散式資料庫來說,為了保證事務地ACID屬性,2PC是必須地。TiKV使用地Percolator演算法就是一種2PC演算法。

在單region上,write batches是可以保證原子執行地。如果一個事務中影響的所有資料都在一個region上,2PC是沒有必要的。如果事務沒有write conflict,那麼事務是可以直接提交的。

五、總結

優點:

  • 事務管理建立在儲存系統之上,整體系統架構清晰,系統擴充套件性好,實現起來簡單;

  • 在事務衝突較少的場景下,讀寫效能還不錯;

缺點:

  • 在事務衝突較多的場景下,效能較差,因為出現了衝突之後,需要不斷重試,開銷很大;

  • 在採用MVCC併發控制演算法的情況下也會出現讀等待的情況,當存在讀寫衝突時,對讀效能有較大影響;

總體上Percolator模型的設計還是可圈可點,架構清晰,且實現簡單。在讀寫衝突較少的場景下,能夠有還不錯的效能。

六、引用

1. Codis作者首度揭祕TiKV事務模型,Google Spanner開源實現

2. Google Percolator 事務模型的利弊分析

3. Large-scale Incremental Processing Using Distributed Transactions and Notifications – Google Research

4. Database · 原理介紹 · Google Percolator 分散式事務實現原理解讀 (taobao.org)

作者:vivo網際網路資料庫團隊-Wang Xiang

相關文章