こんにちは。ブランドソリューション開発部プロダクト開発ブロックの岡元です。普段はFulfillment by ZOZOとZOZOMOのブランド実店舗の在庫確認・在庫取り置きサービスの開発、保守をしています。
本記事では、ブランド実店舗の在庫確認・在庫取り置きサービスで実装したCQRSアーキテクチャについて紹介させていただきます。
CQRSの実装においては、データベース(以下、DB)分割まで行い、コマンド側DBにはAmazon DynamoDB(以下、DynamoDB)、クエリ側DBにはAmazon Aurora MySQL(以下、Aurora MySQL)を用いています。また、コマンド側DBとクエリ側DBの橋渡しを担うメッセージングにおいてはOutboxパターンと変更データキャプチャを用いました。DBとメッセージングシステムへの二重書き込みを避けることで障害などのタイミングで顕在化する潜在的なデータ不整合を回避しています。本記事がCQRS実装の一事例として参考になれば幸いです。
目次
ブランド実店舗の在庫確認・在庫取り置きサービスとは
ブランド実店舗の在庫確認・在庫取り置きサービス(以下、店舗在庫連携サービス)は、2021年11月に発表したOMOプラットフォーム「ZOZOMO」が展開するサービスの1つです。お客様は、ZOZOTOWN上でブランド実店舗の在庫を確認できることに加え、在庫の取り置きもできます。ZOZOMOのサービスに詳細ついては、こちらのプレスリリースで紹介しております。
CQRSの概要
店舗在庫連携サービスでは、アーキテクチャにCQRSを採用しました。CQRSは、コマンド(書き込み)とクエリ(読み取り)の操作を分離するパターンです。
CQRSを用いることで、コマンド側のモデルとクエリ側のモデルを分けて管理できます。複雑なビジネスロジックが必要とされることの多い書き込み操作を読み取り操作の関心事から分離することで、それぞれのモデルを比較的シンプルに保つことができます。
コマンドとクエリで共通のDBを用いることもできますが、別々のDBを用いることも可能です。コマンドとクエリでDBを分割した場合はコマンド側のDBからクエリ側のDBに更新を同期する必要があり、システム全体としてはより複雑になります。
CQRSパターンの詳細については、Microsoft社の以下記事で詳しく説明されているので、そちらを参照してください。
店舗在庫連携サービスにおけるCQRSの利点
店舗在庫連携サービスではドメイン駆動設計(以下、DDD)を参考に開発しており、ビジネスロジックを反映したドメインモデルをなるべくシンプルに保つという思想で設計を進めました。また、DDDにおける集約の状態の保存にDynamoDBを用いています。DynamoDBを用いている理由についてはメッセージングが関係しています。詳細は後述します。
また、店舗在庫連携サービスは既存のZOZOTOWN内に組み込む形でサービスを提供しており、以下のように利用目的が異なる3種類のサービスから利用される予定がありました。そのため、将来的にも多種多様なクエリを処理できる必要があると予想されました。
- ZOZOTOWN - お客様が、実店舗の在庫確認、在庫の取り置き依頼などを行う
- FAANS - 店舗スタッフが、確保が必要な在庫などを確認する
- ZOZOTOWNのバックオフィスシステム - カスタマーサポートが、お客様や店舗スタッフからの問い合わせを受け、取り置き依頼状況の確認などを行う
これを踏まえ、店舗在庫連携サービスでは、主に以下の点が利点になると考えCQRSを採用しました。
- DBを分割することによる柔軟なクエリの実現、処理効率の向上
- モデルを分割することによるモデルの保守性、処理効率の向上
DBを分割することによる柔軟なクエリの実現、処理効率の向上
前述の通り、店舗在庫連携サービスでは、DDDにおける集約の状態の保存にDynamoDBを用いています。
しかし、多種多様で複雑なクエリを処理するのが困難なDynamoDBでこのようなクエリを実現しようとすると非効率な実装をせざるを得ない懸念がありました。同様に、モデルの観点からも、集約を処理の基本単位とするドメインモデルで集約をまたぐクエリを実現しようとすると非効率な実装となる懸念がありました。
CQRSを用いることでコマンド側とクエリ側のDBを分割し、それぞれ別々のDBを用いることができるようになります。クエリ側のDBにはAurora MySQLを用いることで、柔軟なクエリを実現できるだけでなく効率的にクエリを処理できると考えました。
モデルを分割することによるモデルの保守性、処理効率の向上
前述の通り、店舗在庫連携サービスでは、ビジネスロジックを反映したドメインモデルをなるべくシンプルに保つという思想で設計を進めました。
しかし、多種多様な要件を持つクエリの関心事にドメインモデルが巻き込まれることで、必要以上にモデルが複雑となる恐れがありました。
CQRSを用いることで、ドメインモデルからクエリの関心事を分離し、コマンドとクエリでそれぞれ別々のモデルを作成できます。モデルを分割することで、それぞれ以下のような利点があると考えました。
- ドメインモデル(コマンド側)- ビジネスロジックに集中することで、モデルをより洗練させることができる
- クエリモデル(クエリ側)- シンプルなモデルになり、効率的に処理を行うことができる
店舗在庫連携サービスにおけるCQRSの実装
以下に店舗在庫連携サービスの構成図を示します。
上図の通り、店舗在庫連携サービスでは、コマンド側DBとクエリ側DBにそれぞれDynamoDBとAurora MySQLを用いています。DynamoDBからAurora MySQLへのデータの同期にはAmazon Kinesis Data Streams for DynamoDB(以下、Kinesis Data Streams for DynamoDB)を用いました。
各モデルについては、コマンド側のドメインモデルはクエリの関心事を気にせず実装できたことで、より洗練されたモデルにできました。クエリ側のモデルもDBにAurora MySQLを用い、リクエストに対応するSQLをほぼそのまま実行し、結果を返すような構成にすることでよりシンプルで効率的なモデルになりました。
また、今回、非同期的な処理ではDDDにおけるドメインイベントを参考にしました。ドメインイベントを用いることで、ドメイン内で発生する何かの出来事についても重要なドメインモデルの一部として扱うことができました。ドメインイベントはKinesis Data Streams for DynamoDBによって送出され、クエリ側DBの更新に用いられています。それだけでなく、メール送信や外部サービスへの連携などのドメインイベントを契機に発生する処理の実行にも利用できました。
CQRSにおけるコマンド側の構成概要
以降では、店舗在庫連携サービスの開発で特に工夫したコマンド側の構成について紹介させていただきます。
メッセージングのためのDynamoDB
店舗在庫連携サービスで行う処理には以下のようなものがあります。
- 在庫の取り置きがされたとき
- お客様へ、取り置き依頼を受け付けた旨のメールを送信する
- FAANSのシステムへ、店舗在庫の確保を依頼する旨の通知を送る
- 外部サービスへ、取り置き依頼された商品の在庫情報を連携する
- 店舗で商品の在庫が確保されたとき
- お客様へ、来店の準備が完了した旨のメールを送信する
これらはドメイン内で発生する出来事であるドメインイベントが契機となり、非同期的に実行される処理です。こういったイベントの発生を元に実行される処理は、店舗在庫連携サービスにおいて多く存在しました。
そこで、DynamoDBの変更データキャプチャ機能(今回はKinesis Data Streams for DynamoDB)とトランザクション書き込みを利用したOutboxパターンによってメッセージングを実現しました。
コマンド側DBにDynamoDBを用いたのは、以下のような理由からです。
- 変更データキャプチャが第一級のインタフェースとしてサポートされており、容易に利用できる
- 複数テーブルをまたぐトランザクション書き込みをサポートしているため、Outboxパターンと変更データキャプチャによるメッセージングを容易に実現できる
変更データキャプチャやOutboxパターンは他のDBでも実現できます。しかし、変更データキャプチャを第一級のインタフェースとしてサポートしないDBでは、効率的な変更データキャプチャを利用するためにDebeziumなどのツールや追加のサービスを導入する必要があります。このようなツールやサービスの導入により追加のメンテナンスコストが発生してしまう懸念があったため、店舗在庫連携サービスではDynamoDBを利用しました。
DynamoDBでOutboxパターンを実現する
Outboxパターンとは
Outboxパターンは分散トランザクションをサポートしないDB、メッセージングシステムにおいて、データの更新とメッセージの書き込みをアトミックに行うためのパターンです。実際には後述する変更データキャプチャを組み合わせて利用します。
分散トランザクションをサポートしないDBとメッセージングシステムに変更を加える場合、それぞれに書き込みを実行する二重書き込み(Dual Writes)を利用する方法が考えられます。しかし、二重書き込みではどちらかの書き込みは成功し、もう一方の書き込みは失敗するような場合や障害が発生するタイミングなどが原因でデータの不整合が生じます。
二重書き込みの問題点については以下の記事が参考になります。
一方、分散トランザクションを用いる場合は、DBとメッセージングシステム両方で分散トランザクションのサポートが必要となるため、利用できる技術が限定されるという問題があります。実際、本記事の執筆時点でDynamoDBやKinesis Data Streams、Apache Kafkaも分散トランザクションをサポートしていません。
Outboxパターンでは、DBとメッセージングシステムへの分散トランザクションを用いた書き込みは行わず、DB上に追加で作成したOutboxテーブルにメッセージの内容を書き込みます。Outboxテーブルへの書き込みにはDBがサポートするローカルトランザクションを用いることができます。
Outboxテーブルへ書き込まれたメッセージは、後述の変更データキャプチャを利用することでメッセージングシステムへ書き込まれます。こうすることで、DBへの書き込みとメッセージングシステムへのメッセージの書き込みをアトミックに実行できます。
Outboxパターンと変更データキャプチャはSagaパターンにおいても、安全なメッセージングを実現するための重要な構成要素として機能するようです。Outboxパターンと変更データキャプチャをSagaパターンに適用する話は以下の記事が参考になります。こちらの記事でも二重書き込みについて触れられています。
店舗在庫連携サービスにおけるDynamoDBを用いたOutboxパターンの実現方法
店舗在庫連携サービスではDynamoDB上に以下の2種類のテーブルを用意し、DynamoDBのトランザクションを用いて書き込みを行っています。
集約ステートテーブル | ドメインイベントテーブル |
---|---|
集約の状態を保存する | 集約の状態が変更する際に発生したドメインイベントを保存する |
レコードにはバージョン(数値)を持たせておき、集約の状態が変わるたびに1インクリメントする | レコードには保存する集約と同じバージョンをもたせておく |
バージョンはDynamoDBの条件付き書き込みによって楽観的ロックを実現するために利用する | バージョンはコンシューマの重複除去、順序のチェックに利用する(詳細は後述) |
ドメインイベントテーブルに加えられた変更は、Kinesis Data Streams for DynamoDBによってメッセージとして送出されます。こうすることで、分散トランザクションを利用すること無く、集約の状態の更新とメッセージングシステムへのメッセージの書き込みをアトミックに実行できます。
また、DynamoDBを用いた楽観的ロックの実装方法については以下のAWSのドキュメントが参考になります。
店舗在庫連携サービスではDynamoDBMapperを使用していませんが、同等の実装を低レベルインタフェースを用いて行っています。
変更データキャプチャを用い、メッセージを送出する
変更データキャプチャとは
変更データキャプチャ(Change Data Capture、CDC)は、DBの変更を追跡し処理を行うためのパターンです。前述のOutboxパターンと組み合わせて利用することでOutboxテーブルの内容をメッセージングシステムに書き込むことができます。
CDCの種類には様々なものがありますが、ここではQuery-based CDCとLog-based CDCの特徴を取り上げます。それぞれの特徴は以下のとおりです。
- Query-based CDC(Timestamp-based CDC、Polling publisherパターン)
- DBテーブルの内容を定期的にポーリングし、変更が追加されていれば処理を実行する
- ポーリングを行うためDBに余計な負荷をかける場合がある
- DBテーブルのタイムスタンプを見て変更を追跡する場合、範囲クエリのサポートが必要になる
- Log-based CDC(Transaction log tailingパターン)
- DBのトランザクションログを追跡し処理を実行する
- ポーリング、範囲クエリのサポートが必要ない
- トランザクションログを利用するのでDB固有のソリューションが必要になる
CDCの種類とそれぞれの特徴については以下の記事が参考になります。
店舗在庫連携サービスにおける変更データキャプチャの利用方法
店舗在庫連携では、DynamoDBが提供しているKinesis Data Streams for DynamoDBという機能を用いています。前述の分類に当てはめると、Log-based CDCのような特徴を持った機能です。
DynamoDBがサポートするCDCの機能には、DynamoDB StreamsとKinesis Data Streams for DynamoDBの2つがあります。それぞれのメリット、デメリットの概要は以下のとおりです。
- DynamoDB Streams
- メリット:レコードが更新順に現れ、順序が保証される
- デメリット:許容されるコンシューマ数が少ない(1シャードあたり2つまで)
- Kinesis Data Streams for DynamoDB
- メリット:許容されるコンシューマ数が多いなど拡張性が高い
- デメリット:レコードが変更順に現れない
その他の特徴については以下の記事に記載されています。
このようなメリットとデメリットがありますが、店舗在庫連携サービスでは、拡張性の高さからKinesis Data Streams for DynamoDBを採用しています。ただし、レコードの順序保証がされないデメリットについての対策が必要です。そこで、店舗在庫連携サービスではドメインイベントテーブルのレコードに1ずつ増加するバージョンを含めるようにしています。メッセージの処理順序が重要なタスクではコンシューマ側でレコードの順序が崩れていないか(取得されたレコードのバージョンが1ずつ増加しているか)のチェックを行っています。また、同様にメッセージの重複除去もバージョンを用いて行っています。
店舗在庫連携サービスのユースケースでは、同じ集約に対して短時間にリクエストが集中することはまれなためレコードの順序の崩れが起きる可能性は低いと考えています。そのため、万が一順序の崩れが発生した場合は、アラートを発火しリカバリを行うような運用を考えています。
ドメインイベントのスキーマ定義にProtocol Buffersを利用する
店舗在庫連携サービスでは、メッセージ(ドメインイベント)のスキーマの定義にProtocol Buffersを用いています。これは、DDDの「公表された言語」を参考にしました。店舗在庫連携サービス内で発生したイベントに関心のある他サービスや今後の機能拡張もこの公表された言語を利用し、ドメインイベントを介することで、店舗在庫連携サービスとは独立して開発できると考えています。
実際の定義は以下のようなものです。
syntax = "proto3"; package order.events; import "google/protobuf/timestamp.proto"; message OrderEvent { string order_id = 1; int32 version = 2; google.protobuf.Timestamp created_at = 3; oneof body { OrderAccepted order_accepted = 4; OrderCanceled order_canceled = 5; } } message OrderAccepted { string order_id = 1; string order_status = 2; int32 goods_id = 3; }
まとめ
本記事では、店舗在庫連携サービスで実装したDynamoDBを用いたCQRSの実装について紹介しました。DB分割したCQRSの実装で肝となるメッセージングにはOutboxパターンとCDCを利用することで、障害などのタイミングで顕在化する潜在的な不整合を回避できました。
今回、非同期的な処理ではドメインイベントの考えを取り入れたことで、モデルにより豊かな表現を取り込むことができました。それだけでなく、システムを疎結合に保つことができたおかげで機能の追加についても比較的容易に対応できました。噂通りそれなしでは生きられなくなるほどの強力なツールだと実感しています。
ブランドソリューション開発部では仲間を募集しています。ご興味のある方は、以下のリンクからご応募ください!