刪除大量數據,無論是在哪種數據庫中,都是一個普遍性的需求。除了正常的業務需求,我們需要通過這種方式來為數據庫“瘦身”。
為什么要“瘦身”呢?
1、表的數據量到達一定量級后,數據量越大,表的查詢性能會越差。
畢竟數據量越大,b+樹的層級會越高,需要的io也會越多。
2、表的數據有冷熱之分,將很多無用或很少用到的數據存儲在數據庫中會消耗數據庫的資源。
譬如會占用緩存;會增加備份集的大小,進而影響備份的恢復時間等。
所以,對于那些無用的數據,我們會定期刪除。
對于那些很少用到的數據,則會定期歸檔。歸檔,一般是將數據寫入到歸檔實例或抽取到大數據組件中。歸檔完畢后,會將對應的數據從原實例中刪除。
一般來說,這種刪除操作涉及的數據量都比較大。
對于這類刪除操作,很多開發童鞋的實現就是一個簡單的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
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
|
// 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一次性刪除。
具體代碼如下:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
|
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進行刪除。
具體代碼如下:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
|
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的完整語法如下,
1
|
{ 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才能實現。
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,命令在執行的過程中,如果遇到了鎖等待或節點數不滿足要求,會一直阻塞。
如果設置了時間,命令在這個時間內沒有執行成功,則會超時報錯,具體報錯信息如下:
1
2
3
4
5
6
7
8
9
10
11
12
|
rs: primary > db.test. insert ({ "a" : 1}, {writeconcern: {w: "majority" , wtimeout: 100}}) writeresult({ "ninserted" : 1, "writeconcernerror" : { "code" : 64, "codename" : "writeconcernfailed" , "errinfo" : { "wtimeout" : true }, "errmsg" : "waiting for replication timed out" } }) |
刪除過程中遇到的bug
其實,最開始的刪除程序是下面這個版本。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
|
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" ]) }); 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"。
到此這篇關于mongodb中優雅刪除大量數據的文章就介紹到這了,更多相關mongodb刪除大量數據內容請搜索服務器之家以前的文章或繼續瀏覽下面的相關文章希望大家以后多多支持服務器之家!
參考
[1] journaling
[2] write concern
原文鏈接:https://www.cnblogs.com/ivictor/p/15457454.html