MongoDB中如何優雅地刪除大量資料
刪除大量資料,無論是在哪種資料庫中,都是一個普遍性的需求。
除了正常的業務需求,我們也需要透過這種方式來為資料庫“瘦身”。
為什麼要“瘦身”呢?
表的資料量到達一定量級後,資料量越大,表的查詢效能相對也會越差。
畢竟資料量越大,B+樹的層級會越高,需要的IO也會越多。
表的資料有冷熱之分,將很多無用或很少用到的資料儲存在資料庫中會消耗資料庫的資源。
譬如會佔用快取;會增加備份集的大小,進而影響備份的恢復時間等。
所以,對於那些無用的資料,我們會定期刪除。
對於那些很少用到的資料,則會定期歸檔。歸檔,一般是將資料寫入到歸檔例項或抽取到大資料元件中。歸檔完畢後,會將對應的資料從原例項中刪除。
一般來說,這種刪除操作涉及的資料量都比較大。
對於這類刪除操作,很多開發童鞋的實現就是一個簡單的DELETE操作。看上去,簡單明瞭,乾淨利落。
但是,這種方式,危害性卻極大。
以 MySQL 為例:
會造成大事務
大事務會導致主從延遲,而主從延遲又會影響資料庫的高可用切換。
回滾表空間會不斷膨脹
在MySQL 8.0之前,回滾表空間預設是放到系統表空間中,而系統表空間一旦”膨脹“,就不會收縮。
鎖定的記錄多
相對而言,更容易導致鎖等待。
即使是分散式資料庫,如TiDB,如果一次刪除了大量資料,這批資料在進行Compaction時有可能會觸發流控。
所以,對於線上的大規模刪除操作,建議分而治之。具體來說,就是批次刪除,每次只刪除一部分資料,分多次執行。
接下來,就如何刪除大量資料,我們看看MongoDB中的落地方案。
本文主要包括以下四部分內容。
MongoDB中刪除資料的三種方式。 三種方式的執行效率對比。 透過Write Concern規避主從延遲。 刪除過程中碰到的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是刪除條件
var delete_date = new Date("2021-01-01T00:00:00.000Z");
// 獲取程式開始時間
var start_time = new Date();
// 獲取滿足刪除條件的記錄數
rows = db.test_collection.find({"createtime": {$lt: delete_date}}).count()
print("total rows:", rows);
// 定義每批需要刪除的記錄數
var batch_num = 5000;
while (rows > 0) {
// rows也可理解為剩餘記錄數
// 如果剩餘記錄數小於batch_num,則將剩餘記錄數賦值給batch_num
// 為什麼要怎麼做,後面會提到。
if (rows < batch_num) {
batch_num = rows;
}
// 獲取滿足刪除條件的最小的5000個_id(ObjectID)
var cursor = 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"
})
});
}
// 獲取程式結束時間
var end_time = new Date();
// 兩者的差值,即為程式執行時長
print((end_time - start_time) / 1000);
2. deleteMany
例項思路同remove類似,只不過會將待刪除的_id放到一個陣列中,最後再透過deleteMany一次性刪除。
具體程式碼如下:
var delete_date = new Date("2021-01-01T00:00:00.000Z");
var start_time = new Date();
rows = db.test_collection.find({"createtime": {$lt: delete_date}}).count()
print("total rows:", rows);
var batch_num = 5000;
while (rows > 0) {
if (rows < batch_num) {
batch_num = rows;
}
var cursor = db.test_collection.find({"createtime": {$lt: delete_date}}, {"_id": 1}).sort({"_id": 1}).limit(batch_num);
rows = rows - batch_num;
var delete_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"})
}
var end_time = new Date();
print((end_time - start_time) / 1000);
3. bulkWrite
實現思路同deleteMany類似,也是將待刪除的_id放到一個陣列中,最後再呼叫bulkWrite批次刪除。
具體程式碼如下:
var delete_date = new Date("2021-01-01T00:00:00.000Z");
var start_time = new Date();
rows = db.test_collection.find({"createtime": {$lt: delete_date}}).count()
print("total rows:", rows);
var batch_num = 5000;
while (rows > 0) {
if (rows < batch_num) {
batch_num = rows;
}
var cursor = db.test_collection.find({"createtime": {$lt: delete_date}}, {"_id": 1}).sort({"_id": 1}).limit(batch_num);
rows = rows - batch_num;
var delete_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}}
)
}
var end_time = new Date();
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 |
結合表中的資料,可以看出,
執行最慢的是remove,執行最快的是bulkWrite,前者差不多是後者的 2.79 倍。 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,如果命令在執行的過程中,遇到了鎖等待或節點數達不到要求,會一直阻塞。
刪除過程中遇到的Bug
其實,最開始的刪除程式是下面這個版本。
var delete_date = new Date("2021-01-01T00:00:00.000Z");
var start_time = new Date();
var batch_num = 5000;
while (1 == 1) {
var cursor = 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"])
});
// 如果陣列的大小為0,則代表結果集為空,這個時候,可退出迴圈。
if (delete_ids.length == 0) {
break;
}
db.test_collection.deleteMany({
'_id': {"$in": delete_ids},
"createtime": {'$lt': delete_date}
}, {w: "majority"})
}
var end_time = new Date();
print((end_time - start_time) / 1000);
相對於後來的版本,這個版本的程式碼簡潔不少。
它沒有獲取需要刪除的記錄數,也沒有改變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
來自 “ ITPUB部落格 ” ,連結:http://blog.itpub.net/70024420/viewspace-2925495/,如需轉載,請註明出處,否則將追究法律責任。
相關文章
- MongoDB中優雅刪除大量資料的三種方式純尹MongoDB
- 如何優雅地刪除 Linux 中的垃圾檔案Linux
- MongoDB資料庫中更新與刪除資料MongoDB資料庫
- 如何優雅地生成測試資料
- MySQL如何優雅的刪除大表MySql
- mongodb刪除重複資料MongoDB
- 刪除會員的優雅方式,避免留存髒資料
- 如何優雅地求和?
- 如何優雅地改善程式中for迴圈
- 在Java中如何優雅地判空Java
- 如何優雅地使用 macOSMac
- 優雅地除錯線上程式碼除錯
- 如何在 Vue 中優雅地使用 CSS Modules?VueCSS
- Linux如何快速刪除大量碎小檔案?Linux
- 如何優雅地動態插入資料到UITableViewUIView
- 如何優雅地鏈式取值
- Git | 如何優♂雅地管理版本Git
- MongoDB 刪除文件MongoDB
- 如何優雅地實現多資料庫的發件箱模式資料庫模式
- Redis刪除特定字首key的優雅實現Redis
- Python優雅遍歷字典刪除元素的方法Python
- 如何優雅地取消Retrofit請求?
- 如何優雅地向公司提加薪
- Kotlin如何優雅地使用Scope FunctionsKotlinFunction
- Spring Boot中如何優雅地實現非同步呼叫?Spring Boot非同步
- PhpStrom 如何優雅的除錯 HyperfPHP除錯
- Dcat Admin 教程 - 如何優雅地更改表單值的資料型別?資料型別
- 如何優雅地校驗後端介面資料,不做前端背鍋俠後端前端
- 批量刪除大量小檔案
- 如何刪除Removable Drives資料夾?REM
- 如何優雅地恢復執行中的容器應用
- 如何優雅地定位外網問題?
- 如何優雅地處理前端異常?前端
- 如何更優雅地切換 Git 分支Git
- 如何優雅地列印一個Java物件?Java物件
- 如何優雅地停止 Spring Boot 應用?Spring Boot
- 如何優雅地記錄操作日誌
- 如何優雅地記錄操作日誌?