MongoDBの書き込み保証(Write Concern)と読み込み保証(Read Concern)についてまとめます。レプリケーションが設定されたMongoDBの環境において、1台への書き込みが完了した時点で応答を返せば、応答が早くなる反面データロスが発生しうるトレードオフがあります。他のRDBMSと同様に、MongoDBも何台の書き込み/読み込みが完了した時点で応答を返すかのチューニングが可能です。
前提
公式ドキュメント
参考とする公式ドキュメントを以下に示します。
動作確認済環境
- Rocky Linux 8.6
- MongoDB Server 6.0.2
構成図
「MongoDB シャーディング設定」と同等の環境を用いて動作確認をします。
初期設定
シャーディングの設定が完了している事を前提とします。設定方法は「MongoDB シャーディング設定」を参照ください。
Write Concern(書き込み保証)
NoSQLに限らずRDBMSでも同様ですが、多くの製品のデフォルト設定ではメモリにデータの書き込みが終わった瞬間にアプリケーションサーバへ正常応答を返します。もし、ディスクへの書き込みが終わる前に、サーバが破損してしまえばデータは消失してしまいます。もし、データを消失したくないならば、Secondaryへの転送が終わってから、正常応答を返して欲しいと思う事もあるかもしれません。
多くのRDBMSと同様に、MongoDBでも、どこまでの書き込みが完了したら正常応答を返すかの指定ができます。
insert文やremove文のような更新系の処理は、以下のようにwriteConcernというパラメタを付与する事ができます。以下は、Secondaryへのメモリへの書き込みが完了した事を確認してから正常応答を返す操作例です。
db.employee.insertOne( {name:"scott01",password:"tiger01"}, {writeConcern: {w:2, j:false, wtimeout: 100} } )
writeConcernにはw, j, wtimeoutのパラメタを指定できます。それぞれの意味を以下にまとめます。
パラメタ | デフォルト値 | 意味 |
---|---|---|
w | 0 | 何台以上のサーバに書き込みが完了した時点で正常応答を返すかの指定。例えば、2を指定したならば、1台のPrimaryと1台のSecondaryへの書き込みが完了した時点で正常応答を返す。数字または”majority(過半数)”を指定できる。 |
j | false | ジャーナルファイルに書き込んだ時点で正常応答を返すか否か。falseの場合はメモリ書き込み時点で正常応答を返し、trueの場合はジャーナルファイルへの書き込みが完了した時点で正常応答を返す。 |
wtimeout | なし | タイムアウト時間をミリ秒単位で指定します。 |
それでは実際に動作確認をしてみましょう。シャーディングの環境において、どのreplica setをprimary shardを使うかは不定です。動作確認しやすいように、testデータベースはrs2をprimary shardとして使用するように指定します。
sh.enableSharding('test','rs2')
それではやや極端な例ですが、”w:3″のように3台への書き込みが完了した時点で正常応答を返すinsertOneを実行してみます。
“w:3″というのはかなり極端な設定です。多くの場合は二重障害は考慮対象外にするため”w:2″または”w:1″とするケースの方が多数派です。
db.inventory.insertOne( { item: "journal", qty: 25, size: { h: 14, w: 21, uom: "cm" }, status: "A" }, {writeConcern: {w:3, j:false, wtimeout: 100} } )
以下のようにInsertOneが正常終了します。
[direct: mongos] test> db.inventory.insertOne( ... { item: "journal", qty: 25, size: { h: 14, w: 21, uom: "cm" }, status: "A" }, ... {writeConcern: ... {w:3, j:false, wtimeout: 100} ... } ... ) { acknowledged: true, insertedId: ObjectId("6365fa71c801301d7efb1263") } [direct: mongos] test>
それではrs2のうち1台に障害を発生させてみましょう。これでrs2は3台体制から2台体制に変わります。
systemctl stop mongod.service
“w:3″のように3台への書き込みが完了した時点で正常応答を返すinsertOneを実行してみます。
db.inventory.insertOne( { item: "notebook", qty: 50, size: { h: 8.5, w: 11, uom: "in" }, status: "A" }, {writeConcern: {w:3, j:false, wtimeout: 100} } )
3台のMongoDBサーバへ書き込みできないので、以下のようにMongoWriteConcernErrorが返されます。
[direct: mongos] test> db.inventory.insertOne( ... { item: "notebook", qty: 50, size: { h: 8.5, w: 11, uom: "in" }, status: "A" }, ... {writeConcern: ... {w:3, j:false, wtimeout: 100} ... } ... ) Uncaught: MongoWriteConcernError: waiting for replication timed out; Error details: { wtimeout: true, writeConcern: { w: 3, j: false, wtimeout: 100, provenance: "clientSupplied" } } at rs2 Additional information: {} Result: { n: 1, writeConcernError: { code: 64, codeName: 'WriteConcernFailed', errmsg: 'waiting for replication timed out; Error details: { wtimeout: true, writeConcern: { w: 3, j: false, wtimeout: 100, provenance: "clientSupplied" } } at rs2', errInfo: {} }, ok: 1, '$clusterTime': { clusterTime: Timestamp({ t: 1667627925, i: 1 }), signature: { hash: Binary(Buffer.from("0000000000000000000000000000000000000000", "hex"), 0), keyId: Long("0") } }, operationTime: Timestamp({ t: 1667627925, i: 1 }) } [direct: mongos] test>
次の検証シナリオに備え、一時的に停止したmongodを起動します。
systemctl start mongod.service
Read Preference
デフォルト設定の観察
Write Concern(書き込み保証)と同様に、MongoDBはRead Concern(読み取り保証)の設定が可能です。Read Concern(読み取り保証)を理解するには、Read Preferenceの理解が前提になりますので、まずはRead Preferenceについて説明します。
他のRDBMSと同様に、読み取り系のクエリをsecondaryデータベースで実施する事によって、disk I/Oの負荷分散を実現できます。それではデフォルトの挙動を観察していみしょう。
以下のように実行計画を観察すると、どのデータベースからの読み取りを実施しているかを確認します。以下、実行計画を見ると、デフォルトの状態ではPrimary(linux020.gokatie.go)からの読み取りを実施しており、負荷分散が実現されていない事が分かります。
[direct: mongos] test> db.inventory.find().explain().queryPlanner { mongosPlannerVersion: 1, winningPlan: { stage: 'SINGLE_SHARD', shards: [ { shardName: 'rs2', connectionString: 'rs2/172.16.1.20:27017,172.16.1.21:27017,172.16.1.22:27017', serverInfo: { host: 'linux020.gokatei.go', port: 27017, version: '6.0.2', gitVersion: '94fb7dfc8b974f1f5343e7ea394d0d9deedba50e' }, namespace: 'test.inventory', indexFilterSet: false, parsedQuery: {}, queryHash: '17830885', planCacheKey: '17830885', maxIndexedOrSolutionsReached: false, maxIndexedAndSolutionsReached: false, maxScansToExplodeReached: false, winningPlan: { stage: 'COLLSCAN', direction: 'forward' }, rejectedPlans: [] } ] } }
クエリ単位の変更
find()の後にreadPref()を指定する事で、どのデータベースから読み取りを実施するかを明示指定できます。実行計画を見ると、確かにSecondary(linux021.gokatie.go)からの読み取りを実施している事が分かります。
[direct: mongos] test> db.inventory.find().readPref('secondary').explain().queryPlanner { mongosPlannerVersion: 1, winningPlan: { stage: 'SINGLE_SHARD', shards: [ { shardName: 'rs2', connectionString: 'rs2/172.16.1.20:27017,172.16.1.21:27017,172.16.1.22:27017', serverInfo: { host: 'linux021.gokatei.go', port: 27017, version: '6.0.2', gitVersion: '94fb7dfc8b974f1f5343e7ea394d0d9deedba50e' }, namespace: 'test.inventory', indexFilterSet: false, parsedQuery: {}, queryHash: '17830885', planCacheKey: '17830885', maxIndexedOrSolutionsReached: false, maxIndexedAndSolutionsReached: false, maxScansToExplodeReached: false, winningPlan: { stage: 'COLLSCAN', direction: 'forward' }, rejectedPlans: [] } ] } }
セッション単位の変更
セッション単位で、どのデータベースからの読み取りを実施するかを変更する事ができます。以下に示すようにデフォルトではprimaryからの読み取りをします。
[direct: mongos] test> db.getMongo().getReadPref() ReadPreference { mode: 'primary', tags: undefined, hedge: undefined, maxStalenessSeconds: undefined, minWireVersion: undefined } [direct: mongos] test>
setReadPref()を使用すると、セッション単位でどのデータベースからの読み取りを実施するかを変更する事ができます。
この設定はセッションを終了するとprimaryに戻る事に注意ください。セッション確立の都度で必要な操作です。
[direct: mongos] test> db.getMongo().setReadPref('secondary') [direct: mongos] test> db.getMongo().getReadPref() ReadPreference { mode: 'secondary', tags: undefined, hedge: undefined, maxStalenessSeconds: undefined, minWireVersion: undefined } [direct: mongos] test>
実行計画を見ると、確かにSecondary(linux022.gokatie.go)からの読み取りを実施している事が分かります。
[direct: mongos] test> db.inventory.find().explain().queryPlanner { mongosPlannerVersion: 1, winningPlan: { stage: 'SINGLE_SHARD', shards: [ { shardName: 'rs2', connectionString: 'rs2/172.16.1.20:27017,172.16.1.21:27017,172.16.1.22:27017', serverInfo: { host: 'linux022.gokatei.go', port: 27017, version: '6.0.2', gitVersion: '94fb7dfc8b974f1f5343e7ea394d0d9deedba50e' }, namespace: 'test.inventory', indexFilterSet: false, parsedQuery: {}, queryHash: '17830885', planCacheKey: '17830885', maxIndexedOrSolutionsReached: false, maxIndexedAndSolutionsReached: false, maxScansToExplodeReached: false, winningPlan: { stage: 'COLLSCAN', direction: 'forward' }, rejectedPlans: [] } ] } }
Read Concern(読み取り保証)
Primaryへの書き込みが完了しているのにSecondaryへの書き込みが完了していない状態で、Secondaryから読み取りを実施してしまうと一貫性がない状態になってしまいます。これを防ぐ設定がRead Concern(読み取り保証)です。
使用例は以下の通りです。find()メソッドの後にreadConcern()メソッドを指定します。
db.inventory.find().readConcern("majority")
readConernには以下の値を指定できます。
設定値 | 意味 |
---|---|
local | ローカルの値を読み取る(読み取り保証なし) |
majority | 過半数のサーバに書き込まれた値を読み取る |
linearizable | 線形化可能性(linearizable)が保証された状態で読み取りを実行する |
linearizableは順序が保証された処理をする挙動で、majorityよりも厳密な一貫性を提供します(一貫性と応答速度はトレードオフである事に注意ください)。線形化可能性(linearizable)とは、数学の一般用語であるため、本サイトでは説明を省略します。linearizableの詳細は「Read Concern “linearizable”」などを参照ください。
補足
DefaultRWConcern
Write ConcernとRead Concernは以下のような操作でデフォルトの挙動を変更する事もできます。
db.adminCommand({ "setDefaultRWConcern" : 1, "defaultWriteConcern" : { "w" : 3, "j" : false, "wtimeout" : 100 } })