17
1

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

読み取りと書き込みでモデルが別!? - CQRS という概念

17
Posted at

こんな挙動を見たことは?

ECサイトの注文、SNSの投稿、経費申請……。なんでもいいですが、 「何かデータを追加する」操作をした後、一覧にすぐ表示されないぞ? といった経験はないでしょうか。

「反応の悪いシステムだな」と思うかもしれませんが、実はそこには妥当な設計判断が隠れているのかもしれません。こうした状況が起こりうる設計の一つに、 CQRSという設計思想 が存在します。

CQRS はドメイン駆動開発(DDD) と好相性とされる設計ですが、必ずしも DDD の文脈で語らなければならないものではありません。データの行き来が複雑なシステムでは非常に強力な武器になりえます。

今回は少しでもイメージしやすくするため、素朴なシステムがどんな形で行き詰まり、CQRS の採用に舵を切るのかを追っていきます。

題材:ECサイトの注文

話を具体的にするために、ECサイトを題材にします。このシステムでは同じ DB アクセスであっても、性質のまったく違う2つの仕事があります。

注文を受ける(書き込み)
注文対象の在庫を引き当て、注文レコードを作る。1件ずつを確実に処理できることが重要。

注文を見る(読み取り)
ユーザーは自分の注文履歴を眺める。運営側は「先月の注文一覧」「特定商品の売れ行き」を検索・集計する。ここでは 商品マスタやユーザー情報を結合したリッチなデータが必要で、しかも読み取りの回数は書き込みよりずっと多い。

同じ「注文」を扱っていても、この2つは要求がまるで違います。まずはこの違いを頭の隅に置いてください。

まず素直に作ってみる

素直に作るなら、注文テーブルを1つ用意して、そこに書き込み、表示のときは商品マスタなどと JOIN して返します。

-- 書き込みも読み取りも、この1テーブルが起点
CREATE TABLE orders (
  id          BIGINT PRIMARY KEY,
  user_id     BIGINT,
  product_id  BIGINT,
  quantity    INT,
  amount      INT,
  status      VARCHAR(20),
  created_at  TIMESTAMP
);
-- 注文履歴の表示:ユーザー名や商品名は毎回 JOIN で解決する
SELECT o.id, o.created_at, o.amount, o.status,
       u.name AS user_name,
       p.name AS product_name
FROM orders o
JOIN users    u ON o.user_id    = u.id
JOIN products p ON o.product_id = p.id
WHERE o.user_id = ?
ORDER BY o.created_at DESC;

これで動きます。そして多くのサービスは、これで最後まで問題なく回るでしょう。それならそれでいい。必要でないなら、システムは素朴に保つべきです。

問題は、このシステムが大規模になってきたときに起こります。

素直な作りが破綻するとき

サービスが育つと、2つの困りごとが表面化します。

読み取りが重くなり、書き込みを圧迫する。 たとえば管理画面のこんな集計を考えてみてください。

-- 6月の商品別売上ランキング(管理画面)
SELECT p.name, COUNT(*) AS orders, SUM(o.amount) AS revenue
FROM orders o
JOIN products p ON o.product_id = p.id
WHERE o.created_at >= '2026-06-01'
  AND o.created_at <  '2026-07-01'
GROUP BY p.name
ORDER BY revenue DESC;

各商品(名)について、6月中の注文数と売上高を集計するクエリですね。

さて、このクエリは orders テーブルの大量行を走査し、products とのjoin負荷もあります。そして問題なのは、orders テーブルには いまもまさに注文が書き込まれているという事実です。
するとどうか。この読み取り負荷によって、 注文処理も巻き込まれて遅れるのです。

それならいっそ、毎回の JOIN をやめて商品名を注文テーブルに最初から持たせてしまおうかと考えてみる。

ALTER TABLE orders ADD COLUMN product_name VARCHAR(100);

ところがこうすると、商品名が変わるたびに過去の全注文を書き換える羽目になります。正規化を崩した弊害です。

-- 商品名が変更された → 同じ商品の過去の注文をすべて更新
UPDATE orders SET product_name = ? WHERE product_id = ?;

読み取りのための都合が、書き込みモデルを壊しはじめる。読み取りと書き込みがそれぞれの事情で同じテーブルを取り合っているわけです。

なぜこんなことが起こるのか?それは冒頭に示した通り、 読み取りと書き込みは要求が非対称 だからです。非対称性は、だいたい次の4つの軸で現れます。

  • :読み取りが書き込みの何十倍・何百倍ある
  • :書き込みはテーブル整合性のために正規化したい、読み取りは速さのために非正規化したい
  • 変更頻度:画面が増えれば読み取りの形は変わるが、業務の仕組みはそう変わらない
  • 鮮度:注文確定は即座に反映したいが、売上ダッシュボードは5分遅れでも困らない

1つのモデルで両方を満たそうとすると、この非対称性が真正面からぶつかります。

CQRS は最終奥義

CQRS はそんな問題を解決してくれますが、実際のところもっと簡単に解決できる手段はいろいろあります。

↑軽い
| 1. インデックスを足す
| 2. リードレプリカで読みを別負荷に逃がす
| 3. マテリアライズドビューを作る
| 4. 読み取り専用のモデルを別に持つ ← CQRS
↓重い

より軽い技術それぞれの詳細は省きますが、要するに CQRS はかなり重い解決策ということです。CQRS より軽くて枯れた手段がいくつも存在している。軽い方法で済むなら、 CQRS を採用する理由はありません。

読み取りの形がどうしても書き込みの形から素直に導けないときに、ようやく CQRS の出番になります。

読み取り専用のモデルを別に持つ = CQRS

CQRS のコンセプトは簡単です。書き込み用のデータと、読み取り用のデータを、別々に持つ ということです。

CQRS(Command Query Responsibility Segregation) 。「コマンドとクエリでの責務分離」を意味します。

  • コマンド(Command):状態を変える操作。注文する、キャンセルする。
  • クエリ(Query):状態を返す操作。履歴を見る、集計する。

CQRS は、この2つを別のモデルとして独立に設計・運用できるようにする思想です。

ただし、これは システム全体に適用するものではありません。 注文には効いても、お知らせ管理のように読み書きに大した制約のない機能に持ち込んでも、分ける意味がなく単なる二重管理になります。 非対称なところを狙い撃ちして採用するものです。

今回の例ではこんな感じでしょうか。書き込みモデル側の orders テーブルは据え置きで、それとは別に 表示に最適な形へ加工済みの読み取り専用テーブル を用意します。

-- 読み取りモデル:JOIN 済みの結果をあらかじめ埋め込んでおく
CREATE TABLE order_history_view (
  order_id      BIGINT PRIMARY KEY,
  user_id       BIGINT,
  user_name     VARCHAR(100),   -- users テーブルから非正規化して入れこむ
  product_name  VARCHAR(100),   -- products テーブルから非正規化して入れこむ
  amount        INT,
  status        VARCHAR(20),
  created_at    TIMESTAMP
);
-- 読み取りは JOIN なし・単一テーブルで完結する
SELECT * FROM order_history_view
WHERE user_id = ?
ORDER BY created_at DESC;

書き込み(コマンド)側は、注文を記録して「注文された」という事実を通知するだけ。読み取りモデルの形は一切気にしません。

// コマンド:注文を書き込みモデルに記録する
async function placeOrder(cmd: PlaceOrder): Promise<OrderId> {
  const order = await orders.insert({
    id: cmd.orderId,
    userId: cmd.userId,
    productId: cmd.productId,
    quantity: cmd.quantity,
    amount: cmd.amount,
    status: "placed",
    createdAt: now(),
  });

  await publish("OrderPlaced", order); // ← 読み取りモデルへの反映を促す
  return order.id;
}

publish の通知を受けて、読み取りモデルを更新する処理を別に置きます。これが 書き込みモデルと読み取りモデルをつなぐ「反映(プロジェクション)」 です。
結合をここで済ませ、読み取りモデルの形式でデータをもう作ってしまいます。

// 反映:注文イベントを受けて、表示用テーブルを組み立てる
async function onOrderPlaced(order: Order): Promise<void> {
  const user    = await users.find(order.userId);
  const product = await products.find(order.productId);

  await orderHistoryView.upsert({
    orderId:     order.id,
    userId:      order.userId,
    userName:    user.name,      // 表示に必要な名前を、この時点で焼き込む
    productName: product.name,
    amount:      order.amount,
    status:      order.status,
    createdAt:   order.createdAt,
  });
}

書き込み側は 「注文を正しく記録する」 ことに専念し、読み取り側は 「表示・検索・集計に最適な形」 を勝手に持つ。互いの都合に縛られなくなり、それぞれに適したモデルが持てました。
これが、非対称性に対する CQRS の回答です。このとき、SoT(Source of Truth)は書き込みモデルとなります。

しかし、この反映処理 onOrderPlaced は注文処理 placeOrder後から動きます。ここがまさに CQRS が払う代償の核になります。

分けたが最後、遅延が生まれる

ここで、冒頭の「注文したのに履歴に出ない」に戻ってきます。

書き込みモデルと読み取りモデルが別々にある以上、片方への変更がもう片方に伝わるまでには、必ず時間差が生まれます。

[注文する] ──→ 書き込みモデル ──(反映)──→ 読み取りモデル ──→ [履歴を見る]
                    ↑
                ここに時間差が生まれる

注文が成功しても、その時点では読み取りモデルにまだ届いていません。そのため履歴に出てこない。リロードすると出るのは、その間に反映が追いついたからです。

これはバグではありません。 CQRS を選んだ瞬間に確定する性質で、結果整合性(eventual consistency) と呼ばれます。「十分な時間が経てば必ず一致するが、短期的にはズレていることがある」ことを意味します。

遅延とどう付き合うか

遅延そのものを消すことはできません。できるのは「どこに寄せるか」の選択です。具体的には、反映処理である publish をどう実装するかになります。
それぞれに強みや弱みがあるので、システムの要件に合わせて選ぶ必要があります。
またこれらの反映遅延をUX上どう表現するか(楽観的に描画するなど)も実務上重要になりますが、ここでは触れません。

その1:反映処理をストリーミングする
書き込みモデルの更新後、読み取りモデルへの反映処理を非同期に流す形です。APIのようにPromiseで反映リクエストを投げると例えると伝わりやすいでしょうか。

その2:反映処理をバッチ処理する
書き込みモデルの更新後、読み取りモデルへの反映処理を依頼だけして溜め込み、定期処理などで一度に処理する方法です。

その3:読み込みと書き込みをワンセットにする
トランザクション的に書き込みと同時に読み取りモデルも更新し、同期的に反映を行う手です。

手法 pros cons
その1:ストリーミング 書き込み応答が速い 反映処理が重い・多いと遅延が重なっていく
その2:バッチ処理 反映処理が多い場合、負荷が大幅に軽減できる 溜めて処理するまでの待ち時間が遅延に加算される
その3:ワンセット(同期) Read-Write間で強い整合性を確保できる 書き込み処理の確定が反映を待つ分遅い

まとめ

↑軽い
| 1. インデックスを足す
| 2. リードレプリカで読みを別負荷に逃がす
| 3. マテリアライズドビューを作る
| 4. 読み取り専用のモデルを別に持つ ← CQRS
↓重い

CQRS は「読み書きを分ける設計」と説明されがちですが、より本質的には、性質の違う読み取りと書き込みに別々の最適を許すための設計といえます。

しかし分離はタダではありません。書き込みが反映されない時間も、モデル管理の煩雑さも CQRS がもたらす陰の側面です。

実用性は?

さて、ここまで説明してきましたが、実際のところ多くのプロダクトにおいて CQRS は過剰設計になりがちです。 多くの場合、採用することはないと考えています。

じゃあなんでこの記事書いたんだよ という話になりますが、もう一つ伝えたいのはこんな大掛かりな設計が選択肢に挙がるほど、システム要件に合わせた入出力の設計は重要な作業だ ということです。CQRS はその選択肢の一つに過ぎません。

もちろん、設計判断は入出力に限った話ではありません。システムに何が必要で、何が障害になりえて、どのような設計や構成で回避すべきか。それらを見つけ、問い、嗅ぎつけて対処していくことこそが、エンジニアの力量だと考えています。

そして当然、引き出しは多ければ多いほどいい。使わないとしても、選択肢として覚えておけばそれだけで視野が広がると私は信じています。

おまけ:ES(Event Sourcing)との関係

CQRS を調べると、よく「イベントソーシング」という言葉が一緒に出てきます。これは「Command を処理した結果のデータ」ではなく「Command によるデータ変更」を記録する考え方です。銀行でいえば、残高そのものではなく入出金の明細を正とするイメージ。

ES採用 → CQRS採用 が成立し、 CQRS採用 → ES採用 も成立しやすいです(今回の例は採用していません)。特に複雑なケースではESを採用するモチベーションが高まります。詳しくはこちらの記事などを参照してください。
https://blog.j5ik2o.me/entry/2020/09/18/172612

参考

シンシアについて

株式会社シンシアでは、実務未経験のエンジニアの方や学生エンジニアインターンを採用し一緒に働いています。

※ シンシアにおける働き方の様子はこちら
株式会社シンシアの働き方とストーリー - Wantedly

ともに技術力を高め合える仲間として、一緒に働きませんか?コンタクトをお待ちしています!

17
1
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
17
1

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?