讀古今文學網 > MongoDB實戰 > 6.4 具體細節:MongoDB的更新與刪除 >

6.4 具體細節:MongoDB的更新與刪除

要真正掌握MongoDB中的更新,需要徹底理解MongoDB的文檔模型和查詢語言,前幾節裡的例子對此很有幫助。不過,與全書的「具體細節」部分一樣,我們會討論實質性的問題。此處不僅會囊括MongoDB更新接口中每個特性的簡要概述,還有多項與性能相關的說明。簡單起見,後續的示例都使用JavaScript。

6.4.1 更新類型與選項

MongoDB支持針對性更新與替換更新。前者使用一個或多個更新操作符來定義,後者使用一個文檔來替換匹配更新查詢選擇器的文檔。

語法說明:更新與查詢

剛接觸MongoDB的用戶有時會分不清更新與查詢的語法。針對性更新總是由更新操作符開始的,這些操作符幾乎全是動詞形式的。以$addToSet操作符為例:

db.products.update({}, {$addToSet: {tags: 'green'}})
  

如果要為該更新增加一個查詢選擇器,請注意這個查詢操作符在語義上起著形容詞的作用,緊跟在要查詢的字段名之後:

db.products.update({'price' => {$lte => 10}},
   {$addToSet: {tags: 'cheap'}})
  

基本上,更新操作符是前綴,而查詢操作符通常是中綴。

請注意,如果更新文檔含糊不清,更新將會失敗。此處,我們將更新操作符$addToSet和替換風格的{name: "Pitchfork"}結合在一起:

db.products.update({}, {name: "Pitchfork", $addToSet: {tags: 'cheap'}})
  

如果目的是改變文檔的名字,必須使用$set操作符:

db.products.update({},
  {$set: {name: "Pitchfork"}, $addToSet: {tags: 'cheap'}})
  

1. 多文檔更新

默認情況下,更新操作只會更新查詢選擇器匹配到的第一個文檔。要更新匹配到的所有文檔,需要明確指定多文檔更新(multidocument update)。在Shell裡,要實現這一點,可以將update方法的第四個參數設置為true。下面展示如何為產品集合裡的所有文檔添加cheap標籤:

db.products.update({}, {$addToSet: {tags: 'cheap'}}, false, true)
  

使用Ruby驅動(和大多數其他驅動)時,可以更清楚地表示多文檔更新:

@products.update({}, {'$addToSet' => {'tags' => 'cheap'}}, :multi => true)
  

2. upsert

某項內容不存在時進行插入,存在則進行更新,這是很常見的需求。可以使用MongoDB的upsert輕鬆實現這一模式。如果查詢選擇器匹配到文檔,進行普通的更新操作。如果沒有匹配到文檔,將會插入一個新文檔。新文檔的屬性合併自查詢選擇器與針對性更新的文檔。1

1. 請注意,upsert無法用於替換風格的更新操作。

以下是在Shell中使用upsert的簡單示例:

db.products.update({slug: 'hammer'}, {$addToSet: {tags: 'cheap'}}, true)
  

這是Ruby中等效的upsert示例:

@products.update({'slug' => 'hammer'},
   {'$addToSet' => {'tags' => 'cheap'}}, :upsert => true)
  

你應該已經猜到了,upsert一次只能插入或更新一個文檔。在需要原子性地更新文檔,以及無法確定文檔是否存在時,upsert能發揮巨大的作用。6.2.3節中有一個實際的例子,描述了如何向購物車中添加產品。

6.4.2 更新操作符

MongoDB支持很多更新操作符,此處我會為每個更新操作符提供一個簡單的示例。

1. 標準更新操作符

第一組是最常用的操作符,幾乎能用於任意數據類型。

  • $inc

可以使用$inc操作符遞增或遞減數值:

db.products.update({slug: "shovel"}, {$inc: {review_count: 1}})
db.users.update({username: "moe"}, {$inc: {password_retires: -1})
  

也可以用它加或減任意數字:

db.readings.update({_id: 324}, {$inc: {temp: 2.7435}})
  

$inc既方便又高效,因為它很少會改變文檔的大小,$inc通常原地作用在數據的磁盤位置上,所以只會影響到指定的數據對。2

2. 當數字類型發生改變時,情況會有所不同。如果$inc造成32位整數被轉換為64位整數,那麼整個BSON文檔會原地重寫。

正如添加產品到購物車的示例中演示的那樣,$inc能用於upsert中。例如,可以將之前的更新改為upsert:

db.readings.update({_id: 324}, {$inc: {temp: 2.7435}}, true)
  

如果不存在_id324的文檔,會用該_id創建一個新文檔,文檔中temp的值就是$inc2.7435

  • $set$unset

如果需要為文檔中的特定鍵賦值,可以使用$set。為鍵賦值時,可以使用任意合法的BSON類型。也就是說以下更新都是正確的:

db.readings.update({_id: 324}, {$set: {temp: 97.6}})
db.readings.update({_id: 325}, {$set: {temp: {f: 212, c: 100} })
db.readings.update({_id: 326}, {$set: {temps: [97.6, 98.4, 99.1]}})
  

如果鍵已存在,其值會被覆蓋;否則會創建一個新的鍵。

$unset能刪除文檔中特定的鍵。下面展示如何刪除文檔中的temp鍵:

db.readings.update({_id: 324}, {$unset: {temp: 1}})
  

還可以在內嵌文檔和數組之上使用$unset。這兩種情況都要用點符號指定內部對象。如果集合中有兩個文檔:

{_id: 325, 'temp': {f: 212, c: 100}}
{_id: 326, temps: [97.6, 98.4, 99.1]}
  

我們可以用下面的語句刪除第一個文檔裡的華氏溫標讀數,以及第二個文檔中的第0個元素:

db.readings.update({_id: 325},
  {$unset: {'temp.f': 1}})

db.readings.update({_id: 236},
  {$pop: {temps: -1}})
  

$set也能使用訪問子文檔和數組元素的點符號。

  • $rename

如果要更改鍵名,請使用$rename

db.readings.update({_id: 324}, {$rename: {'temp': 'temperature'}})
  

還可以重命名子文檔:

db.readings.update({_id: 325}, {$rename: {'temp.f': 'temp.farenheit'}})
  

對數組使用$unset

請注意,在單個數組元素上使用$unset的結果可能與你設想的不一樣。其結果只是將元素的值設置為null,而非刪除整個元素。要徹底刪除某個數組元素,可以用$pull$pop操作符。

db.readings.update({_id: 325}, {$unset: {'temp.f': 1}})
db.readings.update({_id: 326}, {$unset: {'temp.0': 1}})
  

2. 數組更新操作符

數組在MongoDB文檔模型中的重要性是顯而易見的,因此MongoDB理所當然地提供了很多專門用於數組的更新操作符。

  • $push$pushAll

如果需要為數組追加一些值,可以考慮$push$pushAll,前者能向數組中添加一個值,而後者則支持添加一個值列表。例如,可以很方便地為鏟子添加新標籤:

db.products.update({slug: 'shovel'}, {$push: {'tags': 'tools'}})
  

如果需要在一個更新裡添加多個標籤,同樣不成問題:

db.products.update({slug: 'shovel'},
  {$pushAll: {'tags': ['tools', 'dirt', 'garden']}})
  

注意,可以往數組裡添加各種類型的值,不局限於標量(scalar)。上一節裡,向購物車的明細條目數組裡添加產品的代碼就是一個很好的例子。

  • $addToSet$each

$addToSet也能為數組追加值,不過它的做法更細緻:要添加的值如果不存在才執行添加操作。因此,如果鏟子已經有了tools標籤,那麼以下更新不會修改文檔:

db.products.update({slug: 'shovel'}, {$addToSet: {'tags': 'tools'}})
  

如果想在一個操作裡向數組添加多個唯一的值,必須結合$each操作符來使用$addToSet。下面是一個示例:

db.products.update({slug: 'shovel'},
{$addToSet: {'tags': {$each: ['tools', 'dirt', 'steel']}}})
  

僅當$each中的值不在tags裡時,才會進行添加。

  • $pop

要從數組中刪除元素,最簡單的方法就是使用$pop操作符。如果用$push向數組中追加了一個元素,那麼隨後的$pop會刪除最後添加的內容。雖然$pop常和$push一起出現,但也可以單獨使用。如果tags數組裡包含['tools', 'dirt', 'garden', 'steel']這四個值,那麼下面的$pop會刪除steel標籤:

db.products.update({slug: 'shovel'}, {$pop: {'tags': 1}})
  

$pop的語法和$unset類似,即{$pop: {'elementToRemove': 1}},不同的是$pop還能接受-1來刪除數組的第一個元素。下面展示如何從數組中刪除tools標籤:

db.products.update({slug: 'shovel'}, {$pop: {'tags': -1}})
  

可能有一個地方會讓你不太滿意,即無法返回$pop從數組中刪除的值。儘管它的名字叫$pop,但其結果和你所熟知的棧式操作不太一樣,請注意這一點。

  • $pull$pullAll

$pull的作用與$pop類似,但更高級一點。使用$pull時可以明確用值來指定要刪除哪個數組元素,而不是位置。再來看看標籤示例,如果要刪除dirt標籤,無需知道它在數組中的位置,只需告訴$pull操作符刪除它就可以了:

db.products.update({slug: 'shovel'}, {$pull {'tags': 'dirt'}})
  

$pullAll類似於$pushAll,允許提供一個要刪除值的列表。如果要刪除dirtgarden標籤,可以這樣使用$pushAll

db.products.update({slug: 'shovel'}, {$pullAll {'tags': ['dirt', 'garden']}})
  

3. 位置更新

在MongoDB中建模數據時通常會使用子文檔數組,但在位置操作符出現之前,要操作那些子文檔並非易事。位置操作符允許更新數組裡的子文檔,我們可以在查詢選擇器中用點符號指明要修改的子文檔。若沒有示例,理解起來比較麻煩,因此此處假設有一個訂單文檔,其中一部分內容是這樣的:

{ _id: new ObjectId("6a5b1476238d3b4dd5000048"),
  line_items: [
    { _id: ObjectId("4c4b1476238d3b4dd5003981"),
      sku: "9092",
      name: "Extra Large Wheel Barrow",
      quantity: 1,
      pricing: {
        retail: 5897,
        sale: 4897,
      }
    },

    { _id: ObjectId("4c4b1476238d3b4dd5003981"),
      sku: "10027",
      name: "Rubberized Work Glove, Black",
      quantity: 2,
      pricing: {
       retail: 1499,
        sale: 1299,
      }
    }
  ]
}
  

假設想設置第二個明細條目的數量,把SKU為10027那條的數量設置為5。問題是你不清楚這個特定的子文檔在line_items數組裡的位置,甚至都不知道它是否存在。只需一個簡單的查詢選擇器,以及一個使用了位置操作符的更新文檔,這些問題就都迎刃而解了:

query = {_id: ObjectId("4c4b1476238d3b4dd5003981"),
        'line_items.sku': "10027"}
update = {$set: {'line_items.$.quantity': 5}}

db.orders.update(query, update)
  

'line_items.$.quantity'字符串裡看到的$就是位置操作符。如果查詢選擇器匹配到了文檔,那麼有10027這個SKU的文檔的下標就會替換位置操作符,從而更新正確的文檔。

如果數據模型中包含子文檔,那麼你會發現在執行精細的文檔更新操作時,位置操作符實在太有用了。

6.4.3 findAndModify命令

本章已經出現了很多findAndModify命令的鮮活示例,就差羅列它的選項了。在以下選項中,只有query以及updateremove是必選的。1

1. updateremove二選一。——譯者注

  • query,文檔查詢選擇器,默認為{}

  • update,描述更新的文檔,默認為{}

  • remove,布爾值,為true時刪除對象並返回,默認為false

  • new,布爾值,為true時返回修改後的文檔,默認為false

  • sort,指定排序方向的文檔,因為findAndModify一次只修改一個文檔,sort選項能用來控制處理哪個文檔。例如,可以按照{created_at: -1}來排序,處理最近創建的匹配文檔。

  • fields,如果只需要返回字段的子集,可以通過該選項指定。當文檔很大時,這個選項很有用。能像在其他查詢裡一樣指定字段,請查看第5章中與字段相關的示例。

  • upsert,布爾值,為true時將findAndModify當做upsert對待。如果文檔不存在,則創建之。請注意,如果希望返回新創建的文檔,還需要指定{new: true}

6.4.4 刪除

得知刪除文檔的操作毫無挑戰之後,你一定非常寬慰。我們可以刪除整個集合,也可以向remove方法傳遞一個查詢選擇器,刪除集合的子集。刪除全部評論是很容易的:

db.reviews.remove({})
  

但更常見的做法是刪除特定用戶的評論:

db.reviews.remove({user_id: ObjectId('4c4b1476238d3b4dd5000001')})
  

所有對remove的調用都接受一個可選的查詢選擇器,用於指定要刪除的文檔。正如API所示,沒有其他要說明的內容了。也許你會對這些操作的並發性和原子性心存疑問,下一節裡我會對此做出解釋。

6.4.5 並發性、原子性與隔離性

理解MongoDB中如何保證並發性是很重要的。自MongoDB v2.0起,鎖策略非常粗放,靠一個全局讀寫鎖來控制整個mongod實例。2這也就意味著,任何時刻,數據庫只允許存在一個寫線程或多個讀線程(兩者不能並存)。實際情況比聽上去要好得多,因為這個鎖策略還有一些並發優化措施。其中之一是,數據庫持有一個內部映射,知道哪些文檔在內存裡。對於那些不在內存裡的文檔的讀寫,數據庫會讓步於其他操作,直到文檔被載入內存。

2. 本書翻譯過程後期,MongoDB推出了2.2版本,去掉了全局的寫鎖。——譯者注

第二個優化是寫鎖讓步。任何寫操作都可能耗時很久,所有其他的讀寫操作在此期間都會被阻塞。所有的插入、更新和刪除都要持有寫鎖。插入的耗時一般不長,但更新就不一樣了,比方說更新整個集合需要很久,涉及很多文檔的刪除操作也是如此。當前的解決方案是允許這些耗時很久的操作週期性地暫停,以便執行其他的讀和寫。在操作暫停時,它會自己停下來,釋放鎖,稍後再恢復。3

3. 當然,暫停和恢復通常發生在幾毫秒內,因此我們這裡不討論極端的中斷。

但在更新和刪除文檔時,這種暫停行為可能好壞摻半。很容易想到這種情況:希望在其他操作發生前更新或刪除所有文檔。在這些情況下,可以使用名為$atomic的特殊選項來避免暫停。簡單地在查詢選擇器中添加$atomic操作符即可:

db.reviews.remove({user_id: ObjectId('4c4b1476238d3b4dd5000001'),
{$atomic: true}})
  

對於多文檔更新,也可以做同樣的處理。這迫使整個多文檔更新在隔離的情況下執行完畢:

db.reviews.update({$atomic: true}, {$set: {rating: 0}}, false, true)
  

這個更新操作將所有評論的評分設為0。因為操作是隔離執行的,所以不會暫停,保證系統始終是一致的。4

4. 注意,如果使用了$atomic的操作中途失敗,並不會自動回滾。只有一半文檔被更新,而另一半還是保持原來的值。

6.4.6 更新性能說明

經驗表明,對更新是如何作用於磁盤上的文檔能有一個基本認識,有助於設計出性能更好的系統。你應該理解的第一件事是何種程度的更新能被稱為「原地」更新。理想情況下,在磁盤上,更新對一個BSON文檔的影響只是極小一部分,這樣的性能是最好的,但事實並非總是如此。我來解釋一下其中的緣由。

磁盤上的文檔更新本質上分三種。第一種,也是最高效的,只發生在單值修改並且整個BSON文檔的大小不改變的情況下。這通常會發生在$inc操作符上,因為$inc只會增加一個整數,該值在磁盤上的大小並不改變。如果這個整數是由int表示的,那麼它會佔用四個字節;長整數和雙精度浮點數會佔用八個字節。更改這些數字的值並不需要更多空間,因此磁盤上就只需重寫該文檔的一個值。

第二種更新會改變文檔的大小和結構。BSON文檔會表示為字節數組,文檔的頭四個字節總是存儲文檔的大小。因此,當在文檔上使用$push操作符時,不僅增加整個文檔的大小,還改變它的結構。這要求在磁盤上重寫整個文檔,這麼做的效率還不算太差,但還是應該注意一下。如果在一個更新中使用了多個更新操作符,那麼每個操作符都會重寫一次文檔。這也通常不算什麼大問題,尤其是寫操作發生在內存裡時。但如果文檔特別大,比如有4 MB左右,而你又在用$push向那些文檔裡添加值,那麼服務器端就可能要做很多事情了。5

5. 如果你打算執行很多更新操作,那麼保持較小的文檔是理所應當的事。

最後一種更新是重寫文檔的結果。如果文檔擴大了,無法放入之前分配的磁盤空間裡,那麼該文檔不僅要重寫,而且還必須移到新的空間裡。這種移動操作如果經常發生,代價還是很大的。為了降低此類開銷,MongoDB會根據每個集合的情況動態調整填充因子(padding factor)。也就是說,如果有一個集合會發生很多要重新分配空間的更新,則會增加其內部填充因子。填充因子乘上插入文檔的大小後就能得到要額外創建的空間。這能減少未來重新分配文檔的數量。

要查看指定集合的填充因子,可以運行stats命令:

db.tweets.stats
{
  "ns" : "twitter.tweets",
  "count" : 53641,
  "size" : 85794884,
  "avgObjSize" : 1599.4273783113663,
  "storageSize" : 100375552,
  "numExtents" : 12,
  "nindexes" : 3,
  "lastExtentSize" : 21368832,
  "paddingFactor" : 1.2,
  "flags" : 0,
  "totalIndexSize" : 7946240,
  "indexSizes" : {
  "_id_" : 2236416,
  "user.friends_count_1" : 1564672,
  "user.screen_name_1_user.created_at_-1" : 4145152
},
"ok" : 1 }
  

這一推文集合的填充因子是1.2,即插入100 B的文檔時,MongoDB會在磁盤上分配120 B。默認的填充因子是1,即不會分配額外空間。

現在,有個小小的忠告。此處討論到的注意事項適用於數據大小超過內存總數,或者寫負載極重的情況。因此如果正在為一個高流量網站構建分析系統,請適當參考本節的內容。