MongoDB シャードクラスタの書き込み経路シリーズの第6回(最終回)である。
この記事が答える問い: 読み取りを
secondaryPreferredで secondary に回しておけば、primary が止まっている間も検索は安全か? 結論から書くと安全ではない。シャードクラスタの secondary は、「どのデータがどの shard のものか」を記した所有権リスト(ルーティングメタデータ)を、自分の primary の書き込みを経由して受け取る。そのため primary が書き込みできない shard で再起動した secondary は、所有権リストを受け取れず、読み取りをStaleConfigで拒否する。
基準バージョン: MongoDB 4.2(ソースタグr4.2.12)。最新バージョンは確認していない。
シャードクラスタは、コレクションを chunk というシャードキーの区間単位に分けて複数の shard に置く。balancer は shard 間のデータ量をそろえるために chunk を移動する。移動の順序は「新しい shard にコピーする → 所有権を渡す → 古い shard から削除する」である。
最後の削除が終わるまで、古い shard にはすでに所有権が移った文書が残っている。このような文書を孤立(orphan)文書と呼ぶ。たとえば、store_inventory コレクションの文書 X を含む chunk が shardA から shardB へ移動したとしよう。時点ごとの状態は次のとおりである。
| 時点 | shardA | shardB | 所有者 |
|---|---|---|---|
| 移動前 | X | なし | shardA |
| コピー後、削除前 | X(孤立) | X | shardB |
| 削除後 | なし | X | shardB |
真ん中の時点ですべての shard に問い合わせる検索が来ると、両方の shard に X がある。各 shard が所有権を確認せずに返すと、応答に X が2回現れる。そのため shard は、応答する前に「この文書は自分の所有か」を確認し、孤立文書を除外する必要がある。
除外するには、ノードが所有権リストを持っている必要がある。所有権リストとは、「どの chunk がどの shard の所有か」を記した情報である。ノードは所有権リストとそのバージョンをメモリに置く。MongoDB の用語とは次のように対応する。
この記事では以降、所有権リストという名前だけを使う。
mongos は shard に読み取りを送るとき、自分が知っている shard version を付ける。受け取ったノードは、そのバージョンを自分のバージョンと照合する(collection_sharding_state.cpp:201-278)。3.6 のリリースノートは、primary だけでなく secondary も chunk のメタデータを保持し、secondary の読み取りが孤立文書を返さないようにすると記している。
"Starting in 3.6, all members of a shard replica set, not just the primary, maintain the metadata regarding chunk metadata."
検査の結果は3通りある。
| ノードの状態 | ノードの処理 |
|---|---|
| バージョンが mongos と同じ | 読み取り、孤立文書を除外して応答する |
| バージョンが異なる | 拒否する。古いほうが最新の所有権リストを受け取る |
| 所有権リストがない(UNKNOWN) | 拒否し、所有権リストを受け取りに行く |
拒否するときに使うエラーが StaleConfig である。コードは 13388 である(error_codes.err:319)。所有権リストがない状態をソースは UNKNOWN と呼び、こう定義している(collection_sharding_runtime.h:85-86)。
"all attempts to check for shard version match will fail with StaleConfig errors in order to trigger an update."
つまり StaleConfig は、「自分の所有権リストが古いかないので、新しく受け取ってからやり直そう」というシグナルである。普段は更新がすぐに終わり、mongos が再試行するので、アプリケーションはこのエラーを目にしない。
primary と secondary では、所有権リストを受け取る経路が違う。primary は config サーバーから直接受け取る。secondary は config サーバーに問い合わせず、自分の primary を経由する。4.2 のソースでの順序は次のとおりである(shard_server_catalog_cache_loader.cpp:268-290, 428-431, 585-591)。
_flushRoutingTableCacheUpdates コマンドを送る。ソースはこのコマンドを旧名 forceRoutingTableRefresh で送る。旧名は 3.6.0 互換用のエイリアスである(flush_routing_table_cache_updates_command.cpp:60-63)。制限時間は30秒(Seconds{30})である。config.cache.* コレクションに書き込む。waitUntilOpTimeForRead)。その書き込みがレプリケーションで届いたら、ローカルの config.cache.* を読んでメモリの所有権リストを埋める。secondary の読み取り1つが所有権リストを更新するには、primary の書き込み1つとそのレプリケーションが先に終わっている必要がある。手順2が書き込みだからである。
secondary が config サーバーに直接問い合わせない理由は、ソースのコメントにはない。この記事では、この構造を、レプリケーションされたデータと所有権リストの時点をそろえるための設計とみなす。secondary がまだレプリケーションで受け取っていない chunk の移動を所有権リストだけで先に知ると、文書と所有権リストが食い違う。同じレプリケーションの流れで両方を受け取れば、その食い違いは生じない。この解釈はソースから導いた推論である。
所有権リストはメモリにある。mongod を再起動すると、メモリの所有権リストは空の状態から始まる(metadata_manager.cpp:246-252)。ディスクの config.cache.* は残っているが、メモリの所有権リストは最初の更新のときに埋められる。そのため再起動したノードは、コレクションごとに最初の読み取りを StaleConfig で拒否し、所有権リストを更新する。
ここに、primary が書き込みできない状況を重ねてみよう。プロセスは生きているが書き込みが止まった primary は、このシリーズの第5回で扱った。その shard の secondary を3つの場合に分けて比べると、次のとおりである。
| secondary | メモリの所有権リスト | 読み取りの結果 |
|---|---|---|
| 再起動していない、すでに読んでいたコレクション | ある | 読み続ける |
| 再起動していない、初めて読むコレクション | ない | 更新に失敗して拒否 |
| 再起動した | すべてのコレクションでない | コレクションごとに最初の読み取りで更新に失敗して拒否 |
再起動した secondary は、更新するために primary にコマンドを送る。ところが primary は config.cache.* に書き込めない。secondary は30秒の制限に引っかかるか、レプリケーションを待っているうちに更新に失敗する。所有権リストが UNKNOWN のままなので、次の読み取りも同じ経路をたどる。正常な primary が立つまで、このノードはその shard の読み取りを1つも処理できない。
secondaryPreferred のような readPreference は、読み取りを送るノードを選ぶ規則である。そのノードが所有権リストをどこから受け取るかは決めない。読み取りを secondary に回しても、所有権リストの更新は依然として primary の書き込みに縛られている。
StaleConfig は mongod と mongos の間でやり取りされるエラーである。mongos はこのエラーを受け取ると、更新を待ってから再試行する。そのため、アプリケーションには StaleConfig がそのまま見えないことがある。mongos が再試行している間、アプリケーションのリクエストは応答を待つ。アプリケーション側では、接続タイムアウトやソケットタイムアウトとしてしか見えないことがある。
この状況で探すべきシグナルは次のとおりである。
forceRoutingTableRefresh または _flushRoutingTableCacheUpdates の失敗が残る。StaleConfig(13388)が繰り返し出る。this shard no longer contains chunks for <ns> になる(collection_sharding_state.cpp、ソースに基づく推論)。readConcern: "available" で読むと、ノードは shard version の検査を飛ばす。collection_sharding_state.cpp:210-212 の invariant がこの前提を確認している。所有権リストがなくても、ノードは読み取りに応答する。
代償は孤立文書である。3.6 のリリースノートは、secondary の所有権リストは孤立文書を防ぐ仕組みであり、"available" のときはその保護がないと記している。上の表の真ん中の時点なら、文書 X が2回現れうる。
// mongos에 연결한 셸 예시
db.store_inventory.find({ "_id.productId": 1001 })
.readPref("secondaryPreferred")
.readConcern("available")
使うかどうかは、検索結果がどれだけ正確である必要があるかで決める。重複やすでに移動した文書が混ざってもよい画面(例: おおまかな在庫表示)なら選べる。決済・精算のように件数が合っている必要がある検索には使わない。
readConcern: "available" は、孤立文書を受け入れられる検索にだけ使う。落とし穴: secondary の再起動は、よくある「とりあえず生き返らせてみる」措置である。この状況では、読めていた secondary を読めない secondary に変えてしまう。再起動するとメモリの所有権リストが空になる。その所有権リストを埋めるには、止まった primary の書き込みが必要である。
実行で検証していない手順である。下の手順と結果は、ソースを根拠にした予想である。
準備するものは MongoDB 4.2 のバイナリである。運用者がローカルに config サーバーの replica set 1つ、shard 2つ(それぞれ3ノードの replica set)、mongos 1つを立ち上げる。
store_inventory コレクションを _id の hashed シャードキーでシャーディングし、文書を入れる。sh.enableSharding("demo")
sh.shardCollection("demo.store_inventory", { _id: "hashed" })
for (let p = 1; p <= 100; p++)
for (let s = 1; s <= 20; s++)
db.getSiblingDB("demo").store_inventory.insertOne({ _id: { productId: p, storeId: s }, qty: 10 })
{ node: "a1" }、{ node: "a2" })。同じ reconfig で、2台の secondary の priority を0に変える。primary を止めたときに選挙が起きないようにするための設定である。find(...).explain() の shard 名で確認する。db.store_inventory.find({ _id: { productId: 1, storeId: 1 } })
.readPref("secondary", [{ node: "a1" }]).maxTimeMS(60000)
kill -STOP <pid> を送る。kill -CONT <pid> で primary を起こし、a1 への検索をもう一度送る。予想される結果は次のとおりである。
| 手順 | a1(再起動した) | a2(再起動していない) |
|---|---|---|
| 手順7 | 応答が遅れた末に失敗する。a1 のログに更新失敗、mongos のログに StaleConfig が残る |
すぐに応答する |
| 手順8 | 応答する | 応答する |
この再現の限界:
SIGSTOPはプロセス全体を止める。heartbeat の応答も止まるので、replica set は primary を「連絡が取れない」とみなす。一方、エンジンが livelock に陥った primary は、heartbeat に応答しながら書き込みだけができない(第5回)。priority 0 で選挙は防いだが、a1 が primary を探す段階から実際とは異なってくる。そのため手順7のエラーが、30秒の制限ではなく「primary が見つからない」系統で出ることがある。再起動したノードだけが阻まれるという違いを見る用途に使う。
- この状況で mongos がアプリケーションに返すエラーが
StaleConfigなのかタイムアウト系なのかは、確認できていない。「アプリケーションにはタイムアウトとして見えることがある」は一般論である。- secondary が config サーバーに直接問い合わせない理由はソースに書かれていない。上の説明は推論である。
- シャーディングしていないコレクションが、再起動直後に同じ経路をたどるかは別途確認していない。
- 4.2 以降のバージョンで secondary の更新経路が変わったかは確認していない。
- 「自分で再現してみる」は実行していない。