CQSが効くのはスキーマを動かすとき
読み書き分離の利点として語られる「viewの変更に引きずられない」は本命ではない。列を落とす変更がコンパイルで守られる、マイグレーションの前提をgrepで確認できる、読みがロック待ち行列に並ばない——CQSはオンラインマイグレーションと相性が良く、組み合わせるとスキーマ変更を安全かつ高速に出せるようになる、という話。
CQS(コマンドとクエリの分離)の利点は、たいてい「読みのモデルが更新側の都合に 引きずられない」「読みと書きを別々に最適化できる」と説明される。正しいが、 それだけなら分離のコストに見合わない場面も多い。
検証用のリポジトリでオンラインマイグレーションを繰り返して分かったのは、 この分離とオンラインマイグレーションの相性が良いということだった。 CQS 単体の売りではなく、組み合わせたときにスキーマ変更を安全かつ高速に 出せるようになる。何がどう噛み合うのかを、実際に効いた順に書く。
前提: ここでの CQS はごく軽い形
コラム: CQS と CQRS はどう違うか
CQS は Bertrand Meyer が示した設計原則で、単位はメソッド。メソッドは 状態を変える「コマンド」か、値を返す「クエリ」のどちらかに寄せ、 値を返しながら状態も変えることを避ける。
CQRS は Greg Young がこの原則をモデルの単位に持ち上げたもの。 書き込みに使うモデルと読み取りに使うモデルを、別の型・別の経路に分ける。 提唱者自身が「以前は1つだったオブジェクトを2つにするだけ」と説明している 通り、別のデータストアやイベントソーシングは CQRS の必須要素ではない。 セットで語られることが多いが、あれらは CQRS の上に足せる選択肢であって、 分けた瞬間から CQRS ではある。
この記事で扱うのは、その意味では CQRS の最小形にあたる。 同じ MySQL の同じテーブルに対して、書き込み側と読み側のクエリを 別ディレクトリに置き、別パッケージとしてコード生成するだけ。 ストアは分けない。同期の仕組みも要らない。
例として写真投稿サービスの photos テーブルを考える。SQL は手書きし、
sqlc(SQL から型付き Go コードを生成するツール)に食わせる。
db/
├── schema.sql # マイグレーション全適用後のスキーマの dump
├── queries/repository/ # 書き込み側: Entity の復元・保存。FOR UPDATE で取る
└── queries/readmodel/ # 読み側: 画面に返す形を直接 SELECT で組む
生成の流れはこうなる。
この鎖が全部つながっていることが後で効く。
1. 列を落とす変更がコンパイルで守られる
オンラインマイグレーションの定石に expand / contract がある。列の追加や インデックス作成のような足す変更(expand)を先にリリースし、列の削除のような 引く変更(contract)は、コードがその列を使わなくなった後のリリースで流す。
危険なのは contract 側だ。「もう誰も使っていないはず」の列を落としたら、 実はバッチが読んでいた——という事故は、列への依存が追跡できないことから起きる。
sqlc + CQS の構成では、この依存が生成コードとして固定されている。
caption 列を title に改名する手順で見る。
- expand:
titleを NULL 許容で追加する(ALGORITHM=INSTANTで即座に終わる) - コードを切り替える: 読み側・書き側のクエリを
titleを使う形に直し、再生成する - contract:
captionを落とす。ここでcaptionを参照するクエリが1つでも 残っていれば、schema.sql の更新 → 再生成でコンパイルが失敗する
「使っている列を落とす」が、運用中の障害ではなくビルドの失敗として現れる。
読み書きが1つのモデル(ORM の Entity など)を共有していると、これが濁る。 Entity には表示にしか使わない列と、不変条件に関わる列が同居する。 どの列を誰が使っているかはコードを追わないと分からず、「怖いから残す」列が たまっていく。分離していれば、読み側のクエリ一覧がそのまま 「表示が依存している列の一覧」になる。
2. マイグレーションの前提を機械で確認できる
contract には「流してよい条件」が付くことがある。実例として、
status 列の既定値を安全側に変えて CHECK 制約を足すマイグレーションには、
こういう前提があった。
前提: status を指定しない INSERT がコードに存在しないこと
この前提を確認できたのは、書き込み SQL が queries/repository/ に
しか存在しないからだ。INSERT 文を全部並べて、全部が status を明示している
ことを目で(あるいは grep で)確かめられる。
ORM でクエリがアプリケーションコード中に散っていると、この確認は 「たぶん大丈夫」にしかならない。マイグレーションの安全性の一部は、 クエリの置き場が閉じていることに依存している。CQS はその置き場を さらに「書き込みだけ」に絞ってくれるので、確認の対象が小さくなる。
3. 読みがロックの待ち行列に並ばない
MySQL では、開いたトランザクションは触れたテーブルのメタデータロック(MDL)を
コミットまで握る。DDL はそのテーブルの排他的な MDL を必要とするので、
長い読み取りトランザクションが1つあるだけで、INSTANT の DDL ですら
その後ろで待つ。さらに悪いことに、DDL が待っている間、後続の通常クエリも
全部その後ろに並ぶ。読み取り1本が、テーブル全体の詰まりに化ける。
この構成では、読み側(readmodel)はトランザクションを持たない。
トランザクションを受け取る口が実装に存在しないので、規約ではなく構造として
持てない。書き込み側だけが短いトランザクションで FOR UPDATE を使う。
結果として、マイグレーションを流している最中も読みは MDL の行列に 並ばず、アプリは詰まらない。「オンライン」マイグレーションのオンライン性は、 DDL のアルゴリズム選択だけでなく、アプリ側のトランザクション規律にも かかっている。読み書き分離はその規律を構造で保証する。
4. 表示条件を読み側だけに置ける
小さいが実害を防いだ例をひとつ。アップロード未完了の写真
(status = 'pending_upload')は表示してはいけない。この条件は
読み側の全クエリに AND status = 'ready' として置いてある。
書き込み側は逆に status を問わない。削除や回収の対象には未完了の行も 含まれるからだ。同じテーブルへの同じ SELECT でも、読みと書きで 「見えるべき行」が違う。1つのモデルで両方を賄うと、この違いは 呼び出し側の if 文に漏れ出す。
まとめ: 分離の線が変更の単位と一致している
振り返ると、読み書き分離が効いたのはどれも何かを変えるときだった。
| 変えるもの | 分離が効いた形 |
|---|---|
| スキーマ(列の削除) | 依存が生成コードに固定され、ビルドで失敗する |
| 制約(CHECK の追加) | 前提の確認対象が「書き込みクエリだけ」に絞れる |
| スキーマ(DDL 実行中) | 読みがトランザクションを持たず、ロック待ちに並ばない |
| 表示の仕様 | 条件が読み側に閉じ、書き込み側に波及しない |
「view の変更に引きずられない」は、この表の最後の1行でしかない。 CQS の分離線が、スキーマ・トランザクション・制約という変更の単位と たまたまではなく一致している——これが相性の正体だ。変更のたびに壊れうる 箇所がビルドと grep とロック挙動で守られるから、スキーマ変更を安全に、 ためらわずに出せる。安全かつ高速な開発は CQS 単体の性質ではなく、 この組み合わせから出てくる。
言い換えると、読み書き分離は分離した瞬間には価値をほとんど生まない。 価値が出るのは最初のオンラインマイグレーションを流す日で、そこから先は スキーマを動かすたびに効き続ける。