Share
Embed
Transcript
31 slides
-
なぜイベント駆動が必要なのか CQRS/ES で解く複雑系システムの課題: ダブルライト回避編 イベント駆動型アーキテクチャ - イベントを主役にすることで解決できること - findy アーキテクチャ勉強会 2025/02/17
Ver1.2 加藤潤一(@j5ik2o) IDEO PLUS 合同会社 -
自己紹介 前職:Kubell 社( 旧Chatwork 社) テックリード IDEO PLUS 合同会社 代表。技術顧問&開発も少し手伝っている
個人では、月1 万時間無制限でプライベートコーチやってます 執筆 プロダクトデザインやソフトウェア設計の本を書いてるけどいつ 出るか… レビュー 改訂版 良いコード/悪いコードで学ぶ設計入門 WEB+DB PRESS Vol.132 - 特集1 オブジェクト指向神話からの脱却 ドメイン駆動設計入門 Akka 実践バイブル エリックエヴァンスのドメイン駆動設計 twitter: j5ik2o github: j5ik2o -
【課題】 複雑系システムが直面する課題 変更容易性の課題 データ一貫性管理の課題 スケーラビリティの課題 可用性の問題 リアルタイム性の課題 セキュリティの課題 複雑系システムの課題 変更容易性の課題
データ一貫性の課題 スケーラビリティの課題 可用性の課題 リアルタイム性の課題 セキュリティの課題 -
【課題】変更容易性のための境界設定 機能が複雑化した場合、変更影響が大きく並行開発が困難 機能ごとに分割(モジュール化)すると、マイクロサービスアーキテクチャなどの分散システムへ進化 いずれにしても、一つのシステムを目的ごとにシステムを分けることで、変更容易性を高める モノリス システム モジュラーモノリス システム モジュールA モジュールB
マイクロサービス システムA システムC 機能A 機能B 機能C 機能A 機能B 機能C 機能A 機能B 機能C -
【課題】システム間連携に使う同期型API 通信 システムA からシステムB の機能を呼び出してシステムA の機能を実現する場合 同期型API 通信では故障の影響が波及しやすい システムA からシステムB
への呼び出しで、システムB が故障するとシステムA も故障 システムB はシステムC やシステムD からも呼び出される。システムB が停止すると大事に… 同期型API 通信で連携していると本質的に疎結合にならない 分散システムでは、故障の範囲や境界を考慮する必要がある 非同期処理にしない限り、システム間の依存が強く残り続ける 障害シナリオ B が死んでいると A の処理も停⽌ システムA システムB 同期型API 通信 -
【解決】イベント駆動型アーキテクチャ 「イベント」を非同期に受け取って処理。直接API を呼ばない イベントを発火することで、他のシステムと独立して動作 メッセージブローカーなどを用いてシステム間を疎結合化する アカウントマイクロサービス 認証認可マイクロサービス 決済マイクロサービス アカウントシステム アカウントジャーナル
認証認可システム 決済システム アカウントリードモデル ユーザA ユーザA メッセージブローカー リードモデルがあれば疎結合になる アカウント登録 AccountCreated AccountCreated AccountCreated 商品の決済 -
【課題】安易にダブルライトしがち問題 データ更新とイベント発火の整合性を維持するの が難しい 例: 「データ更新成功→イベント送信失敗」 「イ ベント発生→データ未更新」 トランザクション分断による不整合をどう防ぐか が課題 実際はほとんどのケースでうまくいくが…
不整合が起きる故障を過小評価していることが 多い 不整合時の対応方法も考慮されていなかったり する… 某システムではEvent Sourcing でダブルライトを回 避している アプリ アプリ DB DB イベントキュー イベントキュー データ更新 (COMMIT) イベント送信 イベントの送信に失敗すると データ更新だけ成功してしまう 逆の順序でも同じ -
ダブルライト失敗の影響 ❌ EC サイトの注文処理 現象: 注文データをDB に登録したが、在庫システム向けにイベントを通知できなかった 影響: 在庫が更新されずに、売り切れ商品が購入可能になってしまう ❌
決済システム 現象: 決済データをDB に登録したが、配送システム向けにイベントを通知できなかった 影響: ユーザーが支払ったのに、商品が出荷されない ときにはビジネスルールを壊す影響が発生することもある -
【課題】後付けのイベント発火の実装 可能だが、ダブルライト問題が発生しや すい。データベースとメッセージブローカ ーの一貫性が崩れるリスク Event Sourcing ならよりスマートに解決で きる コマンド 集約(
ドメインモデル群) イベント リポジトリ イベントパブリッシャ イベントサブスクライバ データベース メッセージブローカー (1) が成功して(2) が失敗したら? (2) が成功して(1) が失敗したら? RDB のトランザクションをロールバックできたとしてもSQS などのに送ったイベントはどうなる? fn execute(&mut self, param: &Param) -> Result<(), UseCaseError> { let mut group_chat = self.group_chat_repository .find_by_id(group_chat_id)?; let event = group_chat.post_message(message)?; self.group_chat_repository.store(&event, &group_chat)?; // (1) self.event_publisher.publish(&event)?; // (2) } fn execute(&mut self, param: &Param) -> Result<(), UseCaseError> { let mut group_chat = self.group_chat_repository .find_by_id(group_chat_id)?; let event = group_chat.post_message(message)?; self.event_publisher.publish(&event)?; // (2) self.group_chat_repository.store(&event, &group_chat)?; // (1) } -
ダブルライトに対処する手段 ダブルライトの回避 Outbox pattern Event Sourcing ダブルライトの正しい対処 Process Manager Saga
FYI: https://note.com/j5ik2o/n/n4bd0c6092d77 -
CQS CQRS = Command Query Responsibility Segregation, コマンドクエリ責務分離 は、CQS =
Command Query Separation, コマンドクエリ分離原則 の背景にあるアイデアをサービスのレベルまで拡張させたもの CQS をまず理解しよう。例えばリポジトリで考えると以下のようになる // CQS 違反のリポジトリ ( こういう設計はわかりにくいしスケールしないので避けよう!!!) trait GroupChatRepository { // 書き込んだ後に読み込んで返すの?RDB であればリーダーから読むのではなくライターから読むつもりなのか?? fn store(&mut self, group_chat: &GroupChat): Result<GroupChat, IOError>; // &mut self って内部で書き込みが起きるの?読み込みのメソッドなのに?? fn find_by_id(&mut self, group_chat_id: &GroupChatId): Result<Option<GroupChat>, IOError>; } // CQS に沿ったリポジトリ trait GroupChatRepository { // 書き込み特化 fn store(&mut self, group_chat: &GroupChat): Result<(), IOError>; // 読み込み特化 fn find_by_id(&self, group_chat_id: &GroupChatId): Result<Option<GroupChat>, IOError>; } -
CQRS CQRS = Command Query Responsibility Segregation, コマンドクエリ責務分離 実装イメージは以下。 ただ単に定義を二つにするだけでは不十分。モジュールレベルで明確にお互いが文字通り”
隔離” されていな いといけない。C/Q は混ざってはいけません。 // コマンド用 trait GroupChatRepository { // 集約の更新 fn store(&mut self, group_chat: &GroupChat): Result<(), IOError>; // 更新のための読み込み。find_by_id したら最後にstore を呼ぶ必要がある fn find_by_id(&self, group_chat_id: &GroupChatId): Result<Option<GroupChat>, IOError>; } // クエリ用 trait GroupChatReadModelDao { // リードモデルの更新( リードモデル構築用。API からは利用しない) fn upsert(&mut self, group_chat: &GroupChatReadModel): Result<(), IOError>; // リードモデルの問い合わせ fn find_by_id(&self, group_chat_id: &GroupChatId): Result<Option<GroupChatReadModel>, IOError>; // fn find_by_...(...); } -
CQRS の 基本的な構造 コマンド側(C) とクエリ側(Q) に分割されている C Q コマンド 集約(
ドメインモデル群) ライトDB( ジャーナルDB) リードDB リードモデル クエリ 呼び出す 取得する -
【コマンド】ドメインモデル メソッドを実行すると、状態が遷移しイベントが生成される CPU とメモリを中心に使う。永続 化とは距離を置く // ドメインモデル(正規型) pub struct GroupChat
{ id: GroupChatId, deleted: bool, name: GroupChatName, members: Members, messages: Messages, } impl GroupChat { // リネーム pub fn rename(&mut self, name: GroupChatName, executor_id: UserAccountId) -> Result<GroupChatEvent, GroupChatError> { // ... } // メンバーの追加 pub fn add_member(&mut self, member_id: MemberId, user_account_id: UserAccountId, role: MemberRole, executor_id: UserAccountId, ) -> Result<GroupChatEvent, GroupChatError> { // ... } // メッセージの投稿 pub fn post_message(&mut self, message: Message, executor_id: UserAccountId) -> Result<GroupChatEvent, GroupChatError> { // ... } } -
【コマンド】ビジネスロジック リポジトリは閲覧ではなく振る舞いを起こす(書き込み)のための責務となる ユースケースなどでの使い方 trait GroupChatRepository { // 書き込みメソッド fn store(&mut
self, event: &GroupChatEvent, group_chat: &GroupChat): Result<(), IOError>; // 書き込みのための読み込みメソッド。ID 以外の問い合わせは不要 fn find_by_id(&self, group_chat_id: &GroupChatId): Result<Option<GroupChat>, IOError>; } let mut group_chat = group_chat_repository.find_by_id(group_chat_id)?; // イベント集合から集約のリプレイ let group_chat_event = group_chat.post_message(message, executor_id)?; // ビジネスロジックの起動 group_chat_repository.store(&group_chat_event, &group_chat)?; // イベントをひたすら追記する。group_chat の保存は必須ではない -
Event Sourcing AggregateId Sequence Number Payload GroupChat- 01890535-c59c- 72d5-08a8- dcea316374c8
1 {"type":"GroupChatCreated","id":"01H42KBHCW1BZG504J4ZXKA2F2","aggregate_id":{"value":"01890535-c59c- 72d5-08a8-dcea316374c8"},"seq_nr":1,"name":"test","members":{"members_ids_by_user_account_id": {"01H42KBHCWBDTZYQ7P78T8BTWX":"01H42KBHCWA8NE32M49YH544H1"},"members": {"01H42KBHCWA8NE32M49YH544H1":{"id":"01H42KBHCWA8NE32M49YH544H1","user_account_id": {"value":"01890535-c59c-5b75-ff5c-f63a3485eb9d"},"role":"Admin"}}},"occurred_at":"2023-06- 29T03:32:37.404481Z"} GroupChat- 01890535-c59c- 72d5-08a8- dcea316374c8 2 {"type":"GroupChatMessagePosted","id":"01H42KBHCW1BZG504J4ZXKA2F2","aggregate_id":{"value":"01890535- c59c-72d5-08a8-dcea316374c8"},"seq_nr":2,"message":"test","sender_id": "01890535-c59c-5b75-ff5c- f63a3485eb9d","occurred_at":"2023-06-29T03:32:37.404481Z"} 概念的には空の集約に適用すると最新状態になる 実際にはスナップショットに保存された集約に対してイベントを適用する let aggregate_latest = GroupChat::replay(events, GroupChat::empty())?; let aggregate_latest = GroupChat::replay(events, latest_snapshot)?; -
【コマンド】集約のリプレイ イベントを読み込んだら、集 約に適用する イベントはコマンドに変換さ れ再実行される impl GroupChat { pub fn
replay(events: Vec<GroupChatEvent>, snapshot: GroupChat) -> Self { events.iter().fold(snapshot, |mut result, event| { result.apply_event(event); result }) } fn apply_event(&mut self, event: &GroupChatEvent) { match event { // ... GroupChatEvent::GroupChatMessagePosted(body) => { self .post_message(body.message.clone(),body.executor_id.clone()) .unwrap(); } // ... } } } -
【クエリ】非正規型のリードモデル pub struct GroupChat { id: String, name: String, owner_id:
String, owner_name: String, members_count: u32, created_at: NaiveDateTime, updated_at: NaiveDateTime, } pub struct Member { id: String, group_chat_id: String, group_chat_name: String user_account_id: String, user_account_name: String, role: String, created_at: NaiveDateTime, updated_at: NaiveDateTime, } pub struct Message { id: String, group_chat_id: String, group_chat_name: String, user_account_id: String, user_account_name: String text: String, created_at: NaiveDateTime, updated_at: NaiveDateTime, } -
【クエリ】データアクセスオブジェクト クライアントが求めるデータを返す pub trait GroupChatDao { fn get_group_chat(&self, group_chat_id: String,
user_account_id: String,) -> Result<GroupChat, GroupChatDaoError>; fn get_group_chats(&self, user_account_id: String) -> Result<Vec<GroupChat>, GroupChatDaoError>; } pub trait MemberDao { fn get_member(&self, group_chat_id: String, user_account_id: String) -> Result<Member, MemberDaoError>; fn get_members(&self, group_chat_id: String, user_account_id: String) -> Result<Vec<Member>, MemberDaoError>; } pub trait MessageDao { fn get_message(&self, message_id: String, user_account_id: String) -> Result<Message, MessageDaoError>; fn get_messages(&self, group_chat_id: String, user_account_id: String) -> Result<Vec<Message>, MessageDaoError>; } -
CQRS/Event Sourcing の 基本的な構造 C とQ を繋ぐのがイベント Event Sourcing は状態がイベントに基づくことであり、Pub/Sub
のことではない C Q コマンド 集約( ドメインモデル群) イベント ジャーナルDB プロジェクション リードDB リードモデル クエリ 呼び出す ⽣成する 追記する 状態再⽣ Pub/Sub 変換する 取得する -
FYI: スナップショット機構 イベントが長大な場合リプレイ時間が問題になる イベントN 件ごとに、スナップショット( そのときの集約の状態を保存する) リプレイ時は最新のスナップショットと、それ以降のイベント集合を使って再生する コマンド 集約( ドメインモデル群)
イベント スナップショット ジャーナルDB スナップショットDB 呼び出す ⽣成する 追記する スナップショット保存 差分イベント取得 スナップショット取得 -
【解決】CQRS/ES はダブルライトせずに通知できる CQRS/ES では通知のため にダブルライトしない メッセージブローカーを 間に挟めば、容易に別シ ステムにイベントを通知 できる この方法は安全ではある
が、レイテンシが必要に なる C Q コマンド 集約( ドメインモデル群) イベント ジャーナルDB プロジェクション リードDB リードモデル クエリ メッセージブローカー(Kafka or Kinesis) 別システム 呼び出す ⽣成する 追記する 状態再⽣ 変換する 取得する CDC -
CQRS/ES をサポートするフレームワークやライブラリ Object based Axon Framework: Java event-store-adapter: TypeScript, Go,
Rust, Java, Kotlin, Scala, PHP cqrs-es-example Actor based akka: Java, Scala pekko: Java, Scala -
まとめ 現代のシステム要件やマイクロサービス間の連携を考慮すると、CQRS/ES は重要 な設計アプローチであり、ひとつは変更容易性を確保するために、導入を検討す べきアーキテクチャである。 現代のシステム要件において、CQRS/ES は重要な設計思想の一つ システム連携がある場合、CQRS/ES を念頭に置くべき ダブルライト問題を避けるためのアーキテクチャ設計
イベント駆動型アーキテクチャを活用し、柔軟なシステム設計を 適切なフレームワーク・ライブラリを活用し、効率的な実装を目指す