なぜCQRSなのか

加藤潤一氏(以下、加藤):「なぜCQRSなのか」という話がありますが、自律性を表明することがあって。「独立して行動し、協調的に相互作用するコンポーネントを設計する」という考え方があります。どういうことかというと、自律性って、結局ほかのサービスと独立して機能しないと意味がありません。

マイクロサービスの動作保証は、連携しているほかのサービスと直接関係がないように、常に自分のサービスの行動を保証するだけという原則があります。ドメイン境界で割っていくと、それぞれ独立したコンテキストでシステムが作られていくので、その単位で動作を保証していくというような、分散システムの考え方です。

ドメイン境界で割るだけではなく、その1つのコンテキストの中でも、コマンドとクエリという考え方で分割する考え方もあります。コマンドに障害が起きてもクエリできるようにするには、当たり前ですが、お互いに分離する必要があります。そのような考えがCQRSやEvent Sourcingにはあります。

自律性の観点からみたCQRS

自律性と関係するので、CQRSがどういうものかという話を、自律性の観点から話していきたいと思います。CQRSは、コマンド・クエリ責務分離というもので、Greg Youngさんが考案されたものです。分離というよりは隔離という解釈が正しく、コマンドは書き込み、クエリは読み込みですが、それをスタックごとにそれぞれ隔離する。アプリケーションが分かれているか、モジュールとして分かれているかは別にして、完全に混ぜないで隔離する考え方です。

単にドメインモデルをコマンド用・クエリ用に分割することではないとGreg Youngさんも言われているので、Gregさんの言うCQRSは、完全に隔離する、混ぜないという考え方です。

そういった観点で考えると、僕の解釈は(スライドの)右の図のようなアーキテクチャになります。Command Sideがドメインモデルが出現するスタックで、Query SideがReadModelを使って、クライアントからの要求に対してレスポンスを返すかたちです。

基本、コマンドとクエリを分けたとしても統合しないと意味がないので、Read Model Updaterという仕組みを使い、Writeで起こった変更をReadに伝えることをします。

CQRSはもともとDDDをDDDらしくするために作られたと言われています。そのあたりの話もこのあと話していきたいと思います。

RDBへの書き込みがスケールしない問題

書き込みと読み込みの要件が違うのに、1つのシステムで解決しようとして無理が生じる例で、実際に僕が経験したことをちょっと話したいと思っています。

RDBへの書き込みがスケールしない問題というのがあって。これはもういろいろなやり方があって、組織やそこにいるメンバーのスキルにも依存するので、一概に良い悪いは言えないと思います。僕の感覚で「やってみたらやはり難しかったな」という話を、経験談として聞いてもらいたいです。

RDBを使っている前提でWriterをスケールアウトさせたいとなった場合に、「書き込みをシャーディングしましょう」「Writer分けましょう」というような話は、ソーシャルゲーム業界などいると普通に起きます。

水平や垂直の分割の仕方ありますが、例えば同じようなユーザーデータを書き込むにしても、ユーザーデータ、ユーザーが所属するグループのIDをヒントに、どちらのWriterに書き分けるか決めましょう、みたいな。シャーディングはそういうものなので、そういった考え方があります。

(スライドの)下の図で、Writerを分けたらReadReplicaも分かれるわけなので、違ったReadReplicaからデータを引き抜こうとしても抜けません。シャードIDを間違えて違うコネクション取ってしまうと、そもそもJoinできないこともあると思います。

このへんの難しさを、SRE、運用チーム、インフラチームを含めてカバーできる体勢があればいいですが、これをスタートアップでやっていくのはなかなか大変だろうなと。

例えば、データベースのこのシャーディングするWriterの台数が2台だったらいいですが、4台や8台になった場合、リハッシュのような、データを再配置するようなことをやらないといけません。こういうことを考えただけでも、「うちじゃ無理だな」「どうしたらいいか?」みたいな話はよく話題に上がってました。

NoSQLに変える利点と欠点

「NoSQLに変えてみよう」みたいな話があります。今だとGSI(Global secondary index​​)もけっこう数がいけるのでそういう使い方でいけるかもしれませんが、NoSQL、Dynamoなどだと「ハッシュキーでスケールアウトできるよね」みたいなこともあります。ある意味、プライマリのデータをPushしてGetするのもパーティションキーで取るみたいな。パーティションキーで取るみたいな考え方でないといけませんが、属性やセカンダリインデックスで引きたいみたいなことがほとんどの要求になって。

要するに「RDBでやるようなクエリをNoSQLでやろうとすると、こういう話になるんだよね」という話で、「そもそもなんでNoSQLなの?」みたいな話もあるわけです。 NoSQLに向かない要求を無理やり実現しようとすると、NoSQLの良さもどんどん消えていく。GSIを多用すると、それだけ更新のレイテンシーも嵩んでいくわけです。当たり前。

今、GSIは上限が20個でしたっけ? それぐらいならなんとか収まるのかもしれませんが、僕らがやったときは5個ぐらいしかなくて。「5個超えちゃったら転置インデックスみたいのを作ろう」みたいな話になると、書き込みにもボトルネックというかレイテンシーはもちろん増えるし、読み込みのときも2段引きになったりとかするので、「これはちょっと本当にまずいんじゃないか」みたいな話もありました。

「こういった使い方って、やはりそれぞれのストレージの特製を活かして設計するようにはなってないよね」という話があります。CQRSでは「そういうものは、それぞれに最適化していきましょう」という当たり前の考え方があります。

コマンドとクエリの要件

「そもそもコマンドとクエリは要件が違いますよ。データ構造だけでなく、ほかの要件も異なるので、コマンドとクエリをそれぞれ隔離しましょう」という考え方になっています。

一貫性と可用性、コマンドは必ず前回書き込まれた状態を、例えばストレージから読み出して、前回の変更に対して必ず正しく今の変更が加わらないといけないので、簡単に言うと、強い整合性であるトランザクション整合性、強い一貫性が必要です。

クエリの場合は、結果整合を基本的に使うということで、例えばネットワーク分断が起きた場合でも、ちょっと古いデータでもいいから読めるほうがいい。

そのため、ReadReplicaは必要な分だけのレプリカを作り、それぞれ正しいデータの変更はちょっと遅れてるかもしれないけど、WriterとReadReplicaのネットワーク分断が発生したとしても、ReadReplicaからちょっと古いデータだとしても読み込めるとか。クエリ側は正しいデータを読むというより、読み込めることを重視するという側面が強かったりします。もちろん、正しい整合が取れたデータを読めるならそれが一番いいですが。

あと、データ構造も、トランザクション処理を行って正規化されたデータを保存することが好まれる。集約単位、DDDの集約のような、1つのデータの境界を作って更新するみたいなことがよく起こります。

クエリの場合は、非正規化されたデータ形式。例えば、複数の集約にまたがるようなデータを引き出したり。それはもうクライアントの都合ですね。画面上の都合でこういうデータが、2つのデータをJoinして引っ張るみたいなことはよくあるので、非正規化されたデータ形式が多い。

Chatworkの場合はまた別ですが、一般的にスケーラビリティも書き込みより読み込みのほうがリクエストが多い特徴があります。

書き込みと読み込みのデータ構造

「書き込みと読み込みでデータ構造が正規化と非正規化で違うよね」という話なんですが、左側がコマンドの書き込みモデルで、右側が読み込みモデル。

注文情報を扱うと考えた場合、コマンドリクエスト、コマンドの要求みたいなものは、だいたいはシステムに向けて送られるデータなので、商品IDや購入者IDだとしても、システムは識別できます。商品名や購入者名はいらないわけです。

片や、システムからユーザーに向けて返されるメッセージのデータは、商品IDや購入者IDだけでなく、商品名・購入者名のような人間が閲覧するために必要な情報も一緒に含めないといけなかったりします。これは多くの場合、商品やアカウントのような、別のエンティティと合成して結果を返すようなことをやらないといけないので、非正規化されたデータがよく扱われる違いがある話になります。

CQRSでない場合の問題点

CQRSでない場合の問題点。いきなり実装がScalaのコードで出てくるので、Scalaを知らない人はちょっと「うーん?」という感じになると思います。DDDでいくと、リポジトリがもつような話が、CQRSじゃない場合、クエリするだけでドメインロジックを呼び出さない。

レスポンスを返すだけとか、ページングとかソートなども扱うケースがありますが、「これリポジトリでやることですかね?」みたいな話はあります。ドメインロジックを呼び出すんだったらまだしも、DTOに詰め替えてクライアントに返すだけなのに、リポジトリの責務なのかと話もあります。

あとは、DTO、APIのレスポンスでDTOを返すとき。例えばホテルの予約情報をAPIの形式でレスポンスを返さないといけない状況のときに、N+1クエリが発生しやすいです。リポジトリを使って、IDから実際のホテル名を取ったり顧客名を取ったりするわけなので、その都度SQLが発行されます。しかも必要なデータは一部だったりするので、アプリケーション間で結合して、かつ、大部分のデータは捨てられるようなことが起こります。

こういう問題って、ドメインはドメインの、クエリはクエリの都合で最適化しないと「DDDだから多少飲み込んでよ」みたいなことはあるかもしれませんが、お金を捨てるみたいなところもあって。コスト効率も悪い部分になってくるので、やはり最適化が必要だよねという話になります。

もともとのGreg Youngさんの意図的には、コマンドを意識しない考え方、データ指向では、エンドポイント、アプリケーションサービス、ドメインがCRUDの用語に汚染されてしまうという仮説があります。「商品の注文や注文のキャンセルのときに、“create”とか“update”という動詞を使ってしまうことってあるよね」みたいな。けっこう僕はあると思うんですけど、CQRSでない場合はこういうふうに陥りやすいと。

だから、そのドメインの動詞を重視するコマンド指向だと、orderItemやcancelOrderなど、ユビキタス言語の自然な考え方。それによって意図が明白なインターフェイスを作ることができるとGreg Youngさんが言っています。ただ、この考え方、コミュニティの中でいろいろ議論したら「CRUDであっても、がんばって注意深く設計すればできなくはないよね」という意見も出てきました。確かにそのとおりだなと。

CQRSは、それぞれに最適化しやすいので、非機能の観点から注目されがちです。本来はコマンド指向でドメインモデリングがしやすくなるのが目的だったらしいですが、今はぜんぜん違うところで脚光を浴びているようなところがあります。

CQRSの利点と欠点

CQRSでない場合の利点・欠点みたいなところ。コマンドとクエリが分離しているので、もちろん耐障害性に寄与できます。別々にシステムがあるとしたら、片方落ちても片方が使えるわけなので、耐障害性が高くなりやすい。

別々にデプロイできるのも、手間と捉えるか、「アジリティを確保して変更をどんどんしていくんだ」みたいな考え方でいうと、分かれているほうがいいかもしれない。コマンドとクエリを、必要に応じて個別に最適化できる。これは弾力性(Elastic)に寄与できる、別々にスケールもできます。

欠点としては、CQRSでないものと比べてコストがかかるので、複雑になります。目的ごとにサブシステムを分けるので、構成要素が多くなります。CQRSではCとQごとにモデルが分離するため、単一モデルとしてはシンプルですが、ネットワークというシステム全体で見た場合は複雑になってきます。

(次回につづく)