天天看點

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

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

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

本文來自于騰訊bugly開發者社群,非經作者同意,請勿轉載,原文位址:http://dev.qq.com/topic/57b58022433221be01499480

作者:張三華

前言

本文将分享在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.

    倘若再開啟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上的實作為例:

  1. 通過

    pthread_mutex_lock

    進行線程鎖,防止其他線程介入。然後比較狀态量,若目前狀态不可跳轉,則傳回

    SQLITE_BUSY

  2. fcntl

    進行檔案鎖,防止其他程序介入。若鎖失敗,則傳回

    SQLITE_BUSY

    而SQLite選擇Busy Retry的方案的原因也正是在此---檔案鎖沒有線程鎖類似pthread_cond_signal的通知機制。當一個程序的資料庫操作結束時,無法通過鎖來第一時間通知到其他程序進行重試。是以隻能退而求其次,通過多次休眠來進行嘗試。

    5. 新的方案

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

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

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

  3. pthread_mutex_lock

    進行線程鎖,防止其他線程介入。然後比較狀态量,若目前狀态不可跳轉,則将目前期望跳轉的狀态,插入到一個FIFO的Queue尾部。最後,線程通過

    pthread_cond_wait

    進入 休眠狀态,等待其他線程的喚醒。
  4. 忽略檔案鎖

    當OS層的unlock操作結束後:

  5. 取出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不同的實作原理。後續我們将借鑒它們的優化經驗,嘗試更深入的優化。