MongoDB シャードクラスタの書き込み経路シリーズの第2/6回である。
この記事が答える問い: ソケットタイムアウトになった書き込みはサーバーで止まるのか? ドライバーの retryWrites とアプリケーションの再試行が両方有効なら、1つの書き込みはサーバーに何回届くのか? サーバーでは何個が同時に動くのか? maxTimeMS はこの重なりをどこまで防ぐのか?
基準バージョン: MongoDB 4.2(ソースr4.2.12タグ、4.2 公式ドキュメント)、MongoDB Java ドライバーr5.4.0、retryable writes・driver sessions 仕様(2026-11-05 に取得した版)。最新のサーバーバージョンの動作は確認していない。
書き込みの再試行は2つの層で別々に起きる。ドライバーはネットワークエラーが起きると、retryable writes の規則に従って同じ書き込みをもう一度送る。アプリケーション(例: Spring Kafka コンシューマー)は、ドライバーが最終的に投げた例外を受け取り、書き込みを最初からもう一度実行する。2つの回数は掛け算になる。
socketTimeoutMS は、クライアントが応答を待つのをあきらめるまでの時間である。サーバーはこのタイムアウトを知らない。MongoDB 4.2 では、クライアントの接続が切れても insert・update・delete・findAndModify はサーバーで動き続ける。
ドライバーの再送は、元のリクエストと同じセッション id(lsid)とトランザクション番号(txnNumber)を付ける。サーバーは2つを同じ書き込みとみなし、1回だけ実行する。アプリケーションの再試行は、新しいセッションか新しい番号を付けた新しい書き込みである。同じ書き込みがサーバーのエンジンで別々に実行されるものを、この記事ではコピーと呼ぶ。サーバーへの送信は最大でアプリケーション実行回数の2倍になる。重なって動くコピーは最大でアプリケーション実行回数分になる。
どちらの設定も時間制限だが、時計を持つ側が違う。
| 項目 | socketTimeoutMS |
maxTimeMS |
|---|---|---|
| 時計を持つ側 | クライアント(ドライバー) | サーバー |
| 時間切れになると | ドライバーがソケットの読み取りをあきらめて例外を投げる | サーバーが次の中断チェック位置で操作を止める |
| サーバーの操作は | 知らない。動き続ける | MaxTimeMSExpired エラーで終わる |
| 設定場所 | 接続文字列、MongoClientSettings |
コマンドごとに付ける引数 |
4.2 のドキュメント "Terminate Running Operations" は maxTimeMS をこう説明している。
When the operation reaches the specified time limit, MongoDB interrupts the operation at the next interrupt point.
interrupt point(中断チェック位置)は、操作が「自分を止めろという印があるか」を自分で確認するコード上の位置である。操作はこの位置に来て初めて止まる。このような方式を協調型の中断と呼ぶ。第4/6回で詳しく扱う。
サーバーはコマンドの種類に関係なく一般引数 maxTimeMS を読み、操作に締め切り時刻を設定する(src/mongo/db/service_entry_point_common.cpp:817-830 (r4.2.12))。そのため insert コマンドに maxTimeMS を付けても締め切りがかかる。
MongoDB 4.2 から、一部のコマンドはクライアントが切断されると終了の印(killOp と同じ印)を受け取る。4.2 リリースノート "Client Disconnection" 節のリストには10個のコマンドがある。find、aggregate($out・$merge がない場合)、count、distinct のような読み取りコマンドと、認証・一覧取得のコマンドである。
insert・update・delete・findAndModify はリストにない。ソースでも、切断時に終了の印を付ける関数 handleMarkKillOnClientDisconnect を呼ぶファイルは、上の読み取りコマンドのファイルだけである。書き込みコマンドのファイル write_commands.cpp はこの関数を呼ばない。
終了の印を受け取る読み取りも協調型で止まる。操作が中断チェック位置に来られなければ、読み取りも動き続ける。
公式ドキュメント "Retryable Writes" の最初の文が retryable writes の範囲を定めている。
Retryable writes let drivers retry specific write operations once after network errors or if they cannot find a healthy primary in the replica set or sharded cluster.
MongoDB 4.2 以上と互換のドライバーは、この機能をデフォルトで有効にする。無効にするには接続文字列に retryWrites=false を入れる。再試行の対象は、文書1つだけを変更する書き込みである。
| 区分 | 例 |
|---|---|
| 再試行する | insertOne、insertMany、updateOne、replaceOne、deleteOne、findOneAndUpdate など |
| 再試行する | 上の単一文書の書き込みだけを含む bulkWrite(ordered・unordered とも) |
| 再試行しない | updateMany、deleteMany、multi: true の update や limit: 0 の delete が1つでも含まれる bulk |
| 再試行しない | write concern {w: 0} の書き込み |
ソケット読み取りのタイムアウトも再試行対象のエラーである。Java ドライバー r5.4.0 では MongoSocketReadTimeoutException が MongoSocketException を継承している。CommandOperationHelper.isRetryableException は MongoSocketException なら true を返す。retryable writes 仕様も、ネットワークエラーに RetryableWriteError ラベルを付けるよう定めている。
retryable writes 仕様は再送の規則をこう定めている。
When retrying a write command, drivers MUST resend the command with the same transaction ID.
トランザクション ID は lsid(サーバーセッション id)と txnNumber(そのセッション内で増えていく番号)の組である。mongod は txnNumber があるリクエストを受け取ると、まずそのセッションをチェックアウトする。チェックアウトとは、セッションを1つの操作だけが使えるように確保することである。service_entry_point_common.cpp:397-399 (r4.2.12) のコメントは "only requests with a transaction number will check out the session" と記している。
元のリクエストと再送が同じセッションを取り合うので、結果は2つのうちどちらかになる。
どちらの場合も、実際に書き込みを行うコピーは1つである。再送はサーバーに届くが、仕事を増やさない。
ドライバーの再送まで失敗すると、例外がアプリケーションに上がってくる。アプリケーションが同じ書き込みをもう一度実行すると、ドライバーはそれを初めて見る書き込みとして扱う。
lsid を受け取る。txnNumber が新しく上がる。そのため元の操作がまだ動いていれば、2つの操作がサーバーのエンジン内で並んで動く。
たとえば Spring Kafka の DefaultErrorHandler(FixedBackOff(0L, 3L)) は、間隔0で3回再試行する。最初の1回に再試行3回を足して、レコード1つを4回実行する。何も設定していない DefaultErrorHandler のデフォルト値は FixedBackOff(0L, 9) なので、レコード1つを10回実行する。
| 項目 | ドライバーの retryWrites | アプリケーションの再試行(Spring Kafka の例) |
|---|---|---|
| 設定 | retryWrites=true(4.2 互換ドライバーのデフォルト値) |
DefaultErrorHandler の BackOff |
| いつ | ネットワークエラー、primary が見つからないとき | リスナーが例外を投げたとき |
| 何回 | もう1回(timeoutMS を使わない場合) |
設定した再試行回数分 |
| 何をやり直すか | 失敗したコマンド1つ(bulk ならその batch) | リスナーの処理全体 |
| 間隔 | サーバーを選び直したらすぐ | BackOff が決めた待ち時間 |
| サーバーから見ると | 同じ lsid・txnNumber。同じ書き込み |
新しい lsid または新しい txnNumber。新しい書き込み |
以下は仮想の例である。数字も説明のための値である。
store_inventory コレクションに bulk 書き込みを送る。socketTimeoutMS は30秒、retryWrites はデフォルト値(有効)である。DefaultErrorHandler(FixedBackOff(0L, 3L)) を使い、すべて失敗したらレコードを DLQ(dead letter トピック)に送る。| 時刻 | クライアントで | サーバーで(仮定) |
|---|---|---|
| 0秒 | アプリケーションの1回目の実行。bulk を送信(セッション A、番号 1) | コピー1が動き始める |
| 30秒 | ソケットタイムアウト。ドライバーが同じ bulk を再送(A、1) | 再送がセッション A を待つ。コピー1個 |
| 60秒 | 再送もタイムアウト。例外がアプリケーションまで上がる。アプリケーションの2回目の実行(B、1) | コピー2が動き始める。コピー1も動き続ける |
| 90秒 | ドライバーの再送(B、1) | セッション B を待つ。コピー2個 |
| 120秒 | アプリケーションの3回目の実行(C、1) | コピー3個 |
| 180秒 | アプリケーションの4回目の実行(D、1) | コピー4個 |
| 240秒 | 4回目の実行の再送もタイムアウト。再試行を使い切る。DLQ に送る | コピー4個がまだ動いている |
アプリケーションがレコードをあきらめた240秒の時点で、サーバーには同じ bulk のコピー4個と、セッションを待つ再送4個が残る。サーバーの操作が解放されると、コピー4個がすべて実行される。
서버로 간 전송 최대 = 애플리케이션 실행 수 × 2
엔진에서 겹쳐 도는 사본 최대 = 애플리케이션 실행 수
上の例では、アプリケーションの実行回数は4である。送信は最大8回、コピーは最大4個である。デフォルト値の FixedBackOff(0L, 9) なら、送信20回、コピー10個である。
次の2つの場合は再試行が減り、最大値に届かない。
この計算はレコード1つについてのものである。複数のコンシューマーが同じ状況に陥ると、コピー数はコンシューマー数の分だけさらに掛け算になる。
maxTimeMS をソケットタイムアウトより短くすると、クライアントがあきらめる前にサーバーが先に操作を止め、MaxTimeMSExpired を返す。このエラーはネットワークエラーではないので、ドライバーは再送しない。再試行の重なりがその分減る。
MaxTimeMSExpired(コード 50)が再試行の対象でない根拠は3つある。
ExceededTimeLimit(262)は別のコードである。RetriableError カテゴリのエラーにだけ RetryableWriteError ラベルを付ける(src/mongo/db/error_labels.cpp、r4.4.0)。同じタグの error_codes.yml で、MaxTimeMSExpired のカテゴリは Interruption と ExceededTimeLimitError である。r5.4.0 の RETRYABLE_ERROR_CODES リストに 50 はない。maxTimeMS は列に並んだ操作も整理する。MongoDB は同時にエンジンに入る書き込みの数を ticket(入場券)で制限する。ticket やグローバルロックを待つ待機は中断できるので、締め切りを過ぎると待機中の操作が抜ける。
エンジン内で中断チェック位置に来られない操作は、maxTimeMS でも切れない。第3/6回の WiredTiger ループがその例である。この場合はサーバーが締め切りを守れないので、ソケットタイムアウトが先に起き、上の掛け算がそのまま起きる。
Spring Data MongoDB では、検索に限り Query.maxTime(Duration) で maxTimeMS を渡せる。BulkOperations には渡すオプションがない。Spring Data MongoDB 4.4.0 の BulkOperations と DefaultBulkOperations のソースに maxTime は一度も出てこない。
Java ドライバーの CSOT(Client-Side Operation Timeout、timeoutMS)は、サーバーでの実行まで含めて縛る時間制限である。公式ドキュメントは、timeoutMS を設定すると書き込みを複数回再試行しうると書いている。この場合、上の2倍の計算は成り立たない。
currentOp に、同じコレクションを対象とした書き込みがアプリケーションの実行回数より多く見える。lsid と txnNumber でまとめると、まとまりの数がコピー数である。FixedBackOff はコピーを即座に重ねる。ExponentialBackOffWithMaxRetries で間隔を広げ、コンシューマーが多ければジッター(ランダムな揺らぎ)を加える。間隔を置いても、サーバーが止まっていればコピーは積み上がる。間隔は積み上がる速度を遅くし、サーバーが回復する時間を稼ぐ。maxTimeMS をソケットタイムアウトより短くする。たとえば maxTimeMS 20秒、socketTimeoutMS 30秒にする。サーバーが先に止まれば、ドライバーの再送は起きない。max.poll.interval.ms(Kafka のデフォルト値 300000ms)を超えないようにする。超えるとコンシューマーがグループから外れ、オフセットコミットが失敗する(韓国語)。Spring Kafka のデフォルトの BackOffHandler は、待機中にコンシューマースレッドを眠らせる。余裕を持って見積もる基準式は下のとおりである。max.poll.records × (한 번 실행 최대 시간 × 실행 수 + 대기 합) < max.poll.interval.ms
1回の実行の最大時間はソケットタイムアウト × 2 とする。上の例なら、レコード1つが4回の実行に240秒を使う。_id で upsert する書き込みは、何回重なっても結果が同じである。_id をドライバーが毎回新しく作る insert は、アプリケーションが再試行するたびに文書が1つずつ増える。updateMany・deleteMany は retryWrites が再送しない。失敗したときに一部だけ反映されたかが分からないので、アプリケーションの再試行が安全かを別途検討する必要がある。DefaultErrorHandler.addNotRetryableExceptions(...) で指定する。
- ソケットタイムアウトをキャンセルと読まない。 クライアントがあきらめた書き込みはサーバーで動き続ける。
- retryWrites を無効にしても重なりは消えない。 サーバーのコピー数はアプリケーションの実行回数が決める。retryWrites を無効にすると、サーバーへの送信だけが半分になる。
deleteManyは retryWrites が守ってくれない。 ネットワークエラーが起きると、ドライバーは再送せずに例外を投げ、何件削除されたかは分からない。maxTimeMSを万能の締め切りとして使わない。 中断チェック位置に来られないエンジン内の操作は、締め切りを過ぎても動く。
実行で検証していない手順である。下の結果は、ソースと仕様を読んで予想したものである。
準備するものは2つある。
--setParameter enableTestCommands=1 で起動する。4.2.12 では configureFailPoint がテストコマンドとして登録される(src/mongo/db/commands/fail_point_cmd.cpp の MONGO_REGISTER_TEST_COMMAND)。手順は次のとおりである。
failCommand fail point を設定する。appName が retry-lab の接続の insert を60秒ずつ引き止める。db.adminCommand({
configureFailPoint: "failCommand",
mode: { times: 8 },
data: { failCommands: ["insert"], blockConnection: true,
blockTimeMS: 60000, appName: "retry-lab" }
})
socketTimeoutMS=5000&retryWrites=true&appName=retry-lab で接続する。_id は指定しない。for (let i = 1; i <= 4; i++) {
try { db.retry_lab.insertOne({ productId: 1, storeId: 7, attempt: i }); break; }
catch (e) { print(`attempt ${i}: ${e.name}`); }
}
db.getSiblingDB("admin").aggregate([
{ $currentOp: { allUsers: true } },
{ $match: { "command.insert": "retry_lab" } },
{ $group: { _id: { lsid: "$command.lsid.id", txn: "$command.txnNumber" },
n: { $sum: 1 } } }
])
db.retry_lab.countDocuments({ productId: 1 }) で文書数を数える。シェルのドライバーがソケットタイムアウトの後に再送するなら、予想される結果は次のとおりである。
attempt 1 から attempt 4 まで約10秒ごとに1つずつ表示される。1回の実行が、元の送信の5秒と再送の5秒を使う。n が2である。fail point は service_entry_point_common.cpp:715 でコマンドを引き止める。セッションのチェックアウトはそれより後の、912行の runCommandImpl 呼び出しの中で起きる。そのためこの実験では、再送もセッションを待たず、元のリクエストと一緒に fail point に引き止められている。_id で入った。ドライバーの4回の再送は文書を増やさない。
- セッション待ちのサーバーソース: 再送が元のリクエストの終了まで待つという記述は、driver sessions 仕様に基づく。4.2 サーバーの
session_catalog.cppと直接突き合わせてはいない。- シャードクラスタの経路: mongos が
lsidとtxnNumberを shard に渡し、shard の mongod がセッションをチェックアウトする経路は、ソースで追っていない。この記事のサーバー動作の記述は mongod 基準である。- CSOT の適用範囲: Java ドライバー
timeoutMSの導入バージョン、bulk 書き込みへの適用範囲、その場合に再試行が何回まで起きるかは確認していない。- ジッターのサポート: Spring の
ExponentialBackOff系がジッターをサポートしているかは、バージョンごとに確認していない。- 再現手順のドライバー: mongosh が使う Node.js ドライバーがソケットタイムアウトの後に書き込みを再送するかは、そのドライバーのソースで確認していない。再送しなければ、手順4の
nは1である。$currentOpのフィールド位置:lsidとtxnNumberがcommand文書の中にそのまま見えるかは、実行で確認していない。- 最新のサーバーバージョン: 4.2 以降、切断時に終了の印を受け取るコマンドのリストが変わったかは確認していない。4.2 はサポートが終了したバージョンである。