微信 iOS SQLite 源碼優化實踐

前言

隨著微信iOS客戶端業務的增長,在數據庫上遇到的性能瓶頸也逐漸凸顯。在微信的卡頓監控系統上,數據庫相關的卡頓不斷上升。而在用戶側也逐漸能感知到這種卡頓,尤其是有大量群聊、聯系人和消息收發的重度用戶。


我們在對SQLite進行優化的過程中發現,靠單純地修改SQLite的參數配置,已經不能徹底解決問題。因此從6.3.16版本開始,我們合入了SQLite的源碼,并開始進行源碼層的優化。


本文將分享在SQLite源碼上進行的多線程并發、I/O性能優化等,并介紹優化相關的SQLite原理。


多線程并發優化

1. 背景


由于歷史原因,舊版本的微信一直使用單句柄的方案,即所有線程共有一個SQLite Handle,并用線程鎖避免多線程問題。當多線程并發時,各線程的數據庫操作同步順序進行,這就導致后來的線程會被阻塞較長的時間。


2. SQLite的多句柄方案及Busy Retry方案


SQLite實際是支持多線程(幾乎)無鎖地并發操作。只需


開啟配置 PRAGMA SQLITE_THREADSAFE=2

確保同一個句柄同一時間只有一個線程在操作

Multi-thread. In this mode, SQLite can be safely used by multiple threads provided that no single database connection is used simultaneously in two or more threads.

倘若再開啟SQLite的WAL模式(Write-Ahead-Log),多線程的并發性將得到進一步的提升。


此時寫操作會先append到wal文件末尾,而不是直接覆蓋舊數據。而讀操作開始時,會記下當前的WAL文件狀態,并且只訪問在此之前的數據。這就確保了多線程讀與讀、讀與寫之間可以并發地進行。


然而,阻塞的情況并非不會發生。


當多線程寫操作并發時,后來者還是必須在源碼層等待之前的寫操作完成后才能繼續。

SQLite提供了Busy Retry的方案,即發生阻塞時,會觸發Busy Handler,此時可以讓線程休眠一段時間后,重新嘗試操作。重試一定次數依然失敗后,則返回SQLITE_BUSY錯誤碼。



3. SQLite Busy Retry方案的不足


Busy Retry的方案雖然基本能解決問題,但對性能的壓榨做的不夠極致。在Retry過程中,休眠時間的長短和重試次數,是決定性能和操作成功率的關鍵。


然而,它們的最優值,因不同操作不同場景而不同。若休眠時間太短或重試次數太多,會空耗CPU的資源;若休眠時間過長,會造成等待的時間太長;若重試次數太少,則會降低操作的成功率。



我們通過A/B Test對不同的休眠時間進行了測試,得到了如下的結果:



可以看到,倘若休眠時間與重試成功率的關系,按照綠色的曲線進行分布,那么p點的值也不失為該方案的一個次優解。然而事總不遂人愿,我們需要一個更好的方案。


4. SQLite中的線程鎖及進程鎖


作為有著十幾年發展歷史、且被廣泛認可的數據庫,SQLite的任何方案選擇都是有其原因的。在完全理解由來之前,切忌盲目自信、直接上手修改。因此,首先要了解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的通知機制。當一個進程的數據庫操作結束時,無法通過鎖來第一時間通知到其他進程進行重試。因此只能退而求其次,通過多次休眠來進行嘗試。


5. 新的方案


通過上面的各種分析、準備,終于可以動手開始修改了。


我們知道,iOS app是單進程的,并沒有多進程并發的需求,這和SQLite的設計初衷是不相同的。這就給我們的優化提供了理論上的基礎。在iOS這一特定場景下,我們可以舍棄兼容性,提高并發性。


新的方案修改為,當OS層進行lock操作時:


通過pthread_mutex_lock進行線程鎖,防止其他線程介入。然后比較狀態量,若當前狀態不可跳轉,則將當前期望跳轉的狀態,插入到一個FIFO的Queue尾部。最后,線程通過pthread_cond_wait進入 休眠狀態,等待其他線程的喚醒。

忽略文件鎖

當OS層的unlock操作結束后:


取出Queue頭部的狀態量,并比較狀態是否能夠跳轉。若能夠跳轉,則通過pthread_cond_signal_thread_np喚醒對應的線程重試。

pthread_cond_signal_thread_np是Apple在pthread庫中新增的接口,與pthread_cond_signal類似,它能喚醒一個等待條件鎖的線程。不同的是,pthread_cond_signal_thread_np可以指定一個特定的線程進行喚醒。


新的方案可以在DB空閑時的第一時間,通知到其他正在等待的線程,最大程度地降低了空等待的時間,且準確無誤。此外,由于Queue的存在,當主線程被其他線程阻塞時,可以將主線程的操作“插隊”到Queue的頭部。當其他線程發起喚醒通知時,主線程可以有更高的優先級,從而降低用戶可感知的卡頓。


該方案上線后,卡頓檢測系統檢測到


等待線程鎖的造成的卡頓下降超過90%

SQLITE_BUSY的發生次數下降超過95%



I/O 性能優化

保留WAL文件大小


如上文多線程優化時提到,開啟WAL模式后,寫入的數據會先append到WAL文件的末尾。待文件增長到一定長度后,SQLite會進行checkpoint。這個長度默認為1000個頁大小,在iOS上約為3.9MB。


同樣的,在數據庫關閉時,SQLite也會進行checkpoint。不同的是,checkpoint成功之后,會將WAL文件長度刪除或truncate到0。下次打開數據庫,并寫入數據時,WAL文件需要重新增長。而對于文件系統來說,這就意味著需要消耗時間重新尋找合適的文件塊。


顯然SQLite的設計是針對容量較小的設備,尤其是在十幾年前的那個年代,這樣的設備并不在少數。而隨著硬盤價格日益降低,對于像iPhone這樣的設備,幾MB的空間已經不再是需要斤斤計較的了。


因此我們可以修改為:


數據庫關閉并checkpoint成功時,不再truncate或刪除WAL文件只修改WAL的文件頭的Magic Number。下次數據庫打開時,SQLite會識別到WAL文件不可用,重新從頭開始寫入。

保留WAL文件大小后,每個數據庫都會有這約3.9MB的額外空間占用。如果數據庫較多,這些空間還是不可忽略的。因此,微信中目前只對讀寫頻繁且檢測到卡頓的數據庫開啟,如聊天記錄數據庫。

mmap優化


mmap對I/O性能的提升無需贅言,尤其是對于讀操作。SQLite也在OS層封裝了mmap的接口,可以無縫地切換mmap和普通的I/O接口。只需配置PRAGMA mmap_size=XXX即可開啟mmap。


There are advantages and disadvantages to using memory-mapped I/O. Advantages include:


Many operations, especially I/O intensive operations, can be much faster since content does need to be copied between kernel space and user space. In some cases, performance can nearly double.


The SQLite library may need less RAM since it shares pages with the operating-system page cache and does not always need its own copy of working pages.

然而,你在iOS上這樣配置恐怕不會有任何效果。因為早期的iOS版本的存在一些bug,SQLite在編譯層就關閉了在iOS上對mmap的支持,并且后知后覺地在16年1月才重新打開。所以如果使用的SQLite版本較低,還需注釋掉相關代碼后,重新編譯生成后,才可以享受上mmap的性能。



開啟mmap后,SQLite性能將有所提升,但這還不夠。因為它只會對DB文件進行了mmap,而WAL文件享受不到這個優化。


WAL文件長度是可能變短的,而在多句柄下,對WAL文件的操作是并行的。一旦某個句柄將WAL文件縮短了,而沒有一個通知機制讓其他句柄進行更新mmap的內容。此時其他句柄若使用mmap操作已被縮短的內容,就會造成crash。而普通的I/O接口,則只會返回錯誤,不會造成crash。因此,SQLite沒有實現對WAL文件的mmap。


還記得我們上一個優化嗎?沒錯,我們保留了WAL文件的大小。因此它在這個場景下是不會縮短的,那么不能mmap的條件就被打破了。實現上,只需在WAL文件打開時,用unixMapfile將其映射到內存中,SQLite的OS層即會自動識別,將普通的I/O接口切換到mmap上。


其他優化

禁用文件鎖


如我們在多線程優化時所說,對于iOS app并沒有多進程的需求。因此我們可以直接注釋掉os_unix.c中所有文件鎖相關的操作。也許你會很奇怪,雖然沒有文件鎖的需求,但這個操作耗時也很短,是否有必要特意優化呢?其實并不全然。耗時多少是比出來。


SQLite中有cache機制。被加載進內存的page,使用完畢后不會立刻釋放。而是在一定范圍內通過LRU的算法更新page cache。這就意味著,如果cache設置得當,大部分讀操作不會讀取新的page。然而因為文件鎖的存在,本來只需在內存層面進行的讀操作,不得不進行至少一次I/O操作。而我們知道,I/O操作是遠遠慢于內存操作的。


禁用內存統計鎖


SQLite會對申請的內存進行統計,而這些統計的數據都是放到同一個全局變量里進行計算的。這就意味著統計前后,都是需要加線程鎖,防止出現多線程問題的。



內存申請雖然不是非常耗時的操作,但卻很頻繁。多線程并發時,各線程很容易互相阻塞。


阻塞雖然也很短暫,但頻繁地切換線程,卻是個很影響性能的操作,尤其是單核設備。


因此,如果不需要內存統計的特性,可以通過sqlite3_config(SQLITE_CONFIG_MEMSTATUS, 0) 進行關閉。這個修改雖然不需要改動源碼,但如果不查看源碼,恐怕是比較難發現的。


優化上線后,卡頓監控系統監測到


DB寫操作造成的卡頓下降超過80%

DB讀操作造成的卡頓下降超過85%


結語

移動客戶端數據庫雖然不如后臺數據庫那么復雜,但也存在著不少可挖掘的技術點。本次嘗試了僅對SQLite原有的方案進行優化,而市面上還有許多優秀的數據庫,如LevelDB、RocksDB、Realm等,它們采用了和SQLite不同的實現原理。后續我們將借鑒它們的優化經驗,嘗試更深入的優化。


來源:segmentfault

上一篇: 談談 DSL 以及 DSL 的應用(以 CocoaPods 為例)

下一篇: 快到國內了嗎? 蘋果支持應用支持新的市場

分享到: 更多
最稳的投注方法 360江西时时走势图 飞艇怎么看67码计划 赌大小单双叫什么软件 幸运飞艇6码倍投对照表 七月棋牌 捕鱼达人2旧版本 北京pk10怎么玩 投注单打印机 分分彩app破解 ag假的不能再假 北京pk10全天计划 体彩十一选五投注表 广东11选五计划软件手机版 欢乐生肖平台哪家好 吉林时时规则