SQLite3是移動終端最常用的數據庫,它非常輕量,編譯後只有數百KB。但它麻雀雖小,五髒俱全,它可以支持多線程,支持事務、約束以及幾乎所有的SQL常見特性。iOS中很多App經常會使用到SQLite,在使用SQLite的時候經常會遇到其性能問題。本文將深入SQLite內部實現,分析其性能優化途徑。
一些基本概念
在開始分析之前,首先需要了解一下數據庫的基本知識。
什麼是ACID?
這個術語在數據庫設計者非常熟悉的,而使用者往往不關注這些。ACID是“Atomicity, Consistency, Isolation, Durability”英文的縮寫,它用來確保一個數據庫事務的可靠性。中文意思是“原子性,一致性,隔離性,持久性”。維基百科上有其定義解釋:
原子性:一個事務(transaction)中的所有操作,要麼全部完成,要麼全部不完成,不會結束在中間某個環節。事務在執行過程中發生錯誤,會被回滾(Rollback)到事務開始前的狀態,就像這個事務從來沒有執行過一樣。
一致性:在事務開始之前和事務結束以後,數據庫的完整性沒有被破壞。這表示寫入的資料必須完全符合所有的預設規則,這包含資料的精確度、串聯性以及後續數據庫可以自發性地完成預定的工作。
隔離性:當兩個或者多個事務並發訪問(此處訪問指查詢和修改的操作)數據庫的同一數據時所表現出的相互關系。事務隔離分為不同級別,包括讀未提交(Read uncommitted)、讀提交(read committed)、可重復讀(repeatable read)和串行化(Serializable)。
持久性:在事務完成以後,該事務對數據庫所作的更改便持久地保存在數據庫之中,並且是完全的。
SQLite3是符合ACID的,它保證了即使在程序Crash或者進程被殺,甚至是內核崩潰或者斷電的情況下,數據庫依然是完整的。既要維持ACID特性,同時要保證性能最大化,這是數據庫設計上的一大挑戰。
SQLite3中的事務
SQLite3中可以使用BEGIN TRANSACTION和COMMIT TRANSACTION來開始和結束一個事務。如果你沒有添加這些事務語句,SQLite3會為你的每條SQL語句加上一個事務。
一次正常執行的事務過程
要想優化SQLite3的性能,那麼必須要了解SQLite3中一次事務執行過程。這裡已一個有寫操作的並成功執行的事務來舉例。
1. 初始狀態
數據庫打開後,未進行任何數據庫操作時大概是下圖的狀態。
image
圖片來自sqlite.org
這裡分了三個部分,左面是用戶空間,中間是內核緩存區(文件的讀寫緩存),右邊是物理磁盤設備(iOS的閃存)。在SQLite中數據最小的讀寫單位扇區(sector),通常是512B,圖中每個小矩形代表一個扇區。藍色代表未更改的原始數據,中間白色表示是空的,即此時數據沒有讀取到內核緩存區。
2. 准備讀取(加讀鎖)
image
任何寫操作都會先進行讀操作,因為寫之前要讀取數據庫的schema,插入和修改的位置等。在讀取操作之間要加上讀鎖。加讀鎖是為了防止其它數據庫連接進行寫操作,而保證讀取時數據不被破壞。這時其它數據庫的讀取操作依然可以正常執行。
3. 讀取數據
加了讀鎖之後就開始讀取數據了:
image
這裡讀取了3個扇區的數據,讀取時通過系統文件讀取調用,會從內核緩存中拷貝到用戶空間。(疑問:能否直接讀取到用戶空間?)
4. 准備修改數據(加寫鎖)
數據讀取完畢後,就准備開始修改數據了,修改數據之前首先要加寫鎖,此寫鎖可以和其它進程的讀鎖同時存在:
image
5. 建立回滾日志
開始寫操作之前,先建立一個回滾日志文件,已便進行回滾操作。將更改之前的舊數據保存到回滾日志文件中。
image
回滾日志文件包含一個頭信息(綠色部分),記錄回滾必要信息。
6. 在用戶空間中修改數據
image
圖中粉色表示已修改的數據。
7. 沖(fsync)回滾日志文件
用戶空間修改數據後,未確保回滾日志文件可靠,必須把回滾日志文件沖入物理磁盤進行持久存儲。這樣以確保內核崩潰或斷電後依然可恢復數據。
image
8. 加互斥鎖
准備開始真正的寫文件了,要加互斥鎖了。互斥鎖可以和已經打開的讀鎖同時存在,但不允許新建讀鎖了。
image
9. 寫數據庫文件
現在可以安全的寫數據文件了。
image
10. 沖(fsync)數據庫文件
沖數據庫文件到持久存儲設備。
image
11. 刪除回滾日志
沖入數據庫文件後才能刪除回滾日志,確保內核崩潰或斷電後依然可恢復數據。
image
12. 釋放鎖
image
過程分析
為確保ACID,一次數據庫事務竟需要這麼多步驟。下面對此過程進行分析一下:
一次文件創建(回滾日志)
兩次文件寫入
兩次文件沖入(回滾日志,數據庫文件)
一次文件刪除(回滾日志)
加了3次鎖,最後一次不允許讀取
mmap
在讀取和寫入過程中,每次都將用戶空間的數據和內核空間的數據拷貝一次,能否直接將文件讀取到用戶空間?SQLite3提供了mmap方式的IO。
打開mmap方式的IO只要執行下面語句即可:
sqlite3_exec(db, "PRAGMA mmap_size=268435456;", NULL, NULL, NULL);
理論上mmap方式能減少內核和用戶空間的IO,但在iOS系統中,這個從我這裡測試效果看,影響並不大。
異步IO
在事務操作中有大量寫操作,能否將寫操作放到後台線程執行?SQLite3是支持這種的,SQLite可以自定義文件的讀取、寫入等操作方式。需要配置一下VFS結構即可:
struct sqlite3_vfs { int iVersion; /* Structure version number (currently 3) */ int szOsFile; /* Size of subclassed sqlite3_file */ int mxPathname; /* Maximum file pathname length */ sqlite3_vfs *pNext; /* Next registered VFS */ const char *zName; /* Name of this virtual file system */ void *pAppData; /* Pointer to application-specific data */ int (*xOpen)(sqlite3_vfs*, const char *zName, sqlite3_file*,
int flags, int *pOutFlags);
int (*xDelete)(sqlite3_vfs*, const char *zName, int syncDir); int (*xAccess)(sqlite3_vfs*, const char *zName, int flags, int *pResOut); int (*xFullPathname)(sqlite3_vfs*, const char *zName, int nOut, char *zOut); void *(*xDlOpen)(sqlite3_vfs*, const char *zFilename); void (*xDlError)(sqlite3_vfs*, int nByte, char *zErrMsg); void (*(*xDlSym)(sqlite3_vfs*,void*, const char *zSymbol))(void); void (*xDlClose)(sqlite3_vfs*, void*); int (*xRandomness)(sqlite3_vfs*, int nByte, char *zOut); int (*xSleep)(sqlite3_vfs*, int microseconds); int (*xCurrentTime)(sqlite3_vfs*, double*); int (*xGetLastError)(sqlite3_vfs*, int, char *);
這個結構定義了各種文件操作的函數指針。開啟異步IO需要實現這個結構的定制,官方已提供了這個擴展,下載後加入工程即可:
http://www.sqlite.org/src/tree?name=ext/async
這個我測試了一下,性能提升並不明顯,雖然做了異步,但主線程獲取鎖的等待時間增加太多,實際性能影響不大。
關閉沖文件
事務過程中有兩次沖文件操作,能否將這兩次沖文件關閉?SQLite可以關閉這兩次強制沖文件操作的。
可以通過一下方式關閉:
sqlite3_exec(db, "PRAGMA synchronous=OFF;", NULL, NULL, NULL);
關閉沖文件確實提高了不少性能,但在內核崩潰或者系統斷電時導致數據庫寫入不完整。即使如此,程序本身Crash還是安全的。
Write-Ahead Logging (WAL) 模式
SQLite3.7.0中新增了WAL模式,iOS大概在5.0中引入此支持。WAL模式正好和傳統模式相反,WAL模式會將修改的數據單獨寫到一個WAL文件中,而且多個事務可以共用一個WAL文件。
checkpoint
WAL模式裡有一個重要概念checkpoint。每個checkpoint默認是1000扇區的數據,此值可動態調整。當WAL文件裡的數據更改量達到checkpoint時才會將WAL裡的數據寫回實際的數據庫。
WAL沖入優化
WAL默認在checkpoint時進行文件沖入(fsync)操作,你也可以使其每次事務都進行沖入,以確保數據完全可靠:
sqlite3_exec(db, "PRAGMA synchronous=FULL;", NULL, NULL, NULL);
WAL並發優化
WAL模式會在共享內存中根據數據順序建立索引,每個讀操作都會記錄一下最新的數據更改索引,讀操作只會讀取此索引之前的數據,而寫操作可以繼續在WAL中追加數據,並發性能有一定提升。
那麼WAL模式下相比傳統模式有以下改進:
一次文件創建(回滾日志) —> 僅在第一次和達到checkpoint時創建一次文件
兩次文件寫入 -> 大部分情況下只有WAL文件一次寫入
兩次文件沖入(回滾日志,數據庫文件)-> FULL模式下有一次沖入,Normal模式下僅在checkpoint時沖入
一次文件刪除(回滾日志) -> 大部分情況下不用刪除
加了3次鎖,最後一次不允許讀取 ->
當然WAL模式也有一些缺點:
當每個事務數據量比較大時,接近或超過1000頁的數據量時,會導致WAL內容頻繁同步至實際數據庫文件,導致性能下降。
WAL在並發性方面的優化使用了系統共享內存,那麼在一些網絡文件系統中就無法使用。iOS目前並不存在這種文件系統。
各種模式性能實戰分析
下面我對各種模式進行測試,各模式如下:
正常模式:正常創建的數據庫,不做任何配置。
內存映射:使用語句“PRAGMA mmap_size=268435456;”開啟內存映射。
異步IO:加入官方異步IO擴展並開啟。
WAL模式:使用語句“PRAGMA journal_mode=WAL;”開啟WAL模式。
sync OFF:使用語句“PRAGMA synchronous=OFF;”關閉文件強制同步。
WAL(sync Full):使用語句“PRAGMA journal_mode=WAL;PRAGMA synchronous=FULL;”開啟WAL模式和全同步。
內存數據庫:使用特殊文件名“:memory:”打開的數據庫。
測試分三種情況:
1000條小數據寫入
1000條小數據寫入(合並為一個事務)
100條大數據(474940 Bytes)寫入
測試機型為iPhone4,系統為iOS7.1。
1000條小數據寫入
image
小數據WAL存在明顯優勢,關閉fsync的表現也不賴,但不完全可靠了,不過卻還沒有WAL(sync Full)模式快。
1000條小數據寫入(合並為一個事務)
image
合並為一個事務後,各模式差別不大。因為IO次數有限。相比不合並事務,性能急劇提升到100毫秒級,請注意本圖的單位。
100條大數據(474940 Bytes)寫入
image
100條大數據時WAL模式性能相比其它模式都差一些,單個事務數據量比較大的情況不推薦WAL,或者要修改WAL的checkpoint設置,改的更大一些,以免產生過多的checkpoint。
結論
異步IO似乎並不能提高多少性能,官方已經deprecate它了,推薦使用WAL模式。
大量小記錄寫入(不合並為事務)時,一般模式即使關閉文件sync,還沒有WAL全sync模式快。
總體數據占用量少的,而且可重建恢復的數據,建議使用內存數據庫,必要時做備份到閃存文件。
總體數據占用量大,但是可重建恢復的數據庫,可以關閉synchronous以提高性能。
操作頻繁,單條記錄數據量小的,建議使用WAL模式。
操作少,單條記錄數據量大,建議使用一般數據庫,不要使用WAL模式。