天天看點

Rocksdb 事務(一): 隔離性的實作

文章目錄

  • ​​前言​​
  • ​​1. 隔離性​​
  • ​​2. Rocksdb實作的隔離級别​​
  • ​​2.1 常見的四種隔離級别​​
  • ​​2.2 Rocksdb 支援的隔離級别及基本實作​​
  • ​​2.2.1 ReadComitted 隔離級别的測試​​
  • ​​2.2.2 ReadCommitted的實作​​
  • ​​2.2.3 RepeatableRead的實作​​
  • ​​2.2.4 事務并發處理​​
  • ​​3. 一些總結​​

前言

Rocksdb 作為單機存儲引擎,已經非常成熟得應用在了許多分布式存儲(CEPH, TiKV),以及十分通用的資料庫之上(mysql, mongodb, Drango等),是以Rocksdb本身需要能夠實作ACID屬性,尤其是其中的不同的隔離級别才能夠作為一個公共的存儲元件。本節,結合rocksdb6.4.6代碼以及官網wiki來梳理一下rocksdb的事務管理以及隔離性的實作。

1. 隔離性

ACID中的隔離性意味着 同時執行的事務之間是互不影響的。這個時候,在一些同時執行事務的場景下,就需要有針對事務的隔離級别,來滿足用戶端針對存儲系統的要求。

Rocksdb 事務(一): 隔離性的實作

圖1.1 兩個客戶之間的競争狀态同時遞增計數器

如上圖1.1,user1和user2對資料庫的通路

  • user1先從資料庫中get,得到了42。完成get事務之後拿着get的結果+1,将43set到資料庫中
  • user1下發set的同時user2從資料庫中get,同樣得到了42,也進行42+1 的操作
  • 兩者的事務都是各自隔離的,且是串行執行互不影響(user2的get并無法同時通路user1 set的結果),保證了結果對使用者的正确性
Rocksdb 事務(一): 隔離性的實作

圖1.2 違反了隔離性:一個事務讀取了另一個事務執行的結果

如上圖中,user2将user1的insert過程中的 hello 作為了自己的輸入,即一個事務能夠讀取另一個事務未被執行狀态。這個過程被稱作髒讀

2. Rocksdb實作的隔離級别

2.1 常見的四種隔離級别

  • ​ReadUncommited​

    ​ 讀取未送出内容,所有事務都可以看到其他未送出事務的執行結果,存在髒讀
  • ​ReadCommited​

    ​ 讀取已送出内容,事務隻能看到其他已送出事務的更新内容,多次讀的時候可能讀到其他事務更新的内容
  • ​RepeatableRead​

    ​ 可重複讀,確定事務讀取資料時,多次操作會看到同樣的資料行(innodb引擎使用快照隔離來實作)。
  • ​Serializability​

    ​ 可串行化,強制事務之間的執行是有序的,不會互相沖突。

2.2 Rocksdb 支援的隔離級别及基本實作

2.2.1 ReadComitted 隔離級别的測試

Rocksdb支援​

​ReadCommited​

​的隔離級别,它能夠提供兩個保障

  • 從資料庫讀時,隻能看到已送出的資料(沒有髒讀(dirty reads):不同僚務之間能夠讀到對方未送出的内容)
  • 寫入資料庫時,隻會覆寫已經寫入的資料(沒有髒寫(dirty writes):不同僚務之間的寫在送出之前能夠互相覆寫)

先看一下簡單的測試代碼:

//支援事務的方式打開rocksdb
  Status s = TransactionDB::Open(options, txn_db_options, kDBPath, &txn_db);
  
  // 開啟事務操作,定義目前事務為t1
  Transaction* txn = txn_db->BeginTransaction(write_options);
  assert(txn);

  // 先下發一個t1的讀操作
  s = txn->Get(read_options, "abc", &value);
  assert(s.IsNotFound());

  // 再下發一個t1的寫操作(注意此時是在同一個事務t1内部,現在隻是不同的操作)
  s = txn->Put("abc", "def");
  assert(s.ok());

  // 在目前事務外部下發一個t2讀操作,确認是否存在髒讀(txn_db->Get是一個不同于目前事務的獨立事務,t2)
  s = txn_db->Get(read_options, "abc", &value);
  std::cout << "t2 Get result " << s.ToString() << std::endl;
  
  // 在目前事務外部下發一個t3寫操作,這裡更新的是不同的key,如果更新相同的key。則t1事務commit的時候會報錯
  //s = txn_db->Put(write_options, "xyz", "zzz");
  s = txn_db->Put(write_options, "abc", "zzz");
  std::cout << "t3 Put result " << s.ToString() << std::endl;
  
  // 送出t1事務
  s = txn->Commit();
  assert(s.ok());
  
  //送出之後再get一次
  s = txn_db->Get(read_options, "abc", &value);
  std::cout << "t4 Get result after commit: " << value << std::endl;
  delete txn;      

輸出如下:

# 兩個事務Get時不可見對方未送出内容,不存在髒讀
t2 Get result NotFound: 
# 在送出之後能夠發現Set的結果也并未生效,不存在髒寫,切Put相同的key發現加鎖逾時
t3 Put result Operation timed out: Timeout waiting to lock key
# t4在t1送出之後get t1的結果的時候能夠看到t1的結果生效
t4 Get result after commit def      

通過這個簡單的測試代碼以及對應的輸出結果,我們能夠看出目前Rocksdb已經能夠支援​

​ReadCommited​

​的隔離級别,不存在髒讀,同時髒寫實作看起來像是通過加鎖來避免的。

2.2.2 ReadCommitted的實作

簡單描述一下該隔離特性,Rocksdb的一個事務操作是通過Rocksdb内部WriteBatch實作的,針對不同僚務Rocksdb會為其配置設定對應的WriteBatch,由WriteBatch來處理具體的寫入。同時針對同一個事務的讀操作,會優先從目前事務的WriteBatch中讀,來保證能夠讀到目前寫操作之前未送出的更新。送出的時候則依次寫入WAL和memtable之中,保證ACID的原子性和一緻性。

大體的流程如下2.1圖

Rocksdb 事務(一): 隔離性的實作

圖2.1 通過WriteBatch實作 ReadCommitted

以上過程結合我們的測試代碼,可以有兩種方式來進行

  • 顯式得通過事務的方式寫入,送出
Transaction* txn = txn_db->BeginTransaction(write_options);
txn->Get(read_option,"abc",&value);
txn->Put("abc","value1");
txn->commit();      
  • 直接通過TransactionDB生成一個auto transaction,transactionDB會将這個單獨的操作封裝成事務,并自動commit。
txn_db->Get(read_options, "abc", &value);
txn_db->Put(write_options, "abc", "zzz");      

一種transactionDB這裡沒有鎖的沖突檢查,而我們使用transaction的方式進行Put,實驗代碼中也能看到有鎖的逾時檢查.

2.2.3 RepeatableRead的實作

可重複讀是指Rocksdb重複多次讀取資料的時候,能夠通路到預期的數值,而不會被其他事務的更新操作影響。

這裡的可重複讀其實在SQL指定标準之前是用快照隔離來描述的,通用的關系型資料庫都使用MVCC機制來進行多版本管理,多版本的通路也就是通過快照來進行的。

Rocksdb這裡的實作是通過為每一個寫入的key-value請求添加一個LSN(Log Sequence Number),最初是0,每次寫入+1,達到全局遞增的目的。同時當實作快照隔離時,通過Snapshot設定其與一個lsn綁定,則該snapshot能夠通路到小于等于目前lsn的k-v資料,而大于該lsn的key-value是不可見的。

相關代碼在​

​snapshot_impl.h​

​之中

class SnapshotImpl : public Snapshot {
 public:
  //lsn number
  SequenceNumber number_;  
  ......
  SnapshotImpl* prev_;
  SnapshotImpl* next_;
  SnapshotList* list_;                 // 連結清單頭指針
  int64_t unix_time_; //時間戳
  // 用于寫沖突的檢查
  bool is_write_conflict_boundary_;
};      

snapshot可以有多個,它的建立和删除是通過操作一個全局的雙向連結清單來進行,天然得根據建立的時間來進行排序SetSnapShot()函數建立一個快照。

快照隔離的測試代碼如下:

// 通過設定set_snapshot=true,來在BeginTransaction的時候就設定一個快照
  value = "def";
  
  txn_options.set_snapshot = true;
  txn = txn_db->BeginTransaction(write_options, txn_options);

  //讀取一個快照
  const Snapshot* snapshot = txn->GetSnapshot();

  // 重新生成一個寫入事務
  db->Put(write_options, "abc", "xyz");

  // 通過讀取的snapshot,來通路指定的key
  read_options.snapshot = snapshot;

  // 通過GetForUpdate來進行讀操作,這個函數鎖定多個事務操作,即也會讓之前的Put加入到WriteBatch中。
  s = txn->GetForUpdate(read_options, "abc", &value);
  assert(value == "def");

  // 送出事務
  s = txn->Commit();

  // 新生成的事務可能與讀操作沖突,不過這裡用了GetForUpdate就不會産生沖突了
  assert(s.IsBusy());

  delete txn;
  // 釋放snapshot
  read_options.snapshot = nullptr;
  snapshot = nullptr;      

其中用到了GetForUpdate函數,差別于Get接口,GetForUpdate對讀記錄加獨占寫鎖,保證後續對該記錄的寫操作是排他的。保證了多個事務的操作都能夠被GetForUpdate鎖定,而不是一個GetForUpdate成功,其他的失敗。

2.2.4 事務并發處理

通過對以上事務的隔離性的分析,能夠總結出以下幾種事務并發時Rocksdb的處理方式。

  1. 如果事務都是讀操作,不論操作之間是否有交集,都不會觸發鎖定
  2. 如果事務沖包含讀、寫操作
  • 所有的讀事務都不會觸發鎖定,讀的結果與snapshot請求相關
  • 寫事務之間不存在交集,則不會鎖定
  • 寫事務之間存在交集,如果此時設定了snapshot,則會串行送出;如果沒有設定snapshot,則隻執行第一個寫操作,其他的操作都會失敗。

3. 一些總結

  • 像針對寫事務的交集如何進行沖突檢測以及如何通過鎖機制解決沖突。
  • 預設使用的悲觀鎖以及可以顯式調用的樂觀鎖 在隔離性的幾個級别中是如何生效的。
  • 還有2PC(Two-Pharse-Commit)的實作機制,以及2PC上層的應用場景