讀古今文學網 > MongoDB實戰 > 4.3 具體細節:數據庫、集合與文檔 >

4.3 具體細節:數據庫、集合與文檔

我們暫時將電子商務示例放在一邊,來看看數據庫、集合與文檔的核心細節。其中很多內容涉及了定義、特殊特性和極端情況。如果想知道MongoDB是如何分配數據文件的、文檔中嚴格限制了哪些數據類型、使用固定集合有什麼好處,請繼續讀下去。

4.3.1 數據庫

數據庫是集合的邏輯與物理分組。本節裡,我們會討論創建與刪除數據庫的細節。還會深入探討MongoDB是如何在文件系統上為每個數據庫分配空間的。

1. 管理數據庫

MongoDB裡沒有顯式創建數據庫的方法,在向數據庫中的集合寫入數據時會自動創建該數據庫。看看下面這段Ruby代碼:

@connection = Mongo::Connection.new
@db = @connection['garden']
  

假定之前數據庫並不存在,在執行這段代碼之後仍然不會在磁盤上創建數據庫。此處只是實例化了一個Mongo::DB類的實例。只有在向某個集合寫入數據時才會創建數據文件。接下來:

@products = @db['products']
@products.save({:name => "Extra Large Wheel Barrow"})
  

調用products集合的save方法時,驅動會告訴MongoDB將產品文檔插入到garden. products命名空間裡。如果該命名空間並不存在,則會進行創建;其中還涉及在磁盤上分配garden數據庫。

要刪除數據庫,意味著刪除其中所有的集合,我們要發出一條特殊的命令。在Ruby裡可以這樣刪除garden數據庫:

@connection.drop_database('garden')
  

在MongoDB Shell裡,可以運行dropDatabase方法:

use garden
db.dropDatabase;
  

在刪除數據庫時要格外小心,因為這個操作是無法撤銷的。

2. 數據文件與空間分配

在創建數據庫時,MongoDB會在磁盤上分配一組數據文件,所有集合、索引和數據庫的其他元數據都保存在這些文件裡。數據文件都被放置在啟動mongod時指定的dbpath裡。在未指定dbpath時,mongod會把文件全保存在/data/db裡。讓我們看看在創建了garden數據庫後/data/db目錄裡的情況:

$ cd /data/db
$ls-al
drwxr-xr-x 6 kyle admin          204 Jul 31 15:48 .
drwxrwxrwx 7 root admin          238 Jul 31 15:46 ..
-rwxr-xr-x 1 kyle admin     67108864 Jul 31 15:47 garden.0
-rwxr-xr-x 1 kyle admin    134217728 Jul 31 15:46 garden.1
-rwxr-xr-x 1 kyle admin     16777216 Jul 31 15:47 garden.ns
-rwxr-xr-x 1 kyle admin            6 Jul 31 15:48 mongod.lock
  

先來看mongod.lock文件,其中存儲了服務器的進程ID。1數據庫文件本身是依據所屬的數據庫命名的。garden.ns是第一個生成的文件。文件擴展名ns表示namespaces,意即命名空間。數據庫中的每個集合和索引都有自己的命名空間,每個命名空間的元數據都存放在這個文件裡。默認情況下,.ns文件大小固定在16 MB,大約可以存儲24 000個命名空間。也就是說數據庫中的索引和集合總數不能超過24 000。我們幾乎不可能使用這麼多集合與索引,但如果真有需要,可以使用--nssize服務器選項讓該文件變得更大一點。

1. 永遠不要刪除或修改鎖定文件,除非是在對非正常關閉的數據庫進行恢復。如果在啟動mongod時彈出一個與鎖定文件有關的錯誤消息,很有可能是之前沒有正常關閉,可能需要初始化一個恢復進程。我們會在第10章裡進一步討論該話題。

除了創建命名空間文件,MongoDB還為集合與索引分配空間,就在以從0開始的整數結尾的文件裡。查看目錄的文件列表,會看到兩個核心數據文件,64 MB的garden.0和128 MB的garden.1。這些文件的初始大小經常會讓新用戶大吃一驚,但MongoDB傾向於這種預分配的做法,這能讓數據盡可能連續存儲。如此一來,在查詢和更新數據時,這些操作能更靠近一點,而不是分散在磁盤各處。

在向數據庫添加數據時,MongoDB會繼續分配更多的數據文件。每個新數據文件的大小都是上一個已分配文件的兩倍,直到達到預分配文件大小的上限——2 GB,即garden.2會是256 MB,garden.3是512 MB,以此類推。此處基於這樣一個假設,如果總數據大小呈恆定速率增長,應該逐漸增加數據文件分配的空間,這是一種相當標準的分配策略。當然,這麼做的後果之一就是分配的空間與實際使用的空間之間會存在很大的差距2。

2. 這在空間很寶貴的部署環境下會帶來一些問題,針對此類情況,可以組合使用--noprealloc--smallfiles這兩個服務器選項。

可以使用stats命令檢查已使用空間和已分配空間:

> db.stats
{
   "collections" : 3,
   "objects" : 10004,
   "avgObjSize" : 36.005,
   "dataSize" : 360192,
   "storageSize" : 791296,
   "numExtents" : 7,
   "indexes" : 1,
   "indexSize" : 425984,
   "fileSize" : 201326592,
   "ok" : 1
}
  

在這個例子裡,fileSize字段標明了為該數據庫分配的文件空間的總和,就是簡單地把garden數據庫的兩個數據文件(garden.0和garden.1)的大小加起來。比較有意思的是dataSizestorageSize兩者的差值,前者是數據庫中BSON對象的實際大小,後者包含了為集合增長預留的額外空間和未分配的已刪除空間。3最後,indexSize的值是數據庫索引大小的總合。關注總計索引大小是很重要的,當所有用到的索引都能放入內存時,數據庫的性能是最好的。我將在第7章和第10章裡介紹排查性能問題的技術時詳細討論這個話題。

3. 嚴格說來,集合就是每個數據文件裡按塊分配的空間,這些塊稱為區段(extent)。storageSize就是為集合區段所分配空間的總額。

4.3.2 集合

集合是結構上或概念上相似的文檔的容器。本節會更詳細地描述集合的創建與刪除。隨後,我會介紹MongoDB特有的固定集合,並給出一些例子,演示核心服務器內部是如何使用集合的。

1. 管理集合

正如在上一節裡看到的,在向一個特定命名空間中插入文檔時還隱式地創建了集合。但由於存在多種集合類型,MongoDB還提供了創建集合的命令。在Shell中可以執行:

db.createCollection("users")
  

在創建標準集合時,有選項能指定預先分配多少字節的存儲空間。方法如下(但通常沒必要這麼做):

db.createCollection("users", {size: 20000})
  

集合名裡可以包含數字、字母或.符號,但必須以字母或數字開頭。在MongoDB內部,集合名是用它的命名空間名稱來標識的,其中包含了它所屬的數據庫的名稱。因此,嚴格說起來,在往來於核心服務器的消息裡引用產品集合時應該用garden.products。這個完全限定集合名不能超過128個字符。

有時在集合名裡包含.符號很有用,它能提供某種虛擬命名空間。舉例來說,可以想像有一系列集合使用了下列名稱:

products.categories
products.images
products.reviews
  

請牢記這只是一種組織上的原則,數據庫對名字裡帶有.的集合和其他集合是一視同仁的。 我之前已經提到過從集合中刪除文檔和徹底刪除集合了,現在你還需要知道集合是可以重命名的。比如,可以用Shell裡的renameCollection方法重命名產品集合:

db.products.renameCollection("store_products")
  

2. 固定集合

除了目前為止創建的標準集合,我們還可以創建固定集合(capped collection)。固定集合原本是針對高性能日誌場景設計的。它們與標準集合的區別在於其大小是固定的,也就是說,一旦固定集合到達容量上限,後續的插入會覆蓋集合中最先插入的文檔。在只有最近的數據才有價值的情況下,這種設計免除了用戶手工清理集合的煩惱。

要理解如何使用固定集合,可以假設想要追蹤訪問我們站點的用戶的行為。此類行為會包含查看產品、添加到購物車、結賬與購買。可以寫個腳本來模擬向固定集合記錄這些用戶行為的日誌記錄功能。在這個過程裡,我們會看到這些集合的一些有趣屬性。下面是一個示例。

代碼清單4-6 模擬向固定集合中記錄用戶行為日誌

require 'rubygems'
require 'mongo'  

VIEW_PRODUCT = 0
ADD_TO_CART  = 1
CHECKOUT     = 2
PURCHASE     = 3  

@con = Mongo::Connection.new
@db = @con['garden']  

@db.drop_collection("user.actions")  

@db.create_collection("user.actions", :capped => true, :size => 1024)
@actions = @db['user.actions']

20.times do |n|
  doc = {
    :username => "kbanker",
    :action_code => rand(4),
    :time => Time.now.utc,
    :n => n
  }

  @actions.insert(doc)
end
  

首先,使用DB#create_collection方法4創建一個名為users.actions、大小為1 KB的固定集合。接下來,插入20個示例日誌文檔。每個文檔都包含用戶名、動作代碼(存儲內容為0~3的整數)和時間戳,還要加入一個不斷增加的整數n,這樣就能標識出哪個文檔過期了。現在從Shell裡查詢集合:

4. Shell裡的等效創建命令是db.createCollection("users.actions", {capped: true, size: 1024})。

> use garden
> db.user.actions.count;
10
  

儘管插入了20個文檔,但集合裡卻只有10個文檔,查詢一下集合內容,你就能知道為什麼了:

db.user.actions.find;
{ "_id" : ObjectId("4c55f6e0238d3b201000000b"), "username" : "kbanker",
  "action_code" : 0, "n" : 10, "time" : "Sun Aug 01 2010 18:36:16" }
{ "_id" : ObjectId("4c55f6e0238d3b201000000c"), "username" : "kbanker",
  "action_code" : 4, "n" : 11, "time" : "Sun Aug 01 2010 18:36:16" }
{ "_id" : ObjectId("4c55f6e0238d3b201000000d"), "username" : "kbanker",
  "action_code" : 2, "n" : 12, "time" : "Sun Aug 01 2010 18:36:16" }
...
  

返回的文檔是按照插入順序排列的。仔細觀察n的值,很明顯,集合中最老的文檔是第十個插入的文檔,也就是說文檔0~9都已經過期了。既然該固定集合最大是1024字節,僅包含10個文檔,也就是說每個文檔大致是100字節。後面你將看到如何驗證這個假設。

在此之前,我要再指出固定集合與標準集合之間的幾個不同點。固定集合默認不為_id創建索引,這是為了優化性能,沒有索引,插入會更快。如果實在需要_id索引,可以手動構建索引。在不定義索引的情況下,最好把固定集合當做用於順序處理的數據結構,而非用於隨機查詢的數據結構。為此,MongoDB提供了一個特殊的排序操作符,按自然插入順序5返回集合的文檔。之前的查詢是按自然順序正向輸出結果的,如果要逆序輸出,必須使用$natural排序操作符:

5. 自然順序是文檔保存在磁盤上的順序。

> db.user.actions.find.sort({"$natural": -1});
  

除了按自然順序排列文檔,並放棄索引,固定集合還限制了CRUD操作。比如,不能從固定集合中刪除文檔,也不能執行任何會增加文檔大小的更新操作。6

6. 因為固定集合最早是為日誌記錄功能而設計的,不需要實現刪除或更新文檔功能,這些功能會讓負責舊文檔過期的代碼複雜化。去掉這些功能,固定集合獲得了設計的簡單性和高效性。

3. 系統集合

MongoDB內部對集合的使用方式可以體現它的部分設計思想,system.namespacessystem. indexes就屬於這些特殊系統集合。前者可以查詢到當前數據庫中定義的所有命名空間:

> db.system.namespaces.find;
{ "name" : "garden.products" }
{ "name" : "garden.system.indexes" }
{ "name" : "garden.products.$_id_" }
{ "name" : "garden.user.actions", "options" :
    { "create": "user.actions", "capped": true, "size": 1024 } }
  

後者存儲了當前數據庫的所有索引定義。要獲取garden數據庫的索引,查詢該集合即可:

> db.system.indexes.find;
{ "name" : "_id_", "ns" : "garden.products", "key":{"_id":1}}
  

system.namespacessystem.indexes都是標準的集合,但MongoDB使用固定集合來做複製。每個副本集的成員都會把所有的寫操作記錄到一個特殊的oplog.rs固定集合裡。從節點順序讀取這個集合的內容,再把這些新操作應用到自己的數據庫裡。第9章將更詳細地討論這個系統集合。

4.3.3 文檔與插入

我們將通過討論文檔及其插入的細節來結束這章。

  1. 文檔序列化、類型和限制

正如上一章中說的那樣,所有文檔在發送到MongoDB之前都必須序列化成BSON;隨後再由驅動將文檔從BSON反序列化到語言自己的文檔表述。大多數驅動都提供了一個簡單的接口,可以進行BSON的序列化和反序列化。我們可能會需要查看發送給服務器的內容,因此瞭解這部分功能在驅動中是如何實現的會非常有用。舉例來說,前文在演示固定集合,我們有理由假設示例文檔的大小大約是100字節。可以通過Ruby驅動的BSON序列化器來驗證這一假設:

doc={
  :_id => BSON::ObjectId.new,
  :username => "kbanker",
  :action_code => rand(5),
  :time => Time.now.utc,
  :n => 1
}
bson = BSON::BSON_CODER.serialize(doc)
puts "Document #{doc.inspect} takes up #{bson.length} bytes as BSON"
  

serialize方法會返回一個字節數組。如果運行上述代碼,會得到一個82字節的BSON對象,和我們估計的差不多。如果想要在Shell裡檢查BSON對象的大小,可以這樣做:

> doc = {
   _id: new ObjectId,
   username: "kbanker",
   action_code: Math.ceil(Math.random * 5),
   time: new Date,
   n: 1
}
> Object.bsonsize(doc);
82
  

同樣也是82字節。82字節的文檔大小和100字節的估計值的差別在於普通集合和文檔的開銷。

反序列化BSON也很簡單,可以嘗試運行以下代碼:

deserialized_doc = BSON::BSON_CODER.deserialize(bson)

puts "Here's our document deserialized from BSON:"
puts deserialized_doc.inspect
  

請注意,不是所有Ruby散列都能被序列化。要正確序列化,鍵名必須是合法的,每個值都必須能轉換為BSON類型。合法的鍵名由null結尾的字符串組成,最大長度為255字節。字符串可以包含任意ASCII字符的組合,但有三種情況例外:不能以$開頭,不能包含.字符,除了結尾處外不能包含null字節。在Ruby裡,可以用符號充當散列的鍵,在序列化時它們會被轉換為等效的字符串。

應該慎重選擇鍵名的長度,因為這是存儲在文檔裡面的。這種做法與RDBMS截然不同,RDBMS裡列名總是與數據行分開保存的。因此,在使用BSON時,可以用dob代替date_of_birth作為鍵名,這樣一來每個文檔都能省下10字節。這個數字聽起來並不大,但一旦有了10億個文檔,這個更短的鍵名能幫我們省下將近10 GB的存儲空間。但這也不是讓你肆意縮短鍵名長度,請選擇一個合適的鍵名。如果有大量的數據,更「經濟」的鍵名能幫助省下不少空間。

除了合法的鍵名,文檔還必須包含可以序列化為BSON的值。在http://bsonspec.org可以找到一張BSON類型的表格,其中有示例和註解。此處我只會指出一些重點和容易碰到的陷阱。

  • 字符串

所有字符串都必須編碼為UTF-8,雖然UTF-8就快成為字符編碼的行業標準了,但還是有很多地方仍在使用舊的編碼。在將數據從遺留系統導入到MongoDB時用戶通常會遇到一些問題。解決方案一般是在插入前將內容轉換為UTF-8,或者將文本保存為BSON二進制類型。7

7. 順便說一下,如果你還不太瞭解字符編碼,推薦你讀一下Joel Spolsky那篇著名的介紹字符編碼的文章,參見http://mng.bz/LVO6。如果你是一名Ruby愛好者,也許還會想讀一讀James Edward Gray關於Ruby 1.8和1.9字符編碼的一系列文章,參見http://mng.bz/wc4J。

  • 數字

BSON規定了三種數字類型:double、intlong。也就是說BSON可以編碼各種IEEE浮點數值,以及各種8字節以內的帶符號整數。在動態語言裡序列化整數時,驅動會自己決定是將其序列化為int還是long。實際上,只有一種常見情況需要顯式地決定數字類型,那就是通過JavaScript Shell插入數字數據時。很遺憾,JavaScript天生就支持一種數字類型,即Number,它等價於IEEE的雙精度浮點數。因此,如果希望在Shell裡將一個數字保存為整數,需要使用NumberLongNumberInt顯式指定。試試下面這段代碼:

db.numbers.save({n: 5});
db.numbers.save({ n: NumberLong(5) });
  

這裡向numbers集合添加了兩個文檔,雖然兩個值是一樣的,但第一個被保存成了雙精度浮點數,第二個則被保存成了長整數。查詢所有n5的文檔會將這兩個文檔一併返回:

>db.numbers.find({n: 5});
{ "_id" : ObjectId("4c581c98d5bbeb2365a838f9"), "n":5}
{ "_id" : ObjectId("4c581c9bd5bbeb2365a838fa"), "n" : NumberLong(5)}
  

但是可以看到第二個值被標記為長整數。另一種做法是使用特殊的$type操作符來查詢BSON類型。每種BSON類型都由一個從1開始的整數來標識。如果查看http://bsonspec.org上的BSON規範,會看到雙精度浮點數是類型1,而64位整數是類型18。所以,可以根據類型來查詢集合的值:

> db.numbers.find({n: {$type: 1}});
{ "_id" : ObjectId("4c581c98d5bbeb2365a838f9"), "n":5}

> db.numbers.find({n: {$type: 18}});
{ "_id" : ObjectId("4c581c9bd5bbeb2365a838fa"), "n" : NumberLong(5)}
  

這也證實了兩者在存儲上的不同。在生產環境裡我們幾乎用不上$type操作符,但在調試時,這是個很棒的工具。

另一個和BSON數字類型有關的問題是其中缺乏對小數的支持。這意味著在MongoDB中保存貨幣值時需要使用整數類型,並且以美分為單位來保存貨幣值。

  • 日期時間

BSON的日期時間類型是用來存儲時間的,用帶符號的64位整數來標識Unix epoch8毫秒數,採用的時間格式是UTC(Coordinated Universal Time,協調世界時)。負值代表時間起點之前的毫秒數。

8. Unix epoch是從1970年1月1日午夜開始的協調世界時。

以下是一些使用時的注意事項。首先,如果在JavaScript裡創建日期,請牢記JavaScript日期裡的月份是從0開始的。也就是說new Date(2011, 5, 11)創建出的日期對像表示2011年6月11日。其次,如果使用Ruby驅動存儲時間數據,BSON序列化器會期待傳入一個UTC格式的Ruby Time對象。其結果就是不能使用包含時區信息的日期類,因為BSON 日期時間無法對它進行編碼。

  • 自定義類型

如果希望連同時區一起保存時間該怎麼辦呢?有時候光有基本的BSON類型是不夠的。雖然無法創建自定義BSON類型,但可以結合幾個不同的原生BSON值,以此創建自己的虛擬類型。舉例來說,想要保存時區和時間,可以使用這樣一種文檔結構,Ruby代碼如下:

{:time_with_zone =>
   {:time => Time.utc.now,
    :zone => "EST"
   }
 }
  

要編寫一個能透明處理此類組合表述的應用程序並不複雜。真實情況往往就是這樣的。例如,MongoMapper(用Ruby編寫的MongoDB對像映射器)允許為任意對像定義to_mongofrom_mongo方法,方便此類自定義組合類型的使用。

  • 文檔大小的限制

MongoDB v2.0中BSON文檔的大小被限制在16 MB9。出於兩個原因需要增加這個限制,首先是為了防止開發者創建難看的數據模型。雖然在這個限制下仍然會有差勁的數據模型,但16 MB的限制還是有幫助的,尤其是能避免深層次的嵌套,這種嵌套對於MongoDB的新手是個常見的數據建模問題。深層嵌套的文檔很難使用,最好能將它們展開到各自不同的集合裡。

9. 這個數字在各個服務器版本之間有所不同,而且還在繼續增加。要瞭解正在使用的服務器版本對應的限制值,可以在Shell裡運行db.isMaster,查看maxBsonObjectSize字段。如果沒有這個字段,那麼該限制是4 MB(正在使用一個非常古老的MongoDB版本)。

第二個原因與性能有關,在服務器端查詢大文檔,在將結果發送個客戶端之前需要將文檔複製到緩衝區裡。這個複製動作的代價可能很大,尤其在客戶端並不需要整個文檔時(這種情況很常見)。10此外,一旦發送之後,就會在網絡中傳輸這些文檔,驅動還要對其進行反序列化。如果一次請求大量MB數量級的文檔,這筆開銷會極大。

10. 在下一章裡會看到,可以指定查詢返回文檔的哪些字段,以此控制響應的大小。如果經常這麼做,就可以重新考慮一下你的數據模型了。

結論就是,如果有很大的對象,也許可以將它們拆開,修改其數據模型,使用一到兩個額外的集合。如果僅僅存儲大的二進制對象,比如圖片或視頻,這又是另一種情況,附錄C裡有與處理大型二進制對像相關的內容。

2. 批量插入

在有了正確的文檔之後,就該執行插入操作了。第3章裡已經討論了很多與插入相關的細節,包括生成對像ID、網絡層上插入是如何實現的,還有安全模式。但還有一個特性值得探討,那就是批量插入。

所有的驅動都可以一次插入多個文檔,這在有很多數據需要插入時非常有用,比如初始化批量導入或者從另一個數據庫系統遷移數據時。回想之前向user.actions集合插入20個文檔的例子,如果再去讀下代碼,會發現每次只插入一個文檔。使用下面的代碼,事先構造一個40個文檔的數組,隨後將整個文檔數組傳遞給insert方法:

docs = (0..40).map do |n|
  { :username => "kbanker",
    :action_code => rand(5),
    :time => Time.now.utc,
    :n => n
  }
end
@col = @db['test.bulk.insert']
@ids = @col.insert(docs)

puts "Here are the ids from the bulk insert: #{@ids.inspect}"
  

與單獨返回一個對像ID有所不同,批量插入會返回所有插入文檔的對象ID數組。用戶經常會問,理想的批量插入數量是多少?答案受到太多具體因素的影響,理想的數字範圍為10~200。在具體情況中,基準測試的結果是最有價值的。數據庫方面唯一的限制是單次插入操作不能超過16 MB上限。經驗表明大多數高效的批量插入都遠低於該限制。