你好,WCDB
WCDB是一個高效、完整、易用的移動數(shù)據(jù)庫框架,基于SQLCipher,支持iOS, macOS和Android。
1 基本特性
易用,WCDB支持一句代碼即可將數(shù)據(jù)取出并組合為object。
WINQ(WCDB語言集成查詢):通過WINQ,開發(fā)者無須為了拼接SQL的字符串而寫一大坨膠水代碼。
ORM(Object Relational Mapping):WCDB支持靈活、易用的ORM。開發(fā)者可以很便捷地定義表、索引、約束,并進行增刪改查操作。
[database getObjectsOfClass:WCTSampleConvenient.class
fromTable:tableName
where:WCTSampleConvenient.intValue>=10
limit:20];
高效,WCDB通過框架層和sqlcipher源碼優(yōu)化,使其更高效的表現(xiàn)。
多線程高并發(fā):WCDB支持多線程讀與讀、讀與寫并發(fā)執(zhí)行,寫與寫串行執(zhí)行。
-
批量寫操作性能測試:
更多關于WCDB的性能數(shù)據(jù),請參考benchmark。 完整,WCDB覆蓋了數(shù)據(jù)庫相關各種場景的所需功能。
加密:WCDB提供基于SQLCipher的數(shù)據(jù)庫加密。
損壞修復:WCDB內建了Repair Kit用于修復損壞的數(shù)據(jù)庫。
反注入:WCDB內建了對SQL注入的保護。
2 數(shù)據(jù)庫修復方案
通過收集到的大量案例和日志,分析出實際上移動端數(shù)據(jù)庫損壞的真正原因其實就3個:
- 空間不足
- 設備斷電
- 文件 sync 失敗
我們需要針對這些原因一一進行優(yōu)化
2.1 優(yōu)化空間占用
- 業(yè)務文件先申請后使用,如果某個文件沒有申請就使用了,會被自動掃描出來并刪除;
- 每個業(yè)務文件都要申明有效期,是一天、一個星期、一個月還是永久存儲;
- 過期文件會被自動清理。
對于微信之外的空間占用,例如相冊、視頻、其他App的空間占用,微信本身是做不了什么事情的,我們可以提示用戶進行空間清理
2.2 優(yōu)化文件 sync
2.2.1 synchronous = FULL
設置SQLite的文件同步機制為全同步,亦即要求每個事物的寫操作是真的flush到文件里去。
2.2.2 fullfsync = 1
通過與蘋果工程師的交流,我們發(fā)現(xiàn)在 iOS 平臺下還有 fullfsync 這個選項,可以嚴格保證寫入順序跟提交順序一致。設備開發(fā)商為了測評數(shù)據(jù)好看,往往會對提交的數(shù)據(jù)進行重排,再統(tǒng)一寫入,亦即寫入順序跟App提交的順序不一致。在某些情況下,例如斷電,就可能導致寫入文件不一致的情況,導致文件損壞。
2.3 SQLite 修復邏輯優(yōu)化
官方修復算法是這樣一個流程:從 master 表中讀出一個個表的信息,根據(jù)根節(jié)點地址和創(chuàng)表語句來 select 出表里的數(shù)據(jù),能 select 多少是多少,然后插入到一個新 DB 中。要注意的是 master 表他本身也是一個 B+樹 形式的普通表,DB 第0頁就是他的根節(jié)點。那么只要 master 表某個節(jié)點損壞,這個節(jié)點下面記錄的表就都恢復不了。更壞的情況是 DB 第0頁損壞,那么整個 master 表都讀不出來,就導致整個DB都恢復失敗。這就是官方修復算法成功率這么低的原因,太依賴 master 表了。
2.3.1 解析B-tree恢復方案(RepairKit)
正常情況下,SQLite 引擎打開DB后首次使用,需要先遍歷sqlite_master,并將里面保存的SQL語句再解析一遍, 保存在內存中供后續(xù)編譯SQL語句時使用。假如sqlite_master損壞了無法解析,“Dump恢復”這種走正常SQLite 流程的方法,自然會卡在第一步了。為了讓sqlite_master受損的DB也能打開,需要想辦法繞過SQLite引擎的邏輯。 由于SQLite引擎初始化邏輯比較復雜,為了避免副作用,沒有采用hack的方式復用其邏輯,而是決定仿造一個只可以 讀取數(shù)據(jù)的最小化系統(tǒng)
sqlite_master信息量比較小,而且只有改變了表結構的時候(例如執(zhí)行了CREATE TABLE、ALTER TABLE等語句)才會改變,因此對它進行備份成本是非常低的,一般手機典型只需要幾毫秒到數(shù)十毫秒即可完成,一致性也容易保證, 只需要執(zhí)行了上述語句的時候重新備份一次即可。有了備份,我們的邏輯可以在讀取DB自帶的sqlite_master失敗的時候 使用備份的信息來代替。
DB初始化的問題除了文件頭和sqlite_master完整性外,還有加密。SQLCipher加密數(shù)據(jù)庫,對應的恢復邏輯還需要加上 解密邏輯。按照SQLCipher的實現(xiàn),加密DB 是按page 進行包括頭部的完整加密,所用的密鑰是根據(jù)用戶輸入的原始密碼和 創(chuàng)建DB 時隨機生成的 salt 運算后得出的??梢圆孪氲玫剑绻4鎠alt錯了,將沒有辦法得出之前加密用的密鑰, 導致所有page都無法讀出了。由于salt 是創(chuàng)建DB時隨機生成,后續(xù)不再修改,將它納入到備份的范圍內即可
到此,初始化必須的數(shù)據(jù)就保證了,可以仿造讀取邏輯了。我們常規(guī)使用的讀取DB的方法(包括dump方式恢復), 都是通過執(zhí)行SQL語句實現(xiàn)的,這牽涉到SQLite系統(tǒng)最復雜的子系統(tǒng)——SQL執(zhí)行引擎。我們的恢復任務只需要遍歷B-tree所有節(jié)點, 讀出數(shù)據(jù)即可完成,不需要復雜的查詢邏輯,因此最復雜的SQL引擎可以省略。同時,因為我們的系統(tǒng)是只讀的, 寫入恢復數(shù)據(jù)到新 DB 只要直接調用 SQLite 接口即可,因而可以省略同樣比較復雜的B-tree平衡、Journal和同步等邏輯。 最后恢復用的最小系統(tǒng)只需要:
VFS讀取部分的接口(Open/Read/Close),或者直接用stdio的fopen/fread、Posix的open/read也可以
SQLCipher的解密邏輯
B-tree解析邏輯
即可實現(xiàn)
B-tree解析好處是準備成本較低,不需要經(jīng)常更新備份,對大部分表比較少的應用備份開銷也小到幾乎可以忽略, 成功恢復后能還原損壞時最新的數(shù)據(jù),不受備份時限影響。 壞處是,和Dump一樣,如果損壞到表的中間部分,比如非葉子節(jié)點,將導致后續(xù)數(shù)據(jù)無法讀出。
使用 Repair Kit 可以直接從損壞的數(shù)據(jù)庫里盡量讀出未損壞的數(shù)據(jù),不需要事先準備, 但是先備份 Master 信息可以大大增加恢復成功率。 如果有意使用 Repair Kit 恢復數(shù)據(jù)庫, 建議備份 Master 信息
2.3.2 備份方案
主要的方案有:
拷貝: 不能再直白的方式。由于SQLite DB本身是文件(主DB + journal 或 WAL), 直接把文件復制就能達到備份的目的。
Dump: 上一個恢復方案用到的命令的本來目的。在DB完好的時候執(zhí)行.dump, 把 DB所有內容輸出為 SQL語句,達到備份目的,恢復的時候執(zhí)行SQL即可。
Backup API: SQLite自身提供的一套備份機制,按 Page 為單位復制到新 DB, 支持熱備份。
對以上方案做簡單測試后,備份方案也就基本定下了。測試用的DB大小約 50MB, 數(shù)據(jù)條目數(shù)大約為 10萬條:
微信在Dump + gzip方案上再加以優(yōu)化,由于格式化SQL語句輸出耗時較長,因此使用了自定義 的二進制格式承載Dump輸出。第二耗時的壓縮操作則放到別的線程同時進行,在雙核以上的環(huán)境 基本可以做到無額外時間消耗。由于數(shù)據(jù)保密需要,二進制Dump數(shù)據(jù)也做了加密處理。 采用自定義二進制格式還有一個好處是,恢復的時候不需要重復的編譯SQL語句,編譯一次就可以 插入整個表的數(shù)據(jù)了,恢復性能也有一定提升。優(yōu)化后的方案比原始的Dump + 壓縮, 每秒備份行數(shù)提升了 150%,每秒恢復行數(shù)也提升了 40%。
2.3.3 不同方案的組合
由于解析B-tree恢復原理和備份恢復不同,失敗場景也有差別,可以兩種手段混合使用覆蓋更多損壞場景。 微信的數(shù)據(jù)庫中,有部分數(shù)據(jù)是臨時或者可從服務端拉取的,這部分數(shù)據(jù)可以選擇不修復,有些數(shù)據(jù)是不可恢復或者 恢復成本高的,就需要修復了。
如果修復過程一路都是成功的,那無疑使用B-tree解析修復效果要好于備份恢復。備份恢復由于存在 時效性,總有部分最新的記錄會丟掉,解析修復由于直接基于損壞DB來操作,不存在時效性問題。 假如損壞部分位于不需要修復的部分,解析修復有可能不發(fā)生任何錯誤而完成。
若修復過程遇到錯誤,則很可能是需要修復的B-tree損壞了,這會導致需要修復的表發(fā)生部分或全部缺失。 這個時候再使用備份修復,能挽救一些缺失的部分。
最早的Dump修復,場景已經(jīng)基本被B-tree解析修復覆蓋了,若B-tree修復不成功,Dump恢復也很有可能不會成功。 即便如此,假如上面的所有嘗試都失敗,最后還是會嘗試Dump恢復。
注:了解到iOS端恢復方式只提供Repair Kit, 且所有的備份和恢復操作都需要開發(fā)人員自己調用相應的接口
3 SQLite源文件優(yōu)化
3.1 優(yōu)化并發(fā)效率
3.1.1 SQLite 多句柄方案
我們先講 SQLite 所提供的多線程并發(fā)方案。它對這方面的支持做的很不錯,在使用上,只需
- 1.開啟句柄多線程支持的配置 PRAGMA SQLITE_THREADSAFE=2
- 2.確保同一個句柄同一時間只有一個線程在操作
- 3.(可選)開啟 WAL 模式 PRAGMA journal_mode=WAL
此時寫操作會先 append 到 wal 文件末尾,而不是直接覆蓋舊數(shù)據(jù)。而讀操作開始時,會記下當前的 WAL 文件狀態(tài),并且只訪問在此之前的數(shù)據(jù)。這就確保了多線程讀與讀、讀與寫之間可以并發(fā)地進行。
3.1.2 Busy Retry 方案
而寫與寫之間仍會互相阻塞。SQLite 提供了 Busy Retry 的方案,即發(fā)生阻塞時,會觸發(fā) Busy Handler,此時可以讓線程休眠一段時間后,重新嘗試操作。重試一定次數(shù)依然失敗后,則返回 SQLITE_BUSY 錯誤碼。
下面這段代碼是 SQLite 默認的 Busy Handler
3.1.3 Busy Retry 方案的不足
上面介紹了 SQLite 多線程并發(fā)方案,接下來我們把焦點放在 Busy Retry 這個方案的不足上。
Busy Retry 的方案雖然基本能解決問題,但對性能的壓榨做的不夠極致。在 Retry 過程中,休眠時間的長短和重試次數(shù),是決定性能和操作成功率的關鍵。
然而,它們的最優(yōu)值,因不同操作不同場景而不同。若休眠時間太短或重試次數(shù)太多,會空耗 CPU 的資源;若休眠時間過長,會造成等待的時間太長;若重試次數(shù)太少,則會降低操作的成功率。如下圖
可以看到
- CPU空轉那段,線程一操作還沒結束,這里空耗了 CPU 的資源
- 線程閑置那段,線程一已經(jīng)結束,而線程二仍在等待,空耗了時間
3.1.3 開始改造
當 OS 層進行 lock 操作時:
- 1.通過 pthread_mutex_lock 進行線程鎖,防止其他線程介入。然后比較狀態(tài)量,若當前狀態(tài)不可跳轉,則將當前期望跳轉的狀態(tài),插入到一個 FIFO 的 Queue 尾部。最后,線程通過 pthread_cond_wait 進入 休眠狀態(tài),等待其他線程的喚醒。
- 2.忽略文件鎖
當 OS 層的 unlock 操作結束后:
取出 Queue 頭部的狀態(tài)量,并比較狀態(tài)是否能夠跳轉。若能夠跳轉,則通過 pthread_cond_signal_thread_np 喚醒對應的線程重試。
新的方案可以在 DB 空閑時的第一時間,通知到其他正在等待的線程,最大程度地降低了空等待的時間,且準確無誤。
此外,由于 Queue 的存在,當主線程被其他線程阻塞時,可以將主線程的操作“插隊”到 Queue 的頭部。當其他線程發(fā)起喚醒通知時,主線程可以有更高的優(yōu)先級,從而降低用戶可感知的卡頓
3.2 I/O 性能優(yōu)化
提到 I/O 效率的提升,最容易想到的就是 mmap了,它可以減少數(shù)據(jù)從 kernel 層到 user 層的數(shù)據(jù)拷貝,從而提高效率。
SQLite 不僅支持 mmap,而且推薦使用,在大多數(shù)平臺是在一定程度上默認打開的。然而早期的 iOS 版本的存在一些 bug,SQLite 在編譯層就關閉了在 iOS 上對 mmap 的支持,并且后知后覺地在16年1月才重新打開。所以如果使用的 SQLite 版本較低,還需注釋掉相關代碼后,重新編譯生成后,才可以享受上 mmap 的性能。
主要修改了
- 1.數(shù)據(jù)庫關閉并 checkpoint 成功時,不再 truncate 或刪除 WAL 文件,只修改 WAL 的文件頭的 Magic Number。下次數(shù)據(jù)庫打開時, SQLite 會識別到 WAL 文件不可用,重新從頭開始寫入。
- 2.為 WAL 添加 mmap 的支持
3.3 其他優(yōu)化
禁用文件鎖
如我們在多線程優(yōu)化時所說,對于 iOS app 并沒有多進程的需求。因此我們可以直接注釋掉 os_unix.c 中所有文件鎖相關的操作。也許你會很奇怪,雖然沒有文件鎖的需求,但這個操作耗時也很短,是否有必要特意優(yōu)化呢?其實并不全然。耗時多少是比出來。
SQLite 中有 cache 機制。被加載進內存的 page,使用完畢后不會立刻釋放。而是在一定范圍內通過 LRU 的算法更新 page cache。這就意味著,如果 cache 設置得當,大部分讀操作不會讀取新的 page。然而因為文件鎖的存在,本來只需在內存層面進行的讀操作,不得不進行至少一次 I/O 操作。而我們知道,I/O 操作是遠遠慢于內存操作的。
禁用內存統(tǒng)計鎖
SQLite 會對申請的內存進行統(tǒng)計,而這些統(tǒng)計的數(shù)據(jù)都是放到同一個全局變量里進行計算的。這就意味著統(tǒng)計前后,都是需要加線程鎖,防止出現(xiàn)多線程問題的。
以下 SQLite 內存申請的函數(shù)可以看到,當內存統(tǒng)計打開時,會跑代碼的第二個 if,malloc 的前后被鎖保護了起來。
其實這里內存申請的量不大,并不是非常耗時的操作,但卻很頻繁。多線程并發(fā)時,各線程很容易互相阻塞。因為耗時很短,所以被阻塞的時間也很短暫。似乎不會有太大問題。但頻繁地阻塞卻意味著線程不斷地切換,這是個很影響性能的操作,尤其對于單核設備。
因此,如果不需要內存統(tǒng)計的特性,可以通過 sqlite3_config(SQLITE_CONFIG_MEMSTATUS, 0)進行關閉。這個修改雖然不需要改動源碼,但如果不查看源碼,恐怕是比較難發(fā)現(xiàn)的。
多線程并發(fā)優(yōu)化使得卡頓率從4.08%降至0.19,I/O 優(yōu)化使得讀卡頓從1.50%降至0.20%,寫卡頓從1.18%降至0.21%
4 從FMDB遷移到WCDB好處
4.1 語法上
由于 FMDB 和 WCDB 都基于 SQLite ,因此兩者在數(shù)據(jù)庫的文件格式上一致。用 FMDB 創(chuàng)建、操作的數(shù)據(jù)庫,可以直接通過 WCDB 打開、使用。因此開發(fā)者無需做額外的數(shù)據(jù)遷移
WCDB通過WINQ抽象SQLite語法規(guī)則,使得開發(fā)者可以告別字符串拼接的膠水代碼。通過和接口層的ORM結合,使得即便是很復雜的查詢,也可以通過一行代碼完成,更少的代碼量通常意味著更快的開發(fā)效率和更少的錯誤。并借助IDE的代碼提示和編譯檢查的特性,大大提升了開發(fā)效率。同時還內建了反注入的保護
OC語法式的ORM建模
查詢操作
插入操作
as重定向
鏈式調用
多表查詢
類字段綁定
4.2 方便的數(shù)據(jù)庫升級
WCDB 將數(shù)據(jù)庫升級和 ORM 結合起來,對于需要增刪改的字段,只需直接在 ORM 層面修改,并再次調用 createTableAndIndexesOfName:withClass: 接口即可自動升級
4.3 安全的多線程操作
WCDB 與 FMDB 都支持多線程操作。
在 FMDB 內,當開發(fā)者需要進行多線程操作時,需要使用另外一個類 FMDatabasePool來進行操作。
而 WCDB 基礎的 CRUD 接口都支持多線程,因此開發(fā)者不需要額外關心線程安全的問題。同樣的, WCDB 多線程使用的代碼量也比 FMDB 少得多
4.4 更多
- WCDB 寫操作優(yōu)于 FMDB 28%、批量寫操作優(yōu)于 FMDB 180%; WCDB 的初始化速度有 107% 的性能優(yōu)勢
- WCDB內建了對SQL注入的保護
- WCDB 基于 SQLCipher 提供了加密功能
- WCDB 內提供統(tǒng)計的接口注冊獲取數(shù)據(jù)庫操作的 SQL 、性能、錯誤等,開發(fā)者可以將這些信息打印到日志或上報到后臺,以調試或統(tǒng)計
- WCDB 提供了數(shù)據(jù)庫修復工具,以應對數(shù)據(jù)庫損壞無法使用的極端情況。
5.遷移方案
5.1 逐步遷移
使用WCDB新建一個數(shù)據(jù)庫,和老數(shù)據(jù)庫共存,一步一步遷移過來
優(yōu)點: 可以一步一步慢慢遷移過去,一個表一個表遷移;能保存原始數(shù)據(jù)
缺點: 周期長,需要考慮老版本數(shù)據(jù)庫遷移時;-適配問題
5.2 暴力遷移
刪除原應用所有數(shù)據(jù),讓用戶重新登錄,使用WCDB建完所有表和操作
優(yōu)點: 快、準、狠
缺點: 太狠了,對用戶有一定影響,同時一個版本把所有數(shù)據(jù)庫操作全部替換有一定的人力成本
5.3 直接框架遷移(推薦)
直接將以前FMDB的寫法全部轉換為WCDB的寫法,一步到位
優(yōu)點: 能保存原始數(shù)據(jù)
缺點: 跟方法2一樣,有一定的人力成本;需要考慮到老版本數(shù)據(jù)庫適配問題