「本文已參與好文召集令活動,點擊查看:後端、大前端雙賽道投稿,2萬元獎池等你挑戰!」html
下面是摘自《Principles of Distributed Database Systems, 3rd Edition》中關於事務的一段描述,講述了事務實現所依賴的組件:前端
事務是對數據庫進行一致、可靠訪問的基本單元,做爲一個比較大的原子操做,負責將數據庫從一個狀態轉移到另外一個狀態。爲了知足一致性,須要對數據完整性限制進行定義,而且須要併發控制算法來協調多個事務的執行。併發控制也會處理隔離性的問題,事務的持久性和原子性須要可靠性的支持。持久性是由不一樣的提交協議和提交管理方法實現的;另外爲了知足原子性,還須要開發恰當的恢復協議。ios
事務須要知足ACID屬性,即:原子性、一致性、隔離性、持久性。本篇主要分析隔離性的實現,對其餘三個屬性僅略做說明。算法
隔離級別的定義是從數據庫併發訪問所出現問題引發的,下面是來自postgres針對隔離級別的解釋說明:sql
下表列出了每種隔離級別的含義,主要是描述該級別語義下,上述問題是否容許出現:數據庫
下面解釋了這兩種隔離級別一般的實現手段。後端
snapshot isolation緩存
mvcc:經過版本號來實現快照。markdown
鎖:經過鎖解決write-write衝突。這裏有悲觀和樂觀兩種處理衝突的方式。
serializable
大多數狀況下,都將這兩種隔離級別歸類爲同一種隔離級別,可是兩種隔離級別仍是有細微差異。不過目前主流的數據庫都採用SI,下面是兩種隔離級別分別存在的問題。
這種隔離級別的實現方式是經過鎖來實現的。當T1要按照某個條件查詢數據時,若是查出了10行,就會對這10行數據加鎖。後來的事務若是想改這10行數據,就會申請鎖失敗,所以T1再次讀這10行數據時,內容不會發生變化,這就是repeatable read語義。
可是若是另一個事務T2插入了一行新的數據,這行數據知足T1的過濾條件,這行數據在T1執行時是沒法加鎖的(由於尚未這行數據),T2提交以後,T1若是再次執行一樣的查詢,會查出11行數據,這就是幻讀問題。
SI沒有幻讀問題。SI隔離級別經過快照方式進行數據隔離,所以對於上面的例子,T1執行時的快照不會受T2的影響,T2插入的數據在T1的生命週期中是不會被查詢到的,所以SI隔離級別沒有幻讀問題。
可是SI有Write Skew問題,關於Write Skew問題的例子以下圖,兩個事務:T1要把全部的白球染成黑色,T2要把全部的黑球染成白色。若是是serializable隔離級別,則要麼最終全爲白色,要麼最終全爲黑色,根據事務執行的前後順序而定;若是是SI隔離級別,T1和T2基於同一個快照來執行本身的事務,因爲兩個事務修改的數據不一樣,因此兩個事務不會產生衝突,會分別成功,致使最終的結果與指望不一樣(T1指望全黑,T2指望全白)。也就是說SI隔離級別下,兩個快照會產生遮擋效果。
而repeatable read反而沒有Write Skew問題,由於T1提交前,爲了保證repeatable read的語義,是會經過讀鎖的約束,不容許T2修改數據庫中已有球的顏色的。若是在SI上經過鎖來解決Write Skew的問題,會致使純讀事務堵塞寫事務,喪失了SI的讀不堵塞寫的優點。
上述例子轉換成一個可驗證性較強的SQL示例以下:
T1: blacknum = select count(*) from balls where color=black;
T1: if blacknum > 0 update balls set color=black where color=white;
T2: whitenum = select count(*) from balls where color= white ;
T2: if whitenum > 0 update balls set color=black where color=black;
T1: commit;
T2: commit;
複製代碼
另一個在yugabyte上測試過的例子:
yugabyte=# select * from users;
username | password | age
-----------+----------+-----
zhangsan3 | 111111 | 12
zhangsan1 | 111111 | 11
/* SI 級別測試 */
T1: begin;
T1: select count(*) from users where age = 12;
T1: update users set age=12 where age=11;
T2: begin;
T2: select count(*) from users where age = 11;
T2: update users set age=11 where age =12;
T1: commit;
T2: commit;
yugabyte=# select * from users;
username | password | age
-----------+----------+-----
zhangsan3 | 111111 | 11
zhangsan1 | 111111 | 12
/* serializable 級別測試 */
T1: begin; set transaction isolation level serializable;
T1: select count(*) from users where age = 12;
T1: update users set age=12 where age=11;
T2: begin; set transaction isolation level serializable;
T2: select count(*) from users where age = 11;
T2: update users set age=11 where age =12;
T1: commit;
T2: commit; ERROR: Error during commit: Operation expired: Transaction expired or aborted by a conflict: 40001
yugabyte=# select * from users;
username | password | age
-----------+----------+-----
zhangsan3 | 111111 | 12
zhangsan1 | 111111 | 12
複製代碼
併發控制是實現隔離級別的手段。好比要實現serializable隔離級別,併發控制算法就須要將全部併發事務嚴格的排序,而後串行調度執行。
兩階段提交併發控制已經在理論上證實是能夠實現serializable隔離級別的併發控制算法。可是2PL有2個比較典型的問題:
這裏之因此排序,主要是要實現serializable隔離級別。這種算法處理衝突的方式是給失敗的事務分配一個新的時間戳,而後重啓該事務,所以TOCC算法不存在死鎖場景,代價是事務會重啓不少次。
基於時間的併發控制算法中,時間戳的生成是最核心的工做。要實現分佈式的全局惟一單調遞增的時間戳是很是困難的工做,目前沒有這方面的解決方案。所以目前的時間戳生成都是每一個節點生成本身的時間戳,這帶來的問題是節點間的時間戳可能不一致,某些節點分配的時間戳落後於其餘節點,這會致使由該節點生成的事務一般會被拒絕而後重啓。
爲了儘可能讓節點間的時間戳保持同步,一個優化方式是節點間經過rpc來互相更新時間戳,兩兩之間選大的那個時間戳。
改進:TO最大的問題是會致使事務過多的重啓,一種改進方式是調度器對要執行的事務進行緩存,而不是當即執行,直到調度器確認不會接受到時間戳更小的請求,此時再對事務進行排序執行。這樣會在必定程度上緩解事務衝突的機率(時間戳小的會被先執行),從而減小事務重啓的機率。不過緩存的方式可能引入死鎖。
多版本TO是另一種用來避免重啓事務的算法。基本的思想是:更新操做不修改數據庫,而是建立一個新版本;每一個版本都標記了與該版本相關的事務的時間戳信息(可能包含事務的start和commit時間戳)。
事務管理程序爲每一個事務分配一個時間戳,事務的讀操做會被轉換爲對某個版本的讀(根據事務的當前時間戳來肯定)。對於寫操做,只有一種狀況會被拒絕:已經存在一個時間戳更大(意味着更晚到來,也就是更新)的事務在目標數據上進行讀,這種狀況纔會拒絕這個時間戳較老的事務的執行。
mvcc不對事務進行嚴格排序執行,此種併發控制一般用於實現snapshot isolation級別。SI是目前商業數據庫採用比較多的隔離級別。SI相對serializable,存在一個問題:Write Skew;在大多數場景下SI已經足夠好。
這是針對併發控制算法所採起的機制的一種表述,主要是從性能角度考慮問題。當咱們假設事務之間的衝突時比較頻繁的時候,一般會採起悲觀算法;反之會採起樂觀算法。
悲觀算法不容許兩個事務對同一個數據項進行衝突訪問,所以悲觀算法的執行步驟以下:
有效性驗證 -> 讀 -> 計算 -> 寫
樂觀算法將有效性驗證移到寫以前執行:
讀 -> 計算 -> 有效性驗證 -> 寫
樂觀算法不會堵塞事務的執行,所以具備更高的併發能力。可是若是併發事務之間衝突比較多,可能會致使事務發生較多的重啓,從而影響性能。樂觀算法適合衝突較少發生的併發場景。
一種實現樂觀鎖的方式是在事務有效性驗證的時候分配時間戳,這樣的分配方式產生的結果就是哪一個事務先commit,哪一個事務會得到較早的時間戳;對於樂觀鎖併發控制來講,過早分配時間戳反而會致使沒必要要的衝突斷定發生。
對於併發控制實現的有效性,一般都有比較嚴格的形式化證實。這裏沒有深刻了解。
yugabyte的隔離級別是基於mvcc+鎖類組合實現的(固然從更高的層面來看待這個問題的話,也能夠認爲鎖是實現mvcc的一部分,這裏分紅兩部分來看待有助於更清楚的理解),這裏具體解釋mvcc和鎖在yugabyte的隔離級別實現中分別解決什麼問題:
mvcc
mvcc主要是解決併發訪問問題。傳統的基於鎖的併發訪問控制性能比較差,緣由是傳統的讀鎖會堵塞全部的寫操做,只有讀-讀操做之間不會堵塞。mvcc的提出專門解決這個問題,用戶的讀操做是對特定版本的數據的訪問,寫操做不會修改該版本的數據,所以不會堵塞寫。另外事務的原子性要求在一個事務內提交的全部數據的版本號必須相同,這保證了讀操做不會讀取到事務執行一半後的結果。
若是系統可以保證一個事務在開始階段獲取的數據庫視圖,在整個事務執行過程當中保持不變(除了事務本身產生的變動),那麼系統就完成了多版本(mv)這一層面的工做。可是隻有多版本還不能徹底解決問題,緣由是當多個用戶基於同一個版本的數據進行修改時,仍是會有衝突產生的,解決併發寫衝突問題仍是須要鎖的參與(或者其餘無鎖數據結構)。
鎖
當衝突發生時,有不少識別衝突的機制。加鎖是用來規避衝突的一種方式,實現起來比較簡單直觀。好比T1修改row1的column1的值時,會加鎖;此時T2若是想作一樣的操做經過檢查鎖的存在與否就能夠提早檢測到衝突。
另外,yugabyte的不一樣隔離級別的控制也是經過鎖來實現的。yugabyte定義了很是細粒度的鎖類型,不一樣隔離級別根據其語義要求,在數據訪問時加不一樣的鎖。好比:兩個SI級別的寫鎖在語義上是衝突的,即:在SI隔離級別下,兩個事務對同一個對象申請read-for-update的寫鎖是不被支持的,從而不容許此類事務併發執行(或者在申請時不作檢查,最終提交時檢測衝突時讓一個失敗,根據悲觀仍是樂觀控制方法不一樣而不一樣)。而對於serializable隔離級別的寫鎖,語義上是不衝突的,因爲在該隔離級別下事務是串行執行,多個事務同時更新同一個目標數據是被容許併發執行的,最終的結果是latest hybrid timestamp wins。值得指出的是,yugabyte的SI級別的純寫鎖(區別於read-for-update鎖)與serializable隔離基本的寫鎖採用的是同一種鎖: strong serializable write lock 。關於鎖的更具體的定義接下來會詳細講解。
對於衝突事務的處理,若是是在commit階段檢查衝突(樂觀鎖),一般的處理方式是First-Committer-Wins(FCW),後提交的直接失敗,從而規避Lost Update問題;若是是在事務開始階段檢查衝突(悲觀鎖),則一般採用 First-write-wins(FWW)機制,後啓動的事務直接失敗。 yugabyte沒有采用FWW,而是採用優先級的方式,優先級高的事務能夠繼續執行,優先級低的直接失敗。yugabyte的優先級分配是隨機數,兩個事務的優先級高低是隨機的。不過悲觀鎖和樂觀鎖的優先級處於不一樣的區間,悲觀鎖的優先級區間的全部值都高於樂觀鎖。所以一個樂觀鎖事務若是與一個悲觀鎖事務衝突,則樂觀鎖的事務必定會被取消。
總結:以上就是yugabyte隔離級別實現的所有考慮,剩下的就是基於這些考慮的具體實現。
數據表示
DocDB的數據在磁盤上是以Tablet爲單位管理的,每一個Tablet對應一個rocksdb文件數據庫實例。 rocksdb是一個kv系統,對於一個特定的Key-Value對,yugabyte經過將事務的時間戳編碼到Key中來標記數據的版本號。
版本管理
yugabyte事務中的數據訪問操做最終都會轉換成對一個或多個Tablet的operation,DocDB接收到的每一個請求都是獨立到某個Tablet的,不存着一個請求操做多個Tablet的狀況,所以一個事務若是須要訪問(修改)多個Tablet的數據,在YQL層面會轉換成多個DocDB的rpc請求,而多個Tablet是可能分佈在不一樣節點上的,這裏就須要YQL經過分佈式事務的方式來管理事務,關於分佈式事務的實現這裏不展開討論,有專門的一篇文章來講明。這裏主要是爲了說明一點:到達DocDB的請求必定是具體到某個Tablet了,因此DocDB的版本管理也是在Tablet內部完成的,不是全局管理。
DocDB經過 operation來抽象全部對Tablet的寫操做,operation能夠看作是DocDB內部的事務。 每一個Tablet內部都有一個MvccManager,MvccManager主要負責時間戳管理,他提供以下特性:
爲了支持SNAPSHOT和SERIALIZABLE兩種隔離級別,yugabyte對鎖進行了以下劃分 :
能夠看到SI read並不加鎖,所以SI隔離級別的read不會堵塞任何其餘事務。 下面是這三種鎖的衝突矩陣:
Fine grained locking:
爲了在更細粒度上減小鎖的衝突,yugabyte按照數據讀寫的特色對鎖進行了更近一步的劃分,好比當修改一行中的某個列的value時,在行上加WeakLock,在要修改的列上加StrongLock;這樣當另外一個事務修改的是同一行的另外一個列的value時,WeakLock之間不衝突, StrongLock也不衝突(由於鎖的是不一樣對象), 因此這個事務能夠併發執行,下面是更細粒度的鎖衝突矩陣:
SharedLockManager實現了DocDB的fine-grained locking,每一個Tablet維護一個 SharedLockManager實例,負責當前Tablet的鎖控制。
yugabyte的鎖類型定義:
YB_DEFINE_ENUM(IntentType,
((kWeakRead, kWeakIntentFlag | kReadIntentFlag))
((kWeakWrite, kWeakIntentFlag | kWriteIntentFlag))
((kStrongRead, kStrongIntentFlag | kReadIntentFlag))
((kStrongWrite, kStrongIntentFlag | kWriteIntentFlag))
);
複製代碼
本節將詳細分析事務的執行流程中涉及併發控制部分的工做機制,在這個流程分析過程當中,咱們將重點關注以下一些問題:
Operation
Operation是對寫操做的事務具體操做的抽象,根據操做類型不一樣又派生出具體的Operation對象,如:
Operation主要封裝了以下接口:
OperationDriver
OperationDriver是對一個事務執行流程的抽象,全部事務的執行都由OperationDriver來管理,事務的執行流程抽象步驟:
Init() :事務開始前會先建立一個OperationDriver對象來管理該事務的執行流程。
ExecuteAsync():將OperationDriver提交到Preparer線程並當即返回,後續的執行由Preparer線程控制。
PrepareAndStartTask():調用Prepare() and Start()。
ReplicationFinished():raft完成複製後調用改回調函數,該回調函數是在Init階段賦值給raft的。
HybridTime
用來多版本控制的混合時間戳實現。
MvccManager
MvccManager負責維護每一個tablet的mvcc控制流程。MvccManager維護了一個deque隊列,用來跟蹤全部operation的時間戳, MvccManager要求全部operation的 replicated順序必須與該operation的時間戳的入隊順序保持一致。也就是對同一個tablet上的全部的operation必須按照時間戳申請的前後順序完成。
Tablet
用戶的一個表中的數據會按partition進行分區管理,每一個分區在yugabyte中稱做一個Tablet,raft複製是以tablet爲單位管理的。 每一個tablet維護本身的MvccManager實例,用以管理當前tablet的混合時間戳分配。
SharedLockManager
併發控制的鎖實現。
TransactionCoordinator
負責分佈式事務狀態管理,即:status tablet的讀寫。同時協調TransactionParticipant完成數據最終寫入到normal_db
TransactionParticipant
負責處理APPLYING和CLEANUP請求,這兩個請求一個是commit成功後將數據從intent_db寫入到normal_db,完成最終的數據提交;另外一個是對abort的事務進行rollback操做,清理垃圾數據(從intent_db中刪除)。
該階段是申請事務,會分別在client端和server端建立YBTransaction實例(metadata相同)。
心跳信息,TakeTransaction以後會當即執行。通知status tablet更新狀態,並維護心跳。
這一步驟是進行數據的讀寫,client端構造doc operation,經過rpc發送給DocDB(TabletService)。事務的隔離級別和鎖的控制都在這裏處理。
申請內存鎖階段(內存鎖只在tablet leader上持有)
這裏針對要修改的目標對象申請鎖的類型進行總結(其父路徑所有申請相應的weak鎖):
1)若是是Serializable,讀操做走寫流程,會申請StrongRead鎖
2)若是是snapshot isolation,讀操做不申請任何鎖。
3)若是是Serializable,UpSert操做申請StrongWrite鎖,其餘寫操 做 (insert/update/delete)申請StrongRead + StrongWrite鎖
4)若是是snapshot isolation,申請 StrongRead + StrongWrite鎖
5)全部父路徑都申請對應的weak鎖。
目前看來,yugabyte把不一樣隔離級別的事務衝突語義所有在該階段完成,保證只要這裏放行的事務,後續的併發執行不會出現問題,全部可能引發數據正確性不符合隔離級別的事務要麼在該階段被取消,要麼把其餘正在執行的事務取消,讓本身得以執行。
數據複製階段\
這一階段經過raft將對數據庫的修改操做複製到全部副本所在節點上,當獲得大多數節點完成複製的響應後,Leader會將數據寫入到rocksdb(對於分佈式事務,寫intent_db,記錄中包含鎖),而後leader會釋放內存鎖。
// 收到commit消息後,會檢查事務是否被其餘事務給取消了
// raft將TransactionStatus::COMMITTED消息複製到全部副本,複製完成後,更新 commit_time_
commit_time_ = data.hybrid_time; // 這個時間戳是 COMMITTED這rpc請求對應的operation在start階段從MvccManager申請的
// 而後調用StartApply將 APPLYING請求入隊,該請求的參數以下:
void StartApply() {
if (context_.leader()) {
for (const auto& tablet : involved_tablets_) { // 通知當前事務的全部涉及到的tablet,執行APPLY操做
context_.NotifyApplying({
.tablet = tablet.first,
.transaction = id_,
.commit_time = commit_time_, // commit_time就是 COMMITTED消息被raft複製完成後的時間戳
.sealed = status_ == TransactionStatus::SEALED});
}
}
}
複製代碼
// COMMITTED 消息處理完後,對發起事務的客戶端來講,事務的流程已經走完了,數據已經可見了。而對yugabyte來講,
// 數據還停留在intent_db中,須要移動到normal_db,並設置LocalCommitedTime,供其餘併發事務檢測衝突使用。
// 該消息是由TransactionParticipant處理的,一樣的會先將該消息經過raft複製到tablet的全部副本,raft複製完該消息後,
// 執行ProcessApply,將數據從intent_db 遷移到 normal_db,而後註冊異步任務刪除intent_db中的數據
HybridTime commit_time(data.state.commit_hybrid_time()); // 這是 COMMITTED的時間戳
TransactionApplyData apply_data = {
data.leader_term,
id, // 事務ID
data.op_id, // operation id
commit_time, // COMMITTED的時間戳
data.hybrid_time, // APPLYING的時間戳
data.sealed,
data.state.tablets(0) };
ProcessApply(apply_data);
// 每一個tablet上的 ProcessApply 工做流程(目前只有局部tablet視角)
CHECKED_STATUS ProcessApply(const TransactionApplyData& data) {
{ // 一頓操做,主要是設置 Local Commit Time
// It is our last chance to load transaction metadata, if missing.
// Because it will be deleted when intents are applied.
// We are not trying to cleanup intents here because we don't know whether this transaction
// has intents of not.
auto lock_and_iterator = LockAndFind(
data.transaction_id, "pre apply"s, TransactionLoadFlags{TransactionLoadFlag::kMustExist});
if (!lock_and_iterator.found()) {
// This situation is normal and could be caused by 2 scenarios:
// 1) Write batch failed, but originator doesn't know that.
// 2) Failed to notify status tablet that we applied transaction.
LOG_WITH_PREFIX(WARNING) << Format("Apply of unknown transaction: $0", data);
NotifyApplied(data);
CHECK(!FLAGS_fail_in_apply_if_no_metadata);
return Status::OK();
}
lock_and_iterator.transaction().SetLocalCommitTime(data.commit_ht);
LOG_IF_WITH_PREFIX(DFATAL, data.log_ht < last_safe_time_)
<< "Apply transaction before last safe time " << data.transaction_id
<< ": " << data.log_ht << " vs " << last_safe_time_;
}
// 經過事務反向索引,找到全部的intent,而後apply到normal_db
CHECK_OK(applier_.ApplyIntents(data));
{// 這裏發起異步刪除 intent 任務(刪除intent_db中的數據)
MinRunningNotifier min_running_notifier(&applier_);
// We are not trying to cleanup intents here because we don't know whether this transaction
// has intents or not.
auto lock_and_iterator = LockAndFind(
data.transaction_id, "apply"s, TransactionLoadFlags{TransactionLoadFlag::kMustExist});
if (lock_and_iterator.found()) {
RemoveUnlocked(lock_and_iterator.iterator, "applied"s, &min_running_notifier);
}
}
NotifyApplied(data);
return Status::OK();
}
複製代碼
事務由於某些緣由(發生錯誤或用戶主動abort)走到abort流程後,須要分別由coordinator變動status tablet狀態,由participate清理垃圾數據。