一、前言
事務是傳統關係型資料庫中必不可少的功能,例如 Mysql、Oracle、PostgreSql 都支援事務,但是在 NoSQL 資料庫中,事務的概念比較弱化,在實現上也沒有關係型資料庫那麼複雜。
但是為了資料的完整一致性,大多數 k-v 都會實現事務的基本特性,例如 k-v 資料庫的兩大鼻祖 LevelDB 和 RocksDB,一些 Go 語言實現的開源 k-v 也都支援事務,例如 Bolt,Badger 等。
rosedb 的事務目前剛實現了一個初級的版本,程式碼還比較簡單,只不過在我的預期構思內,後續可能會慢慢演化得更加複雜。
需要說明的是,在實現 rosedb 的事務之前,我對事務的理解也僅限於 ACID 這些基礎概念,所以這次實現完全是摸著石頭過河,可能存在一些槽點,大家有什麼疑問可以指出來,我後面也會繼續學習並完善。
二、基本概念
說到事務,就很容易想到事務的 ACID 特性,帶大家回顧一下:
- 原子性(Atomicity):一個事務中的所有操作,要麼全部完成,要麼全部失敗,不會在中間環節結束。如果事務執行過程中發生錯誤,能夠被回滾至事務開始之前的狀態。
- 一致性(Consistency):在事務開始前和結束後,資料庫的完整性沒有被破壞,這意味著資料狀態始終符合預期。
- 隔離性(Isolation):隔離性描述的是多個執行中的事務相互影響的程度,有常見的四種隔離級別,表示事務之間不同的影響程度:
- 讀未提交(read uncommitted):一個事務還未提交,另一個事務就能看到它所做的修改(存在髒讀)
- 讀提交(read committed):一個事務對資料的修改,只能等到它提交之後,其他事務才能看到(沒有髒讀,但是不可重複讀)
- 可重複讀(repeatable read):一個事務在執行過程中獲取到的資料,和事務開始時的資料一致(沒有髒讀,可以重複讀,但是有幻讀)
- 序列化(serializable):讀寫互斥,避免事務併發,一個事務必須等到前一個事務提交後才能執行(無髒讀,可重複讀,無幻讀)
- 永續性(Durability):一個事務提交之後,它所做的修改是永久的,即使資料庫崩潰之後也能夠保證安全。
ACID 的概念看起來挺多,但並不難理解,要實現事務,其實就是保證在資料讀寫時,滿足事務的這幾個基本概念,其中 AID 是必須保證的。
而 Consistency 即一致性,可以簡單理解為它就是事務的最終目標,資料庫通過 AID 來保證一致性,而我們在應用層面也要保證一致性,假如我們寫入的資料本身邏輯上就是錯誤的,那麼即使資料庫事務再完善,也無法保證一致性。
三、具體實現
在講解事務實現之前,先來看看 rosedb 當中事務的基本用法:
// 開啟資料庫例項
db, err := rosedb.Open(rosedb.DefaultConfig())
if err != nil {
panic(err)
}
// 在事務中運算元據
err = db.Txn(func(tx *Txn) (err error) {
err = tx.Set([]byte("k1"), []byte("val-1"))
if err != nil {
return
}
err = tx.LPush([]byte("my_list"), []byte("val-1"), []byte("val-2"))
if err != nil {
return
}
return
})
if err != nil {
panic(fmt.Sprintf("commit tx err: %+v", err))
}
首先還是會開啟一個資料庫例項,然後呼叫 Txn
方法,這個方法的入參是一個函式,事務的操作都在這個函式中完成,在提交的時候一次性執行。
像這樣使用的話,事務會自動提交,當然也可以手動開啟事務並提交,並且在有錯誤發生時手動回滾,如下:
// 開啟資料庫例項
db, err := rosedb.Open(rosedb.DefaultConfig())
if err != nil {
panic(err)
}
// 開啟事務
tx := db.NewTransaction()
err = tx.Set([]byte("k1"), []byte("val-1"))
if err != nil {
// 有錯誤發生時回滾
tx.Rollback()
return
}
// 提交事務
if err = tx.Commit(); err != nil {
panic(fmt.Sprintf("commit tx err: %+v", err))
}
當然還是推薦第一種用法,省去了手動提交事務和回滾。
Txn
方法表示的是讀寫事務,此外還有一個 TxnView
方法,表示的是隻讀事務,使用方式完全一致,只不過在 TxnView
方法內的寫入命令都會被忽略。
db.TxnView(func(tx *Txn) error {
val, err := tx.Get([]byte("k1"))
if err != nil {
return err
}
// 處理 val
hVal := tx.HGet([]byte("k1"), []byte("f1"))
// 處理 hVal
return nil
})
瞭解了事務的 ACID 基本概念和 rosedb 事務基本用法之後,再來看看在 rosedb 當中,事務究竟是怎麼實現的,也可以認為是如何來保證 AID 特性的。
3.1 原子性
前面已經說到,原子性指的是的事務執行的完整性,要麼全部成功,要麼全部失敗,不能停留在中間狀態。
要實現原子性其實不難,可以藉助 rosedb 的寫入特性來解決。先來回顧一下 rosedb 資料寫入的基本流程,兩個步驟:首先資料會先落磁碟,保證可靠性,然後更新記憶體中的索引資訊。
對於一個事務操作,要保證原子性,可以先將需要寫入的資料在記憶體中暫存,然後在提交事務的時候,一次性寫入到磁碟檔案當中。
這樣存在一個問題,那就是在批量寫入磁碟的時候出錯,或者系統崩潰了怎麼辦?也就是說可能有一些資料已經寫入成功,有一些寫入失敗了。按照原子性的定義,這一次事務沒有提交完成,是無效的,那麼應該怎麼知道已經寫入的資料是無效的呢?
目前 rosedb 採用了一種最容易理解,也是比較簡單的一種辦法來解決這個問題。
具體做法是這樣的:每一次事務開始時,都會分配一個全域性唯一的事務 id,需要寫入的資料都會帶上這個事務 id 並寫入到檔案。當所有的資料寫入磁碟完成之後,將這個事務 id 單獨存起來(也是寫入到一個檔案當中)。在資料庫啟動的時候,會先載入這個檔案中的所有事務 id,維護到一個集合當中,稱之為已提交的事務 id。
這樣的話,就算資料在批量寫入時出錯,由於沒有存放對應的事務 id,所以在資料庫啟動並取出資料構建索引的時候(回憶一下 rosedb 的啟動流程),能夠檢查到資料對應的事務 id 沒有在已提交事務 id 集合當中,所以會認為這些資料無效。
大多數 LSM 流派的 k-v 都是利用類似的思路來保證事務的原子性,例如 rocksdb 是將事務中所有的寫入都存放到了一個 WriteBatch 中,在事務提交的時候一次性寫入。
3.2 隔離性
目前 rosedb 支援兩種事務型別:讀寫事務和只讀事務。只能同時開啟一個讀寫事務,只讀事務則可以同時開啟多個。
在這種模式下,讀會加讀鎖,寫會加寫鎖,也就是說,讀寫會互斥,不能同時進行。可以理解為這是四種隔離級別中的序列化,它的優點是簡單易實現,缺點是併發能力差。
需要說明的是,目前的這種實現在後面大概率會進行調整,我的設想是可以使用快照隔離的方式來支援讀提交或者可重複讀,這樣資料讀取能夠讀到歷史版本,不會造成寫操作的阻塞,只不過在實現上要複雜得多了。
3.3 永續性
永續性需要保證資料已經寫到了非易失性儲存介質當中,比如最常見的有磁碟或者 SSD,這樣即使發生系統異常,也能夠保證資料安全。
在 rosedb 當中,寫入資料時,如果走預設的刷盤策略,是將資料寫到了作業系統頁快取當中,實際上並沒有落磁碟。如果作業系統還沒來來得及將頁快取的資料刷到磁碟,那麼會造成資料丟失。這樣雖不能完全保證永續性,但效能是相對更好的,因為 Sync 刷磁碟是一次極其慢速的操作。
如果在啟動 rosedb 的時候指定了配置項 Sync 為 true,那麼每次寫入都會強行 Sync,能夠保證資料不丟,但是寫效能會下降。
實際應該怎麼選擇,可以根據自己的使用場景來,如果系統穩定,對效能的要求較高,並且能夠容忍丟失少量資料,那麼可以採用預設策略,即 Sync 為 false,否則可以強制刷盤。
四、缺陷
經過上面的簡單分析,可以看到 rosedb 已經基本實現了事務的 AID 特性,整體來說還是挺簡單的,易於學習和使用,並且能夠很好理解便於進一步的擴充套件。當然,目前也存在一些缺陷亟待解決。
第一個便是上面提到的隔離級別的問題,目前這種方式太過簡單,使用一把全域性大鎖搞成了序列化,後續可以考慮只鎖定需要操作的某個 key,減小鎖的粒度。
還有一個問題便是,由於 rosedb 支援了多種資料結構,但是像 List、ZSet 這種結構,在事務中支援全部命令的難度較大,因此目前 List 只支援了 LPush 和 RPush,ZSet 只支援了ZAdd、ZScore、ZRem 命令。
主要的原因是如果在事務中對已經存在的 key 進行讀寫,那麼去支援像範圍查詢這種型別的命令就會很困難,目前我還沒有想到比較好的解決方案。
最後,附上專案地址:github.com/roseduan/rosedb,歡迎各位前來圍觀吐槽。
本作品採用《CC 協議》,轉載必須註明作者和本文連結