漫談 SQLite

做爲移動開發者,或多或少會與 SQLite 直接或間接打過交道,在使用過程當中可能有以下疑問:html

  • SQLite 是線程安全的嗎?
  • SQLite 支持併發讀寫嗎?
  • 常在數據庫文件目錄看到的 -wal 文件是什麼?
  • 常看到的概念 checkpoint 是什麼?

本文是淺層次但較系統學習 SQLite 後的總結筆記,看完或許能解答上述問題;本文敘述的出發點是從設計一個簡單的 SQLite framework 開始;關於 SQLite 的第三方庫有不少,對於 iOS 生態,知名的包括 FMDB、WCDB、GRDB、SQLite.swift 等,學習它們也是本文的一個任務之一。git

以熟悉 Swift 和 SQLite 爲目的,寫了一個相似於 FMDB 的 SQLite wrapper,詳見 SBDBgithub

在設計一個 SQLite framework 過程當中,須要理解 SQLite APIs 的使用,以及一些核心概念,包括 SQLite 的線程安全模型、所支持的併發模型等等。編寫一個 SQLite 工具庫並不是常見需求,但私覺得此過程有助於幫助更全面理解 SQLite 以及更好地使用 SQLite。sql

概述

關於 SQLite 的介紹能夠從官方的 About SQLite 開始,本文羅列一些重要的點:數據庫

  • 發明人:D. Richard Hipp
  • 本質上是一個 ANSI-C 庫,輕量(百 KB 級別),開源
    • 兼容性好,全部系統均可以調用 C 語言寫的庫
    • 低依賴,在最小配置下,只使用了 memcmp、strcmp 等少數幾個標準庫 API
  • 主流移動操做系統(iOS & Android)已內置
  • 數據庫文件格式穩定,向後兼容,跨平臺;堅持 50 年不動搖(2000-2050)
  • 本地存儲,不支持網絡訪問
  • 無權限管理機制
  • 不支持加密
    • 可使用開源的加密庫代替系統內置的動態庫實現加密,譬如 SQLCipher
  • 支持全文搜索(FTS)
  • 當前主流版本是 v3,這也是本文內容的參考版本
  • 變長記錄存儲,即刪除數據也不會減少數據庫文件,除非使用 vacuum 命令 rebuild 數據庫文件

數據類型

把 SQLite 數據類型專門擰出來介紹是由於它相對於其餘 SQL 數據庫有一些特別之處...swift

SQLite 支持 5 種存儲類型:安全

  • integer: 有符號整型,根據數值大小,可能存 1/2/3/4/6/8 bytes
  • real: 浮點類型,8 bytes
  • text: 字符串(支持 utf-八、utf-16)
  • blob: 二進制數據,輸入啥,就存啥
  • null: NULL 值

和其餘 SQL 數據庫不太同樣的是,SQLite 的列沒有真正的類型約束;做爲對比,其餘數據庫譬如 MySQL 在建立數據表定義字段時,必定要指定列(字段)類型,以後插入數據時,得確保值和列類型匹配。SQLite 沒有這樣的約束,任何列(除了 integer 型主鍵列 )均可以同時存儲如上 5 種類型值。性能優化

> create table foo (bar);               -- 定義字段 bar,但沒有指定類型
> insert into foo (bar) values (42);    -- 插入整型值
> insert into foo (bar) values (NULL);  -- 插入 null
> insert into foo (bar) values ("42");  -- 插入字符串
> insert into foo (bar) values (42.0);  -- 插入浮點值
> select typeof(bar), bar from foo;     -- 其中 `typeof` 返回值類型
42   | integer
     | null
42   | text
42.0 | real
複製代碼

然而,SQLite 定義數據表時也是能夠爲字段指定類型的,譬如: create table foo (field1 numeric, field2 blob, field3),但這些類型並不起約束做用,它們在 SQLite 語義中被稱爲:type affinity,常譯爲「類型相像」。也有 5 種類型:integerrealtextblobnumeric微信

能夠把 type affinity 理解爲轉換器,以 text 爲例,若是列的 type affinity 爲 text,那麼 insert 數據時,內部會將插入的數據儘量轉爲字符串,譬如插入 1,則存爲 "1";插入 2.0,則存爲 "2.0";若是插入 null,則仍然存爲 nullSQLite 官方文檔 中有着詳細說明,本文很少贅述。網絡

核心 APIs

SQLite 的 APIs 超過 200 個,但查看使用 SQLite 的知名庫(譬如 FMDB、YYCache 等)的源碼,能夠發現它們使用的 API 都很是少。

實際上,基於 2 個類型,8 個核心 APIs 就能完成基本功能:

{
    // 兩個核心類型:
    // - sqlite3: 句柄,表明鏈接
    // - sqlite3_stmt: statement,能夠簡單理解爲 sql 語句的抽象
    sqlite3 *db = NULL; sqlite3_stmt *stmt = NULL;

    // 八個核心 APIs:
    // - sqlite3_open/sqlite3_close: 用於打開/關閉鏈接
    // - sqlite3_prepare/sqlite3_finalize: 建立/銷燬 statement
    // - sqlite3_bind 系列: 爲 statement 綁定參數
    // - sqlite3_step: 執行 statement,對於 select 語句,可能要執行屢次
    // - sqlite3_reset: 將 statement 恢復到初始狀態(譬如解除綁定的參數),以便重複使用
    // - sqlite3_exec: sqlite3_prepare/sqlite3_step/sqlite3_finalize 的 wrapper
    sqlite3_open("path/to/db", &db);
    sqlite3_prepare(db, "select * from someTable where id = ?", -1, &stmt, NULL);
    sqlite3_bind_int(stmt, 1, 42);
    while (sqlite3_step(stmt) == SQLITE_ROW) {
        // 使用 sqlite3_column 系列 API 提取數據
    }
    sqlite3_finalize(stmt); // 或者使用 `sqlite3_reset(stmt);`
    sqlite3_exec(db, "drop table someTable", nil, NULL, NULL);
    sqlite3_close(db);
}

// 如上 API 中,除了 sqlite3_stmt 和相關 API,都比較容易理解;
// 對於 sqlite3_stmt 和相關 API,花太多文字描述感受意義不大,寫點 demo 就能很快理解了
複製代碼

看起來挺簡單?然而,實際操做中有很多問題要處理,包括但不限於:

  • 基本問題
    • 類型適配、API 封裝
    • 線程安全:選擇合適的線程模型
    • 事務與併發:設計合適的事務管理模式
    • 提升易用性:ORM、避免用戶寫 SQL 語句
    • 防注入
  • 性能與可靠性
    • 性能監控
    • 損壞修復
    • 性能提高

本文沒打算將上述全部點都涉及到,將內容主要收斂在基礎方面,討論:線程安全、事務、併發。

線程安全

不一樣場景下,討論線程安全的關注點可能不同,譬如死鎖、非主線程執行 UI 操做等。對於 SQLite,本文討論的點是:是否能夠在任何線程使用 SQLite 的 API,且不會帶來數據安全問題。

SQLite 的 API 是支持多線程訪問的,多線程訪問必然帶來數據安全問題。

爲了確保數據庫安全,SQLite 內部抽象了兩種類型的互斥鎖(鎖的具體實現和宿主平臺有關)來應對線程併發問題:

  • fullMutex
    • 能夠理解爲 connection mutex,和鏈接句柄(上問描述的 sqlite3 結構體)綁定
    • 保證任什麼時候候,最多隻有一個線程在執行基於鏈接的事務
  • coreMutex
    • 當前進程中,與文件綁定的鎖
    • 用於保護數據庫相關臨界資源,確保在任什麼時候候,最多隻有一個線程在訪問

下面畫了一張圖用來描述 fullMutex 和 coreMutex 所起到的做用:

如何理解 fullMutex?SQLite 中與數據訪問相關的 API 都是經過鏈接句柄 sqlite3 進行訪問的,基於 fullMutex 鎖,若是多個線程同時訪問某個 API -- 譬如 sqlite3_exec(db, ...),SQLite 內部會根據鏈接的 mutex 將該 API 的邏輯給保護起來,確保只有一個線程在執行,其餘線程會被 mutex 給 block 住。

對於 coreMutex,它用來保護數據庫相關臨界資源,包括本文將要介紹的文件鎖。

用戶能夠配置這兩種鎖,對這兩種鎖的控制衍生出 SQLite 所支持的 三種線程模型

  • single-thread
    • coreMutex 和 fullMutex 都被禁用
    • 用戶層須要確保在任什麼時候候只有一個線程訪問 API,不然報錯(crash)
  • multi-thread
    • coreMutex 保留,fullMutex 禁用
    • 能夠多個線程基於不一樣的鏈接併發訪問數據庫,但單個鏈接在任什麼時候候只能被一個線程訪問
    • 單個 connection,若是併發訪問,會報錯(crash)
      • 報錯信息:illegal multi-threaded access to database connection
  • serialized
    • coreMutex 和 fullMutex 都保留

如何配置線程模型呢?有三個階段能夠配置線程模型:

  • compile-time
    • 至關於全局設置
    • 在編譯時設置編譯選項 SQLITE_THREADSAFE 的值指定線程模型:
      • 0 : single-thread, 1 : serialized, 2: multi-thread
    • 經過 sqlite3_threadsafe() 能夠在運行時知道所用的 sqlite3 庫的 SQLITE_THREADSAFE 編譯選項值
  • start-time
    • 至關於應用級設置,會覆蓋 SQLITE_THREADSAFE 的選項配置
    • 在第一次使用 SQLite API 以前,經過 sqlite3_config 指定線程模型
      • sqlite3_config(SQLITE_CONFIG_SINGLETHREAD: 設置 single-thread
        • 經測試驗證,iOS 和 macOS 內置的 SQLite 不容許該模式
      • sqlite3_config(SQLITE_CONFIG_MULTITHREAD): 設置 multi-thread
      • sqlite3_config(SQLITE_CONFIG_SERIALIZED): 設置 serialized
  • run-time
    • 使用 sqlite3_open_v2() 創建鏈接時爲第三個參數(flags 參數)指定值,即爲每一個鏈接配置 fullMutex 的使能開關:
      • SQLITE_OPEN_NOMUTEX,關閉 fullMutex,即 multi-thread 模式
      • SQLITE_OPEN_FULLMUTEX,打開 fullMutex,即 serialized 模式

來個 Demo 直觀感覺這幾種線程模型:

// multi-thread.c
void* access_database(void *db) {
    for (int i = 0; i < 1000; i++) {
        sqlite3_exec((sqlite3 *)db, "begin", NULL, NULL, NULL);
        sqlite3_exec((sqlite3 *)db, "end", NULL, NULL, NULL);
    }
    return (void *)NULL;
}

int main() {
    if (sqlite3_config(SQLITE_CONFIG_MULTITHREAD) == SQLITE_OK) 
        printf("設置 multi-thread 模式成功\n");
    
    sqlite3 *db = NULL;
    sqlite3_open("./test.db", &db);
    {
        // 模擬主線程和子線程併發訪問數據庫
        pthread_t p;
        pthread_create(&p, NULL, access_database, db);

        access_database(db);
    }
    sleep(2); 
    sqlite3_close(db);
    return 0;
}

// gcc multi-thread.c -lsqlite3
複製代碼

上述 demo 代碼中,兩個線程基於同一個鏈接訪問數據庫,分別訪問了 1000 次,基本上可以觸發兩個線程同時訪問數據庫的場景,程序的執行會以 crash 結束,由於在一開始經過 sqlite3_config() 配置了 multi-thread 線程模式。

若是將 SQLITE_CONFIG_MULTITHREAD 改成 SQLITE_CONFIG_SERIALIZED,即將 multi-thread 線程模式改成 serialized 模式,程序就能夠正常運行。

另外,若是上述 demo 中兩個線程使用的是獨立的鏈接,又是不同的結果:multi-thread 模式下,SQLite 容許多個線程使用訪問數據庫,只要不是同一個鏈接就 ok 了。

搞清楚了 fullMutex 和 coreMutex 的做用,理解 SQLite 提供的這三種線程模型並不難,此處稍做總結。

single-thread 模式下,使用者要承擔較多線程安全職責:確保在任什麼時候候,只有一個線程訪問數據庫,對於移動端而言,實在想不到使用它的收益,對於別的場景,譬如單線程模式下的嵌入式設備,或許有它存在的價值。經測試,macOS、iOS 內置 SQLite 禁掉了該模型。

Serialized 模式看似是最省心的,用戶徹底不用擔憂「illegal multi-threaded access to database connection」crash;但它付出的代價是,任何基於鏈接句柄的 API 操做都會有一個鎖檢測邏輯,對效率有所折損。

關於 fullMutex 鎖的效率折損,經 demo 測試:上千次連續讀寫,發現並不明顯。

在實際使用中,選擇最多的是 multi-thread 模式,它也是 iOS/macOS 內置 sqlite 庫的默認模式;在multi-thread 模式,須要在用戶層保證任什麼時候候只有線程在使用鏈接句柄(sqlite3 實例)訪問數據庫。

事務與併發

SQLite 的線程安全問題相對來講是比較容易搞定的,畢竟能夠經過配置合適的線程模型,或者在應用層經過隊列等手段來規避;但併發問題就複雜得多。

不一樣語境下併發所指的意義可能不同,對於 SQLite 而言,討論併發的粒度是事務;也就 SQLite 是否支持併發事務;因此本文將事務和併發放在一塊兒討論。先拋出結論:

  • SQLite 支持併發執行讀事務,便可以同時開啓多個進程/線程從數據庫讀數據
  • SQLite 不支持併發執行寫事務,即不能多個進程/線程同時往數據庫寫數據

如上並不是完整的結論,SQLite 對併發讀寫 -- 也即同時進行讀事務和寫事務 -- 的支持如何?這個問題的答案與用戶所選擇的日誌模型有關,下文有分析。

上文介紹了 coreMutex、fullMutex 兩種鎖,可能對理解併發形成一些干擾,此處補充一些說明。在 serialized 和 multi-thread 模式下,用戶能夠併發訪問 SQLite API,但並不意味着能夠讀寫成功,「支持併發訪問 API」和「支持併發執行事務」徹底是兩回事。

接下來的重點敘述包括:理解 SQLite 事務,理解 SQLite 事務的實現原理。

事務

事務是 SQL 數據庫裏的通用概念,它描述的是一個或一組數據庫操做指令的執行單元;具備四個屬性:原子性、一致性、隔離性、持久性,即所謂 ACID,關於它的概念本文不過多贅述。

默認狀況下,SQLite 數據庫的全部操做都是事務的,即所謂隱式事務(implicit transaction)。以下就是一個隱式事務:

sqlite3_stmt *stmt = NULL;
sqlite3_prepare(db, "select * from table_name", -1, &stmt, NULL);
while (sqlite3_step(stmt) == SQLITE_ROW) {
    // 使用 sqlite3_column 系列 API 提取數據
}
sqlite3_finalize(stmt); // 或者使用 `sqlite3_reset(stmt);`
複製代碼

用戶還能夠自行指定事務的開始與結束,即所謂顯式事務(explicit transaction),語法詳見 這裏,以下是一個小 demo:

sqlite3_exec(db, "begin", nil, NULL, NULL);
while (i < 5000) {
    sqlite3_exec(db, "insert into table_name (column_name) values (42)", nil, NULL, NULL);
    i++;
}
sqlite3_exec(db, "end", nil, NULL, NULL);
複製代碼

不管顯式事務仍是隱式事務,根據是否會對數據庫進行修改,能夠分爲:讀事務寫事務。理清楚讀事務和寫事務這兩個概念對於分析事務併發很是重要。

drop、update、insert 等 SQL 語句,由於都涉及數據庫變動,因此包含這些語句的事務都是寫事務;若是事務中只有 select 語句,那麼它屬於讀事務。

對事務有一個基本的瞭解後,如今將注意力集中在問題中:SQLite 是如何實現事務的呢?

這個問題涉及太多細節,難以用幾段文字把它描述清楚;但若是想用好 SQLite,這個問題又不得不理清楚。咱們先從 SQLite 的日誌模型開始討論。

兩種日誌模型

一個很重要的事實是,要想實現事務,單靠數據庫文件是難以完成的,須要藉助一個文件輔助完成,這個輔助文件被稱爲日誌文件(journal)。

SQLite 支持兩種日誌記錄方式,或者說兩種日誌模型:RollbackWAL

這兩種模型,日誌的文件格式不一樣,更重要的是日誌在事務執行過程當中扮演的角色不一樣;換句話說,選擇了日誌模型,至關於選擇了一種事務處理模型。

下面來分別簡述這兩種日誌模型中的事務處理邏輯。

理解 Rollback

在 rollback 日誌模型中,當執行寫事務的時候,會在數據庫文件(本文稱爲 .db)所在目錄下產生一個日誌文件(本文稱爲 .db-journal),下圖簡單描述了寫事務執行過程當中,.db-journal 所起到的做用:

以下補充一些文字說明:

  • 1 初始狀態,此時只有數據庫文件
  • 2 執行寫事務,SQLite 檢測到要修改 page 1 和 page 3;建立 .db-journal 文件,將 page 1 和 page 3 的內容拷貝到其中,做爲備份
  • 3 在數據庫文件中直接修改
  • 4 Commit 或者 Rollback(只能二選一)
    • 4.1 提交修改,刪除 .db-journal 文件
    • 4.2 放棄修改,即回滾,使用 .db-journal 裏的拷貝將數據庫文件恢復到事務發生以前的模樣

SQLite 官方在 Atomic Commit In SQLite 花了至關多的筆墨介紹 rollback 日誌模式下事務處理的邏輯細節。上圖省掉了不少細節(鎖管理、內存-磁盤交互等),將重點放在了描述日誌文件自己上,能夠看出:

  • 寫操做是直接在數據文件 .db 上進行的
  • .db-journal 在寫事務中起到了備份做用。備份要修改的 pages、.db 文件原大小,在寫的過程當中能夠回退,即將備份信息給還原回去:恢復 pages 的原數據,或將 .db 文件切回以前的 size
  • .db-journal 是一個臨時文件。當寫事務完成提交(commit)或回退(rollback)時,該文件會被清理掉(有多種清理方式)
  • 在任什麼時候候一個數據庫文件最多隻對應一個 .db-journal 文件
  • .db-journal 文件是否存在且有效是描述 .db 文件是否穩定完整的核心依據
    • 建立 .db-journal 後,若是發生了斷電或者程序崩潰退出等異常狀況,下次從新訪問數據庫時,會首先根據 .db-journal 將數據庫恢復到原來的樣子,這保證了數據庫的一致性(consistency)

接下來談談 rollback 模式下的鎖邏輯。SQLite 使用文件鎖來保證事務之間的隔離性(isolation)和原子性(atomicity)。

所謂文件鎖,並非一個計算機原語,也即沒有所謂的 API 來直接控制它;它是 SQLite 抽象的一個概念,具體的實現和宿主有關,其實現細節並不是本文討論重點;須要注意的是它的 feature:

  • 和數據庫文件關聯。這意味它不只能夠實現線程阻塞,也能夠實現進程阻塞
  • 有五種狀態。UNLOCKED、SHARED、RESERVED、PENDING、EXCLUSIVE
    • 關於這五種狀態的敘述參考 這裏

SQLite 文件裏專門有一段數據區域與鎖有關,詳見 Database File Format 的「The Lock-Byte Page」;文件鎖的具體實現與宿主有關,對於 Unix 而言,詳見 src/os_unix.c 裏 unixLock() 函數。根據官方文檔的說法,文件鎖相關數據不會回寫到磁盤,因此不用擔憂某個進程持有該鎖後,由於異常沒法釋放致使永久死鎖。

針對文件鎖五種狀態的轉換,Understanding SQLITE_BUSY 畫了一張很是棒的圖,copy 以下:

  • .db 文件鎖的初始狀態是 unlocked
  • 任何鏈接想要開啓讀操做,須要獲取 shared 鎖
    • 能夠有多個鏈接獲取 shared 鎖
  • 任何鏈接想要開啓寫操做,須要獲取 reserved 鎖
    • 只能有一個鏈接獲取 reserved 鎖
    • 獲取到 reserved,transaction 能夠寫數據,但只是寫在用戶空間
  • 將修改的數據同步到數據庫文件中(提交事務),須要獲取 exclusive 鎖
    • 若是此時有鏈接在讀數據,鎖會變成 pending 狀態,其餘鏈接都退出讀狀態後,才進入 exclusive 狀態
    • 當文件鎖處於 pending 狀態,或者 exclusive 狀態,表示磁盤中的 .db 文件即將或正在發生變化,此時是不可讀的

下面從代碼層面進一步理解一下:

sqlite3 *db = NULL;
sqlite3_open("path/to/db", &db);
// 開始事務
sqlite3_exec(db, "begin", nil, NULL, NULL);
// 獲取 shared 鎖
// 若是文件鎖處於 pending 或 exclusive 狀態,則失敗,返回:SQLITE_BUSY 錯誤碼
sqlite3_exec(db, "select * from table_name", nil, NULL, NULL);
// 獲取 reserved 鎖
// 僅當文件鎖處於 shared 或 unlocked 狀態,才能成功;不然失敗,返回:SQLITE_BUSY 錯誤碼
sqlite3_exec(db, "drop table table_name", nil, NULL, NULL);
// 獲取 pending 鎖,等待升級爲 exclusive 鎖
// 僅當 shared 鎖所有被釋放,才能成功執行,不然失敗返回:SQLITE_BUSY 錯誤碼
sqlite3_exec(db, "commit", nil, NULL, NULL);
複製代碼

有些相似於 2PL 併發控制機制;但 SQLite 作得更復雜一些,以規避 dead lock。

此處能夠對 rollback 日誌模式稍做總結:

  • 每次寫事務都有兩個寫 IO 的操做(一次是建立 .db-journal,一次修改數據庫)
  • 能夠同時執行多個讀事務
  • 不能同時執行多個寫事務
  • 讀事務會影響寫事務,若是讀事務較多,寫事務在提交階段(獲取 exclusive 鎖)常會遇到 SQLITE_BUSY 錯誤
  • 寫事務會影響讀事務,在寫事務的提交階段,讀事務是沒法進行的
  • 寫事務遇到 SQLITE_BUSY 錯誤的節點較多

使用 Rollback 模式

激活 rollback 日誌模式能夠在鏈接數據庫後使用 pragma journal_mode 開啓:

sqlite3 *db = NULL;
sqlite3_open("path/to/db", &db);
sqlite3_exec(db, "pragma journal_mode=delete", nil, NULL, NULL);
複製代碼

其中 rollback 模式下 journal_mode 的可選值包括以下值,它們用於指定 .db-journal 的清理方式:

  • delete: 清理 .db-journal 的方式是直接刪除
  • truncate: 清理 .db-journal 的方式是清空文件內容(不刪除,保留文件留做下次使用)
  • persist: 保留 .db-journal 文件,但會對文件的 header 作一些處理,以便 SQLite 能識別該文件是否有效
  • memory: .db-journal 文件不寫磁盤,而是放在內存中;這種模式下,若是程序崩潰、斷電,數據庫可能就 gg 了

SQLite 默認的日誌模式是 rollback,清理模式爲 delete。

理解 WAL

WAL 的全稱是 Write-Ahead Logging。

在 rollback 日誌模式中,寫操做是直接發生在數據庫文件上的,日誌充當備份用,主要用於確保數據庫的一致性;正常完成寫事務後,它就被銷燬了。

wal 日誌模式中,提供了另外一種日誌類型,常稱爲 wal 文件,記爲 .db-wal,在這個模型中,寫操做都發生在 wal 文件中;另外一個不一樣點是,.db-wal 文件是持久存儲的,它是數據庫完整的重要組成部分。

SQLite 官方對 rollback 日誌模式有着很是詳細的圖文並茂的 介紹,但 wal 日誌模式的待遇沒那麼好,介紹信息 相對來講沒那麼生動,因而動手根據本身的理解分別針對寫操做和讀操做畫了兩張圖。

先看看寫事務:

從上圖能夠看出,數據庫的全局數據可能分佈在兩個地方:.db 和 .db-wal 中,那麼讀操做是怎樣讀數據的呢?詳見下圖:

對 WAL 模式稍做一些總結:

  • wal 模式比 rollback 模式有更高的併發性:讀寫互相不影響
  • wal 模式比 rollback 模式下降了損壞率(寫數據庫操做頻率極大下降)
  • 使用好 wal 模式的關鍵點在於設計良好的 checkpoint 策略
    • wal 文件記錄了更改的全量,會膨脹得很是快
    • checkpoint 頻率太低,致使 wal 文件過大,消耗磁盤空間,且影響 read 的效率
    • checkpoint 頻率太高,增大數據庫的損壞率

使用 WAL 模式

激活 wal 模式能夠在鏈接數據庫後使用 pragma 開啓:

sqlite3 *db = NULL;
sqlite3_open("path/to/db", &db);
sqlite3_exec(db, "pragma journal_mode=wal", nil, NULL, NULL);
// `pragma journal_mode=wal` 語句會有一個返回值,返回當前 journal mode
// 若是不爲 wal,表示失敗
複製代碼

三種事務類型

再回過頭來補充一些事務類型相關內容,使用 SQLite 時可能常會和它們打交道。開啓顯式事務時,能夠指定三種類型:

  • begin deferred ... end
  • begin immediate ... end
  • begin exclusive ... end

默認狀況下,SQLite 選用 deferred 類型。該類型下,調用 begin deferred 並不會立馬開始一個 transaction,而是延遲到第一次訪問數據庫時,若是事務的全部指令都是讀操做,那麼這一個事務被認爲是讀事務;只要其中包括寫事務,那麼它就升級爲寫事務。以下 demo:

sqlite3_exec(db, "begin deferred", nil, NULL, NULL);
// 這是一個讀事務,獲取 shared 鎖
sqlite3_exec(db, "select * from table_name", nil, NULL, NULL);
// 升級爲寫事務,獲取 reserved 鎖
sqlite3_exec(db, "drop table table_name", nil, NULL, NULL);
sqlite3_exec(db, "commit", nil, NULL, NULL);
複製代碼

Immediate 類型也被較多使用,它至關於直接告訴 SQLite 開始寫事務了,即使包含的 SQL 語句所有是讀操做,甚至不執行任何 SQL 語句;它會嘗試獲取 reserved 鎖,由於 reserved 鎖有惟一約束,因此在執行 begin immediate 時可能會產生 SQLITE_BUSY 錯誤。

對於 exclusive 類型事務,兩種日誌模式下的表現不同。在 wal 模式下,它和 immediate 類型同樣。在 rollback 日誌模式下,它會嘗試獲取 exclusive 鎖,要求獨佔數據庫,這意味着它成功的前提是:當前數據庫是徹底閒置的,沒有其餘的讀事務或寫事務在進行;換句話說,執行 begin exclusivebegin immediate 產生 SQLITE_BUSY 錯誤的機率更大。

搞清楚這三種事務以及互相的影響,有利於理解 SQLite 的事務處理邏輯,以及各類鎖在哪一個階段發揮做用,限於篇幅,本文不展開贅述,直接拋結果。

下表中,將事務分爲 4 類:deferred read、deferred write、immediate、exclusive;爲敘述方便,將每一個事務的執行分爲:三個階段:begin -> execute -> commit。表中描述某個事務執行過程當中對其餘事務的影響。

第一列縱座標表示正在執行(還沒有 commit)的事務類型,橫座標描述該事務對其餘類型事務的影響。

Rollback 日誌模式下,事務之間的影響以下表:

rollback 模式下事務之間的影響

值得注意的是:

  • 對於 immediate 事務
    • begin immediate 會嘗試獲取 reserved 鎖
    • commit 會嘗試獲取 exclusive,哪怕 immediate 事務啥都沒作(空事務)
  • 對於 exclusive 事務
    • begin immediate 會嘗試獲取 exclusive 鎖

WAL 日誌模式下,事務之間的影響以下圖:

wal 模式下事務之間的影響

注意:exclusive 類型在 wal 模式下不起做用,和 immediate 效果同樣。

「無影響」 表格即表明着所支持的併發狀況,顯然,wal 日誌模式下的併發支持要比 rollback 模式下支持得好得多。

上述兩個表格的 cases 能夠從:Rollback 日誌模式下事務之間的影響WAL 日誌模式下事務之間的影響 驗證獲得。

Busy 錯誤與處理

上文頻繁出現 SQLITE_BUSY,它是 SQLite 內部用來描述併發錯誤的錯誤碼,詳見 這裏;從上文對事務邏輯的分析能夠看出,SQLite 事務執行的過程當中,可能出現 SQLITE_BUSY 錯誤的節點很是多,在 rollback 日誌模式下尤爲如此。

對於任何一個 SQLite 庫,處理 busy 錯誤是必不可少的。

對於 SQLite 自己而言,它提供了簡單的 busy retry 方案,即設置超時時間,設超時後,SQLite 在遇到 SQLITE_BUSY 錯誤後,會在內部作一些重試嘗試。有多種設置超時時間的方式:

基於 SQLite 提供的 busy retry 方案,在 retry 過程當中,休眠時間的長短和重試次數,是決定性能和操做成功率的關鍵。Retry 超時時間的設置因不一樣操做不一樣場景而不一樣。若休眠時間過短或重試次數太多,會空耗CPU的資源;若休眠時間過長,會形成等待的時間太長;若重試次數太少,則會下降操做的成功率。

即使有了 busy retry 方案,SQLITE_BUSY 錯誤還可能仍是會出現,使用過程當中不該該忽視該問題的存在,在知名的 SQLite 庫的裏都能看到大量的 APIs 返回布爾值:

/** FMDB 庫的一些 APIs **/
- (BOOL)executeUpdate:(NSString*)sql, ...;

/** WCDB 庫的一些 APIs **/
- (BOOL)insertObject:(WCTObject *)object into:(NSString *)tableName;
- (BOOL)beginTransaction;
- (BOOL)commitTransaction;
複製代碼

像 beginTransaction、commitTransaction 這種 API 返回 false 的緣由基本上就是 SQLITE_BUSY 錯誤致使的。

從框架的角度來看,彷佛很難在內部規避掉 SQLITE_BUSY 錯誤,由於很難去約束用戶的使用姿式、譬如日誌模型、線程管理等。

對於 iOS 生態,由於每一個應用都是獨立進程,無需擔憂多進程引發的併發問題,問題相對簡單了一些。若是遵循以下姿式使用 SQLite 數據庫,應該能基本上規避 busy 問題:

  • 日誌模式使用 wal 模式
  • 用串行隊列管理寫操做

這是根據先驗知識所總結的,不具有權威性,其實是否還會存在一些其餘邊界沒考慮到,得通過充分的實踐才能知道。

一些第三方庫

這一部分簡單介紹 iOS 平臺中一些知名第三方 SQLite 庫。

FMDB

FMDB 多是 Objective-C 社區使用最多的 SQLite 第三方庫,它對 sqlite3 C API 比較薄地包了一層,很是輕量級,沒有任何限制,有三個主要類:

  • FMDatabase: 對 API 的直接封裝,每一個對象對應一個鏈接句柄,沒有作線程管理、日誌模型約束等
  • FMDatabaseQueue: 對 FMDatabase 進行封裝,每一個 FMDatabaseQueue 對象對應一個 FMDatabase 實例;約束全部數據庫操做都在一個串行隊列上進行,避免併發
  • FMDatabasePool: 管理多個 FMDatabase,即維護一個鏈接池,支持併發;但仍然沒有約束用戶選擇日誌模型

使用 FMDatabaseQueue 比較安全,但效率顯然較低,若是是時間敏感型業務,用它可能有些捉急。

但若是使用 FMDatabasePool,做者在 FMDatabasePool.h 裏留下的 comment 可能讓用戶有些緊張:

這段 comment 恐嚇若是使用不當可能會致使死鎖;然而,做者給的死鎖 case 不太靠譜,經測試沒用,做者彷佛也認識到了,詳見 這裏;我的感受,FMDatabasePool 仍是值得一用的。

總之,FMDB 是一個沒有態度的 SQLite 三方庫,沒有約束日誌模型、checkpoint 策略等,只是提供了最簡單直接的使用姿式。

沒有提供 ORM 功能,除了易用性上有些捉急以外,我認爲 FMDB 還有一個不足點:缺少日誌收集、性能監控相關 API;對於深度使用 SQLite 的功能,這是一個很是大的不足。

WCDB

WCDB 是微信團隊出品的,經得起檢驗。相對於 FMDB,WCDB 要重得多。對於 iOS,若是使用 WCDB,意味着要引入兩個庫:WCDBOptimizedSQLCipher 和 WCDB。

WCDBOptimizedSQLCipher 是從 SQLCipher fork 的一個庫,後者提供了加密功能,微信團隊在此基礎上作了大量的性能優化。WCDB 提供的 性能報告,在多方面都吊打 FMDB,就是由於 WCDBOptimizedSQLCipher 從 SQLite 源碼層面作了一些優化工做,詳見 微信 iOS SQLite 源碼優化實踐

WCDB 庫自己而言,最大的特點是提供了 ORM 等易用性方面的功能,使用體驗挺不錯。支持 ORM 對於不少高級語言(譬如 Swift)而言,是一件挺簡單的事情;但對於 Objective-C 而言挺爲難,語言 feature 太少了,譬如不支持符號重載,這在支持 ORM 中可能用得較多。

微信讀書團隊基於 FMDB 作了一個 ORM 庫:GYDataCenter,但看起來不太好用。

WCDB 主要代碼都是 C++,基於 C++ 的 feature 實現的 ORM 使用起來要好用得多。此外,WCDB 還針對數據庫損壞,提供了修復功能。

WCDB 默認使用 wal 日誌模型,和 FMDatabasePool 相似,在內部維護了一個鏈接池,提供了良好的併發性。

總之,WCDB 是一個很是牛 x 的庫,若是開發比較重的、強依賴 SQLite 的業務,它多是一個不錯的選擇。

SQLite.swift

SQLite.swift 是 Swift 生態中,stars 最多的第三方庫。但我的觀感,雖然 stars 多,但質量通常,在學習分析過程當中,發現了好幾處 bug;更新頻次低;issues 較多但大多沒有響應,給人感受這個庫沒人維護了。

GRDB

GRDB 是另外一個 Swift 生態中的 SQLite 三方庫;維護良好,感受比 SQLite.swift 要好不少。但 stars 比後者少得多(2.5k v.s 6.5k),大概是由於取名沒有後者好,出現時機沒有後者早吧。

更多參考

原博客閱讀體驗更好哦:zhangbuhuai.com/post/sqlite…

相關文章
相關標籤/搜索