本番規模で測る — DBベンチマークの落とし穴と、"相関×直積"という真のボトルネック
静的な見た目や1回の計測は、しばしば嘘をつく。計測の作法と、実際に効く2つのボトルネック、その定石を共有する。
MEASURE計測の作法 — まず「測り方」を疑う
大規模なリレーショナルDB(例:クラウドのMySQL互換)でクエリを最適化するとき、最初の敵は遅いクエリではなく、信頼できない計測結果である。改善の前に、次の3点を守りたい。
- 準備コストを本体から分離する。 テスト用の検索対象を用意する際、「索引の効かない ORDER BY で大テーブルから LIMIT 取得」すると、その準備自体が filesort で極端に遅くなる。準備の遅さを本体クエリの遅さと取り違えると、無実のクエリを犯人にしてしまう。準備は必ず索引の効く条件で取得する。
- ウォームで測る。 初回はコールドキャッシュのため数十倍遅く出ることがある。1回目は捨て、2回目以降の安定した値を採る。
- 実運用の入力規模で検証する。 本番は1〜数件の入力が主流だ。大バッチだけ速くても意味はない。少件数で劣化しないかを確認する。fan-out 系の最適化は、小バッチでは固定オーバーヘッドの増加として跳ね返ることがある。
FIGURE計測の順序を図にする
CAUSE真のボトルネックは主に2種
結合の「数」を静的に数えて重さを判定すると、過検出になりがちだ。実際に効くのは次の2つである。
- ① 相関サブクエリを fan-out(直積)の内側にインラインしている。 単一入力でも壊滅的に遅くなる。行が展開されるたびに相関サブクエリが再評価されるためだ。
- ② 独立した1対多を2枝以上、直接結合している。 枝Aの行数と枝Bの行数が掛け算になり、結果行が爆発する。
逆に、単純な親→子→孫の連鎖(直積にならない)や、COUNT(DISTINCT)・GROUP_CONCAT で畳むだけの処理は、本番規模でも十分速い。「結合が多い=遅い」ではなく、「直積になっているか」を見る。
DIAGRAM直積の爆発と、集約による回避
FIX修正の定石
- 枝ごとに事前集約してから LEFT JOIN。 各1対多の枝を「子ごとに別の一時表へ集約(1キー1行)」してから結合し、直積を排除する。
- 相関サブクエリは一時表に置き換える。 fan-out の内側で再評価させず、事前計算した一時表を1回だけ結合する。
- 保有判定は EXISTS で。 「存在するか」を知りたいだけなら、DISTINCT で大テーブルを畳むのではなく、索引参照の EXISTS を使う。
- 小バッチでは一時表への余分な索引付与はむしろ無駄。 索引作成の固定コストが少件数では割に合わない。
CODE擬似SQL — 悪い例と良い例
SELECT p.id, -- 相関サブクエリを直積の内側でインライン(毎行 再評価) (SELECT COUNT(*) FROM child_c c WHERE c.pid = p.id) AS c_cnt FROM parent p JOIN branch_a a ON a.pid = p.id -- 枝A:m行 JOIN branch_b b ON b.pid = p.id -- 枝B:n行 → m×n に爆発 WHERE p.id IN (:ids);
-- 枝ごとに 1キー1行へ事前集約 CREATE TEMPORARY TABLE tmp_a AS SELECT pid, GROUP_CONCAT(val) AS a_vals FROM branch_a WHERE pid IN (:ids) GROUP BY pid; CREATE TEMPORARY TABLE tmp_b AS SELECT pid, COUNT(DISTINCT val) AS b_cnt FROM branch_b WHERE pid IN (:ids) GROUP BY pid; SELECT p.id, a.a_vals, b.b_cnt, -- 保有判定は EXISTS(索引参照)で EXISTS(SELECT 1 FROM child_c c WHERE c.pid = p.id) AS has_c FROM parent p LEFT JOIN tmp_a a ON a.pid = p.id -- 1キー1行なので直積にならない LEFT JOIN tmp_b b ON b.pid = p.id WHERE p.id IN (:ids);
TAKEAWAY
計測は「準備の分離・ウォーム・実運用規模」の3点を守り、静的な結合数ではなく直積の有無を疑う。相関サブクエリのインラインと独立した1対多の直接結合という2つの直積を、枝ごとの事前集約と EXISTS で断てば、本番規模でも安定して速い。