世の中にはクラウドを採用していても、スケーラビリティのないサービスを開発・運用しているエンジニアは少なくないと思います。過去の私もその一人でした。CQRS/Event Sourcingはその解の一つです。私自身も2016年にCQRS/ESに出会って以来、AWS上でその根本的な課題に取り組んで来ました。その経験を生かしてAWSでの実現方法について解説します。
そろそろマネージド、クラウドネイティブで行こう!2021/08/12Chatwork株式会社テックリード加藤潤一AWSでCQRS/Event Sourcingするにはどうすればいいのか1
View Slide
© Chatwork● @j5ik2o● 属性○ Chatwork テックリード■ 次期アーキテクチャのプランニングや設計・開発○ 職歴■ SIer → ドワンゴ → グリー → Chatwork○ DDD, CQRS/Event Sourcing○ Scala, Rust● 最近はまっていること○ Akka,Actixなどを参考に、Rustでアクターモデルを実装すること(習作目的)。既存実装は50弱ある(えっ…)自己紹介2
© Chatworkセッション概要とアジェンダ● セッション概要○ 世の中にはクラウドを採用していても、スケーラビリティのないサービスを開発・運用しているエンジニアは少なくないと思います。過去の私もその一人でした。CQRS/Event Sourcingはその解の一つです。私自身も2016年にCQRS/ESに出会って以来、AWS上でその根本的な課題に取り組んで来ました。その経験を生かしてAWSでの実現方法について解説します● アジェンダ○ CQRS/Event Sourcingの概念理解編○ CQRS/Event Sourcingのアーキテクチャ・実装編■ 実装レベルでどうするか■ ツールは何を使えばよいか■ AWSでどうするとよいか■ Chatworkでの検討中のアーキテクチャ○ まとめ3
© Chatwork概念理解編4
© ChatworkCQRSとは5
© ChatworkCQRSとは● Command and Query ResponsibilitySegregation = コマンド・クエリ責務分離 のこと。分離というより隔離という解釈が正しい● コマンド(書き込み)とクエリ(読み込み)を完全に隔離することを意味する。単にドメインモデルをコマンド用・クエリ用に分割することではない● CQRSはDDDを前提とします(ドメインから本質的ではないクエリ責務を排除するための設計パターン)。詳しくは CQRSDocuments by Greg Young を参照のこと6Write DB Read DBInterface AdaptorCommand ProcessorDomainInterface AdaptorInterface AdaptorRead Model UpdaterQuery ProcessorCommand Side Query SideRead Model UpdaterClient
© Chatworkコマンドとクエリでは要件が異なるデータ構造だけではなく他の要件も異なるので、コマンドとクエリをそれぞれを隔離する7コマンド クエリ一貫性/可用性 トランザクション整合性を使い強い一貫性を重視する結果整合を使い可用性を重視するデータ構造 トランザクション処理をおこない正規化されたデータを保存することが好まれる(集約単位など)非正規化したデータ形式を取得することが好まれる(クライアント都合のレスポンスなど)スケーラビリティ 全体のリクエスト比率とごく少数のトランザクション処理しかしない。必ずしもスケーラビリティは重要ではない全体のかなりのリクエスト比率を占める処理をおこなうため、クエリ側はスケーラビリティが重要
© Chatworkそれぞれの特性にあったストレージを使うべきだが…8
© ChatworkRDBへの書き込みがスケールしない問題● Writeのシャーディング。自前は無理スジ○ Write Sharding: Writer, Read Repclicaのセットを分割して書き分ける○ Vitess(ヴィテス)● いずれにしてもアプリケーション(もしくはミドルウェア)で書き込みを分割する。柔軟なクエリができなくなるという代償を払う必要がある○ 書き込むデータからヒントを得て、どのDBに書き込むかを決める○ 分割されたデータどうしでは結合するクエリはできない○ 2台→4台→8台と手動でWriterを増やすさいデータの移動が必要になる99WriterアプリケーションA-1B-1A-2B-2ReadReplicaReadReplicaWriterアプリケーションA-1A-2ReadReplicaReadReplicaB-1 A-1B-2 A-2WriterB-1B-2A-1B-1A-2B-2A-1B-1A-2B-2そもそもRDBが向かない要件をRDBで解決しようとして複雑化する…
© ChatworkNoSQLでRDBのクエリのような使い方をしてしまう問題● NoSQLはハッシュキーでスケールアウトできる○ ハッシュキーで自動的にシャーディング(パーティショニング)される○ ただしキーでしかエンティティを解決できない● エンティティの属性でもクエリしたい…○ GSIを多用する■ 個数の上限がある● 上限があるなら、転置インデックスを作ろう○ エンティティの更新以外にインデックスデータも更新する必要がある○ エンティティの取得の前にインデックスの解決が必要になる10DynamoDBアプリケーションA-1B-1A-2B-2DynamoDB{ A-1, 技術部, KATO } B-1{ A-2, 総務部, SATO } B-2GSIで部署名で検索できるように…EMP{ A-1, 技術部, KATO }{ A-2, 総務部, SATO }EMP_DEPT_IDX{ 技術部, [ A-1, A-3 ] }{ 総務部, [ B-2, B-4 ] }逆引きする際は、EMP_DEPT_IDXでIDを解決してからEMPを引くことになる…転置インデックスNoSQLで複雑なクエリ要件を取り込もうとしてシステムが複雑化する…使い方を考え直したほうがいい
© Chatworkデータ構造はそもそも非対称11
© Chatwork「注文コマンド要求」と「注文クエリ結果」の違い● コマンド要求はシステムに送られるメッセージ○ システム向けのデータは正規化される● クエリ結果はシステムから返されるメッセージ○ 人間向けのデータは非正規化される12注文ID注文日時商品ID注文数購入者ID注文ID注文日時商品ID商品名注文数購入者ID購入者名商品アカウント注文コマンド要求注文情報のクエリ結果system
© Chatworkスケーラビリティもそもそも非対称13
© Chatwork仮にC/Qを同居させてスケールアウトさせた場合の問題● ほとんどのユースケースではC:Q =2:8● QのコンテナにCを同居させた場合は、Cが過剰にスケールさせてしまう○ リクエストが来なければリソース消費しない工夫ができそう● CのコンテナにQを同居させた場合は、Qのスケーラビリティが不足する○ 物理的に足りないのは問題なので結局多いほうに合わせることになる14C CQ Q Q Q Q Q Q Q Q QC CQ Q Q Q Q Q Q Q Q QC C C C C C C CQに合わせるとCが過剰になるCに合わせるとQが不足する
© Chatwork実装上の問題15
© Chatwork実装上の問題(1/2)16● クエリ要件を満たすことでリポジトリが複雑になる。クエリするだけでドメインロジックを呼び出さない。他にもページングやソートも扱うケースがある…。● レスポンス用DTOをリポジトリで組み立てるため、非効率なN+1クエリが発生するval employees = employeeRepository.findByDeptIdsWithEmpNamePatterns(deptIds, empNamePatterns)// このあとに、ドメインロジックはない。 DTOに詰め直してクライアントに返すだけ。// ドメインロジックを起動するためではなく、データを閲覧するためだけに使っていることがあるval reservationDtos = reservationRepository.findByIds(ids) // SQL発行.map{ reservation =>val hotel = hotelRepository.findById(reservation.hotelId) // SQL発行val customer = cusotmerRepository.findById(reservation.customerId) // SQL発行new ReservationDto(reservation, hotel.name, customer.name) // アプリケーション空間で結合及びデータを捨てる}ドメインはドメインの、クエリはクエリの都合で最適化が求められる
© Chatwork実装上の問題(2/2)17● コマンドを意識しないデータ指向では、エンドポイント、アプリケーションサービス、ドメインがCRUDの用語に汚染されてしまう、という仮説がある○ 商品の注文がcreatePurchaseItem?○ 注文のキャンセルがupdatePurchaseItem?● ドメインの動詞を重視するコマンド指向では、orderItem, cancelOrderなどユビキタス言語にフォーカスできるようになる。コマンドの表現によって意図が明白なインターフェイスを作ることができる○ ただこの考え方は、CRUDであっても注意深く設計すれば可能…。○ 実装というより分析の段階でコマンドを使うことのメリットが強いCQRSは非機能の観点が注目されがちだが、本来の目的はコマンド指向のドメインモデリングにある…
© ChatworkCQRSのPros/Cons18● 利点○ コマンドとクエリが分離しているため、耐障害性が高くなる(耐障害性に寄与する)。別々にデプロイできる○ コマンドとクエリを必要に応じて個別に最適化できる(弾力性に寄与する)。別々にスケールさせることもできる● 欠点○ 非CQRSと比べて手間が掛かる。目的ごとにサブシステムを分けるので構成要素が多くなる
© ChatworkEvent Sourcingとは19
© ChatworkEvent Sourcingとは● 唯一信頼できる情報源(Single Source Of Truth)は、状態(ステート)ではなく(ドメイン)イベントという考え方● CRUDでは従来からの最新状態を常に上書きするが、イベントは事実を記録するだけ20Event SourcingCRUD(State Sourcing)Account { ID=1,NAME=KATO }Account { ID=1,NAME=SATO }AccountCreated{ ID=1, NAME=KATO }AccountRenamed{ ID=1, NAME=SATO }最新のエンティティを上書きする そのときのイベントを追記する
© Chatworkドメインイベントは● イベントは過去に起きた出来事を意味する● ドメインイベントは、ドメイン上のイベントを意味する● 一般的には過去形の動詞として表現される○ CargoShipped○ CustomerRelocated● イベントからコマンドが想起可能○ ShipCargo○ RelocateCustomer● イベントとコマンドは似ているが別概念○ コマンドは拒否されることがある○ イベントはすでに起こったことを示す21ショッピングカートのイベント
© Chatwork通知にイベントを使う● CQRSはコマンド側からクエリ側に変更を伝える必要がある○ コマンド側のドメインイベントをクエリ側に伝える● 上記の現実的な実現手段として以下がある○ ポーリング○ Pub/Sub● (賢くない)ポーリングは極力避ける○ 賢くないポーリングだと、ただ負荷をかけるだけになる○ 見た目はPub/Subであっても内部実装はポーリングだったりすることがある。無駄なI/Oがなければよい● 通知は上流から下流にイベントを流すことが都合がよい22CQCQ変更がないときもポーリングで負荷をかけてしまう変更があるときだけイベントを通知するポーリングはスケールしないEventをPub/Subする
© ChatworkEvent SourcingのPros/Cons● 利点○ イベントは更新されず追記のみなので、スケーラビリティが確保しやすい○ 特定の時点のリードモデルをイベントから導出することができる■ ドメインイベントがあれば、リードモデルの設計をいつでもやり直せる○ データマイグレーションコストではゼロではないが■ 監査ログや行動履歴の分析に利用することができる● 欠点○ 大量のイベントから状態をリプレイする際に時間がかかる■ 最新状態を保存したスナップショットを使うとリプレイ時間を短縮できる○ 原則的にすべてのイベントをストレージに保存する必要がある■ スナップショット保存時に、古いイベントを消すことも可能23
© Chatworkアーキテクチャ・実装編24
© ChatworkCQRS/ESを前提にしたアプリケーションアーキテクチャ25ドメインイベント集約リードモデルコマンドプロセッサクエリプロセッサコマンドリクエストコマンドレスポンスクエリリクエストクエリレスポンスリードモデルアップデータクライアントドメインオブジェクト群ドメインの語彙で命令するクライアントの画面や帳票に合わせたクエリ結果を返すPKey=集約ID, SKey=シーケンス番号,本体=ドメインイベントドメインイベントを基にリードモデルを作るリードモデル構築時間はレスポンスタイムに反映されない
© Chatworkコマンドプロセッサの実装イメージ(1)● CRUDとはまったく様相が異なるコードになります○ DBには追記しかしない● 大まかな流れ○ イベントからドメインオブジェクトを再生○ ドメインロジックを実行○ イベントを永続化26・データ競合を防ぐためのロックができないのでは…・イベントが長大な場合、集約の再生に時間かかるのでは…
© Chatworkスナップショット機構を追加する27イベント集約リードモデルコマンドプロセッサクエリプロセッサコマンドリクエストコマンドレスポンスクエリリクエストクエリレスポンスリードモデルアップデータクライアントドメインオブジェクト群ドメインの語彙で命令するクライアントの画面や帳票に合わせたクエリ結果を返すスナップショット
© Chatworkコマンドプロセッサの実装イメージ(2)● スナップショットには集約の最新状態が含まれる。スナップショット以降に発生したイベントの部分集合だけを取得することで、再生時間をショートカットできる● スナップショットの楽観的ロックを使うことでデータ競合を回避できる。○ スナップショットとイベントは同一トランザクションが望ましい28リクエスト毎に、リプレイやスナップショット保存のオーバヘッドがかかる…
© Chatwork具体的に実装するには…29
© Chatwork● akka/akka○ JVM, .NET版○ 2011年から● AsynkronIT/protoactor○ Go, C#, JVM対応○ 2016年から。まだリリースはされていない?● commanded/commanded○ Elixir○ 2016年● VLINGO XOOM○ JVM, .NET対応○ 2020年から○ 実践ドメイン駆動設計の著者ヴァーノンさんの会社で開発しているCQRS/ESのためのツール30
© ChatworkFYI: AWSに対応したakka-persistenceプラグイン● ジャナールをDynamoDBに、スナップショットをS3に配置するためのプラグインがある● 他のストレージに対応したプラグインも数多くある31
© Chatwork特別な仕組みなし&DynamoDBでやるなら…● CartSnapshot, CartEventsを同じトランザクションで書き込む。CartEventsのNewImageをStreamからコンシュームし後段につなげる。もしくは、CartSnapshotだけにして、OldImage, NewImageの差分計算にyよってCartEventを生成する方式。後段につなげるパターンは同じ。● 後段はKCLで使う。リードモデルは必要に応じてNoSQL, RDBMSを選択する● 詳しくは DynamoDBを使ったCQRS/Event Sourcingシステムの構築方法(言語・F/W非依存) を参照32
© ChatworkAkkaでは一行で済みます!● Akkaでは集約をクラスタリングされたノード上に起動するアクターとして実装します● ひとつひとつに集約のIDが振られていて一意に識別されます。どのノードからでも集約アクターへコマンドを送信し返事をもらうことができます● コマンドが受理されるとイベントが永続化されます。永続化されたイベントはアクターの再生に利用されます。スナップショットの保存タイミングも選択することができます● 最初のリクエストでアクターが起動しワークロードがなくなるとアクターは停止します33
FYI:Chatwork新アーキテクチャの概要stateShardShardShardRegionRoomAggregateActorJournal DB(DynamoDB)SnapshotStore(S3)MessageBusRMU Read DBReadAPIReadAPIReadAPIControllerUseCaseWrite APIServerWrite APIServerWrite APIServerコマンドサイドakka-clsuter他のMSへlogicClientID = 1クエリサイドサーバーサイド・チームクライアントサイド・チーム● 非同期・ノンブロッキング● スーパービジョン● 位置透過性● ステートフル● DBとの完全な同期によって読み込み不要● ワークロードのパーティショニング● 正規化されたデータ構造を扱う● ネットワーク分断時は一貫性を重視コマンドサイドではドメインロジックを実行してドメイン状態を変える機能のみを提供するクエリサイドはドメインイベントをもとにクライアントにとって都合のよいリードモデルを構築する● 非同期・ノンブロッキング● ステートレス● 非正規型データを扱う● ネットワーク分断時は可能性を重視● ラムダアーキテクチャでも十分可能MessagePostedMessageDTOPostMessageMessageDTOID = 2ID = 3
© Chatworkまとめ● 可用性やスケーラビリティが問われるシステムでは、CQRS/EventSourcingはよい選択肢になる。実現難易度はそこそこあるが一昔前より環境が整ってきているので、マイクロサービスアーキテクチャを考えるうえで検討の余地があると思います● FYI: DDD, CQRS/Event Sourcingに興味があるけどどこから手を出せばいいかわからない人、質問箱に投稿してくれれば答えます35
© Chatwork本日はありがとうございました36