1. 程式人生 > 其它 >MongoDB中如何優雅地刪除大量資料

MongoDB中如何優雅地刪除大量資料

刪除大量資料,無論是在哪種資料庫中,都是一個普遍性的需求。除了正常的業務需求,我們需要通過這種方式來為資料庫“瘦身”。

為什麼要“瘦身”呢?

  1. 表的資料量到達一定量級後,資料量越大,表的查詢效能會越差。

    畢竟資料量越大,B+樹的層級會越高,需要的IO也會越多。

  2. 表的資料有冷熱之分,將很多無用或很少用到的資料儲存在資料庫中會消耗資料庫的資源。

    譬如會佔用快取;會增加備份集的大小,進而影響備份的恢復時間等。

所以,對於那些無用的資料,我們會定期刪除。

對於那些很少用到的資料,則會定期歸檔。歸檔,一般是將資料寫入到歸檔例項或抽取到大資料元件中。歸檔完畢後,會將對應的資料從原例項中刪除。

一般來說,這種刪除操作涉及的資料量都比較大。

對於這類刪除操作,很多開發童鞋的實現就是一個簡單的DELETE操作。看上去,簡單明瞭,乾淨利落。

但是,這種方式,危害性卻極大。

以 MySQL 為例:

  • 會造成大事務

    大事務會導致主從延遲,而主從延遲又會影響資料庫的高可用切換。

  • 回滾表空間會不斷膨脹

    在MySQL 8.0之前,回滾表空間預設是放到系統表空間中,而系統表空間一旦”膨脹“,就不會收縮。

  • 鎖定的記錄多

    相對而言,更容易導致鎖等待。

即使是分散式資料庫,如TiDB,如果一次刪除了大量資料,這批資料在進行Compaction時有可能會觸發流控。

所以,對於線上的大規模刪除操作,建議分而治之。具體來說,就是批量刪除,每次只刪除一部分資料,分多次執行。

就如何刪除大量資料,接下來我們看看MongoDB中的落地方案。

本文主要包括以下四部分內容。

  1. MongoDB中刪除資料的三種方式。
  2. 三種方式的執行效率對比。
  3. 通過Write Concern規避主從延遲。
  4. 刪除過程中碰到的Bug。

MongoDB中刪除資料的三種方式

在MongoDB中刪除資料,可通過以下三種方式:

  • db.collection.remove()

    刪除單個文件或滿足條件的所有文件。

  • db.collection.deleteMany()

    刪除滿足條件的所有文件。

  • db.collection.bulkWrite()

    批量操作介面,可執行批量插入、更新、刪除操作。

接下來,對比下這三種方式的執行效率。

三種方式的執行效率對比

環境:MongoDB 3.4.4,副本集。

測試思路:分別使用 remove、deleteMany、bulkWrite 刪除 10w 條記錄(每批刪除 5000 條),交叉執行 5 次。

1. remove

//delete_date是刪除條件
vardelete_date=newDate("2021-01-01T00:00:00.000Z");
//獲取程式開始時間
varstart_time=newDate();
//獲取滿足刪除條件的記錄數
rows=db.test_collection.find({"createtime":{$lt:delete_date}}).count()
print("totalrows:",rows);
//定義每批需要刪除的記錄數
varbatch_num=5000;
while(rows>0){
//rows也可理解為剩餘記錄數
//如果剩餘記錄數小於batch_num,則將剩餘記錄數賦值給batch_num
//為什麼要怎麼做,後面會提到。
if(rows<batch_num){
batch_num=rows;
}
//獲取滿足刪除條件的最小的5000個_id(ObjectID)
varcursor=db.test_collection.find({"createtime":{$lt:delete_date}},{"_id":1}).sort({"_id":1}).limit(batch_num);
rows=rows-batch_num;
cursor.forEach(function(each_row){
//通過remove刪除記錄,這裡指定了"justOne":true,每次只能刪除一條記錄。
//為了避免誤刪除,這裡同時指定了主鍵和刪除條件。
db.test_collection.remove({'_id':each_row["_id"],"createtime":{'$lt':delete_date}},{
"justOne":true,
w:"majority"
})
});
}
//獲取程式結束時間
varend_time=newDate();
//兩者的差值,即為程式執行時長
print((end_time-start_time)/1000);

2. deleteMany

例項思路同remove類似,只不過會將待刪除的_id放到一個數組中,最後再通過deleteMany一次性刪除。

具體程式碼如下:

vardelete_date=newDate("2021-01-01T00:00:00.000Z");
varstart_time=newDate();
rows=db.test_collection.find({"createtime":{$lt:delete_date}}).count()
print("totalrows:",rows);
varbatch_num=5000;
while(rows>0){
if(rows<batch_num){
batch_num=rows;
}
varcursor=db.test_collection.find({"createtime":{$lt:delete_date}},{"_id":1}).sort({"_id":1}).limit(batch_num);
rows=rows-batch_num;
vardelete_ids=[];
//將滿足條件的主鍵值放入到陣列中。
cursor.forEach(function(each_row){
delete_ids.push(each_row["_id"]);
});
//通過deleteMany一次刪除5000條記錄。
db.test_collection.deleteMany({
'_id':{"$in":delete_ids},
"createTime":{'$lt':delete_date}
},{w:"majority"})
}
varend_time=newDate();
print((end_time-start_time)/1000);

3. bulkWrite

實現思路同deleteMany類似,也是將待刪除的_id放到一個數組中,最後再呼叫bulkWrite進行刪除。

具體程式碼如下:

vardelete_date=newDate("2021-01-01T00:00:00.000Z");
varstart_time=newDate();
rows=db.test_collection.find({"createtime":{$lt:delete_date}}).count()
print("totalrows:",rows);
varbatch_num=5000;
while(rows>0){
if(rows<batch_num){
batch_num=rows;
}
varcursor=db.test_collection.find({"createtime":{$lt:delete_date}},{"_id":1}).sort({"_id":1}).limit(batch_num);
rows=rows-batch_num;
vardelete_ids=[];
cursor.forEach(function(each_row){
delete_ids.push(each_row["_id"]);
});
db.test_collection.bulkWrite(
[
{
deleteMany:{
"filter":{
'_id':{"$in":delete_ids},
"createTime":{'$lt':delete_date}
}
}
}
],
{ordered:false},
{writeConcern:{w:"majority",wtimeout:100}}
)
}
varend_time=newDate();
print((end_time-start_time)/1000);

接下來,看看三者的執行效率。

刪除方式平均執行時間(s)第一次第二次第三次第四次第五次
remove 47.341 49.606 48.487 49.314 47.572 41.727
deleteMany 16.951 16.566 18.669 17.932 18.66 12.928
bulkWrite 16.476 17.247 14.181 16.151 18.403 16.397

結合表中的資料,可以看出,

  1. 執行最慢的是remove,執行最快的是bulkWrite,前者差不多是後者的 2.79 倍。
  2. deleteMany 和 bulkWrite 的執行效率差不多,但就語法而言,前者比後者簡潔。

所以線上如果要刪除大量資料,推薦使用 deleteMany + ObjectID 進行批量刪除。

通過 Write Concern 規避主從延遲

雖然是批量刪除,但在MySQL中,如果沒控制好節奏,還是很容易導致主從延遲。在MongoDB中,其實也有類似的擔憂,不過我們可以通過 Write Concern 進行規避。

Write Concern,可理解為寫安全策略,簡單來說,它定義了一個寫操作,需要在幾個節點上應用(Apply)完,才會給客戶端反饋。

看下面這個原理圖。

圖中是一個一主兩從的副本集,設定了w: "majority",代表一個寫操作,需要等待副本集中絕大多數節點(本例中是兩個)應用完,才能給客戶端反饋。

在前面的程式碼中,無論是remove,deleteMany還是bulkWrite方法,都設定了w: "majority"。

之所以這樣設定,一方面是為了保證資料的安全性,畢竟刪除操作能在多個節點落盤,另一方面,還能有效降低批量操作可能導致的主從延遲風險。

Write Concern的完整語法如下,

{w:<value>,j:<boolean>,wtimeout:<number>}

其中,

w:指定節點數或tags。其有如下取值:

  • <number>:顯式指定節點數量。

    設定為0,無需Server端反饋。

    設定為1,只需Primary節點反饋。

    設定為2,在副本集中,需要一個Primary節點(Primary節點必需)和一個Secondary節點反饋。

    需要注意的是,這裡的Secondary節點必須是資料節點,可以是隱藏節點、延遲節點或Priority為 0 的節點,但仲裁節點(Arbiter)絕對不行。

    一般來說,設定的節點數越多,資料越安全,寫入的效率也會越低。

  • majority:副本集大多數節點。

    與上面不一樣的是,這裡的Secondary節點不僅要求是資料節點,它的votes(members[n].votes)還必須大於0。

  • <custom write concern name>:指定tags。

    tag,顧名思義,是給節點打標籤。常用於多資料中心部署場景。

    如一個叢集,有5個節點,跨機房部署。其中3個節點在A機房,另外2個節點在B機房,因為對資料的安全性、一致性要求很高,我們希望寫操作至少能在A機房的2個節點落盤,B機房的1個節點落盤。

    對於這種個性化的需求,只有通過tags才能實現。

    具體使用,可參考:https://docs.mongodb.com/manual/tutorial/configure-replica-set-tag-sets/#configure-custom-write-concern

j:是否需要等待對應操作的日誌持久化到磁碟中。

在MongoDB中,一個寫操作會涉及到三個動作:更新資料,更新索引,寫入oplog,這三個動作要麼全部成功,要麼全部失敗,這也是MongoDB單行事務的由來。

對於每個寫操作,WiredTiger都會記錄一條日誌到 journal 中。

日誌在寫入journal之前,會首先寫入到 journal buffer(最大128KB)中。

Journal buffer會在以下場景持久化到 journal 檔案中:

  • 副本集中,當有操作等待oplog時。

    這類操作包括:針對oplog最新位置點的掃描查詢;Causally consistent session中的讀操作;對於Secondary節點,每次批量應用oplog後。

  • Write Concern 設定了 j: true。

  • 每100ms。

    由 storage.journal.commitIntervalMs 引數指定。

  • 建立新的 journal 檔案時。

    當 journal 檔案的大小達到100MB時會自動建立一個新的journal 檔案。

wtimeout:超時時長,單位ms。

不設定或設定為0,命令在執行的過程中,如果遇到了鎖等待或節點數不滿足要求,會一直阻塞。

如果設定了時間,命令在這個時間內沒有執行成功,則會超時報錯,具體報錯資訊如下:

rs:PRIMARY>db.test.insert({"a":1},{writeConcern:{w:"majority",wtimeout:100}})
WriteResult({
"nInserted":1,
"writeConcernError":{
"code":64,
"codeName":"WriteConcernFailed",
"errInfo":{
"wtimeout":true
},
"errmsg":"waitingforreplicationtimedout"
}
})

刪除過程中遇到的Bug

其實,最開始的刪除程式是下面這個版本。

vardelete_date=newDate("2021-01-01T00:00:00.000Z");
varstart_time=newDate();
varbatch_num=5000;
while(1==1){
varcursor=db.test_collection.find({"createtime":{$lt:delete_date}},{"_id":1}).sort({"_id":1}).limit(batch_num);
delete_ids=[]
cursor.forEach(function(each_row){
delete_ids.push(each_row["_id"])
});

if(delete_ids.length==0){
break;
}
db.test_collection.deleteMany({
'_id':{"$in":delete_ids},
"createtime":{'$lt':delete_date}
},{w:"majority"})
}
varend_time=newDate();
print((end_time-start_time)/1000);

相對於效率對比章節的版本,這個版本的程式碼簡潔不少。

  1. 不用額外獲取需要刪除的記錄數。
  2. batch_num在整個執行過程中也是不變的。

但用這個版本在線上刪除資料時,發現了一個問題。

在刪除到最後一批時,程式會hang在那裡。重試了多次依然如此。分析如下:

  • 最後一批的文件數小於batch_num時,會出現這個問題。

    刪除同例項下另外一個集合,也出現了類似的問題。

    但在測試環境,刪除一個簡單的集合卻沒有復現出來,懷疑這個Bug與線上集合的記錄過長有關。

  • cursor只是一個迭代物件,並不是查詢結果。基於cursor可以分批返回記錄,類似於Python中的迭代器。

    最後一批也不是完全沒有返回,而是在返回100條之後才hang在那裡。

  • 不使用sort沒有這個問題。

    為什麼要使用sort呢?這樣可保證得到的id是有序且在物理上的儲存是相鄰的。這樣,在執行批量刪除操作時,效率也會相對較高。

    經過實際測試,當要刪除的資料量較大時,使用sort的效率確實比不使用的要高。

    如果刪除的資料量較小,使不使用sort則沒多大區別。

總結

從最佳實踐的角度出發,無論是在哪種資料庫中,如果都刪除(更新)大量資料,都建議分而治之,分批執行。

在MongoDB中,如果要刪除大量資料,推薦使用deleteMany + ObjectID進行批量刪除。

為了保證操作的安全性及規避批量操作帶來的主從延遲風險,建議在執行刪除操作時,將Write Concern設定為w: "majority"。

參考

[1] Journaling

[2] Write Concern