iOS端資料庫解決方案分析

mrpeak發表於2016-11-19

很早之前就想寫一篇iOS端資料庫相關的總結文章,梳理下使用移動端資料庫的一些重要知識點,再綜合對比下sqlite和CoreData的優缺點,希望能幫助一些這方面經歷較少的同學少走一些彎路。

為什麼要用資料庫

iOS端持久化的方案選擇比較多,NSUserDefault,Keychain,File,sqlite都可以幫助儲存關鍵的業務資料。NSUserDefault和Keychain都是輕量級解決方案,自定義資料格式的File則讀取麻煩一些,每次更新部分資料都會導致整個檔案io,資料的結構一旦複雜起來,最後還是會走向sqlite。

sqlite是移動端的輕量級資料庫解決方案,它的應用之廣幾乎已經遍及我們日常生活當中所使用的主流App,大部分人所熟知的CoreData或者FMDB,其核心都是基於sqlite。現在第三方的封裝使得sqlite的使用更為便捷,但資料庫是電腦科學一大知識體系,其涵蓋的知識點相當龐大,簡單用起來很簡單,用得合理用得溜就不那麼容易了。一個高頻次使用的App,一年之後還要保持高效的讀寫真不是個簡單的活。

在具體深入CoreData和Sqlite細節之前,先梳理下資料相關的重要知識點,以下關於資料庫的討論都是以sqlite為範疇。

Relation(關係) vs Object(物件)

在開始討論之前,分清楚Relation(關係)和Object(物件)之間的差別非常重要。這兩個概念對很多最初接觸資料庫的同學來說可能有些模糊不清,特別是直接上手一些第三方封裝過Sqlite庫,很容易認為表和物件之間存在天然的對映關係,畢竟table當中的記錄剛好可以對應一個object。

其實對於sqlite這類關係型資料庫,在資料的儲存和表現形式上和麵向物件當中的object還是存在很大差異的。我們平常使用OOP程式語言的時候,習慣性思維會用物件去模擬,描述一切和業務相關的存在,比如使用者,商品,購物車,瀏覽記錄,購買記錄等等,這些可以方便的對應到一個個的table,但Object在描述物件的時候更加靈活,比如UserProfile物件,他可以有一個property來描述他的朋友列表:

@interface UesrProfile : NSObject
@property (nonatomic, strong) NSArray*                 friends;
@end

可以用Array這類集合的概念進一步細化表示Object,但sqlite的table只能儲存scalar type,也就是單一資料型別,無法去儲存Array,關係型資料庫的做法通常是通過主鍵和外來鍵,在兩個表之間來表示關係。當然我們也可以在UserProfile表中增加一個自定義的blobdata或者格式化後的特殊String來儲存array,但這種設計已經脫離關係係資料庫的範疇了。

總而言之,sqlite這類關係型資料庫更加強調關係,將記憶體中的OOP物件保持至資料庫的時候需要進行一步轉化的工作,將OOP的Relation轉化成sqlite的Relation。

index(索引)

索引是平常資料庫使用當中基礎中的基礎,如果只是將資料轉化為表進行保持,下次用時再取,在表記錄變得龐大以後很容易出現效能問題。用資料庫保持資料的另一大好處是資料的讀取可以很快,和傳統的檔案儲存相比,效能不在一個量級。當然我們需要索引的幫助,index可以讓我們以特定的方式快速讀取或查詢某些記錄,有多快呢?理解index有多快需要一些演算法知識的儲備,並不是很複雜的演算法。

我在之前一篇文章中介紹過集合類查詢資料的一些演算法基礎,抽象來看,資料庫也可以看做是一種集合類。

對於無序Array,我們需要完整的遍歷整個集合才能找到我們感興趣的元素,查詢的時間複雜度為O(N)。

有序的Array,二分法查詢可以將時間複雜度降為O(logN),但插入為O(N)。

有沒有一種資料儲存方式可以同時讓insert和search都快呢?Tree可以,Binary Search Tree可以在insert和search之間取得平衡,達到O(logN)的速度。

再繼續深入之前,我們先抽象的看下sqlite是如何儲存資料的。

db

所謂的建索引是給原資料表新建了一個index表來方便查詢,如果我們給User表中的Name做了索引,當我們根據Name去做sql查詢的時候,第一步其實是去User表對應的Index表去做查詢,Index表以Name為key建立了一個B+Tree的樹形結構。上圖中雖然Index表看上去也是一個table,但背後其實是以B+Tree為資料結構進行了整理,在B+Tree(Index表)中找到記錄A之後,第二步可以從A記錄中的地址資訊找到原User表對應的記錄資訊。這裡可以看出主要的效能損耗在第一步,第二步是普通的磁碟I/O。索引並不是萬金油,我們可以通過分析第一步來了解建立了索引之後的查詢效能瓶頸在哪。要分析第一步,還得先了解其他幾個知識點:

磁碟I/O瓶頸

在學校學習計算機基礎這門課程的時候,我們都知道記憶體(Memory)較之於磁碟(Disk),讀取速度快,但由於價格貴所以空間比Disk小。也正是由於這個原因導致我們大尺寸的資料都只能存在Disk上,用的時候再去記憶體取,沒讀一次就觸發一次磁碟I/O,同理我們的sqlite其實說白了就是一個xxx.db檔案,每次去做sql查詢的時候就要去讀檔案,大多數時候一次查詢往往無法通過一次I/O完成,所以如何減少磁碟I/O的次數成為我們優化sqlite效能的關鍵指標。

理解磁碟和記憶體定址效能的差異還有一個很重要的知識點,Random Access和Sequential Access。Random Access是指我們訪問的地址是隨機分佈的,當前需要讀取0×00000001,下一刻可能讀取0x000A0001。而Sequential Access則是嚴格按照順序定址的,0×00000001下一刻跟的是0×00000002。對於記憶體來說,Random Access和Sequential Access在效能上沒有任何差異。對於Disk,Sequential Access也能很好的適應,磁碟的機械旋轉就能順利的讀到連續的地址,Random Access就比較費時了,可能需要磁頭和磁碟的多次機械運動才能重新定位到目標地址。

磁碟讀取方式

從以前計算機基礎教程當中我們都見過機械磁碟物理定址的示意圖,一個磁頭牽頭移動配合磁碟的旋轉來找到具體的分割槽和地址,正是由於磁頭的尋道和磁碟的旋轉都是機械運動,直接導致定址效能和記憶體定址差了幾個量級。磁碟讀取資料的時候都是以Page為單位,頁(page)的概念很重要,Page是計算機儲存時所使用的基礎邏輯單位,記憶體和磁碟當中的資料儲存和互動都是以頁為單位。即使記憶體只需要1個位元組的資料,從磁碟讀取的時候也是拿到一個或多個page,這是一種常用的預先快取策略,因為記憶體在程式執行的下一刻極有可能會需要讀取這一個位元組周圍的資料,明白頁的概念有助於我們形成資料庫讀取資料時的抽象示意圖,對於後面我們分析一些sqlite的效能問題有很大的幫助。

說到Page,還值得囉嗦一些。Page這個計算機基礎概念在很多場景中都有用到,很容易混淆。

  • VM(Virtual Memory)在處理虛擬地址和實體地址之間轉換的時候,是以Page為單位,稱之為Memory Page。
  • 磁碟儲存資料的時候也是以Page或者Block為單位,也就是上面所說的按頁讀取,一般大小為4KB,稱之為Disk Page。
  • Sqlite本身讀取資料的時候也有自己的單位,也是叫做Page,預設大小是1KB,稱之為sqlite Page。

這些都統稱為Page,但在不同場景含義並不相同,閱讀英文文件的時候需要仔細區分。

table記錄的儲存方式

明白了Page的概念後,還需要了解一個table當中的記錄是如何以頁為單位儲存的。做一個簡單的計算就能夠明白其中的關聯。上圖中User表各欄位我們分別假設type為:ID(Int,4 Bytes),,Name(String,128 Bytes),Gender(Int,4 Bytes),Address(String,128 Bytes),所以一行記錄所佔的空間就是4+128+4+128=264 Bytes。假設一個Page大小為4 KB,那麼一頁我們可以儲存4*1024/264≈15條記錄,也就是說我們一次I/O我們可以獲取到User表15調記錄,如果這15條記錄中不包含我們的查詢目標,我們需要再做一次I/O。不過我們建立的索引表並不需要包含原表的全部資料,比如上圖中Index表只需要Name(128位元組)和Position(4位元組)即可,那麼一頁可以儲存

B+Tree

上面提到使用Tree來儲存資料可以獲得不錯的Insert和Search效率,使用Binary Search Tree或者紅黑樹可以讓查詢的時間複雜度為O(logN),logN表示樹的高度h,即使是完全平衡的紅黑樹,樹的高度都無法控制在理想的範圍,而B Tree和B+Tree相比能夠將h控制一個極小的值,不過節點數是一定的,高度h變小了,每個節點的子節點數(degree)必然就增加了,由於查詢的效能和樹的高度相關,所以B+ Tree是更好的選擇。關於B+ Tree的演算法原理這裡就不展開了,感興趣的同學可以自己搜尋相關資料。

根據上面幾個知識點,我們可以在腦中形成一個抽象的查詢流程:

  • sql語句觸發磁碟I/O。
  • 磁碟返回一個Page,當中包含索引表中的若干條記錄。
  • 在上述記錄當中找到目標記錄,根據position資訊找到原User表記錄位置。
  • 讀取User表當中的目標記錄完成sql查詢。

接下來我們根據上述資訊做簡單的推理,得出一些和index相關的Best Practice。

場景一:索引並不是越多越好。

雖然索引能加快查詢的速度,但同時增加了額外的一個表來儲存B+Tree結構的資料,1million條記錄就對應一個1million條記錄的Index表,額外開銷非常可觀。所以我們平常應該只給必要的欄位(有被查詢需求)建索引,而且索引還會增加insert和delete的時間複雜度。

場景二:給數值型別建索引會比String型別建索引,效率更好。

其實更合理的表述應該是,建立Index的欄位的Data Type大小越小,我們索引查詢的效能就越高。原因很簡單,資料越小,單條記錄的磁碟開銷就越小,一個Page所包含的記錄數量也就越多,這樣我們磁碟I/O的時候自然命中率就越高。這也是為什麼我們總是給ID建索引,而很少對Name建索引。當然這種效能的差異只有在表記錄非常龐大的時候才能看出差別。

場景三:索引之後查詢並不一定快

可能有些人覺得建了索引查詢就沒效能問題了,比如上面User表,針對ID建了Index。下次查詢的時候就可以隨心所欲寫sql了,實際上還是需要具體場景具體分析。

索引使用B+Tree作為背後的資料結構支撐,其本質上還是一種有序的資料結構,對於B+Tree來說,第1000個節點需要連續讀取1000個節點才能獲取到。所以當我們執行如下sql的時候,速度並不理想:

select Name from User order by ID limit 1000, 10

即使我們對ID做了索引,讀取1001~1010個元素和讀取第1~10個元素速度完全不同,這裡的關鍵在於offset,limit這種寫法對於sqlite來說效率很低,每次查詢的時候第一步要跳到offset,需要執行O(offset)次讀取才能定位到目標位置。正確的做法是使用>=或者<=來做第一次跳轉:

select Name from User where ID >= 1000 order by ID limit 10

這樣第一步可以使用Binary Search快速定位到大於1000的位置,再連續的讀取10個節點就可以了。Sqlite有篇文件解釋了這種場景,在設計翻頁的時候我們經常會遇到。

索引優化是個複雜的問題,需要大量的理論和實踐來認知,但上述這些基礎知識點的理解可以幫助解決大部分應用場景下遇到的索引問題,或者是作為分析複雜場景的起點。

Sqlite基礎知識

移動端的資料方案大多是基於sqlite,CoreData,FMDB等都不例外,掌握一些sqlite的基礎知識對於平常選擇技術方案,分析技術問題很有幫助。

檔案分析

我們先來直觀的認識下sqlite,sqlite的主要儲存其實就是一個檔案,另外再配有兩個功能輔助檔案。使用itools將

App的db檔案匯出可以看到三個以下檔案:

db1

其中MyDB.db不用多說,是各個tables儲存的位置,前面提到的原始表,索引表等都在這個檔案當中。

MyDB.db-wal和MyDB.db-shm是做什麼用的呢?

-wal是sqlite的日誌檔案,全稱是write-ahead log。在wal出現之前,sqlite使用的是-journal檔案,現在有些sqlite的版本還是使用的-journal模式。簡單來說,-journal是用來配合事務(Transaction)做原子提交的,每次提交一個事務之前,sqlite會先將.db的狀態保持至-journal檔案,然後再提交事務資料,如果事務順利提交,再刪除-journal檔案中的狀態,如果事務中途被異常中斷,比如斷電或者程式crash,下次sqlite被開啟的時候,會去檢查-journal檔案,如果發現日誌,會將.db檔案恢復到事務之前的狀態,所以-journal檔案是sqlite的rollback日誌。

-wal檔案是-journal的替代品,其工作方式和journal剛好相反,所有的事務都是先提交到wal檔案,原db檔案保持不變,到特定的時機點時才把wal檔案merge到db檔案。所以如果是使用journal模式,新提交的資料是在db檔案中,而使用wal模式的話,新提交的資料要在wal檔案中查詢。wal的好處是,允許不同的連線,一個讀db檔案,另一個寫wal檔案,讀和寫操作可以並行。

wal檔案和db檔案一樣是以page為單位儲存的,預設情況下,如果wal檔案達到1000個page(一個page為1KB大小)的時候,會產生一次checkpoint行為,即把wal檔案中的資料append到db檔案之中。CoreData據我測試wal檔案產生checkpoint的臨界值是4000page,也就是4M大小。所以大家平常使用CoreData提交資料的時候,可以清楚的看到wal檔案慢慢變大,而db檔案保持不變,直到wal檔案接近4M大小的時候,才merge到db檔案之中。

當然也可以通過命令列的方式手動merge wal和db檔案,後面實踐的時候再做演示。

MyDB.db-shm檔案是用來輔助-wal檔案的,shm是shared memory的縮寫,可以看做是wal檔案的一個index檔案,是為了輔助sqlite快速定位wal檔案資訊(每一次完整的commit)。shm檔案之中本身不儲存任何和table相關的資料,如果我們用vim將-shm檔案開啟是看不到任何業務資料記錄的。

到這裡我就對sqlite的三個相關檔案有了初步直觀的認識,下面我看下如何用命令列去讀取db當中的資料。

使用命令列分析sqlite db檔案

sqlite的命令列互動方式很豐富,下面我做下最常用的使用方式演示:

開啟db

sqlite3 MyDB.db

展示db檔案中的tables

.tables

展示某個table的欄位構成(schema)

.schema tableName

執行sql語句

select * from tableName where ...;

展示結果的時候顯示頂部column名稱

.head on

通過上面簡單幾步就可以通過終端直觀的瀏覽一個db當中的table資料。更多的命令可以檢視這篇文件

對於命令列互動還一個PRAGMA語句值得一提,PRAGMA語句提供了更豐富全面的互動支援,比如上面我們所提的手動checkpoint操作,可以在開啟db的前提下,通過如下PRAGMA語句來完成:

PRAGMA checkpoint_fullfsync = true

在退出sqlite命令模式的時候,就可以發現wal檔案被清空了,資料全被append到db檔案之中。

平常debug的時候,經常需要檢視資料是否寫成功了,使用命令列互動檢視資料快速高效。

CoreData

CoreData具體怎麼定義可謂是眾說紛紜,關於它的吐槽和總結非常之多。在我看來,CoreData是作為database的儲存和oop的Object之間的橋樑,並在儲存之上提供了一層object graph的封裝,這個object graph才是CoreData的重點,CoreData並不能算是ORM,它的儲存後端雖然是關係型資料庫sqlite,但也可以是其他型別的資料庫,重點在於object graph,為了方便開發者快速構建model層,所有關於CoreData的功和罪都是源自於這一善意的出發點。

從xcdatamodeld檔案的圖形編輯介面,到NSFetchedResultsController,可以看出蘋果是想提供一整套完整的方案,從底層database的資料存取,到應用層Controller的資料的展示和更新,一站式解決。對於簡單小型應用,使用起來確實很簡便,能快速的搭建一套持久化方案。但是用簡單的方案來簡化原本複雜的流程,就不可避免的要隱藏和遮蔽一些原本需要被暴露的細節,喪失可定製化的靈活性。這也是為什麼CoreData在被應用於複雜專案時會不停踩坑的根本原因。

我個人認為,對於業務相對複雜的專案,持久化以及model的處理應該被隔離在單獨的一層,不應該將持久化的處理直接延伸至應用層(Controller)。通過interface分層,可以將model的變化和業務流程獨立開來,維護單獨的model layer可以更方便我們檢視和控制整個程式的狀態變化,在必要的時候甚至可以做持久層的資料遷移。

在ORM的處理方案中,Active Record鼓勵將table中的資料直接對應到model,同時在model之中編寫domain logic,我認為domain logic不應該包括具體的應用層業務流程,而是指和model本身相關的邏輯,比如提供fullName方法拼接firstName和lastName。而在Controller層使用model的時候需要做多一步model的轉換,做持久層的model和應用層的model之間的隔離。

CoreData正是因為想做的太多,導致最後既不是database,又不像ORM,其提供的一套不透明的object graph機制使得做效能分析優化的時候踩坑不斷。我們具體來看下CoreData和sqlite有哪些差別。

CoreData和Sqlite最大的區別在於,CoreData更接OOP的Object,而FMDB這種Sqlite的封裝則更靠近關係型儲存。CoreData雖然是基於sqlite的封裝,但為了貼近OOP的思維方式,犧牲或者說遮蔽了很多資料庫本身的特性。

不透明的object graph

object graph並不是新的概念,無非是把db當中的table對映成了上層的model,有些model相互之間產生關聯,彼此引用,形成一張完整的graph。object graph不但做了orm的工作,還暗自維護了model的cache,還將磁碟的io操作也替你遮蔽了,所以在使用model的時候,你並不知道什麼時候會觸發具體的I/O,很有可能是在你訪問如下屬性的時候:

NSString* name = userEntity.name;

這一切都會自動發生,有CoreData替你完成,方便的同時,也失去了深度控制model行為的可能。

批量更新

在iOS 8之前,由於CoreData提供的都是一個個的model,所有要做批量更新的話,只能一個個遍歷然後呼叫commit,無法批量提交更新導致一些場景有效能問題。iOS 8之後CoreData終於提供了批量更新的介面:

NSBatchUpdateRequest *req = [[NSBatchUpdateRequest alloc] initWithEntityName:@"Message"];
req.predicate = [NSPredicate predicateWithFormat:@"read == %@", @(NO)];
req.propertiesToUpdate = @{
    @"read" : @(YES)
};
req.resultType = NSUpdatedObjectsCountResultType;
NSBatchUpdateResult *res = (NSBatchUpdateResult *)[context executeRequest:req error:nil];

可上面看上去還是更像sql語句一些,由此可見蘋果還是一直在嘗試讓CoreData變得更完美和更全能,既能像sql一樣思考,又提供model層的便捷。

沒有Primary Key

如果使用過CoreData就會發現其UI操作介面並沒有設定Primary Key的地方,如果你想讓你的Key是唯一的,只能自己在記憶體中去計算維護一套生成key的機制,CoreData通過Object的這一層抽象將Primary Key遮蔽掉了。一種簡答的唯一性Key生成機制是:利用NSUserDefault儲存一個int,每次read都+1,然後存回NSUserDefault,read和write都是加鎖,效能上雖然差一些,完全可以滿足移動端的需要。

資料庫多執行緒模型

全面的理解sqlite的多執行緒模型對於編寫複雜資料儲存場景的app很有必要,先來看些sqlite多執行緒相關的基礎知識。

sqlite在多執行緒訪問的場景下,通過db鎖來控制併發,db鎖有五種狀態。

  • Unlocked:預設狀態。
  • Shared:共享鎖,多個讀執行緒可以同時持有共享鎖,共享鎖存在時,不允許寫操作發生。
  • Reserved:當某個執行緒嘗試寫操作時,先持有Reserved鎖,如果有多個寫操作同時發生,只有一個能獲得Reserved鎖,當某個寫執行緒持有Reserved鎖時,其他的讀執行緒還是可以繼續加入持有Shared鎖。簡單來說,Reserved鎖排斥其他寫操作,不排斥讀操作。
  • Pending:某個獲得Reserved鎖的寫操作會進一步變為Pending鎖,此時新的讀操作和寫操作都是不能進入的,等待所有現有的寫操作(Shared鎖)釋放之後,下一步變為Exclusive。
  • Exclusive:寫操作從Pending變為Exclusive,此時寫操作可以安全進行。

從上面的幾種鎖狀態可以得出結論,sqlite支援多個讀操作併發執行,但同時只能有一個寫操作在發生。從Reserved開始一直到Exclusive,都只能有一個寫操作在進行,但在Pending之前,新的讀操作都是可以繼續加入,這種粒度的鎖對多執行緒讀寫併發場景下讀操作有較好的支援,同時也通過Pending鎖避免了write starvation的問題。

針對上述鎖的分析,我們在建立多執行緒模型的時候,主要有以下幾種模型:

  1. 讀和寫都在主執行緒。
  2. 讀在主執行緒,一個子執行緒複雜全部的寫。
  3. 讀在主執行緒,多個子執行緒負責併發的寫。

第一種是最簡陋的做法,寫操作會影響UI執行緒的效能。第二種是比較普遍的做法,寫操作都放到子執行緒當中,當然子執行緒也可以產生讀操作,這種做法可以做到讀寫併發,同時又不影響UI執行緒。第三種做法使用多個寫執行緒來提高寫操作的效率,但從上面鎖狀態可以看出,從Reserved開始就已經是寫操作互斥了,我個人感覺這種做法對寫操作效能的提升相當有限。一般推薦第二種做法。

CoreData的多執行緒模型

CoreData預設使用的是sqlite的多執行緒模式,這種模式下不能跨執行緒共享資料庫的連線,雖然不清楚CoreData的內部實現細節,總體使用下來感覺一個NSManagedObjectContext對應一個資料庫連線,同時再維護一套自己的object graph,object graph並不是多執行緒安全的,object graph當中的object 不能跨執行緒直接共享,NSManagedObjectContext也不能跨執行緒使用。所以使用CoreData建立多執行緒模型的時候有如下規則:

  • 不同的執行緒要建立自己的NSManagedObjectContext,維護各自的object graph。
  • NSManagedObject不能跨執行緒傳遞使用,只要通過傳遞NSManagedObjectID,再通過ID去從各自的Context中獲取Object。

不同的context之間並不是自動同步資料的,在write context寫入的資料並不能直接在main context中讀取到。我們需要自己建立同步機制,一般有兩種方式。

方式一:監聽context的寫通知

//主執行緒監聽write context的寫操作
[[NSNotificationCenter defaultCenter] addObserver:self.observer
                                                 selector:@selector(mocDidSave:)
         name:NSManagedObjectContextDidSaveNotification
                                                   object:self];
//merge 來自 write context中的資料變化
NSError *error = nil;
        [[self managedObjectContextForMainThreadWithError:&error] mergeChangesFromContextDidSaveNotification:saveNotification];

方式二:共享context

為了避免多個context之間的merge操作,可以在多個context之間建立paret child關係,使用這種方式一般會建立一個公共的background context,其他所有的main context和background context都是它的child。這種方式確實可以避免merge的問題,但我感覺本質上是把所有的讀和寫操作都序列化了,雖然最後讀寫行為都是在子執行緒發生,但併發的效能反而不如方式一好。

CoreData的第三方封裝也有一些,我使用過其中一款RHManagedObject,在多執行緒上根據上述第一種方式做過一些修改,目前經過2年多的實際專案驗證還比較穩定,感興趣的同學可以在我公眾號回覆db,獲得demo的下載地址。

結束語

我個人就CoreData和FMDB都在實際專案當中使用過,總體感覺CoreData更適合小型儲存需求的專案,快速搭建方便上手,Sqlite或者FMDB則更適合複雜儲存需求的專案,更靈活更可控。尤其是對讀寫操作頻繁的App比如IM這一類,需要對讀寫併發做深入優化時,CoreData並不是一個好的選擇。

相關文章