本文來自於騰訊bugly開發者社區,非經做者贊成,請勿轉載,原文地址:http://dev.qq.com/topic/57b57...html
做者:趙豐git
iOS 程序能從網絡獲取數據。少許的 KV 類型數據能夠直接寫文件保存在 Disk 上,App 內部經過讀寫接口獲取數據。稍微複雜一點的數據類型,也能夠將數據格式化成 JSON 或 XML 方便保存,這些通用類型的增刪查改方法也很容易獲取和使用。這些解決方案在數據量在數百這一量級有着不錯的表現,但對於大數據應用的支持則在穩定性、性能、可擴展性方面都有所欠缺。在更大一個量級上,移動客戶端須要用到更專業的桌面數據庫 SQLite。sql
這篇文章主要從 SQLite 數據庫的使用入手,介紹如何合理、高效、便捷的將這個桌面數據庫和 App 全面結合。避免 App 開發過程當中可能遇到的坑,也提供一些在開發過程當中經過大量實踐和數據對比後總結出的一些參數設置。整篇文章將以一個個具體的技術點做爲講解單元,從 SQLite 數據庫生命週期起始講解到其終結。但願不管是從微觀仍是從宏觀都能給工程師以幫助。數據庫
在寫提綱的時候發現,原來 SQLite 初始化居然是技術點一點也很多。編程
網上有不少的文章提到了,在內存容許的狀況下增長 page_size 和 cache_size 可以得到更快的查詢速度。但過大的 page_size 也會形成 B-Tree 查詢退化到二分查找、CPU 佔用增長以及 OS 級 cache 命中率的降低的問題。數組
經過反覆比較測試不一樣組合的 page_size、cache_size、table_size、存儲的數據類型以及各類可能的增刪查改比例,咱們發現後三者都是引發 page_size 和 cache_size 性能波動的因素。也就是說對於不一樣的數據庫並不存在廣泛適用的 page_size 和 cache_size 能一勞永逸的幫咱們解決問題。緩存
而且在對比測試中咱們發現 page_size 的選取每每會出現一個拐點。拐點之前隨着 page_size 增長各類性能指標都會持續改善。但一旦過了拐點,性能將沒有明顯的改變,各個指標將圍繞拐點時的數據值小範圍波動。性能優化
那麼如何選取合適的 page_size 和 cache_size 呢?服務器
上一點咱們已經提到了可能影響到 page_size 和 cache_size 最優值選取的三個因素:網絡
table_size
存儲的數據類型
增刪查改比例
咱們簡單的分析一下看看爲何這三個變量會共同做用於 page_size 和 cache_size。
SQLite 數據庫把其所存儲的數據以 page 爲最小單位進行存儲。cache_size 的含義爲當進行查詢操做時,用多少個 page 來緩存查詢結果,加快後續查詢相同索引時方便從緩存中尋找結果的速度。
瞭解了二者的含義,咱們能夠發現。SQLite 存儲等長的 int int64 BOOL 等數據時,page 能夠優化對齊地址存儲更多的數據。而在存儲變長的 varchar blob 等數據時,一則 page 由於數據變長的影響沒法提早計算存儲地址,二則變長的數據每每會形成 page 空洞,空間利用率也有降低。
下表是設置不一樣的 page_size 和 cache_size 時,數據庫操做中最耗時的增查改三種操做分別與不一樣數據類型,表列數不一樣的表之間共同做用的一組測試數據。
其中各列數據含義以下,時間單位爲毫秒
從上表咱們看到,放大 page_size 和 cache_size 並不能不斷的得到性能的提高,在拐點之後提高帶來的優化不明顯甚至是反作用了。這一點甚至體現到了數據庫大小這方面。從 G 列能夠看到,page_size 的增長對於數據庫查詢的優化明顯優於插入操做的優化。從0五、06行能夠發現,增長 cache_size 對於數據庫性能提高並不明顯。從 J 列能夠看到,當插入操做的數據量比較小的時候,反而是小的 page_size 和 cache_size 更有優點。但 App DB 耗時更多的體如今大量數據增刪查改時的性能,因此選取合適的、稍微大點的 page_size 是合理的。
因此經過表格分析之後,咱們傾向於選擇 DB 線程總耗時以及線程內部耗時最多的三個方法,做爲衡量 page_size 優劣的參考標準。
page_size 有兩種設置方法。一是在建立 DB 的時候進行設置。二是在初始化時設置新的 page_size 後,須要調用 vacuum
對數據表對應的節點從新計算分配大小。這裏可參考 pragma_page_size 官方文檔
Transaction 是任何一個數據庫中最核心的功能,但其對 Server 端和客戶端的意義卻不盡相同。對 Server 而言,一個 Transaction 是主備容災分片的最小單位(固然還有其餘意義)。對客戶端而言,一個 Transaction 可以大大的提高其內部的增刪查改操做的速度。SQLite 官方文檔以及工程實測的數據都顯示,事務的引入能提高性能 兩個數量級 以上。
實現方案其實很是簡單。程序初始化完畢之後,啓動一個事務,並建立一個 repeated 的 Timer
在 Timer 的回調函數 RenewTransaction 中,提交事務,並新啓動一個事務
這樣就能實現自動化的事務管理,將優化的實現黑盒化。邏輯使用方能將更多精力集中在邏輯實現方面,不用關心性能優化、數據丟失方面的問題。
從手動事務管理到自動事務管理會引起一個問題:
當兩份數據必須擁有相同的生命週期,同時寫入 DB、同時從 DB 刪除、同時被修改時,經過時間做爲提交事務的惟一標準,就有可能引起兩份數據的操做進入了不一樣的事務。而第二個事務若是不能正確的提交,就會形成數據丟失或錯誤。
解決這個問題,能夠利用 SQLite 的事務嵌套功能,設計一組開啓事務和關閉提交事務的接口,供邏輯使用者按照其需求調用事務的開始、提交和關閉。讓內層事務保證兩(多)份數據的完整性。
和其餘不少編程語言同樣,數據庫使用的 SQL 語句也須要通過編譯後才能被執行使用。SQL 語句的編譯結果若是可以被緩存下來,第二次及之後再被使用時就能直接利用緩存結果,大大減小整個操做的執行時間。與此同理的還有 Java 數學庫優化,經過把極其複雜的 Java 數學庫實現翻譯成 byte code,在調用處直接執行機器碼,能大大優化 Java 數學庫的執行速度和 C++ 持平甚至優於其。而對 SQLite 而言,一次 compile 的時間根據語句複雜程度從幾毫秒到十幾毫秒不等,對於批量操做性能優化是極其明顯的。
其實在上面的第2點中,已是用一個專門的類將編譯結果保存下來。每次根據文件名稱和行號爲索引,得到對應位置的 SQL 語句編譯結果。爲了便於你們理解,我在註釋中也將 SQLIite 內部最底層的方法寫出來供你們參考和對比性能數據。
移動客戶端中的數據庫運行環境要遠複雜於桌面平臺和服務器。掉電、後臺被掛起、進程被 kill、磁盤空間不足等緣由都有可能形成數據庫的損壞。SQLite 提供了檢查數據庫完整性的命令
PRAGMA integrity_check
該 SQL 語句的執行結果若是不爲 OK ,則意味着數據庫損壞。程序能夠經過 ROLLBACK 到一個稍老的版本等方法來解決數據庫損壞帶來的不穩定性。
代碼管理能夠用 git、svn,數據庫若是要作升級邏輯相對來講會複雜不少。好在咱們能夠利用 SQLite,在內部用一張 meta 表專門用於記錄數據庫的當前版本號、最低兼容版本號等信息。用好了這張表,咱們就能夠對數據庫是否須要升級、升級的路徑進行規範。
咱們代入一個簡單銀行客戶的例子來講明如何進行數據庫的升級。
a. V1 版本對數據庫的要求很是簡單,保存客戶的帳號、姓、名、出生日期、年齡、信用這6列。以及對應的增刪查改,對應的SQL語句以下
而且在 meta 表中保存當前數據庫的版本號爲1,向前兼容的版本爲1,代碼以下
b. V2 版本時須要在數據庫中增長客戶在銀行中的存款和欠款兩列。首先咱們須要從 meta 表中讀取用戶的數據庫版本號。增長了兩列後建立 table 和增刪查改的 SQL 語句都要作出適當的修改。代碼以下
很顯然 V2 版本的 SQL 語句不少都和 V1 是不兼容的。V1 的數據使用 V2 的 SQL 進行操做會引起異常產生。因此在 SQLite 封裝層,咱們須要根據當前數據庫版本分別進行處理。V1 版本的數據庫須要經過 ALTER 操做增長兩列後使用。記得升級完畢後要更新數據庫的版本。代碼以下
c. V3 版本發現出生日期與年齡兩個字段有重複,冗餘的數據會帶來數據庫體積的增長。但願 V3 數據庫可以只保留出生日期字段。咱們依然從 meta 讀取數據庫版本號信息。不過此次須要注意的是直到 SQLite 3.9.10 版本並無刪掉一列的操做。不過這並不影響新版本建立的 TABLE 會去掉這一列,而老版本的DB也能夠和新的 SQL 語句一塊兒配合工做不會引起異常。代碼以下
注意 last_compatible_version 這裏能夠填2也能夠填3,主要根據業務邏輯合理選擇
d. 除了數據庫結構發生變化時能夠用上述的方法升級。當發現老版本的邏輯引起了數據錯誤,也能夠用相似的方法從新計算正確結果,刷新數據庫。
這個部分將以 App 開發中常常面對的場景做爲樣例進行對比分析。
或許不少開發都知道,當用某列或某些列做爲查詢條件時,給這些列增長索引是能大大提高查詢速度的。
但真的如此的簡單嗎?
要回答這個問題,咱們須要藉助 SQLite 提供的 explain query 工具。
顧名思義,它是用來向開發人員解釋在數據庫內部一條查詢語句是如何進行的。在 SQLite 數據庫內部,一條查詢語句可能的執行方式是多種多樣的。它有可能會掃描整張數據表,也可能會掃描主鍵子表、索引子表,或者是這些方式的組合。具體的關於 SQLite 查詢的方式能夠參看官方文檔 Query Planning
簡單的說,SQLite 對主鍵會按照平衡多叉樹理論對其建樹,使其搜索速度下降到 Log(N)。
針對某列創建索引,就是將這列以及主鍵全部數據取出。以索引列爲主鍵按照升序,原表主鍵爲第二列,從新建立一張新的表。須要特別注意的是,針對多列創建索引的內部實現方案是,索引第一列做爲主鍵按照升序,第一列排序完畢後索引第二列按照升序,以此類推,最後以原表主鍵做爲最後一列。這樣就能保證每一行的數據都不徹底相同,這種多列建索引的方式也叫 COVERING INDEX。因此對多列進行索引,只有第一列的搜索速度理論上能到 Log(N)。
更重要的是,SQLite 這種建索引的方式確實能夠帶來搜索性能的提高,但對於數據庫初始化的性能有着很是大的負面影響。這裏先點到爲止,下文會專門論述如何進行優化。這裏以 SQLite 官方的一個例子來講明,在邏輯上 SQLite 是如何創建索引的。
實際上 SQLite 創建索引的方式並非下列圖看起來的彙集索引,而是採用了非彙集索引。由於非彙集索引的性能並不比彙集索引低,但空間開銷卻會小不少。SQLite 官方圖片只是示意,請必定注意
一列行號外加三列數據 fruit state price
當咱們用 CREATE INDEX Idx1 ON fruitsforsale(fruit)
爲 fruit 列建立索引後,SQLite 在內部會建立一張新的索引表,並以 fruit 爲主鍵。如上圖所示
而當咱們繼續用 CREATE INDEX Idx3 ON FruitsForSale(fruit, state)
建立了 COVERING IDNEX 時,SQLite 在內部並不會爲全部列單首創建索引表。而是以第一列做爲主鍵,其餘列升序,行號最後來建立一張表。如上圖所示
咱們接下來要作的就是利用 explain query 來分析不一樣的索引方式對於查詢方式的影響,以及性能對比。
不加索引的時候,查詢將會掃描整個數據表
針對 WHERE CLAUSE 中的列加了索引之後的狀況。SQLite 在進行搜索的時候會先根據索引表i1找到對應的行,再根據 rowid 去原表中獲取 b 列對應的數據。可能有些工程師已經發現了,這裏能夠優化啊,不必找到一行數據後還要去原表找一次。剛纔不是說了嘛,對多列建索引的時候,是把這些列的數據都放入一個新的表。那咱們試試看。
果真,一樣的搜索語句,不一樣的建索引的方式,SQLite 的查詢方式也是不一樣的。此次 SQLite 選擇了索引 i2 而非索引 i1,由於 a、b 列數據都在同一張表中,減小了一次根據行號去原表查詢數據的操做。
看到這裏不知道你們有沒有產生這樣的一個疑問,若是咱們用 COVERING INDEX i2 的非第一列去搜索是否是並無索引的效果?
WTF,果真,看起來咱們爲 b 列建立了索引 i2,但用 EXPLAIN QUERY PLAN 一分析發現 SQLite 內部依然是掃描整張數據表。這點也和上面分析的對 COVERING INDEX 建索引表的理論一致,不過狀況依然沒這麼簡單,咱們看看下面三個搜索
WTF,搜索的時候用 AND 和 OR 的效果是不同的。其實多想一想 COVERING INDEX 的實現原理也就想通了。對於沒有建索引的列進行搜索那不就是掃描整張數據表。因此若是 App 對於兩列或以上有搜索需求時,就須要瞭解一個概念 「前導列」 。所謂前導列,就是在建立 COVERING INDEX 語句的第一列或者連續的多列。好比經過:CREATE INDEX covering_idx ON table1(a, b, c)建立索引,那麼 a, ab, abc 都是前導列,而 bc,b,c 這樣的就不是。在 WHERE CLAUSE 中,前導列必須使用等於或者 in 操做,最右邊的列可使用不等式,這樣索引才能夠徹底生效。若是確實要用到等於類的操做,須要像上面最後一個例子同樣爲右邊的、不等於類操做的列單獨建索引。
不少時候,咱們對於搜索結果有排序的要求。若是對於排序列沒有建索引,能夠想象 SQLite 內部會對結果進行一次排序。實際上若是對沒有建索引,SQLite 會建一棵臨時 B Tree 來進行排序。
因此咱們建索引的時候別忘了對 ORDER BY 的列進行索引
講了這麼多關於 SQLite 建索引,其實也不過官方文檔的萬一。可是瞭解了 SQLite 建索引的理論和實際方案,掌握了經過 EXPLAIN QUERY PLAN 去分析本身的每一條 WHERE CLAUSE和ORDER BY。咱們就能夠分析出性能到底還有沒有能夠優化的空間。儘可能減小掃描數據表的次數、儘可能掃描索引表而非原始表,作好與數據庫體積的平衡。讓好的索引加快你程序的運行。
是的,當我第一眼看見這個結論時,我甚至以爲這是搞笑的。當我去翻閱 SQLite 官方文檔時,並無對此相關的說明文檔。看着 StackOverflow 上面華麗麗的 insert first then index VS insert and index together 的對比數據,當我真的將建索引挪到了數據初始化插入後,奇蹟就這樣發生了。XCode Instrument 統計的十萬條數據的插入CPU耗時,下降了20%(StackOverflow 那篇介紹文章作的對比測試降低還要更多達30%)。
究其緣由,索引表在 SQLite 內部是以 B-Tree 的形式進行組織的,一個樹節點通常對應一個 page。咱們能夠看到數據庫要寫入、讀取、查詢索引表其實都須要用到公共的一個操做是搜索找到對應的樹節點。從外存讀取索引表的一個節點到內存,再在內存判斷這個節點是否有對應的 key(或者判斷節點是否須要合併或分裂)。而統計研究代表,外存中獲取下一個節點的耗時比內存中各項操做的耗時多好幾個數量級。也就是說,對索引表的各項操做,增刪查改的耗時取決於外存獲取節點的時間(SQLite 用 B-Tree 而非 STL 中採用的 RB-Tree 或平衡二叉樹,正是爲了儘量下降樹的高度,減小外存讀取次數)。一邊插入原始表的數據,一邊插入索引表數據,有可能形成索引表節點被頻繁換到外存又從外存讀取。而同一時間只進行建索引的操做,OS 緩存節點的量將增長,命中率提升之後速度天然獲得了必定的提高。
SQLite 的索引採用了 B-Tree,樹上的一個 Node 通常佔用一個 page_size。
B-Tree 的搜索節點複雜度如上。咱們能夠看到公式中的 m 就是 B-Tree 的階數也就是節點中最大可存放關鍵字數+1。也就是說,m 是和 page_size 成正比和複雜度成反比和樹的高度成反比和讀取外存次數成反比和耗時成反比。因此 page_size 越大確實能夠減小 SQLite 含有查詢類的操做。但無限制的增長 page_size 會使得節點內數據過多,節點內數據查詢退化成線性二分查詢,複雜度反而有些許上升。
因此在這裏仍是想強調一下,page_size 的選擇沒有普適標準,必定要根據性能工具的實際分析結果來肯定
有過 SQLite 開發經驗的工程師都知道,INSERT 插入數據時若是主鍵已經存在是會引起異常的。而這時每每邏輯會要求用新的數據代替數據庫已存在的老數據。曾經老版本的 SQLite 只能經過先 SELECT 查詢插入數據主鍵對應的行是否存在,不存在才能 INSERT,不然只能調用 UPDATE。而3.x版本起,SQLite 引入了 INSERT OR REPLACE INTO,用一行 SQL 語句就把原來的三行 SQL 封裝替代了。
不過須要注意的是,SQLite 在實現 INSERT OR REPLACE INTO 時,實現的方案也是先查詢主鍵對應行是否存在,若是存在則刪除這一行,最後插入這行的數據。從其實現過程來看,當數據存在時原來只須要刷新這一行,如今則是刪掉老的插入新的,理論速度上會變慢。這種寫法僅僅是對數據庫封裝開發提供了便利,對性能仍是有些許影響的。不過對於數據量比較少不足1000行的狀況,用這種方法對性能的損耗仍是細微的,且這樣寫確實方便了不少。但對於更多的數據,插入的時候仍是推薦雖然寫起來很麻煩,可是性能更好的,先 SELECT 再選擇 INSERT OR UPDATE 的方法。
INTEGER 類的數據可以很方便的建索引,但對於 VARCHAR 類的數據,若是不建索引則只能使用 LIKE 去進行字符串匹配。若是 App 對於字符串搜索有要求,那麼基本上 LIKE 是知足不了要求的。
FTS 是 SQLite 爲加快字符串搜索而建立的虛擬表。FTS 不只能經過分詞大大加快英文類字符串的搜索,對於中文字符串 FTS 配合 ICU 也能對中文等其餘語言進行分詞、分字處理,加快這些語言的搜索速度。下面這個是 SQLite 官方文檔對二者搜索速度的一個對比。
上面建立 FTS 虛擬表的方式只能對英文搜索起做用,對其餘語言的支持是經過 ICU 模塊支持來實現的。因此工程是須要編譯建立 ICU 的靜態庫,編譯 SQLite 時須要指定連接ICU庫。
其實不管建立數據表的時候是否建立了行號(rowid)列,SQLite 都會爲每一個數據表建立行號列。想一想上面的 fruitsforsale,當數據表沒有任何列建了索引的時候,行號就是數據表的惟一索引。FTS 表略微不一樣的是,它的行號叫 docid,而且是能夠用 SQL 語句訪問的。咱們通常會用字符串在原始表中的行號做爲這裏的 docid。
若是你仔細看搜索語句你會發現和官方文檔不太同樣的是,對於 MATCH 的結果咱們會再用 LIKE 過濾一次。
在回答這個問題前,咱們須要知道 SQLite 默認對英文是按單詞(空格爲分隔符)進行分詞,對中文則是按照字進行拆分。當中文是按字進行拆分時,SQLite 會對關鍵字也按字進行拆分後進行搜索。這會帶來一個 bug,當關鍵字是疊詞時,好比「每天」,除了能夠把正確的如「每天向上」搜索出來,還能把「今每天氣不錯,挺風和日麗的」給搜索出來。就是由於關鍵詞「每天」也被按字拆分了。若是咱們把 SQLite 內英文搜索設置成按字母拆分,同樣會產生相同的問題。因此咱們須要把結果再 LIKE 一次,由於在一個小範圍內 LIKE 且不用加%通配符,這裏的速度也是很快的。
若是但願對英文也按字母拆分,使得輸入關鍵字 "cent",就能匹配上 "Tencent" 也很是簡單。只須要找到,SQLite 實現的 icuOpen 方法。
其實只須要改變讀取 ICU 的方式,就能支持英文按字母拆分了。
在設計數據庫時,咱們會把一個對象的屬性分紅不一樣的列按行存儲。若是屬性是個數量不定的數組,切忌不要把這個數組屬性放到一個新表裏面。上面咱們提到過數據操做最耗時的實際上是訪問外存上面的數據。當數據量很大時,多張表的外存訪問是很是慢的。這裏的作法是講數組數據用 JSON 序列化後,已 VARCHAR 或者 BLOB 的形式存成一列,和其餘的數據放在同一個數據表當中。
先說結論,這樣作是數據庫 Model 跨 iOS、Android 平臺的解決方案。兩個平臺用同一份 proto 文件分別生成各自的實現文件。須要跨平臺時將數據序列化後,以傳遞內存的方式經過 JNI 接口將數據傳遞給對方平臺。對方平臺有相應的方式進行反序列化。JNI 封裝層的工做也大大下降了。這樣作還有個好處是,後臺返回 protobuf 的結果,網絡只須要拷貝在內存一份數據(實際上若是 UI、DB 是不一樣的線程,有可能會須要兩份)就能讓數據庫進行使用,減小了沒必要要的內存開銷。
標題已經賽過千言萬語了。多線程版的 SQLite 但是對每行操做加鎖的,性能是比較差的,一樣的操做耗時是單線程版本的2倍。
好的 App 架構,必定會爲數據庫單獨安排一個線程。在多線程環境下,UI 線程發起了數據庫接口請求後,必定要保證接口是異步返回數據才能保證整個UI操做的流暢性。可是異步接口開發最大的麻煩在於調用在A處,還要實現一個 B 方法來處理異步返回的結果。這裏推薦使用 C++11的 lambda 表達式加模板函數 base::Bind 來實現像 JavaScript 語言同樣,可以將異步回調方法做爲輸入參數傳遞給執行方,待執行完成操做後進行異步回調。用異步化接口編程,大大下降開發難度和實現量,並帶來了流暢的界面體驗。
C++要實現將回調函數做爲輸入參數傳遞給函數執行者,並在執行者完成預約邏輯得到返回結果時調用回調函數傳遞迴結果,有兩個難點須要克服。
如何將函數變成一個局部變量(C++11 lambda 表達式)
如何將一個函數匿名化(C++11 auto decltype 聯合推導 lambda 表達式的類型)
有些時候,出於某種考慮,咱們須要加密數據庫。SQLite 數據庫加密對性能的損耗按照官方文檔的評測大約在3%的 CPU 時間。實現加密一種方案是購買 SQLite 的加密版本,大約是3000刀。還有一種就是本身實現數據庫的加密模塊。網上有不少介紹如何實現 SQLite 免費版中空實現的加密方法。
最後,但願本文能對你們有所幫助。