你好,歡迎來到IOS教程網

 Ios教程網 >> IOS編程開發 >> IOS開發基礎 >> iOS端數據庫解決方案分析

iOS端數據庫解決方案分析

編輯:IOS開發基礎

74.jpg

很早之前就想寫一篇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是如何存儲數據的。

14.png

所謂的建索引是給原數據表新建了一個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是指我們訪問的地址是隨機分布的,當前需要讀取0x00000001,下一刻可能讀取0x000A0001。而Sequential Access則是嚴格按照順序尋址的,0x00000001下一刻跟的是0x00000002。對於內存來說,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文件導出可以看到三個以下文件:

15.png

其中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的問題。

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

  • 讀和寫都在主線程。

  • 讀在主線程,一個子線程復雜全部的寫。

  • 讀在主線程,多個子線程負責並發的寫。

第一種是最簡陋的做法,寫操作會影響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並不是一個好的選擇。

歡迎關注公眾號:MrPeakTech

  1. 上一頁:
  2. 下一頁:
蘋果刷機越獄教程| IOS教程問題解答| IOS技巧綜合| IOS7技巧| IOS8教程
Copyright © Ios教程網 All Rights Reserved