HAQM Web Services ブログ
HAQM DynamoDB を使った CQRS イベントストアの構築
コマンドクエリ責務分離(command query responsibility segregation, CQRS)パターンは、もともとコマンドクエリ分離の原則から導出されたもので、ドメイン駆動設計コミュニティの啓蒙によって広く知られるようになったものです。イベントソーシングを使用した CQRS アーキテクチャは、イベントストア と呼ばれる追加のみを許されたログに、作成されたイベントを保存します。イベントソーシングを利用することによっていろいろなメリットがありますが、中でも次のようなことができるようになります:
- データベースとメッセージブローカーを跨ぐような複雑な分散トランザクション(いわゆる 二相コミットプロトコルによるトランザクション)を用いなくてもデータベースの更新とイベントやメッセージの送信を両立させるアプリケーションを設計することができる
- オブジェクト-リレーショナル間における意味的な不整合(object-relational impedance mismatch)を軽減することができる
- データ変更の全ての履歴を保持することができる
- イベントストアを監査ログやデバッグの補助として利用することができる
- 専用の非正規化されたビューを使った高パフォーマンスなクエリをサポートすることができる
気を付けなければならないのは、CQRS とイベントソーシングを用いたパターンは複雑な構成なので、シンプルな create / read / update / delateのようなスタイルのアプリケーションでは用いるべきではないということです。更にいうと、実装者は結果整合性を見越してそれを管理しなくてはなりません。これを前提に、CQRS とイベントソーシングは、以下のようなユースケースには有用とされる可能性があります:
- リッチなドメインモデルをサポートしており、モデルが複雑なビジネスロジックやルールを内包している
- マイクロサービス間やシステム境界を跨いでイベントを伝播させる必要がある
- イベントストーミングのようなイベント起点のアプローチを使って設計されている.
この記事では、HAQM DynamoDB を使ってイベントストアを実装する方法について議論していきます。
始める前に
この記事は、読者が既に CQRS とイベントソーシング、DynamoDB について基礎的な部分を理解していることを想定しています。(訳注:CQRS とイベントソーシングの基礎については訳者の登壇しているこちらのウェビナーがご参考になりますので是非ご覧になってみてください。PDFはこちら。)
イベントソーシングと CQRS
下記の図1で示されているダイアグラムは、READ と WRITE のモデルを含んだ CQRS のアーキテクチャを示しています。
図1 – Event sourcing を用いた CQRS の READ / WRITE モデル
CQRS の READ / WRITE モデルは、アプリケーションの要件を満たすために独立して設計することができます。WRITE モデルはコマンドを処理する集約で構成されています。コマンドはシステムを変更する意図を表すものとして使われます。READ モデルはクエリを受け取り、そのクエリをマテリアライズドビューに対して適用するもので、読み取り要求をサポートするために使われます。
集約というのは、一つ以上のコンポーネントオブジェクトから構成されるもので、それぞれの集約内のオブジェクトは、ドメインのビジネスロジックをカプセル化し、振る舞いを強制しています。CQRS アーキテクチャがイベントソーシングを使用する場合、集約によって作成されたデータは順序のついたイベント のコレクションとして保存されます。例えば、Widget(部品)
という集約があり、ウェブサイトで販売されている小さなデバイスのドメインを管理する責務を持っているものとします。この Widget
集約は部品の名前を変更するという機能を持っています。クライアントシステムはこの集約に ChangeWidgetName
というコマンドを送って部品の名前の変更を要求することができます。このコマンドの処理が成功すると、Widget
集約は自身の変更された状態を WidgetNameChanged
というイベントの形で発生させます。この新しいイベントは新しい行としてイベントストアに保存されることになります。
状態をリストアするためには、集約はイベントストアからこれまでに発生したイベントを時系列で取り出さなければなりません。発生したイベントはアーキテクチャ内の READ モデルに流され、非正規化を行うコンポーネントが流れてきたイベントを使ってクエリ要求をサポートするためのマテリアライズドビューを作成します。
DynamoDB によるイベントストアの設計
HAQM DynamoDB は、あらゆるスケールで1桁ミリ秒単位のパフォーマンスを実現することのできる key-value 型の NoSQL データベースです。フルマネージド・マルチリージョン・マルチアクティブなデータベースで、セキュリティ、バックアップとリストア、インターネット規模のアプリケーションに対応したインメモリキャッシングといった機能を提供しています。
重要: DynamoDB を利用する際は、DynamoDB を含めた設計やアーキテクティングの際のベストプラクティスに従ってください。DynamoDB では、テーブルのスキーマ構造は柔軟にすることができます。違った構造の項目を同じテーブルに格納することができ、その際に強制されるのはテーブルのプライマリキーとセカンダリインデックスを持つことのみです。項目が一意であることを保証するために、複合キーを使っている場合はパーティションキーやソートキーに異なるプレフィックス値をつけることもできます。構築するアプリケーションにどのようなアクセスパターンがあるかを理解することが、DynamoDB の実装を成功させるための重要なポイントとなります。
イベントストアは、トラディショナルなリレーショナルデータベースのテーブルを使っても実装することができます。しかし、DynamoDB を使うことによってこのアプローチよりも多くの利点が出てきます。例えば:
- DynamoDB は、サーバーレステクノロジー群に属するため、サーバーの管理は不要ですし、インフラの管理に多大な時間をかけなければならないということもありません。つまり、ビジネスロジックの構築や自分のプロダクトの拡張に、もっと時間をかけることができます。
- NoSQL テクノロジーに関連したパフォーマンスにおける利点を享受できます。イベントストアの設計がテーブル間や関係間の結合によって特徴づけられるようなものではないという点において、DynamoDB の理想的な候補となると言えるでしょう。
- DynamoDB は HAQM DynamoDB Streams を利用した変更データキャプチャ(Change Data Capture, CDC)を、ネイティブでサポートしています。こちらの詳細は、後ほどこの記事の中で取り上げます。リレーショナルデータベースにCDC を追加することは通常簡単なことではなく、追加でツールを購入しなければならないようなケースもあります。
- DynamoDB global tables 機能は、異なる HAQM Web Services (AWS)リージョン間で書き込みを行いながら、サービスにデータのレプリケーションや競合の解消を任せられるような分散アーキテクチャを構築する際の有力な選択肢となります。
イベントストアには、下記の図2で示す通り3つのエンティティタイプが存在します(訳注:このエンティティタイプは直接DynamoDBのテーブル名を指すので以後テーブルを指す場合にはアルファベット表記にしています):
- Aggregate(集約):ビジネスロジックの実装をカプセル化するCQRSパターン
- Event:何かが起こったことを示すイベント
- Snapshot:特定の時系列ポイントまでに起こったイベントから導出された状態を示すスナップショット。スナップショットを使うとランタイムで全てのイベントの履歴を保持したりロードしたりする必要がなくなります。
図2 -イベントストアのエンティティ関係を示す図式
この設計では、それぞれのエンティティタイプはそれぞれのアクセスパターンに基づいて読み込まれるものなので、このモデルを構成するにはエンティティタイプごとに一つテーブルを対応させるのが良いでしょう。このことにより、ストレージとスループットの効率化のために、プライマリキーとデータ型を最適化することができ、さらにテーブルレベルの選択肢としてバックアップ、エキスポート、定期スキャン、ストレージクラス、キャパシティモードを、それぞれのエンティティタイプとそのアクセスパターンの異なる要件に対してサポートすることができます。
Event テーブル
識別子(ID)として 123
という値を持つ Widget
集約があると仮定します。下記の図3ではこの例を使って、時間を追うごとにどのようにこの集約の状態がEvent テーブルに一連のイベントとして保存されていることを示しています。
図3 – DynamoDB Event テーブル
DynamoDB では、項目(item)はリレーショナルデータベースの行と同等のもので、属性(attribute)はフィールドと同質のものです。前述の図3では、Event テーブルは3つのイベントを含んでいます:
WidgetCreated
: パーティションキー(PK)=123
、ソートキー(SK)=1
を持つWidgetNameChanged
: パーティションキー(PK)=123
、ソートキー(SK)=2
を持つWidgetDescriptionChanged
: パーティションキー(PK)=123
、ソートキー(SK)=3
を持つ
Event テーブルの設計では、複合プライマリキーを使っています。パーティションキーを number 型、ソートキーも number 型として構成しています。ソートキーを number 型とした場合、範囲クエリを行ってイベントを時系列に従ってイベント番号をインクリメンタルにソートすることができます。ソートキーとして string を使った場合、DynamoDB は UTF-8 の文字列エンコーディングのバイトとして文字列を照合・比較します。
注意: 本番環境で使えるような CQRS ソリューションは、もっと多くの属性値が必要となりますが、イベントストアのデザインを表現するという目的においては、今回の属性のみで十分でしょう。
イベントのペイロードは JSON にシリアライズされた状態で payload 属性に保存されています。しかしながら payload 属性は文字列として定義されているため、他のシリアライズのためのフォーマットを使うことも可能です。(例えば protocol buffers など)
ソートキー
の値は、イベントのエンティティが作成された順番を示しています。この値は所有者である集約の単位で一意であり、その集約自身に管理される形でイベントの数や順序を追跡することができます。
イベント項目は、既に起こった事実についての言及なので変更不可能であるべきです。イベント項目を保護するために、特定のテーブルや項目に対してのアイデンティティベースのアクセスポリシーを作成して、アクセスの制御をすることができます。
Aggregate テーブル
下記の図4では、aggregate(集約)テーブルの描写をしています。テーブルには例が一つ、PK= 123
を持つ集約ルート(訳注:集約におけるトップレベルオブジェクト)である Widget
が入っています。
図4 – DynamoDB Aggregate テーブル
Aggregateの項目 123
では、集約ルート項目である一つのエントリだけが必要で、それが Widget
集約として表現されています。この項目は last_events
属性という Widget
集約によって生成された最新の未処理イベントのソート済みマップを含んでいます。この例では、集約 123
が前回コマンドに呼び出された際、イベント 4
の WidgetStockUpdated
が作成されましたが、このタイミングではまだイベント項目としては記録されていない状態です。このイベントは最終的には後続のステップの一部によってEvent テーブルの個々の項目として作成されるもので、これについては後ほど出てくる集約読み込みのアルゴリズムのセクションで議論します。その他の興味深い属性としては version
があります。集約ルート項目である 123
は version
の値として 2
を保持しています。つまり、この集約は作成されてから2回更新されたということになります。version
属性の値の初期値は 0
です。DynamoDB は version
属性をうまく使うことによって楽観的ロックをサポートすることができます。これは同時接続の制御メカニズムで競合状態(race condition)を取り扱うことができるものです。DynamoDB の条件式の機能を使って実装することができます。
重要:本番システムでは、集約 ID として扱われるパーティションキーの値は、一意で均等に分布したデータをサポートするようにしてださい。
Snapshot テーブル
図5は Snapshot テーブルの設計を示しています。このテーブルには、これまでに起こったイベントに対するスナップショットが含まれており、ここではイベント2という、PK= 123
でSK= 1
の項目が入っています。
図5 – DynamoDB Snapshot テーブル
スナップショットで使われるソートキー
の値は、イベントの時と同じように、どのシーケンス番号でスナップショットが作成されたかを示しています。この値は所有している集約のコンテキスト内では一意であり、スナップショットの数や順序を集約自身が管理するためのものとして使われます。event_number
属性はスナップショットが作成された時に処理されたイベントの数を格納します。
注意:本番環境に耐えうるような CQRS ソリューションでは、10、100、もしくは1000イベントといった単位でのみスナップショットを取るような設計をすることもあります。
有効期限属性を使ったイベントとスナップショット項目の期限設定
イベントやスナップショットが新しいものに取って代わられたら、DynamoDB の有効期限(Time to live, TTL)属性が古くなった項目に追加されます。TTLを使うと、有効期限を示すタイムスタンプのついている項目については DynamoDB が綺麗に消去してくれるので、Event と Snapshot テーブルについてはサイズを最小限に抑えることができます。それぞれの項目は有効期限を迎えて消去されてしまう前にアーカイブすることができます。TTLは、スナップショットが作成されたタイミングで HAQM DynamoDB Streams と AWS Lambda Triggerを使って設定することができます。
下記の図6は、Snapshot テーブルにスナップショット項目が書き入れられた結果として、DynamoDB Streams が Lambda 関数を起動し TTL 属性を Event テーブルのイベント項目に設定する方式を示しています。
図6 – DynamoDB Streams と Lambda 関数の流れ
注意:TTL を使って削除された項目についても、DynamoDB Streams によって処理することができます。DynamoDB は TTL 値が示している値から48時間以内に項目を消去しようとします。
集約読み込みのアルゴリズム
集約が何らかの処理のためのコマンドを受け取ると、集約の状態が読み込まれ復元されることになります。コマンドは集約のIDを持っている必要があります(前述の例では、123
がこれにあたります)。
この集約読み込みアルゴリズムはキー条件式を適用したクエリを利用しており、その中で集約 ID はパーティションキーとして関連した項目のコレクションを選択的に読み込む範囲を決めるために使用されます。ソートキーも使っているので、最新のスナップショットは ScanIndexForward
パラメータを false
に、そして Limit
を 1
にすることで取得することができ、新しいイベントはbetween / greater than or equal to / less than or equal to といった範囲関数を使って読み込むことができます。
注意:処理されたコマンドが新しい集約を作成する場合、前述の読み込み処理は発生しません。
集約の状態を復元するための手順はこちら:
- 集約ルート項目を読み込む
- 現在の集約の状態を準備する(準備フェーズ)
- (もしあれば)最後のスナップショットを読み込む
- スナップショットに取り込んでいない残りのイベントを読み込む
下記の図7は、イベントストアテーブル群から集約の状態を読み込んでくる手順のシーケンスを示したものです。この図は、サービスやレポジトリといったアプリケーション層と層同士のやり取りについても表現しています。
図7 – 集約読み込みのシーケンス図
図7で描かれている集約の状態を読み込む手順の詳細は以下の通りです:
- 集約ルート項目の読み込み:GetItem オペレーションを使って、渡された識別子をもとに、Aggregate テーブルから関連する集約項目を取得します。もしこの操作で項目が見つからなければクライアントにはエラーが返ることになります。
- 現在の集約の状態の準備:イベントデータの準備フェーズによって構成されます。前回集約が保存された際に作成された(一つ又は複数の)イベントは、このタイミングで Event テーブルに個々の項目として永続化されます。
PutItem
オペレーションはlast_events
属性内の各イベントのために作られます。イベントが既に Event テーブルに存在している場合、イベントは変更不可能なので永続化する必要はありません。一般的には、last_events
に保存されているマップには一件のイベントしか存在しないはずなので、それほど大きなオーバーヘッドにはならないでしょう。このステップは、ビジネスロジックの適用の前にイベントと集約のデータが正しい状態にあることを保証するために重要なものです。
重要:DynamoDB における項目の最大サイズは、400KB となっているため、項目のサイズをこの制限に対応させるために、各イベントを別々の項目として保存することは重要なことです。
- 最新スナップショットの読み込み:対象の集約がスナップショットを保持しているかの確認から始まります。これを行うには、DynamoDB に Snapshot テーブルから最新のスナップショットを見つけるクエリを発行する必要があります。このクエリの中では、
ScanIndexForward
をfalse
に設定してソートキー上で反転ソートを行い、Limit
を1
に設定します。この設定によって、最新のスナップショットのみが返却されることを保証することができます。スナップショット項目はevent_number
属性を保持しており、これを使って次のステップでどのイベントを読み込めば良いかを決めることができます。この属性は、前回スナップショットが取られてから発生したイベントのみを Event テーブルから取得することを保証するものとなります。 - 残りの項目の読み込み:関連するすべてのイベントを読み込んで集約の状態を復元します。範囲キークエリを使ってイベントを取得することができます。これを行うには、
ソートキー >= n
(n は取得するのに必要な最新のイベントの数)の条件を使って Event テーブル上のイベントをクエリします。
手順4の完了を以て、この集約と関連データはすべてメモリ上に読み込んだと見なすことができ、現在受け取っているコマンドの処理ができるようになるというわけです。
集約の保存アルゴリズム
集約はコマンドの処理が成功した後には、保存されることになります。この処理に成功した集約は、イベントと(オプションで)スナップショットを発生させます。
イベントストアに集約の状態を保存するには以下の手順を行います:
- 集約ルート項目を保存する
- (オプションで)スナップショット項目を保存する
注意:手順1の前提としては、既存の集約の場合はその状態がメモリ上に完全に展開されています。新しい集約についてはこの前提は適用されません。下記の図8は、集約をイベントストアテーブルに保存する手順のシーケンスを示したものです。
図8 – 集約保存のシーケンス図
図8に描かれている集約を保存するための手順は以下の通りです:
- 集約ルート項目の保存は、Aggregate テーブルに対する単一の PutItem オペレーションからなります。このオペレーションは、不正確に既存のデータが上書きされないように
version
属性を使います。DynamoDBは、単一項目のアトミックな書き込みに対して条件式 の使用をサポートしています。DynamoDB Transactions は、二つ以上の項目を変更したいときにおすすめです。このケースでは設計上、手順1で行われる集約の保存処理における単一の項目への書き込みだけが必要です。集約項目に対するPutItem
オペレーションは、現在のコマンドを処理した応答の中で集約によって生成された、イベントの順序付きマップ(last_events
属性)が含まれます。マップ内のイベントは、集約が読み込まれる際の準備フェーズで個々の項目として改めて保存されます。このオペレーションは集約の読み込みアルゴリズムのセクションで既に議論しました。 - スナップショット項目の保存は、処理の完了した集約がイベントの他にスナップショットを放出する場合のための追加手順です。スナップショットを保存する際には Snapshot テーブルにに対する
PutItem
オペレーションが追加で必要になります。
集約の読み込みと保存の例
このセクションでは、集約が保存/読み込みされる際にどのようにイベントストアテーブル群が更新されていくかを示します。保存と読み込みの処理は集約がコマンドを処理する際に開始されます。
CreateWidget コマンド
下記の図9に示している通り、Widget
集約項目は、CreateWidget
コマンドの処理が成功した後、Aggregate テーブルに作成されます。
図9 – CreateWidget コマンドは aggregate テーブルを更新する
ChangeWidgetName コマンド
下記の図10では、ChangeWidgetName
コマンドを受け取って処理する際の更新内容を示しています。前回(Aggregate テーブルに)保存されていた WidgetCreated
イベントが Event テーブルに新しい項目として作成されています。
図10 – ChangeWidgetName の前処理の中で Event テーブルを更新する
下記の図11は、集約が ChangeWidgetName
コマンドの処理をした後で、Aggregate テーブルの集約項目が更新されるところを示しています。
図11 – ChangeWidgetName コマンドは、aggregate テーブルの更新を行う
ChangeWidgetDescription コマンド
下記図12は、ChangeWidgetDescription
コマンドを受け取って処理する際の更新内容を示しています。先ほど保存された WidgetNameChanged
イベントが新しい項目としてEvent テーブルに作成されます。
図12 – ChangeWidgetDescription コマンドの前処理でEvent テーブルを更新する
下記の図13は、集約が ChangeWidgetDescription
コマンドの処理をした後で、Aggregate テーブルの集約項目が更新されることを示しています。
図13 – ChangeWidgetDescription コマンドは、Aggregate テーブルの更新を行う
DynamoDB Streams によるイベントストアの変更データキャプチャ
すべてのEvent テーブルの変更は、データを24時間永続化しておくこのとできるメッセージバスである DynamoDB Streams を通して利用可能となります。DynamoDB Streams はテーブルで発生したすべての変更を保存し、関連する項目をちょうど一度だけ、(項目ごとに)変更の発生した順序で届ける保証をしてくれます。アプリケーションはこの変更ログから読み取りを行い、他の(複数の)サブスクライバにイベントを伝播することができます。
結論
もしあなたの組織がデータドリブンであるなら、アプリケーション設計の際に今現在や未来のデータのインサイトをサポートすることを保証しなければなりません。AWS上でDynamoDB を中心に据えた CQRS とイベントソーシングのソリューション構成を利用すれば、将来においても有効なデータプラットフォーム構築への著しい進展が期待できます。
この記事では、DynamoDB を利用してどのようにイベントストアを構築するかの概要をお伝えしました。以下のようなDynamoDB の機能を使って、イベントストアの実装に必要な基本的な手順をご紹介してきました:
- TTL(有効期限)属性
- 楽観的ロックと条件式
- 変更データキャプチャのための DynamoDB Streams
- パーティションキーとソートキーを使用したDynamoDB クエリ効率の最大化
AWS では、CQRS アーキテクチャのその他のコンポーネント群を構築するために利用できるサービスも用意しています。例えば:
- HAQM Kinesis Data Streams と HAQM EventBridge による、イベントバスやコレオグラフィーの機能の提供
- AWS Step Functions によるオーケストレーションや Saga パターンのサポート
- AWS Lambda による集約や非正規化のためのコードのホスト
- HAQM S3 によるイベントのアーカイブや、イベントストアから流れてくるリッチなビジネスイベントによって構成されたデータレイクのホスト
更なる情報はこちらからご覧いただけます:
- イベントソーシングや CQRS についてのMartin Fowler 氏の記事(martinFowler.com)
- イベントソーシングや CQRS についてのChris Richardson 氏の記事(Microservices.io)
翻訳はソリューションアーキテクトの野村侑志(@ugnomura)が担当しました。原文はこちらです。