【騰訊Bugly乾貨分享】微信 iOS SQLite 源碼優化實踐

本文來自於騰訊bugly開發者社區,非經做者贊成,請勿轉載,原文地址:http://dev.qq.com/topic/57b58...html

做者:張三華算法

前言

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

咱們在對SQLite進行優化的過程當中發現,靠單純地修改SQLite的參數配置,已經不能完全解決問題。所以從6.3.16版本開始,咱們合入了SQLite的源碼,並開始進行源碼層的優化。數據庫

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

多線程併發優化

1. 背景

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

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

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

  1. 開啓配置 PRAGMA SQLITE_THREADSAFE=2架構

  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.app

假若再開啓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架構

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層的sqlite3OsLocksqlite3OsUnlocksqlite3OsShmLock上具體實現。

它們在鎖的實現比較相似。以lock操做在iOS上的實現爲例:

  1. 經過pthread_mutex_lock進行線程鎖,防止其餘線程介入。而後比較狀態量,若當前狀態不可跳轉,則返回SQLITE_BUSY

  2. 經過fcntl進行文件鎖,防止其餘進程介入。若鎖失敗,則返回SQLITE_BUSY

而SQLite選擇Busy Retry的方案的緣由也正是在此---文件鎖沒有線程鎖相似pthread_cond_signal的通知機制。當一個進程的數據庫操做結束時,沒法經過鎖來第一時間通知到其餘進程進行重試。所以只能退而求其次,經過屢次休眠來進行嘗試。

5. 新的方案

經過上面的各類分析、準備,終於能夠動手開始修改了。

咱們知道,iOS app是單進程的,並沒有多進程併發的需求,這和SQLite的設計初衷是不相同的。這就給咱們的優化提供了理論上的基礎。在iOS這一特定場景下,咱們能夠捨棄兼容性,提升併發性。

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

  1. 經過pthread_mutex_lock進行線程鎖,防止其餘線程介入。而後比較狀態量,若當前狀態不可跳轉,則將當前指望跳轉的狀態,插入到一個FIFO的Queue尾部。最後,線程經過pthread_cond_wait進入 休眠狀態,等待其餘線程的喚醒。

  2. 忽略文件鎖

當OS層的unlock操做結束後:

  1. 取出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%

等鎖卡頓

朋友圈Busy

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的性能。

SQLite開啓iOS 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%

db讀寫卡頓

結語

移動客戶端數據庫雖然不如後臺數據庫那麼複雜,但也存在着很多可挖掘的技術點。本次嘗試了僅對SQLite原有的方案進行優化,而市面上還有許多優秀的數據庫,如LevelDB、RocksDB、Realm等,它們採用了和SQLite不一樣的實現原理。後續咱們將借鑑它們的優化經驗,嘗試更深刻的優化。

相關文章
相關標籤/搜索