データベース

MongoDBの複数ドキュメントトランザクション: いつ使用すべきでないか

マルチドキュメントトランザクションは安全なデフォルトのように見えますが、コミットの曖昧さとPSAの可用性の落とし穴が、それらをコストの高いものにしています。ここでは、それらを省略すべき場合について説明します。

この記事は英語の原文をAIモデルが翻訳したものです。表現が原文と異なる場合があります。 英語の原文を読む

MongoDBは4.0で複数ドキュメントトランザクションを追加しましたが、それが登場した途端、ほとんどのチームはリレーショナルデータベースの場合と同じようにそれに手を伸ばします。その直感は通常、間違っています。トランザクションは、それ自体で書き込みを安全にするものではなく、操作をべき等にするという義務をなくすものでもありません。そして、一般的なレプリカセットトポロジーでは、ノードがダウンしたまさにその時に、データベースが書き込みを拒否する原因となり得ます。この投稿では、あるチームがトランザクションを多用する設計を断念する原因となった3つの罠と、その大部分を置き換えた単一ドキュメントパターンについて説明します。

マルチドキュメントトランザクションが実際に保証すること

MongoDBのトランザクションは、複数のドキュメントにわたるall-or-nothing (オールオアナッシング) の書き込みを提供します。セッションを開き、トランザクションを開始し、そのセッションに対して書き込みを実行し、コミットします。いずれかの書き込みが失敗した場合、またはアボートした場合、それらのいずれも可視になりません。分離レベルはスナップショットレベルであるため、並行リーダーはコミットされた変更の完全なセットを見るか、あるいは何も見ないかのどちらかになります。

それは真の保証であり、それを必要とする問題が存在します。問題は、保証が見た目よりも狭く、コストが見た目よりも広いことです。トランザクションは、データベースへの部分的な書き込みを防ぎます。自身のクライアントによるコミットの再送信、マジョリティを形成できないレプリカセット、または負荷時に複数ドキュメントの書き込みが作成するロック競合からは保護しません。これら3つのギャップが、設計が破綻する箇所です。

これが、誰もが最初に書く形式です。

session, err := client.StartSession()
if err != nil {
	return err
}
defer session.EndSession(ctx)

_, err = session.WithTransaction(ctx, func(sc mongo.SessionContext) (interface{}, error) {
	if err := debit(sc, from, amount); err != nil {
		return nil, err
	}
	if err := credit(sc, to, amount); err != nil {
		return nil, err
	}
	return nil, nil
})

すっきりと読め、デモ映えもします。

障害モードは本番環境でしか現れません。

UnknownTransactionCommitResult の問題

最初の落とし穴は、テストでは決して見られないものです。commit を呼び出すと、ドライバーはプライマリーにコマンドを送信し、確認応答を待ちます。書き込みが永続化された後、しかし確認応答があなたに届く前に、ネットワークが切断されたりプライマリーが降格したりした場合、ドライバーはコミットが成功したかどうかを判断できません。これは UnknownTransactionCommitResult というラベルを持つエラーとして表面化します。

公式のガイダンスはコミットをリトライすることであり、ドライバーの WithTransaction ヘルパーがこれを代行します。これで問題が解決したかのように聞こえますが、コミットのリトライが何を意味するのかを見てみましょう。あなたは、その結果がわからない操作を再送しているのです。最初のコミットが実際に成功していた場合、リトライはすでにコミットされたトランザクションをコミットすることになり、MongoDB はこれを no-op として扱うため、その特定のケースは安全です。危険は、もう一段階上にあります。もしトランザクション全体が、クライアントのタイムアウト、キューの再配信、ユーザーのダブルクリックなど、それ自体がリトライされうるリクエストによって駆動される場合、借方と貸方の全体が全く新しいトランザクションとして2回実行される可能性があります。トランザクションの境界は、それを防ぐためには何もしません。それは各実行がアトミックであることを保証するだけであり、論理的な操作が一度だけ実行されることを保証するものではありません。

// Retrying the commit is safe. Retrying the whole operation is not,
// unless debit() and credit() are idempotent by construction.
for {
	err := doTransfer(ctx, from, to, amount, transferID)
	if err == nil {
		break
	}
	if hasErrorLabel(err, "TransientTransactionError") {
		continue // safe to replay the transaction
	}
	if hasErrorLabel(err, "UnknownTransactionCommitResult") {
		continue // safe to retry the commit
	}
	return err
}

TransientTransactionError ラベルは、トランザクション全体を安全にリプレイできるエラーを示し、UnknownTransactionCommitResult はリトライ可能なコミットを示します。どちらのリトライ ループも、基盤となる処理が繰り返し実行しても安全であることを前提としています。その前提は、あなたが構築しなければならないものです。トランザクションは、自動的にべき等性を提供するものではありません。送金処理を厳密に一度だけ適用する必要がある場合は、リプレイが no-op になるようにトランザクション内で記録・確認する、安定したべき等キー(通常は transferID)が依然として必要になります。そのキーがあれば、トランザクションによって得られるメリットのほとんどは、すでにそのキーによって処理されます。

w=majority を使用した場合のPSAの可用性の罠

2つ目の罠は構造的なもので、正当性ではなく可用性に影響を与えます。トランザクションはデフォルトで w=majority の書き込み懸念でコミットされますが、これは望ましいことです。なぜなら、より弱い懸念でコミットされたトランザクションはフェイルオーバー時にロールバックされる可能性があり、それでは意味がないからです。問題は、プライマリ-セカンダリ-アービター (Primary-Secondary-Arbiter) トポロジにおいて過半数が何を意味するかです。

PSAは、アービターが安価であるため、人気のある3ノード構成です。アービターは選挙で投票しますがデータは保持しないため、2つのデータ保持ノードの料金を支払うだけでフェイルオーバーを実現できます。落とし穴は、アービターが書き込みを承認できないことです。3メンバーのセットにおける過半数は2ノードですが、3つのメンバーのうちデータを保持しているのは2つだけです。どちらかのデータ保持ノードがダウンすると、過半数の書き込みを承認できるノードが1つ、できないノードが1つという状況になります。このセットは通常の挿入に対しては書き込み可能なままですが、すべてのトランザクションコミットが該当する w=majority の書き込みは、もはや過半数に達することができなくなります。コミットは、2つ目のデータ保持ノードが復旧するまでブロックされるか、タイムアウトします。

トポロジ データノード 過半数 w=majority の場合にデータノード1台のダウンを許容
PSA (プライマリ-セカンダリ-アービター) 2 2 いいえ
PSS (プライマリ-セカンダリ-セカンダリ) 3 2 はい

つまり、ノード障害を許容するように見えたトポロジは、通常の書き込みに対しては許容しますが、トランザクションに対しては許容しないのです。この問題は、まさにレプリカセットを構築した目的である障害の発生中に発覚します。午前3時にセカンダリが停止し、通常のトラフィックは流れ続けますが、すべてのトランザクションの書き込みパスがハングします。解決策は、3つ目のデータ保持ノードが必要になるPSSにするか、あるいはそのパスで過半数に承認される複数ドキュメントのコミットを一切要求しないことです。もしそこでトランザクションを使用していなければ、w=1 での単一ドキュメントの書き込みはサービスを提供し続けていたでしょう。

パフォーマンスとロックコスト

3つ目の罠はより静かで、レイテンシーとして現れます。トランザクションは、その全期間にわたってリソースを保持します。スナップショットを固定するため、ストレージエンジンはトランザクションの存続期間中、ドキュメントの古いバージョンを読み取り可能に保つ必要があり、また、同じドキュメントへの他のライターが待機するドキュメントレベルのロックを取得します。競合下では、ホットなドキュメントにアクセスするトランザクションは互いに直列化され、遅いトランザクションは、そのドキュメントにアクセスするすべてのライターを遅くします。

MongoDBはまた、トランザクションを小さく保つように促す厳しい制限を課しています。デフォルトのトランザクション存続期間は60秒で、それを超えるとサーバーはトランザクションを中止します。16MBのoplog変更を超えて大きくなったトランザクションは、内部的に分割される必要があり、より多くのコストがかかります。これらはどれも致命的ではありませんが、総合すると、トランザクションはバルク作業を行う場所ではなく、遅い外部呼び出しや大きなバッチにまたがるトランザクションは設計上の間違いであることを意味します。健全なトランザクションとは、少数のドキュメントへの少数の書き込みを、ミリ秒単位で保持するものです。あなたのトランザクションがそうでなくなった瞬間、トランザクションは間違ったツールであり、チューニングが必要なツールではありません。

ほとんどのトランザクションに取って代わるパターン

人々がトランザクションで保護しようとする不変条件のほとんどは、実際には複数のドキュメントにまたがるものではありません。それらは1つのドキュメントの複数のフィールドにまたがるか、あるいは1つのドキュメントとしてモデル化できる単一の論理的な事実です。MongoDBはトランザクションをまったく使わずに単一のドキュメントをアトミックに更新し、そのアトミックな単一ドキュメント更新とべき等なupsertで、驚くほど多くのケースをカバーします。

教科書的な2ドキュメントのトランザクションのように見える、送金の例を考えてみましょう。もし残高が通貨のマップとして1つのアカウントドキュメント内に存在するなら、操作全体が単一のアトミックな更新になります。それらが別々のドキュメントに存在しなければならない場合は、送金自体を決定論的なIDを持つドキュメントとしてモデル化し、各サイドがそのIDに対してべき等に適用するようにします。

// One document, one atomic update. No transaction, no majority commit
// required, and it stays writable when a data node is down.
res, err := accounts.UpdateOne(ctx,
	bson.M{"_id": from, "balance": bson.M{"$gte": amount}},
	bson.M{"$inc": bson.M{"balance": -amount}},
)
if err != nil {
	return err
}
if res.ModifiedCount == 0 {
	return ErrInsufficientFunds // the guard failed atomically
}

フィルターが不変条件を担います。balance >= amount 条件と $inc は、サーバー上で単一のドキュメントに対して一緒に評価されるため、並行書き込みがチェックとデクリメントの間に割り込む余地はありません。競合する2つの引き出しは、両方がガードを通過することはできません。なぜなら、2つ目はすでにデクリメントされた残高を参照するからです。これは、トランザクションが単一ドキュメントのケースで提供するであろうものと同じ安全性であり、コミットの曖昧さ、マジョリティ要件、そして2つのドキュメントにまたがって保持されるロックがありません。

操作が厳密に1つだけ存在しなければならないレコードを真に生成する場合は、決定論的な idupsert を使用して、リトライ時に複製する代わりに上書きされるようにします。

// Idempotent by id. First call inserts, every retry overwrites identical
// data, the row count never grows. Safe to replay from any retry loop.
id := TransferID(from, to, requestID)

_, err := transfers.UpdateByID(ctx, id,
	bson.M{"$setOnInsert": bson.M{
		"from": from, "to": to, "amount": amount, "at": time.Now(),
	}},
	options.Update().SetUpsert(true),
)

これはべき等なアップサートのための決定論的ULIDの背後にある考え方と同じです。IDは操作を識別するものから導出されるため、2回目の試行は同じIDをターゲットにし、no-opになります。アトミックなガード付き更新とべき等なアップサートによって、トランザクションはほとんどのパスからなくなり、それに伴って上記の3つの罠もなくなります。これが、トランザクションファーストで始めたチームが、インシデントによってトランザクションが実際にどれだけのコストをかけていたかを学んだ後、非トランザクション設計に回帰する傾向がある理由です。

トランザクションが実際に必要な場合

このことは、トランザクションが決して正しくないという意味ではありません。正当なケースとは、1つにマージできない複数のドキュメントにまたがる、真の全か無かの不変条件であり、単一ドキュメントのガードもべき等キーもその制約を表現できない場合です。読み取り側がアイテムを両方にもどちらにもない状態でも決して見てはならない、独立してクエリされる2つのコレクション間でアイテムを移動させることは、実際の例です。別々のドキュメントにある借方行と貸方行が両方存在するか、どちらも存在しないかのどちらかでなければならず、かつ、そのペアが単一のドキュメントにまとめることができないほど頻繁に独立してクエリされる簿記台帳も同様です。

テストは簡単です。その不変条件が本当に2つ以上のドキュメントを一緒に変更する必要があるか、そして読み取り側がルールを破るような中間状態を観測できるかどうかを問うてみてください。両方への答えが「はい」の場合、トランザクションは適切なツールであり、そのコストを意図的に受け入れるべきです。これは、マジョリティコミットがノード損失を乗り越えられるようにPSSトポロジを選択し、トランザクションを小さく高速に保ち、それでもなお、リプレイが二重適用されないように、囲んでいる操作をべき等にすることを意味します。どちらかへの答えが「いいえ」の場合、あなたはデータモデルについて考えるのを避けるためにトランザクションに手を伸ばしており、そのモデルにはより簡単な答えがあります。

トランザクションに手を出す前のチェックリスト

  • 不変条件は、単一ドキュメントの複数フィールドだけでなく、1つとしてモデル化できない複数のドキュメントに純粋にまたがっている。
  • 並行リーダーが実際に壊れた中間状態を観測できるため、ドキュメント間の原子性は、単なる整理のためではなく、必須である。
  • コミットが UnknownTransactionCommitResult を返す可能性があり、リクエスト全体をトランザクションの上位で再試行できるため、包含する操作は安定したIDでキー付けされ、いずれにせよべき等である。
  • レプリカセットはPSAではなくPSSであるため、データ保持ノードが1つダウンしていても w=majority のコミットは成功する。
  • トランザクションは小さく短時間であり、ミリ秒単位で保持される少数のドキュメントで、その内部に低速な外部呼び出しや一括書き込みがない。

この5つすべてをクリアすれば、トランザクションは他の何ものにもできない仕事をしていることになり、そのコストはあなたが選択した代価です。最初の2つをクリアできなければ、単一ドキュメントの原子的な更新とべき等なupsertを組み合わせることで、より高い可用性とより少ない競合で同じ不変条件を実現できます。デフォルトはよりシンプルな設計にすべきであり、トランザクションは一つ一つのパスについて正当化できる例外であるべきです。