Upgrade to Pro — share decks privately, control downloads, hide ads and more …

AWSでCQRS Event Sourcing するにはどうすればいいのか

AWSでCQRS Event Sourcing するにはどうすればいいのか

世の中にはクラウドを採用していても、スケーラビリティのないサービスを開発・運用しているエンジニアは少なくないと思います。過去の私もその一人でした。CQRS/Event Sourcingはその解の一つです。私自身も2016年にCQRS/ESに出会って以来、AWS上でその根本的な課題に取り組んで来ました。その経験を生かしてAWSでの実現方法について解説します。

かとじゅん

August 12, 2021
Tweet

More Decks by かとじゅん

Other Decks in Programming

Transcript

  1. © Chatwork • @j5ik2o • 属性 ◦ Chatwork テックリード ▪

    次期アーキテクチャのプランニングや設計・ 開発 ◦ 職歴 ▪ SIer → ドワンゴ → グリー → Chatwork ◦ DDD, CQRS/Event Sourcing ◦ Scala, Rust • 最近はまっていること ◦ Akka,Actixなどを参考に、Rustでアクターモデル を実装すること(習作目的)。既存実装は50弱ある (えっ…) 自己紹介 2
  2. © Chatwork セッション概要とアジェンダ • セッション概要 ◦ 世の中にはクラウドを採用していても、スケーラビリティのないサービスを開発 ・運用しているエンジニアは少なくないと思います。過去の私もその一人でし た。CQRS/Event Sourcingはその解の一つです。私自身も2016年にCQRS/ESに

    出会って以来、AWS上でその根本的な課題に取り組んで来ました。その経験を生 かしてAWSでの実現方法について解説します • アジェンダ ◦ CQRS/Event Sourcingの概念理解編 ◦ CQRS/Event Sourcingのアーキテクチャ・実装編 ▪ 実装レベルでどうするか ▪ ツールは何を使えばよいか ▪ AWSでどうするとよいか ▪ Chatworkでの検討中のアーキテクチャ ◦ まとめ 3
  3. © Chatwork CQRSとは • Command and Query Responsibility Segregation =

    コマンド・クエリ責務分 離 のこと。分離というより隔離という解 釈が正しい • コマンド(書き込み)とクエリ(読み込み)を 完全に隔離することを意味する。単にド メインモデルをコマンド用・クエリ用に 分割することではない • CQRSはDDDを前提とします(ドメインか ら本質的ではないクエリ責務を排除する ための設計パターン)。詳しくは CQRS Documents by Greg Young を参照のこと 6 Write DB Read DB Interface Adaptor Command Processor Domain Interface Adaptor Interface Adaptor Read Model Updater Query Processor Command Side Query Side Read Model Updater Client
  4. © Chatwork コマンドとクエリでは要件が異なる データ構造だけではなく他の要件も異なるので、コマンドとクエリをそれぞれを隔離する 7 コマンド クエリ 一貫性/可用性 トランザクション整合性を使い強い一 貫性を重視する

    結果整合を使い可用性を重視する データ構造 トランザクション処理をおこない正規 化されたデータを保存することが好 まれる(集約単位など) 非正規化したデータ形式を取得するこ とが好まれる(クライアント都合のレス ポンスなど) スケーラビリティ 全体のリクエスト比率とごく少数のト ランザクション処理しかしない。必ず しもスケーラビリティは重要ではない 全体のかなりのリクエスト比率を占め る処理をおこなうため、クエリ側はス ケーラビリティが重要
  5. © Chatwork RDBへの書き込みがスケールしない問題 • Writeのシャーディング。自前は無理スジ ◦ Write Sharding: Writer, Read

    Repclica のセットを分割して書き分ける ◦ Vitess(ヴィテス) • いずれにしてもアプリケーション(もしくはミド ルウェア)で書き込みを分割する。柔軟なクエリ ができなくなるという代償を払う必要がある ◦ 書き込むデータからヒントを得て、どの DBに書き込むかを決める ◦ 分割されたデータどうしでは結合するク エリはできない ◦ 2台→4台→8台と手動でWriterを増やす さいデータの移動が必要になる 9 9 Writer アプリケーション A-1 B-1 A-2 B-2 ReadReplica ReadReplica Writer アプリケーション A-1 A-2 ReadReplica ReadReplica B-1 A-1 B-2 A-2 Writer B-1 B-2 A-1 B-1 A-2 B-2 A-1 B-1 A-2 B-2 そもそもRDBが向かない要件をRDBで解決しよう として複雑化する…
  6. © Chatwork NoSQLでRDBのクエリのような使い方をしてしまう問題 • NoSQLはハッシュキーでスケールアウトできる ◦ ハッシュキーで自動的にシャーディング(パー ティショニング)される ◦ ただしキーでしかエンティティを解決できない

    • エンティティの属性でもクエリしたい… ◦ GSIを多用する ▪ 個数の上限がある • 上限があるなら、転置インデックスを作ろう ◦ エンティティの更新以外にインデックスデータ も更新する必要がある ◦ エンティティの取得の前にインデックスの解決 が必要になる 10 DynamoDB アプリケーション A-1 B-1 A-2 B-2 DynamoDB { A-1, 技術部, KATO } B-1 { A-2, 総務部, SATO } B-2 GSIで部署名で検索できるように … EMP { A-1, 技術部, KATO } { A-2, 総務部, SATO } EMP_DEPT_IDX { 技術部, [ A-1, A-3 ] } { 総務部, [ B-2, B-4 ] } 逆引きする際は、EMP_DEPT_IDXでIDを解 決してからEMPを引くことになる… 転置インデックス NoSQLで複雑なクエリ要件を取り込もうとしてシステムが複 雑化する…使い方を考え直したほうがいい
  7. © Chatwork 「注文コマンド要求」と「注文クエリ結果」の違い • コマンド要求はシステムに送られるメッセージ ◦ システム向けのデータは正規化される • クエリ結果はシステムから返されるメッセージ ◦

    人間向けのデータは非正規化される 12 注文ID 注文日時 商品ID 注文数 購入者ID 注文ID 注文日時 商品ID 商品名 注文数 購入者ID 購入者名 商品 アカウント 注文コマンド要求 注文情報のクエリ結果 system
  8. © Chatwork 仮にC/Qを同居させてスケールアウトさせた場合の問題 • ほとんどのユースケースではC:Q = 2:8 • QのコンテナにCを同居させた場合 は、Cが過剰にスケールさせてしまう

    ◦ リクエストが来なければリソー ス消費しない工夫ができそう • CのコンテナにQを同居させた場合 は、Qのスケーラビリティが不足する ◦ 物理的に足りないのは問題なの で結局多いほうに合わせること になる 14 C C Q Q Q Q Q Q Q Q Q Q C C Q Q Q Q Q Q Q Q Q Q C C C C C C C C Qに合わせるとCが過剰になる Cに合わせるとQが不足する
  9. © 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) // アプリケーション空間で結合及びデータを捨てる } ドメインはドメインの、クエリはクエリの都合で最適化が求められる
  10. © Chatwork 実装上の問題(2/2) 17 • コマンドを意識しないデータ指向では、エンドポイント、アプリケーションサービス、ドメ インがCRUDの用語に汚染されてしまう、という仮説がある ◦ 商品の注文がcreatePurchaseItem? ◦

    注文のキャンセルがupdatePurchaseItem? • ドメインの動詞を重視するコマンド指向では、orderItem, cancelOrderなどユビキタス言語 にフォーカスできるようになる。コマンドの表現によって意図が明白なインターフェイスを 作ることができる ◦ ただこの考え方は、CRUDであっても注意深く設計すれば可能…。 ◦ 実装というより分析の段階でコマンドを使うことのメリットが強い CQRSは非機能の観点が注目されがちだが、本来の目的はコマンド指向のドメインモデリングにある …
  11. © Chatwork CQRSのPros/Cons 18 • 利点 ◦ コマンドとクエリが分離しているため、耐障害性が高くなる(耐障害性に寄与す る)。別々にデプロイできる ◦

    コマンドとクエリを必要に応じて個別に最適化できる(弾力性に寄与する)。別々に スケールさせることもできる • 欠点 ◦ 非CQRSと比べて手間が掛かる。目的ごとにサブシステムを分けるので構成要素が 多くなる
  12. © Chatwork Event Sourcingとは • 唯一信頼できる情報源(Single Source Of Truth)は、状態(ステート)ではなく(ドメイン)イベントという考え方 •

    CRUDでは従来からの最新状態を常に上書きするが、イベントは事実を記録するだけ 20 Event Sourcing CRUD(State Sourcing) Account { ID=1, NAME=KATO } Account { ID=1, NAME=SATO } AccountCreated{ ID=1, NAME=KATO } AccountRenamed{ ID=1, NAME=SATO } 最新のエンティティを上書きする そのときのイベントを追記する
  13. © Chatwork ドメインイベントは • イベントは過去に起きた出来事を意味する • ドメインイベントは、ドメイン上のイベント を意味する • 一般的には過去形の動詞として表現される

    ◦ CargoShipped ◦ CustomerRelocated • イベントからコマンドが想起可能 ◦ ShipCargo ◦ RelocateCustomer • イベントとコマンドは似ているが別概念 ◦ コマンドは拒否されることがある ◦ イベントはすでに起こったことを示す 21 ショッピングカートのイベント
  14. © Chatwork 通知にイベントを使う • CQRSはコマンド側からクエリ側に変更を伝える必 要がある ◦ コマンド側のドメインイベントをクエリ側に 伝える •

    上記の現実的な実現手段として以下がある ◦ ポーリング ◦ Pub/Sub • (賢くない)ポーリングは極力避ける ◦ 賢くないポーリングだと、ただ負荷をかける だけになる ◦ 見た目はPub/Subであっても内部実装はポー リングだったりすることがある。無駄なI/O がなければよい • 通知は上流から下流にイベントを流すことが都合が よい 22 C Q C Q 変更がないと きもポーリング で負荷をかけ てしまう 変更があるときだけ イベントを通知する ポーリングはスケールし ない EventをPub/Subする
  15. © Chatwork Event SourcingのPros/Cons • 利点 ◦ イベントは更新されず追記のみなので、スケーラビリティが確保しやすい ◦ 特定の時点のリードモデルをイベントから導出することができる

    ▪ ドメインイベントがあれば、リードモデルの設計をいつでもやり直せる ◦ データマイグレーションコストではゼロではないが ▪ 監査ログや行動履歴の分析に利用することができる • 欠点 ◦ 大量のイベントから状態をリプレイする際に時間がかかる ▪ 最新状態を保存したスナップショットを使うとリプレイ時間を短縮でき る ◦ 原則的にすべてのイベントをストレージに保存する必要がある ▪ スナップショット保存時に、古いイベントを消すことも可能 23
  16. © Chatwork CQRS/ESを前提にしたアプリケーションアーキテクチャ 25 ドメイン イベント 集約 リード モデル コマンド

    プロセッサ クエリ プロセッサ コマンド リクエスト コマンド レスポンス クエリ リクエスト クエリ レスポンス リードモデル アップデータ クライアント ドメインオブ ジェクト群 ドメインの語彙で命 令する クライアントの画面 や帳票に合わせた クエリ結果を返す PKey=集約ID, SKey=シーケンス番号, 本体=ドメインイベント ドメインイベントを基にリー ドモデルを作る リードモデル構築時間はレスポンスタイム に反映されない
  17. © Chatwork コマンドプロセッサの実装イメージ(1) • CRUDとはまったく様相が異な るコードになります ◦ DBには追記しかしない • 大まかな流れ

    ◦ イベントからドメインオブ ジェクトを再生 ◦ ドメインロジックを実行 ◦ イベントを永続化 26 ・データ競合を防ぐためのロックができないのでは … ・イベントが長大な場合、集約の再生に時間かかるのでは …
  18. © Chatwork スナップショット機構を追加する 27 イベント 集約 リード モデル コマンド プロセッサ

    クエリ プロセッサ コマンド リクエスト コマンド レスポンス クエリ リクエスト クエリ レスポンス リードモデル アップデータ クライアント ドメインオブ ジェクト群 ドメインの語彙で命 令する クライアントの画面 や帳票に合わせた クエリ結果を返す スナップ ショット
  19. © Chatwork コマンドプロセッサの実装イメージ(2) • スナップショットには集約の 最新状態が含まれる。スナッ プショット以降に発生したイ ベントの部分集合だけを取得 することで、再生時間を ショートカットできる

    • スナップショットの楽観的 ロックを使うことでデータ競 合を回避できる。 ◦ スナップショットとイベ ントは同一トランザク ションが望ましい 28 リクエスト毎に、リプレイやスナップショット保存のオーバヘッドがか かる…
  20. © Chatwork • akka/akka ◦ JVM, .NET版 ◦ 2011年から •

    AsynkronIT/protoactor ◦ Go, C#, JVM対応 ◦ 2016年から。まだリリースはされていない? • commanded/commanded ◦ Elixir ◦ 2016年 • VLINGO XOOM ◦ JVM, .NET対応 ◦ 2020年から ◦ 実践ドメイン駆動設計の著者ヴァーノンさんの会社で開発している CQRS/ESのためのツール 30
  21. FYI:Chatwork新アーキテクチャの概要 state Shard Shard ShardR egion RoomAggregateActor Journal DB (DynamoDB)

    SnapshotStore (S3) Message Bus RMU Read DB Read API Read API Read API Controller UseCase Write API Server Write API Server Write API Server コマンドサイド akka-clsuter 他のMSへ logic Client ID = 1 クエリサイド サーバーサイド・チーム クライアントサイド・チーム • 非同期・ノンブロッキング • スーパービジョン • 位置透過性 • ステートフル • DBとの完全な同期によって読み込み不要 • ワークロードのパーティショニング • 正規化されたデータ構造を扱う • ネットワーク分断時は一貫性を重視 コマンドサイドではドメインロジックを実行してドメイン状 態を変える機能のみを提供する クエリサイドはドメインイベント をもとにクライアントにとって 都合のよいリードモデルを構 築する • 非同期・ノンブロッキング • ステートレス • 非正規型データを扱う • ネットワーク分断時は可能性を重視 • ラムダアーキテクチャでも十分可能 MessagePosted MessageDTO PostMessage MessageDTO ID = 2 ID = 3