Slide 1

Slide 1 text

そろそろマネージド、 クラウドネイティブで行こう! 2021/08/12 Chatwork株式会社 テックリード 加藤潤一 AWSでCQRS/Event Sourcing するにはどうすればいいのか 1

Slide 2

Slide 2 text

© Chatwork ● @j5ik2o ● 属性 ○ Chatwork テックリード ■ 次期アーキテクチャのプランニングや設計・ 開発 ○ 職歴 ■ SIer → ドワンゴ → グリー → Chatwork ○ DDD, CQRS/Event Sourcing ○ Scala, Rust ● 最近はまっていること ○ Akka,Actixなどを参考に、Rustでアクターモデル を実装すること(習作目的)。既存実装は50弱ある (えっ…) 自己紹介 2

Slide 3

Slide 3 text

© Chatwork セッション概要とアジェンダ ● セッション概要 ○ 世の中にはクラウドを採用していても、スケーラビリティのないサービスを開発 ・運用しているエンジニアは少なくないと思います。過去の私もその一人でし た。CQRS/Event Sourcingはその解の一つです。私自身も2016年にCQRS/ESに 出会って以来、AWS上でその根本的な課題に取り組んで来ました。その経験を生 かしてAWSでの実現方法について解説します ● アジェンダ ○ CQRS/Event Sourcingの概念理解編 ○ CQRS/Event Sourcingのアーキテクチャ・実装編 ■ 実装レベルでどうするか ■ ツールは何を使えばよいか ■ AWSでどうするとよいか ■ Chatworkでの検討中のアーキテクチャ ○ まとめ 3

Slide 4

Slide 4 text

© Chatwork 概念理解編 4

Slide 5

Slide 5 text

© Chatwork CQRSとは 5

Slide 6

Slide 6 text

© 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

Slide 7

Slide 7 text

© Chatwork コマンドとクエリでは要件が異なる データ構造だけではなく他の要件も異なるので、コマンドとクエリをそれぞれを隔離する 7 コマンド クエリ 一貫性/可用性 トランザクション整合性を使い強い一 貫性を重視する 結果整合を使い可用性を重視する データ構造 トランザクション処理をおこない正規 化されたデータを保存することが好 まれる(集約単位など) 非正規化したデータ形式を取得するこ とが好まれる(クライアント都合のレス ポンスなど) スケーラビリティ 全体のリクエスト比率とごく少数のト ランザクション処理しかしない。必ず しもスケーラビリティは重要ではない 全体のかなりのリクエスト比率を占め る処理をおこなうため、クエリ側はス ケーラビリティが重要

Slide 8

Slide 8 text

© Chatwork それぞれの特性にあった ストレージを使うべきだが… 8

Slide 9

Slide 9 text

© 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で解決しよう として複雑化する…

Slide 10

Slide 10 text

© 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で複雑なクエリ要件を取り込もうとしてシステムが複 雑化する…使い方を考え直したほうがいい

Slide 11

Slide 11 text

© Chatwork データ構造はそもそも非対称 11

Slide 12

Slide 12 text

© Chatwork 「注文コマンド要求」と「注文クエリ結果」の違い ● コマンド要求はシステムに送られるメッセージ ○ システム向けのデータは正規化される ● クエリ結果はシステムから返されるメッセージ ○ 人間向けのデータは非正規化される 12 注文ID 注文日時 商品ID 注文数 購入者ID 注文ID 注文日時 商品ID 商品名 注文数 購入者ID 購入者名 商品 アカウント 注文コマンド要求 注文情報のクエリ結果 system

Slide 13

Slide 13 text

© Chatwork スケーラビリティもそもそも非対称 13

Slide 14

Slide 14 text

© 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が不足する

Slide 15

Slide 15 text

© Chatwork 実装上の問題 15

Slide 16

Slide 16 text

© 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) // アプリケーション空間で結合及びデータを捨てる } ドメインはドメインの、クエリはクエリの都合で最適化が求められる

Slide 17

Slide 17 text

© Chatwork 実装上の問題(2/2) 17 ● コマンドを意識しないデータ指向では、エンドポイント、アプリケーションサービス、ドメ インがCRUDの用語に汚染されてしまう、という仮説がある ○ 商品の注文がcreatePurchaseItem? ○ 注文のキャンセルがupdatePurchaseItem? ● ドメインの動詞を重視するコマンド指向では、orderItem, cancelOrderなどユビキタス言語 にフォーカスできるようになる。コマンドの表現によって意図が明白なインターフェイスを 作ることができる ○ ただこの考え方は、CRUDであっても注意深く設計すれば可能…。 ○ 実装というより分析の段階でコマンドを使うことのメリットが強い CQRSは非機能の観点が注目されがちだが、本来の目的はコマンド指向のドメインモデリングにある …

Slide 18

Slide 18 text

© Chatwork CQRSのPros/Cons 18 ● 利点 ○ コマンドとクエリが分離しているため、耐障害性が高くなる(耐障害性に寄与す る)。別々にデプロイできる ○ コマンドとクエリを必要に応じて個別に最適化できる(弾力性に寄与する)。別々に スケールさせることもできる ● 欠点 ○ 非CQRSと比べて手間が掛かる。目的ごとにサブシステムを分けるので構成要素が 多くなる

Slide 19

Slide 19 text

© Chatwork Event Sourcingとは 19

Slide 20

Slide 20 text

© 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 } 最新のエンティティを上書きする そのときのイベントを追記する

Slide 21

Slide 21 text

© Chatwork ドメインイベントは ● イベントは過去に起きた出来事を意味する ● ドメインイベントは、ドメイン上のイベント を意味する ● 一般的には過去形の動詞として表現される ○ CargoShipped ○ CustomerRelocated ● イベントからコマンドが想起可能 ○ ShipCargo ○ RelocateCustomer ● イベントとコマンドは似ているが別概念 ○ コマンドは拒否されることがある ○ イベントはすでに起こったことを示す 21 ショッピングカートのイベント

Slide 22

Slide 22 text

© Chatwork 通知にイベントを使う ● CQRSはコマンド側からクエリ側に変更を伝える必 要がある ○ コマンド側のドメインイベントをクエリ側に 伝える ● 上記の現実的な実現手段として以下がある ○ ポーリング ○ Pub/Sub ● (賢くない)ポーリングは極力避ける ○ 賢くないポーリングだと、ただ負荷をかける だけになる ○ 見た目はPub/Subであっても内部実装はポー リングだったりすることがある。無駄なI/O がなければよい ● 通知は上流から下流にイベントを流すことが都合が よい 22 C Q C Q 変更がないと きもポーリング で負荷をかけ てしまう 変更があるときだけ イベントを通知する ポーリングはスケールし ない EventをPub/Subする

Slide 23

Slide 23 text

© Chatwork Event SourcingのPros/Cons ● 利点 ○ イベントは更新されず追記のみなので、スケーラビリティが確保しやすい ○ 特定の時点のリードモデルをイベントから導出することができる ■ ドメインイベントがあれば、リードモデルの設計をいつでもやり直せる ○ データマイグレーションコストではゼロではないが ■ 監査ログや行動履歴の分析に利用することができる ● 欠点 ○ 大量のイベントから状態をリプレイする際に時間がかかる ■ 最新状態を保存したスナップショットを使うとリプレイ時間を短縮でき る ○ 原則的にすべてのイベントをストレージに保存する必要がある ■ スナップショット保存時に、古いイベントを消すことも可能 23

Slide 24

Slide 24 text

© Chatwork アーキテクチャ・実装編 24

Slide 25

Slide 25 text

© Chatwork CQRS/ESを前提にしたアプリケーションアーキテクチャ 25 ドメイン イベント 集約 リード モデル コマンド プロセッサ クエリ プロセッサ コマンド リクエスト コマンド レスポンス クエリ リクエスト クエリ レスポンス リードモデル アップデータ クライアント ドメインオブ ジェクト群 ドメインの語彙で命 令する クライアントの画面 や帳票に合わせた クエリ結果を返す PKey=集約ID, SKey=シーケンス番号, 本体=ドメインイベント ドメインイベントを基にリー ドモデルを作る リードモデル構築時間はレスポンスタイム に反映されない

Slide 26

Slide 26 text

© Chatwork コマンドプロセッサの実装イメージ(1) ● CRUDとはまったく様相が異な るコードになります ○ DBには追記しかしない ● 大まかな流れ ○ イベントからドメインオブ ジェクトを再生 ○ ドメインロジックを実行 ○ イベントを永続化 26 ・データ競合を防ぐためのロックができないのでは … ・イベントが長大な場合、集約の再生に時間かかるのでは …

Slide 27

Slide 27 text

© Chatwork スナップショット機構を追加する 27 イベント 集約 リード モデル コマンド プロセッサ クエリ プロセッサ コマンド リクエスト コマンド レスポンス クエリ リクエスト クエリ レスポンス リードモデル アップデータ クライアント ドメインオブ ジェクト群 ドメインの語彙で命 令する クライアントの画面 や帳票に合わせた クエリ結果を返す スナップ ショット

Slide 28

Slide 28 text

© Chatwork コマンドプロセッサの実装イメージ(2) ● スナップショットには集約の 最新状態が含まれる。スナッ プショット以降に発生したイ ベントの部分集合だけを取得 することで、再生時間を ショートカットできる ● スナップショットの楽観的 ロックを使うことでデータ競 合を回避できる。 ○ スナップショットとイベ ントは同一トランザク ションが望ましい 28 リクエスト毎に、リプレイやスナップショット保存のオーバヘッドがか かる…

Slide 29

Slide 29 text

© Chatwork 具体的に実装するには… 29

Slide 30

Slide 30 text

© Chatwork ● akka/akka ○ JVM, .NET版 ○ 2011年から ● AsynkronIT/protoactor ○ Go, C#, JVM対応 ○ 2016年から。まだリリースはされていない? ● commanded/commanded ○ Elixir ○ 2016年 ● VLINGO XOOM ○ JVM, .NET対応 ○ 2020年から ○ 実践ドメイン駆動設計の著者ヴァーノンさんの会社で開発している CQRS/ESのためのツール 30

Slide 31

Slide 31 text

© Chatwork FYI: AWSに対応したakka-persistenceプラグイン ● ジャナールをDynamoDBに、スナップショットをS3に配置するためのプラグインがある ● 他のストレージに対応したプラグインも数多くある 31

Slide 32

Slide 32 text

© Chatwork 特別な仕組みなし&DynamoDBでやるなら… ● CartSnapshot, CartEventsを同じトランザクションで書き込む。CartEventsのNewImageをStreamからコ ンシュームし後段につなげる。もしくは、CartSnapshotだけにして、OldImage, NewImageの差分計算にy よってCartEventを生成する方式。後段につなげるパターンは同じ。 ● 後段はKCLで使う。リードモデルは必要に応じてNoSQL, RDBMSを選択する ● 詳しくは DynamoDBを使ったCQRS/Event Sourcingシステムの構築方法(言語・F/W非依存) を参照 32

Slide 33

Slide 33 text

© Chatwork Akkaでは一行で済みます! ● Akkaでは集約をクラスタリングされたノード上に起動するアクターとして実装します ● ひとつひとつに集約のIDが振られていて一意に識別されます。どのノードからでも集約ア クターへコマンドを送信し返事をもらうことができます ● コマンドが受理されるとイベントが永続化されます。永続化されたイベントはアクターの 再生に利用されます。スナップショットの保存タイミングも選択することができます ● 最初のリクエストでアクターが起動しワークロードがなくなるとアクターは停止します 33

Slide 34

Slide 34 text

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

Slide 35

Slide 35 text

© Chatwork まとめ ● 可用性やスケーラビリティが問われるシステムでは、CQRS/Event Sourcingはよい選択肢になる。実現難易度はそこそこあるが一昔前 より環境が整ってきているので、マイクロサービスアーキテクチャ を考えるうえで検討の余地があると思います ● FYI: DDD, CQRS/Event Sourcingに興味があるけどどこから手を出 せばいいかわからない人、質問箱に投稿してくれれば答えます 35

Slide 36

Slide 36 text

© Chatwork 本日はありがとうございました 36