SQLite 併發的四種處理方式

BigNerdCoding發表於2019-03-04
business-dog-paws-on-keyboard_925x.jpg

SQLite 是一款輕型的嵌入式資料庫它佔用資源非常的低,處理速度快,高效而且可靠。在嵌入式裝置中,可能只需要幾百 K 的記憶體就夠了。因此在移動裝置爆發時,它依然是最常見的資料持久化方案之一。不過即使 SQLite 已經非常成熟,但是我們在程式設計中依然會遇到一些問題,其中最常見也最難搞的就是 —— 併發。

就像其他類似的問題一樣,SQLite 在移動端的併發處理也存在多種不同的設計。下面我們通過 iOS 中四個常用類庫 (SQLite.swift, FMDB, GRDB, Core Data) 來看看這些設計。不過在此之前,我們需要明確 SQLite 在併發程式設計環境下到底存在哪些問題:

  1. 併發寫操作:某一時刻可能存在對同一個資料庫的寫操作,而這是 SQLite 不允許的行為。
  2. 操作隔離:連續的兩個資料庫查詢操作可能會出現結果差異,因為在併發環境下你無法保證著兩個讀操作中間不會出現寫操作。
  3. 操作衝突:併發環境下資料庫的新增和修改操作執行的時序並不一定與呼叫時序是一致的。這就導致一個可能的情形就是:資料庫多個更新操作呼叫後可能存在一些意料之外的情形,而且你還難以追蹤排除。

明確這些問題後,接下來我們就來看看這些類庫做出了何種應對。

SQLite.swift 方案

SQLite.swift 採用了最簡單粗暴的一種方案,使用者只會得到一個資料庫連線,所有的操作都是在該連線上串下執行,類庫的作者並沒有提供資料庫連線池類似的特性。通過這種設計,任意時刻都只會存在一個執行緒對資料庫擁有訪問許可權。也就是說上訴第一個併發問題被完美解決了。

然而改方案卻無法應對第二個問題。例如,我們需要為資料庫中的某位使用者設定頭像,如果該使用者存在時則執行插入操作,對應程式碼如下:

let userAvatars = avatars.filter(userId == 1)
let insert = avatars.insert(userId <- 1, url <- avatarURL)
if db.scalar(userAvatars.count) == 0 {
    try db.run(insert)
}
複製程式碼

咋看之下程式碼邏輯並沒有任何問題和缺陷,但是在併發環境下這裡存在一個隱藏的問題。你無法保證在執行 * try db.run(insert)* 沒有任何地方執行相同的操作。雖然這種情形很少見而且資料庫在這種情形下也沒有 Crash 出現,但是可能在一開始資料庫在設定的時候就約定了每一個使用者只能存在一條頭像資訊,這就導致了業務邏輯錯誤或者衝突。

當然這個問題我們可以在資料庫定義時就能遮蔽掉,或者我們顯式的通過事務對其進行處理:

try db.transaction {
    let userAvatars = avatars.filter(userId == 1)
    let insert = avatars.insert(userId <- 1, url <- avatarURL)
    if db.scalar(userAvatars.count) == 0 {
        try db.run(insert)
    }
}
複製程式碼

但是有些時候,開發人員可能因工期等等問題而忽略上訴,最終埋下了隱患。對於第三個問題,類庫並沒有任何處理永遠都是 the last write always win

FMDB 方案

FMDB 與 SQLite.swift 一樣都是採用序列設計,只不過 FMDB 在此基礎上做了些加強:FMDB 中使用者不會接觸到資料庫連線而是通過在 API 閉包中組織語句來實現資料庫訪問。

dbQueue.inDatabase { db in
    if db.intForQuery("SELECT COUNT ...") == 0) {
        db.executeUpdate("INSERT INTO avatars ...")
    }
}
複製程式碼

這種方式不僅解決了同時寫的問題而且還非常平滑的解決了操作隔離問題,相比上一個方案明顯更為友好。

GRDB 方案

此方案借鑑了 FMDB 中的 API 設計,使用者通過在閉包中組織語句來實現資料庫訪問。不過與前兩個相比,GRDB 最大的不同就是它不再使用序列佇列設計。通過對 SQLite 本身 WAL 模式進行,GRDB 支援多執行緒同時進行讀寫操作。

注意:寫操作依然是序列進行,WAL 依然需要遵守 SQLite 單寫策略

try dbPool.write { db in
    if Int.fetchOne(db, "SELECT COUNT ...") == 0) {
        try db.execute("INSERT INTO avatars ...")
    }
}
複製程式碼

該模式最大的特點在於,我們在進行資料庫寫操作的同時,依然能並行的執行讀操作。這意味著,在特定執行緒執行費時的資料庫同步寫操作的時候用於更新 UI 的資料庫讀操作不會像前兩種方案一樣被阻塞住。也就是說,寫操作對於讀操作來說是透明的。

dbPool.read { db in
    // Those values are guaranteed to be equal:
    let count1 = User.fetchCount(db)
    let count2 = User.fetchCount(db)
}
複製程式碼

並且 GRDB 通過 DatabaseSnapshot 對資料庫訪問進行了讀寫分離實現,進一步提高了多執行緒訪問的安全。

Core Data 方案

雖然 Apple 官方並沒有說 Core Data 是 SQLite 的一個封裝和實現,但是我們都知道其實它底層還是使用 SQLite 作為儲存引擎。

為了解決文章前面提到的 SQLite 併發情形下的典型問題,Core Data 自己實現並維護了一套上下文管理邏輯。 SQLite.swift 關注的上下文是其執行期間的單個SQL語句。 對於FMDB和GRDB 關注的上下文環境則是閉包中的 SQL 語句塊。 而 Core Data 託管上下文則是 NSManagedObjectContext 例項的整個生命週期,包含資料庫修改和記憶體修改。

這讓 Core Data 能夠應對併發問題中的第三種情形,同一個物件如果在不同上下文中同時發生修改則會被檢測出來(文件)。而前面三種方案只要 SQL 語句沒有違背表定義都能進行記錄更新而且最後一個永遠是贏家。

但是這種設計也存在缺點,首先擴大後的上下文管理是一件非常麻煩的事,另外所有的寫操作都會被嚴格束縛而且衝突處理依然很棘手,最後嚴格的上下文管理也讓 Core Data 中編寫正確的多執行緒程式碼也變得很困難。

總結

每一類庫的作者都對 SQLite 併發處理有著自己的思考,所以沒有這裡並不存在一種標準處理方式。如果封裝過於簡單的話,那麼對使用者的要求就會比較高否則就會出現很多意想不到的錯誤或崩潰。封裝過於複雜的話則又有導致處理的靈活性變得很差。如果搞的大而全的話則有可能導致 SQLite 的執行效率變得很差。

總體而言,FMDB 和 GRDB 採用的方式從安全性和靈活性上會更好一點。順便提一下,根據微信團隊的文章他們採用的可能是 GRDB 那種方式,因為在微信的應用場景下寫操作是瓶頸所在。

原文地址

相關文章