微信 iOS SQLite 源碼優化實踐分享實錄
引言
SQLite 是我們在移動端常用的數據庫,微信也是基于它封裝了一層 ObjC 接口。我們知道,微信里消息的收發是很頻繁的,尤其是對于重度用戶,這對于數據庫的多線程并發和 I/O 是很大的挑戰。
通常對這部分做優化,有兩種方式:
- 一是修改 SQLite 的參數,如 Cache Size 等
- 二是改業務層調用,如主線程操作 dispatch 到子線程。
然而,前者有明顯的瓶頸,后者則是個 endless 的工作。我們希望能一勞永逸地解決同類問題。這就是我們本次所要分享的優化。
1. 多線程并發優化
1.1 SQLite 多句柄方案
我們先講 SQLite 所提供的多線程并發方案。它對這方面的支持做的很不錯,在使用上,只需
- 開啟句柄多線程支持的配置 PRAGMA SQLITE_THREADSAFE=2
- 確保同一個句柄同一時間只有一個線程在操作
- (可選)開啟 WAL 模式 PRAGMA journal_mode=WAL
此時寫操作會先 append 到 wal 文件末尾,而不是直接覆蓋舊數據。而讀操作開始時,會記下當前的 WAL 文件狀態,并且只訪問在此之前的數據。這就確保了多線程讀與讀、讀與寫之間可以并發地進行。
1.2 Busy Retry 方案
而寫與寫之間仍會互相阻塞。SQLite 提供了 Busy Retry 的方案,即發生阻塞時,會觸發 Busy Handler,此時可以讓線程休眠一段時間后,重新嘗試操作。重試一定次數依然失敗后,則返回 SQLITE_BUSY 錯誤碼。
下面這段代碼是 SQLite 默認的 Busy Handler
1.3 Busy Retry 方案的不足
上面介紹了 SQLite 多線程并發方案,接下來我們把焦點放在 Busy Retry 這個方案的不足上。
Busy Retry 的方案雖然基本能解決問題,但對性能的壓榨做的不夠極致。在 Retry 過程中,休眠時間的長短和重試次數,是決定性能和操作成功率的關鍵。
然而,它們的最優值,因不同操作不同場景而不同。若休眠時間太短或重試次數太多,會空耗 CPU 的資源;若休眠時間過長,會造成等待的時間太長;若重試次數太少,則會降低操作的成功率。如下圖
可以看到
- CPU空轉那段,線程一操作還沒結束,這里空耗了 CPU 的資源
- 線程閑置那段,線程一已經結束,而線程二仍在等待,空耗了時間
對于這個的優化,簡單的方法可以是修改休眠時間,盡最大限度縮短以上兩段空耗的資源。
我們通過 A/B Test 對不同休眠時間進行了實驗,得到了如下的結果
可以看到,倘若休眠時間與重試成功率的關系,按照綠色的曲線進行分布,那么 p 點的值也不失為該方案的一個次優解。然而不同業務和操作的需求,還是有很大的不同的。
既然 SQLite 的方案不行,我們就要開始往深層探索新的可能性了。
1.4 SQLite 中控制并發相關的原理
SQLite是一個適配不同平臺的數據庫,不僅支持多線程并發,還支持多進程并發。它的核心邏輯可以分為兩部分:
- Core 層 。包括了接口層、編譯器和虛擬機。通過接口傳入 SQL 語句,由編譯器編譯SQL生成虛擬機的操作碼 opcode。而虛擬機是基于生成的操作碼,控制 Backend 的行為。
- Backend 層 。由 B-Tree、Pager、OS 三部分組成,實現了數據庫的存取數據的主要邏輯。
在架構最底端的 OS 層是對不同操作系統的系統調用的抽象層。它實現了一個 VFS(Virtual File System),將 OS 層的接口在編譯時映射到對應操作系統的系統調用。鎖的實現也是在這里進行的。
SQLite 通過兩個鎖來控制并發。第一個鎖對應 DB 文件,通過5種狀態進行管理;第二個鎖對應WAL文件,通過修改一個 16-bit 的 unsigned short int 的每一個 bit 進行管理。盡管鎖的邏輯有一些復雜,但此處并不需關心。這兩種鎖最終都落在 OS 層的 sqlite3OsLock、sqlite3OsUnlock 和 sqlite3OsShmLock 上具體實現。
它們在鎖的實現比較類似。以 lock 操作在 iOS 上的實現為例:
- 通過 pthread_mutex_lock 進行線程鎖,防止其他線程介入。然后比較狀態量,若當前狀態不可跳轉,則返回 SQLITE_BUSY
- 通過 fcntl 進行文件鎖,防止其他進程介入。若鎖失敗,則返回 SQLITE_BUSY
而 SQLite 選擇 Busy Retry 的方案的原因也正是在此
文件鎖沒有線程鎖類似 pthread_cond_signal 的通知機制。當一個進程的數據庫操作結束時,無法通過鎖來第一時間通知到其他進程進行重試。因此只能退而求其次,通過多次休眠來進行嘗試。
1.5 新的方案
搞清楚了 SQLite 并發的實現,我們就是可以開始改造了。
我們知道,iOS app 是單進程的,并 沒有多進程并發的需求 ,這和 SQLite 的設計初衷是不相同的。這就給我們的優化提供了理論上的基礎。在 iOS 這一特定場景下,我們可以舍棄兼容性,提高并發性。
新的方案修改為,當 OS 層進行 lock 操作時:
- 通過 pthread_mutex_lock 進行線程鎖,防止其他線程介入。然后比較狀態量,若當前狀態不可跳轉,則將當前期望跳轉的狀態,插入到一個 FIFO 的 Queue 尾部。最后,線程通過 pthread_cond_wait 進入 休眠狀態,等待其他線程的喚醒。
- 忽略文件鎖
當 OS 層的 unlock 操作結束后:
- 取出 Queue 頭部的狀態量,并比較狀態是否能夠跳轉。若能夠跳轉,則通過 pthread_cond_signal_thread_np 喚醒對應的線程重試。
新的方案可以在 DB 空閑時的第一時間,通知到其他正在等待的線程,最大程度地降低了空等待的時間,且準確無誤。
此外,由于 Queue 的存在,當主線程被其他線程阻塞時,可以將主線程的操作“插隊”到 Queue 的頭部。當其他線程發起喚醒通知時,主線程可以有更高的優先級,從而降低用戶可感知的卡頓
2. I/O 性能優化
上面介紹了多線程并發的優化,接下來將介紹 I/O 方面的優化。
2.1 mmap
提到 I/O 效率的提升,最容易想到的就是 mmap了,它可以減少數據從 kernel 層到 user 層的數據拷貝,從而提高效率。
SQLite 不僅支持 mmap,而且推薦使用,在大多數平臺是在一定程度上默認打開的。然而早期的 iOS 版本的存在一些 bug,SQLite 在編譯層就關閉了在 iOS 上對 mmap 的支持,并且后知后覺地在16年1月才重新打開。所以如果使用的 SQLite 版本較低,還需注釋掉相關代碼后,重新編譯生成后,才可以享受上 mmap 的性能。
下圖就是 SQLite 注釋掉相關代碼的 commit
開啟 mmap 后,SQLite 性能將有所提升,但這還不夠。因為它只會對 DB 文件進行了 mmap,而 WAL 文件享受不到這個優化。原因如下:
開啟 WAL 模式后,寫入的數據會先 append 到 WAL 文件的末尾。待文件增長到一定長度后,SQLite 會進行 checkpoint。這個長度默認為1000個頁大小,在 iOS 上約為3.9MB。
而在多句柄下,對 WAL 文件的操作是并行的。一旦某個句柄將 WAL 文件縮短了,而沒有一個通知機制讓其他句柄進行更新 mmap 的內容。此時其他句柄若使用 mmap 操作已被縮短的內容,就會造成 crash。而普通的 I/O 接口,則只會返回錯誤,不會造成 crash。因此,SQLite 沒有實現對 WAL 文件的 mmap。
顯然 SQLite 的設計是針對容量較小的設備,尤其是在十幾年前的那個年代,這樣的設備并不在少數。而隨著硬盤價格日益降低,對于像 iPhone 這樣的設備,幾 MB 的空間已經不再是需要斤斤計較的了。
另一方面,文件重新增長,對于文件系統來說,這就意味著需要消耗時間重新尋找合適的文件塊。
權衡兩者,我們可以改為
- 數據庫關閉并 checkpoint 成功時,不再 truncate 或刪除 WAL 文件,只修改 WAL 的文件頭的 Magic Number。下次數據庫打開時, SQLite 會識別到 WAL 文件不可用,重新從頭開始寫入。
- 為 WAL 添加 mmap 的支持 有了上面兩個優化,整體性能就會提升不少了。
這里我沒有貼具體代碼需要改哪些地方,一方面是因為改動點較零散,另一方面是代碼上的改動并不難。這個優化的工作量主要是在 SQLite 原理和優化點的挖掘上了,大家可以根據優化方案去嘗試。
3. 其他優化
不過我們還有一些簡單易行且效果還不錯的小優化,希望可以成為大家打開 SQLite 黑盒的一個契機。
3.1 禁用文件鎖
如我們在多線程優化時所說,對于 iOS app 并沒有多進程的需求。因此我們可以直接注釋掉 os_unix.c 中所有文件鎖相關的操作。也許你會很奇怪,雖然沒有文件鎖的需求,但這個操作耗時也很短,是否有必要特意優化呢?其實并不全然。耗時多少是比出來。
SQLite 中有 cache 機制。被加載進內存的 page,使用完畢后不會立刻釋放。而是在一定范圍內通過 LRU 的算法更新 page cache。這就意味著,如果 cache 設置得當,大部分讀操作不會讀取新的 page。然而因為文件鎖的存在,本來只需在內存層面進行的讀操作,不得不進行至少一次 I/O 操作。而我們知道,I/O 操作是遠遠慢于內存操作的。
3.2 禁用內存統計鎖
SQLite 會對申請的內存進行統計,而這些統計的數據都是放到同一個全局變量里進行計算的。這就意味著統計前后,都是需要加線程鎖,防止出現多線程問題的。
以下 SQLite 內存申請的函數可以看到,當內存統計打開時,會跑代碼的第二個 if,malloc 的前后被鎖保護了起來。
其實這里內存申請的量不大,并不是非常耗時的操作,但卻很頻繁。多線程并發時,各線程很容易互相阻塞。因為耗時很短,所以被阻塞的時間也很短暫。似乎不會有太大問題。但頻繁地阻塞卻意味著線程不斷地切換,這是個很影響性能的操作,尤其對于單核設備。
因此,如果不需要內存統計的特性,可以通過 sqlite3_config(SQLITE_CONFIG_MEMSTATUS, 0)進行關閉。這個修改雖然不需要改動源碼,但如果不查看源碼,恐怕是比較難發現的。
4. 結語
總的來說,移動客戶端數據庫雖然不如后臺數據庫那么復雜,但也存在著不少可挖掘的技術點。
這次也只嘗試了對 SQLite 原有的方案進行優化,而市面上還有許多優秀的數據庫,如 LevelDB、RocksDB、Realm 等,它們采用了和 SQLite 不同的實現原理。后續我們將借鑒它們的優化經驗,嘗試更深入的優化。
以上就是我今天的分享,謝謝大家。
問答環節
Q1:前一陣微信提示我微信數據文件發現有損壞,這個是什么原因呢?
這個是數據庫損壞,SQLite 是以B樹結構存儲的,如果某一個節點發生損壞,可能導致無法讀取數據。損壞的原因多種多樣,如斷電、文件系統錯誤、硬盤損壞等。據我所知很多產品都出現了類似問題。
你看到的那個是微信的損壞監測和修復邏輯,我們做了自研的工具進行修復。這塊我們后續也會分享 db 損壞的監測、保護、修復方案的
Q2:請問 sqlite 有時候會出 signal 11的錯誤,可能是什么原因導致的
signal 11 就是 SQLITE_CORRUPT,上面提到的數據庫損壞的其中一種。另一種是26 SQLITE_NOTADB
Q3:請問微信在全文索引上有實踐嗎?有沒有自己做本地的搜索索引
SQLite 是支持有全文索引的支持的,我們要做的是提供一個好的,支持中文的分詞器。
Q4:請問微信在 db 文件修復上有什么心得呢?
看來大家對 db 文件損壞很關注啊。SQLite 提供了 PRAGMA integrity_check 的工具檢測損壞 和 DUMP 工具導出損壞 db。但從實踐來看,效果并不理想。我們采用了按 BTree 結構遍歷修復的方式,以后有機會可以分享給大家
Q5:目前有沒有已有的優化過的 sqlite 框架可供使用呢?
iOS上SQLite 的框架似乎只有 FMDB 和 CoreData,坦白說兩個都不是很好。我們是自己封裝的 WCDB 框架。
Q6:微信的 orm 是怎么搞的
通過封裝和規范來處理 ORM
Q7:請問下多句柄怎么開啟,是修改 sqlite 源碼后再編譯的嗎?
這個最開始有提到了
- 開啟句柄多線程支持的配置 PRAGMA SQLITE_THREADSAFE=2
- 確保同一個句柄同一時間只有一個線程在操作
Q8:微信是怎么分析它的鎖競爭的?
最重要的是讀懂源碼。輔助手段可以有 SQLite 官方的 Technical/Design Document 和 Instrument 工具
Q9:請問有沒有對能耗的監測和優化經驗?
檢測相關的我們有卡頓監控系統,可以到我們的公眾號 WeMobileDev 上了解
Q10:請問 sqlite 優化后有性能對比數據嗎,差別有多大?
性能數據我以我們的卡頓系統為準,多線程并發優化使得卡頓率從4.08%降至0.19,I/O 優化使得讀卡頓從1.50%降至0.20%,寫卡頓從1.18%降至0.21%
Q11:iOS 客戶端用操作數據庫需要每次先 open,執行完了再 close,每次都這樣,還是 app 只需要開關一次比較好呢?
常用的 db 沒有必要經常開關,db 占用的內存并不高,可以權衡一下
Q12:微信對于本地空間不足會有一個強提醒,這是出于什么考慮?不同機型有不同的策略嗎?
空間不足是個硬傷,所謂巧婦難為無米之炊。如16GB 的 iPhone,其實很影響正常使用了。不同機型會做細化
Q13:請問 sqlite 多線程機制,大概能應付多大量級的數據庫操作(基本無卡頓),微信有這方面的測試體驗嗎,然后是使用了底層代碼修改多線程機制后,有大概的提升量級嗎?
優化的效果我們是以卡頓系統檢測到的為準的。能否減少用戶感知到的卡頓,優化用戶體驗才是重點,而不在于能承受多大的量級
Q14:微信對于數據庫升級有沒有特別優化的地方?或者說不同版本的跳版本升級
不知道這個問題指的是 SQLite 的升級還是表結構的升級。前者的話,暫時沒看到 SQLite 新版本有比較大的特性值得我們跟進。后者可以用 alter table 在封裝層支持升級,性能損耗不大
Q15:請問微信的 SQLite 有沒有開啟加密?如果有,性能是否有提升空間?
iOS 版本目前沒有開啟加密
Q16:微信 sqllite 數據庫用的內存數據庫嗎?那和文件數據庫導入導出怎么控制的?
沒有使用內存數據庫
Q17:可以問一下,目前做 iOS 版,沒有針對 android 版么?
這次分享的大部分內容,對Android也是通用的,觸類旁通即可。
Q18:請問下,句柄開幾個比較合適?讀寫分離開來對性能是否會有提升呢?
我們是按需生成新句柄的,并設了上限,若超過上限會有報警。如果同一時間并發量太大的話,其實更多要考慮業務層是否適用得當。至于業務層的使用,若能做細化那自然是更好
來自:http://dev.qq.com/topic/57b6a449433221be01499486