キーポイント
-
NoSQLクラウドデータベースサービスは、キーバリュー操作、高可用性、高スケーラビリティ、予測可能なパフォーマンスで人気がある。これらの特徴は、一般的にトランザクションのサポートとは相反すると考えられている。DynamoDBはトランザクションをサポートし、パフォーマンス、可用性、スケールに妥協することなくそれを実現している。
-
DynamoDBは、キーバリューストアのセマンティクスを利用しながら、タイムスタンプ順序プロトコルを使用してトランザクションを追加し、トランザクション操作と非トランザクション操作の両方で低レイテンシを実現した。
-
Amazon DynamoDBは、2つの新しいシングルリクエスト操作を導入した。
TransactGetItems
とTransactWriteItems
だ。これらのオペレーションは、任意のテーブルの任意のアイテムに対して、一連のオペレーションをアトミックかつシリアライズ可能に実行できる。Amazon DynamoDB上のトランザクション操作は、リージョン内での原子性、一貫性、独立性、および耐久性(ACID)の保証を提供する。 -
本番実装に対する実験結果は、パフォーマンス、可用性、スケールを損なうことなく、完全なACID特性を持つ分散トランザクションをサポートできることを示している。
NoSQLデータベース
DynamoDBのようなNoSQLデータベースは、その柔軟なデータモデル、シンプルなインターフェイス、スケール、パフォーマンスによって採用が進んでいる。SQLクエリやトランザクションを含むリレーショナルデータベースのコア機能は、無制限のスケーラビリティのための自動パーティショニング、耐障害性のためのレプリケーション、予測可能なパフォーマンスのための低レイテンシアクセスを提供するために犠牲にされた。
Amazon DynamoDB(Dynamoと混同しないように)は、何十万もの顧客と、Alexa、Amazon.comサイト、すべてのAmazonフルフィルメントセンターを含む、トラフィックの多い複数のAmazonシステムのアプリケーションを強力にサポートしている。
2023年、プライムデーの期間中、AmazonのシステムはDynamoDB APIに何兆回ものコールを行った。DynamoDBは1桁ミリ秒のレスポンスを提供しながら高い可用性を維持し、ピーク時には1秒あたり1億2600万回のリクエストを処理していた。
DynamoDBの顧客がACIDトランザクションを要求した時、この重要なインフラサービスの特徴である「高いスケーラビリティ」「高い可用性」「スケール時の予測可能なパフォーマンス」を犠牲にすることなく、いかにトランザクション処理を統合するかが課題となった。
なぜトランザクションが重要なのかを理解するために、NoSQLデータベースでトランザクションをサポートせず、基本的なPut
とGet
操作のみを使用してアプリケーションを構築する例を説明しよう。
トランザクション
トランザクションとは、1つの論理単位として一緒に実行される一連の読み取りと書き込み操作のことである。トランザクションはACID特性と関連している。
-
Atomicity(原子性)は、トランザクション内のすべての操作が実行されるか、あるいは実行されないかを保証し、All-or-Nothingのセマンティクスを提供する。
-
Consistency(一貫性)は、操作の結果、データベースが一貫した正しい状態になることを保証する。
-
Isolation(独立性)は、複数の開発者が同時にデータを読み書きできるようになり、同時処理が確実にシリアライズされる。
-
Durability(耐久性)は、トランザクション中に書き込まれたデータが永久に残ることを保証する。
なぜNoSQLデータベースにトランザクションが必要なのか?トランザクションの価値は、複数項目の不変量を維持が必要な、正しく信頼性の高いアプリケーション構築を支援する能力にある。このような性質の不変量は、さまざまなアプリケーションに見られる。例えば、Maryというユーザーが、本とペンを一緒に1つの注文として購入するオンライン電子商取引アプリケーションを想像してほしい。この文脈での不変量は、本が在庫切れの場合は販売できない、ペンが在庫切れの場合は販売できない、Maryが本とペンの両方を購入するには有効な顧客でなければならない、などである。
図1:単純な電子商取引のシナリオ
しかし、これらの不変量を維持することは、特にアプリケーションの複数のインスタンスが並列に実行され、同時にアクセスする場合、困難な場合がある。さらに、複数アイテムの不変量を保持するタスクは、ノード障害などの障害が発生した場合に困難になる。トランザクションは、アプリケーションに同時アクセスと部分的な障害という2つの課題に対処するソリューションを提供し、開発者がこれら2つの課題に対処するために過剰な量の追加コードを書く必要性を軽減する。
Maryの注文を作成するために、トランザクションをサポートしていないデータベースに依存するクライアントサイドのeコマースアプリケーションを開発しているとしよう。あなたのアプリケーションには、在庫テーブル、顧客テーブル、注文テーブルの3つのテーブルがある。購入時に何を考慮する必要があるだろうか?
図2: 在庫、顧客、注文の3つの独立したNoSQLテーブル
まず、Maryが認証済みユーザーであるか確認する必要がある。次に、本の在庫と販売可能な状態を確認する必要がある。また、ペンについても同じ確認を行う必要がある。それから、新しい注文を作成し、在庫の本とペンのステータスと数を更新する必要がある。これを実現する一つの方法は、必要なロジックをすべてクライアントサイドに書くことである。
重要な点は、最終的な状態が正しい値を持ち、注文書が作成されている間に他の読者がデータベースの矛盾した状態を見ないようにするために、すべてのオペレーションがアトミックに実行されなければならないということである。トランザクションがなければ、複数のユーザーが同時に同じデータにアクセスした場合、矛盾したデータに遭遇する可能性がある。例えば、ある本がMaryに売約済みと表示されても、注文の作成に失敗する可能性がある。トランザクションは、これらの操作を論理的な単位として実行する手段を提供し、顧客が矛盾した状態を観察するのを防ぎながら、すべてが成功するか、すべてが失敗するかを保証する。
図3:トランザクションなしでクラッシュが発生したらどうなるか?
トランザクションなしでアプリケーションを構築するには、ネットワーク障害やアプリケーションのクラッシュなど、他のさまざまな潜在的な落とし穴をくぐり抜ける必要がある。これらの課題を軽減するためには、堅牢なエラー処理と回復力を実現するために、クライアントサイドのロジックを追加実装する必要がある。開発者は、未完了のトランザクションを削除するロールバック・ロジックを実装する必要がある。マルチユーザーシナリオでは、テーブルに格納されたデータがすべてのユーザー間で一貫していることを保証する必要があるため、さらに複雑なレイヤーが発生する。
トランザクションとNoSQLの懸念
データベースにトランザクション・システムを実装する際に生じるトレードオフについて、しばしば懸念されることがある。NoSQLデータベースは、低レイテンシのパフォーマンスとスケーラビリティを提供することが期待されているが、多くの場合、一貫したレイテンシを持つGet
操作とPut
操作のみを提供している。
図4:トランザクションは予測可能なパフォーマンスを提供できるか?
多くのNoSQLデータベースはトランザクションを提供していない。一般的な懸念は、非トランザクションのワークロードを壊すこと、APIの複雑さ、デッドロック、競合、非トランザクションとトランザクションのワークロード間の干渉などのシステム問題である。一部のデータベースは、分離レベルやトランザクションのスコープを制限し、単一のパーティションで実行できるようにするなどの制限機能を提供することで、これらの問題に対処しようとしていた。また、プライマリ・キーやハッシュ・キーに制約を設けたり、トランザクションに含まれると予想されるすべてのパーティションを前もって特定することを要求したりするものもある。
これらの制限は、システムをより予測しやすくし、複雑さを軽減するように設計されているが、スケーラビリティを犠牲にしている。データベースが成長し、複数のパーティションに分割されると、単一のパーティションへのデータ制限は可用性の問題につながる可能性がある。
DynamoDBトランザクションの目標
DynamoDBにトランザクションのサポートを追加しようとしたとき、チームは、予測可能なパフォーマンスで、非トランザクションのワークロードに影響を与えずに、特定のリージョン内のテーブル間のアイテムに対してアトミックでシリアライズ可能な処理を実行する機能を顧客に提供することを目指した。
カスタマーエクスペリエンス
カスタマーエクスペリエンスに焦点を当て、DynamoDBでトランザクションサポートを提供するためのオプションを探ってみよう。従来、トランザクションは "begin transaction "ステートメントで開始され、"commit transaction "で終了する。その間に、顧客はすべてのGet
操作とPut
操作を記述できる。このアプローチでは、単一のアイテムに対する既存の操作は、単一の操作で構成される暗黙のトランザクションとして単純に扱うことができる。独立性を確保するために、2フェーズのロッキングを使用でき、同時に2フェーズのコミットによって原子性を実現可能だ。
しかし、DynamoDBはマルチテナントシステムであり、長時間のトランザクションを許可するとシステムリソースを無期限に拘束する可能性がある。シングルトンのGetとPut操作に対して完全なトランザクションコミットプロトコルを強制することは、トランザクションを利用するつもりのないユーザーにとってパフォーマンスに対し悪影響を及ぼす。さらに、ロックはデッドロックのリスクをもたらし、システムの可用性に大きな影響を与える可能性がある。
その代わりに、TransactGetItems
と TransactWriteItems
という2つの新しいシングルリクエスト操作を導入した。これらの操作は、他のDynamoDBの操作に対して、アトミックかつシリアライズ可能な順序で実行される。TransactGetItems
は、一貫性のあるスナップショットから複数のアイテムを取得する読み取り専用トランザクションに設計されている。これは、読み取り専用トランザクションが他の書き込みトランザクションに対してシリアライズされることを意味する。TransactWriteItems
は、1つまたは複数のテーブルにおいて、複数のアイテムをアトミックに作成、削除、または更新することを可能にする、同期的でべき等な書き込み操作である。
このようなトランザクションは、オプションとして、項目の現在値に関する1つ以上の前提条件を含むことができる。事前条件によって、アイテムの存在、特定の値、数値範囲など、アイテムの属性に関する特定の条件のチェックが可能になる。DynamoDBは、いずれかの前提条件が満たされない場合、TransactWriteItems
は要求を拒否する。前提条件は、変更されるアイテムだけでなく、トランザクションで変更されないアイテムにも追加できる。
これらの操作は同時実行を制限せず、バージョニングを必要とせず、シングルトン操作のパフォーマンスに影響を与えず、個々のアイテムに対する楽観的な同時実行制御を可能にする。すべてのトランザクションとシングルトン操作は、一貫性を確保するためにシリアライズされる。TransactGetItems
とTransactWriteItem
によって、DynamoDBはACIDコンプライアンスを満たすスケーラブルでコスト効率の高いソリューションを提供できる。
銀行送金の文脈でのトランザクションの活用を示す別の例を考えてみよう。MaryがBobに送金したいとする。従来のトランザクションではMaryとBobの口座残高を読み取り、資金の利用可能性を確認、TxBegin
とTxCommit
ブロック内でトランザクションを実行する。DynamoDBでは、TransactWriteItems
操作を使うことで、同じトランザクション動作を1回のリクエストで実現可能だ。つまり、残高をチェックし、TransactWriteItemsで
送金することで、TxBegin
とTxCommit
が不要になる。
トランザクションのハイレベルアーキテクチャ
トランザクションがどのように実装されたかを理解するために、DynamoDBリクエストのワークフローを掘り下げてみよう。アプリケーションがPut/Get
操作をリクエストすると、リクエストはフロントエンドのロードバランサーによってランダムに選択されたリクエストルーターにルーティングされる。リクエストルーターは、メタデータサービスを利用して、テーブル名と主キーを、アクセスするアイテムを格納するストレージノードのセットにマッピングする。
図5:ルーター
DynamoDBのデータは複数のアベイラビリティゾーンにレプリケートされ、1つのレプリカがリーダーとして機能する。Put
操作の場合、要求はリーダーのストレージノードにルーティングされ、リーダーは異なるアベイラビリティゾーンの他のストレージノードにデータを伝播する。過半数のレプリカがアイテムの書き込みに成功すると、完了応答がアプリケーションに返送される。削除操作と更新操作も同様のプロセスをたどる。取得も、単一のストレージノードで処理される以外は同様である。一貫性のある読み取りの場合、リーダーレプリカが読み取り要求を処理する。しかし、最終的な一貫性のある読み出しの場合は、3つのレプリカのどれでも要求に対応できる。
トランザクションを実装するために、トランザクションコーディネーターの専用フリートが導入された。フリート内のどのトランザクションコーディネーターも、どのトランザクションに対しても責任を持つことができる。トランザクション要求を受け取ると、要求ルーターは必要な認証と認可をかけ、トランザクションコーディネーターのいずれかに要求を転送する。これらのコーディネーターは、トランザクションに関係するアイテムを担当する適切なストレージノードへのリクエストのルーティングを処理する。ストレージノードからの応答を受け取った後、コーディネーターはクライアントに対して、トランザクションの成否を示すトランザクション応答を生成する。
図6:トランザクションコーディネーター
トランザクションプロトコルは、原子性を保証するために2段階のプロセスである。最初のフェーズでは、トランザクションコーディネーターが、書き込まれるアイテムのリーダーストレージノードにPrepare
メッセージを送信する。Prepare
メッセージを受信すると、各ストレージノードはアイテムの前提条件が満たされているかどうかを検証する。すべてのストレージノードがPrepare
を受諾すると、トランザクションは第2フェーズに進む。
このフェーズでは、トランザクションコーディネーターがトランザクションをコミットし、ストレージノードに書き込みを実行するよう指示する。トランザクションがいったん第二フェーズに入ると、そのトランザクション全体が正確に一度だけ実行されることが保証される。コーディネーターは全ての書き込みが成功するまで、各書き込み操作を再試行する。書き込みはべき等であるため、コーディネーターはタイムアウトに遭遇したようなシナリオの場合に、書き込み要求を安全に再送できる。
図7:トランザクションが失敗した場合
Prepare
メッセージが参加ストレージノードのいずれからも受け入れられなかった場合、トランザクションコーディネーターはトランザクションをキャンセルする。キャンセルするには、トランザクションコーディネーターが参加するすべてのストレージノードにRelease
メッセージを送信し、トランザクションがキャンセルされたことを示す応答をクライアントに送信する。最初のフェーズでは書き込みが発生しないので、ロールバック処理は必要ない。
トランザクションのリカバリ
トランザクションの原子性を確保し、障害が発生した場合にトランザクションの完了を保証するために、コーディネーターは各トランザクションとその結果の永続的な記録を台帳に保持する。定期的にリカバリーマネージャーが元帳をスキャンし、まだ完了していないトランザクションを特定する。そのようなトランザクションは、トランザクションプロトコルの実行を再開する新しいトランザクショ ンコーディネータに割り当てられる。コミット操作とリリース操作はべき等であるため、複数のコーディネーターが同時に同じトランザクションに取り組むことは許容される。
図8:トランザクションコーディネーターと障害
トランザクションが正常に処理されると、元帳に完了のマークが付けられ、それ以上の操作は必要ないことが示される。トランザクションに関する情報は、べき等なTransactWriteItems
リクエストに対応するため、トランザクションの完了から10分後に台帳から消去される。クライアントがこの10分の時間枠内に同じリクエストを再発行した場合、そのリクエストがidempotentであることを確認するために、情報は元帳から検索される。
図9:トランザクションコーディネーターと台帳
シリアライズ可能性の確保
タイムスタンプ順序は、トランザクションの論理的な実行順序を定義するために使用される。トランザクション要求を受け取ると、トランザクションコーディネーターは現在のクロックの値を用いてトランザクションにタイムスタンプを割り当てる。タイムスタンプが割り当てられると、トランザクションに参加するノードは協調することなく操作できる。各ストレージノードは、アイテムに関係する要求が適切な順序で実行されることを保証し、順序から外れる可能性のある競合するトランザクションを拒否する責任を負う。各トランザクションが割り当てられたタイムスタンプで実行されれば、直列化可能性が達成される。
図10:タイムスタンプに基づく順序プロトコルの使用
トランザクションからの負荷を処理するために、多数のトランザクションコーディネーターが並列に動作する。クロックの非同期による不要なトランザクションの中断を防ぐため、システムはAWSが提供する時間同期サービスを利用し、コーディネーターフリート内のクロックを密接に同期させている。しかし、クロックが完全に同期していても、遅延やネットワーク障害などの問題によりトランザクションが順番通りにストレージノードに到着しないことがある。ストレージノードは、保存されたタイムスタンプを使用して、任意の順序で到着したトランザクションに対処する。
TransactGetItems
TransactGetItems
APIはTransactWriteItems
APIと同様に機能するが、待ち時間とコストを避けるために台帳を使用しない。TransactGetItems
は、読み取りトランザクションを実行するための2フェーズ書き込みなしプロトコルを実装している。最初のフェーズでは、トランザクションコーディネーターはトランザクションのリードセット内のすべての項目を読み取る。これらのアイテムのいずれかが別のトランザクションによって書き込まれている場合、読み取りトランザクションは拒否される。それ以外の場合、読み取りトランザクションは第2フェーズに移行する。
そうでない場合、読み出しトランザクションは第2フェーズに移る。トランザクショ ンコーディネータへの応答において、ストレージノードはアイテムの値だけでなく、ストレージ ノードによって最後に確認された書き込みを表す、現在のコミットされたログシーケンス番号 (LSN)も返す。第2フェーズでは、アイテムが再読み取りされる。2つのフェーズの間にアイテムに変更がなければ、読み取りトランザクショ ンはフェッチされたアイテム値で正常に戻る。しかし、2つのフェーズの間にアイテムが更新された場合、読み取りトランザクショ ンは拒否される。
トランザクション型ワークロードと非トランザクション型ワークロード
トランザクションを使用しないアプリケーションのパフォーマンスを低下させないために、非トランザクション操作は、トランザクションコーディネーターと2フェーズプロトコルをバイパスする。これらの操作は、リクエストルーターからストレージノードに直接ルーティングされるため、パフォーマンスへの影響はない。
トランザクションの目標の再検討
冒頭で述べたスケーラビリティに関する懸念はどうだろうか?DynamoDBにトランザクションを追加することで、何が達成されたかを見てみる。
- 従来の
Get/Put
操作は影響を受けておらず、トランザクションを伴わないワークロードと同じパフォーマンスを持っている。 TransactGetItems
APIはTransactWriteItems
APIと同様に動作するが、レイテンシとコストを避けるために元帳を使用しない。- システムがスケールしても、すべての操作は同じレイテンシーを維持する。
- シングルリクエストトランザクションとタイムスタンプ順序を利用することで、トランザクションとスケーラビリティの両方を実現している。
図11:トランザクションの予測可能な待ち時間
ベストプラクティス
DynamoDBでトランザクションを利用するためのベストプラクティスとは何か。
-
べき等書き込みトランザクション:TransactWriteItemsを呼び出すとき、要求がべき等であることを保証するためにクライアントトークンを含めるオプションがある。トランザクションにべき等性を組み込むことで、同じ操作が不注意で複数回送信された場合の潜在的なアプリケーションエラーを防げる。この機能は、AWS SDKを利用する際にデフォルトで利用可能だ。
-
自動スケーリングまたはオンデマンド:自動スケーリングを有効にするか、オンデマンドテーブルの利用を推奨する。これにより、トランザクションのワークロードを効率的に処理するために必要なキャパシティが確保される。
-
一括ロードのためのトランザクションを避ける:一括ロードを目的とする場合は、トランザクションに依存するのではなく、DynamoDBの一括インポート機能を利用する方が費用対効果が高く効率的である。
DynamoDBのトランザクションは、ユーザーの貴重なフィードバックに大きく影響されており、ユーザーに代わって革新を起こすよう私たちにインスピレーションを与えてくれた。このような優れたチームが私の側にいてくれたことを非常にうれしく思う。Elizabeth Solomon氏、Prithvi Ramanathan氏、Somu Perianayagam氏に深く感謝したい。DynamoDBについてはUSENIX ATC 2022で発表された論文で、DynamoDBのトランザクションについてはUSENIX ATC 2023で発表された論文で詳しく学ぶことができる。