本文來自於騰訊bugly開發者社區,非經做者贊成,請勿轉載,原文地址:http://dev.qq.com/topic/57b58...html
做者:張三華算法
隨着微信iOS客戶端業務的增加,在數據庫上遇到的性能瓶頸也逐漸凸顯。在微信的卡頓監控系統上,數據庫相關的卡頓不斷上升。而在用戶側也逐漸能感知到這種卡頓,尤爲是有大量羣聊、聯繫人和消息收發的重度用戶。sql
咱們在對SQLite進行優化的過程當中發現,靠單純地修改SQLite的參數配置,已經不能完全解決問題。所以從6.3.16版本開始,咱們合入了SQLite的源碼,並開始進行源碼層的優化。數據庫
本文將分享在SQLite源碼上進行的多線程併發、I/O性能優化等,並介紹優化相關的SQLite原理。性能優化
因爲歷史緣由,舊版本的微信一直使用單句柄的方案,即全部線程共有一個SQLite Handle,並用線程鎖避免多線程問題。當多線程併發時,各線程的數據庫操做同步順序進行,這就致使後來的線程會被阻塞較長的時間。微信
SQLite實際是支持多線程(幾乎)無鎖地併發操做。只需多線程
開啓配置 PRAGMA SQLITE_THREADSAFE=2
架構
確保同一個句柄同一時間只有一個線程在操做併發
假若再開啓SQLite的WAL模式(Write-Ahead-Log),多線程的併發性將獲得進一步的提高。
此時寫操做會先append到wal文件末尾,而不是直接覆蓋舊數據。而讀操做開始時,會記下當前的WAL文件狀態,而且只訪問在此以前的數據。這就確保了多線程讀與讀、讀與寫之間能夠併發地進行。
然而,阻塞的狀況並不是不會發生。
當多線程寫操做併發時,後來者仍是必須在源碼層等待以前的寫操做完成後才能繼續。
SQLite提供了Busy Retry的方案,即發生阻塞時,會觸發Busy Handler,此時可讓線程休眠一段時間後,從新嘗試操做。重試必定次數依然失敗後,則返回SQLITE_BUSY
錯誤碼。
Busy Retry的方案雖然基本能解決問題,但對性能的壓榨作的不夠極致。在Retry過程當中,休眠時間的長短和重試次數,是決定性能和操做成功率的關鍵。
然而,它們的最優值,因不一樣操做不一樣場景而不一樣。若休眠時間過短或重試次數太多,會空耗CPU的資源;若休眠時間過長,會形成等待的時間太長;若重試次數太少,則會下降操做的成功率。
咱們經過A/B Test對不一樣的休眠時間進行了測試,獲得了以下的結果:
能夠看到,假若休眠時間與重試成功率的關係,按照綠色的曲線進行分佈,那麼p點的值也不失爲該方案的一個次優解。然而事總不遂人願,咱們須要一個更好的方案。
做爲有着十幾年發展歷史、且被普遍承認的數據庫,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的通知機制。當一個進程的數據庫操做結束時,沒法經過鎖來第一時間通知到其餘進程進行重試。所以只能退而求其次,經過屢次休眠來進行嘗試。
經過上面的各類分析、準備,終於能夠動手開始修改了。
咱們知道,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%
如上文多線程優化時提到,開啓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對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:
然而,你在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不一樣的實現原理。後續咱們將借鑑它們的優化經驗,嘗試更深刻的優化。