0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

なぜ複数データ取得にサブクエリは不向きなのか?JOINへの書き換えで「N+1」を防ぐ

0
Posted at

はじめに

現在、GoでXクローン作成中のrikutoです。
先日、いいね、リツイート、ブックマーク、を含んだツイート一覧取得のSQLで、サブクエリを使ってしまいました。

今回の記事では、

  • なぜサブクエリがダメなのか?
  • サブクエリを使わない場合は、どのようなSQLを書けばいいのか?

という点をまとめようと思います。

同じようなポイントで「おや?」と思っている方の参考になれば幸いです。
もし内容に誤りや、もっと良い書き方があれば、ぜひコメントで教えていただけると嬉しいです!

サブクエリとは?

SELECT 
  id,
  name,
  (SELECT sub_value FROM sub_table)
FROM table;

「1件ごとに何度も聞きに行く」スタイル
メインのクエリが1行実行されるたびに、その都度サブクエリが実行される。

  • 特徴: メインデータがN件あれば、サブクエリもN回実行されるため、データ量が増えると急激に重くなります。
  • 使う場面: 取得件数が極めて少ない場合や、一時的な値の確認など。
  • イメージ: ビュッフェで「ご飯とってこよう」「次はサラダ...」のように、1品ずつ何度も往復して料理を取りに行くイメージ。

「メインデータ(1)を取得した後に、その数(N)だけサブクエリが働くこの現象はN+1問題と呼ばれ、パフォーマンス劣化の代表的な原因の1つである」

JOINとは?

SELECT 
  t.id, 
  t.content, 
  l.id AS like_id -- いいねのID
FROM tweets AS t
LEFT JOIN likes AS l ON t.id = l.tweet_id;

「あらかじめ大きなテーブルを作って一気に持ってくる」スタイル
複数のテーブルを最初に「1つの大きなテーブル」として連結し、そこから必要なデータをまとめて抽出する。

  • 特徴: データベース内部でテーブル同士を効率よく結合するため、一度のアクセスで大量のデータを高速に集計できる。
  • 使う場面: 「一覧表示」などの、複数のデータをまとめて取得する場合など。
  • イメージ: 注文した料理が全て載った「完成済みのプレート」を一気に運んでもらうようなイメージ。

問題のSQL (サブクエリ)

$1,$2はアプリケーション側から渡される引数である。
以下のSQLはいいね、リツイート、ブックマーク、を含んだツイートの一覧を取得するSQLです。

SELECT 
  t.id, 
  t.user_id, 
  t.content, 
  t.created_at,
  
  -- 以下がサブクエリ
  (SELECT COUNT(*) FROM likes l WHERE l.tweet_id = t.id) AS like_count,
  (SELECT COUNT(*) FROM retweets r WHERE r.tweet_id = t.id) AS retweet_count,
  EXISTS (
    SELECT 1 FROM likes l 
    WHERE l.tweet_id = t.id AND l.user_id = $1
  ) AS is_liked,
  EXISTS (
    SELECT 1 FROM retweets r 
    WHERE r.tweet_id = t.id AND r.user_id = $1
  ) AS is_retweeted,
  EXISTS (
    SELECT 1 FROM bookmarks b 
    WHERE b.tweet_id = t.id AND b.user_id = $1
  ) AS is_bookmarked
FROM tweets t
WHERE t.id = $2;

問題のSQLの挙動

このSQLを実行した時、データベース内部では、以下のような「往復」が発生しています。

  1. tweetsテーブルからツイートを1件取り出す。
  2. 取り出したツイートに対して、書かれたサブクエリの数だけ別テーブルへ問い合わせを行う。
    • likesテーブルへ件数を聞きに行く
    • retweetsテーブルへ件数を聞きに行く
    • existsで自分の状態を確認しに行く...(以下、サブクエリ分繰り返す)
  3. 次のツイートに移り、また1〜2を繰り返す。

取得するツイートが増えれば増えるほど、この「往復回数」が雪だるま式に増えていくのが、このSQLの恐ろしいところです。

また、上記のSQLを「N+1」に当てはめてみると
「1」 はツイート一覧を取得するメインクエリ
「N」 は取得したツイートの件数
になります。

改善したSQL (JOIN)

$1,$2はアプリケーション側から渡される引数である。
以下のSQLはいいね、リツイート、ブックマーク、を含んだツイートの一覧を取得するSQLです。

SELECT
  t.id,
  t.user_id,
  t.content,
  t.created_at,
  COUNT(DISTINCT l.id) AS like_count,
  MAX(CASE WHEN l.user_id = $1 THEN 1 ELSE 0 END)::boolean AS is_liked,
  COUNT(DISTINCT r.id) AS retweet_count,
  MAX(CASE WHEN r.user_id = $1 THEN 1 ELSE 0 END)::boolean AS is_retweeted,
  MAX(CASE WHEN b.user_id = $1 THEN 1 ELSE 0 END)::boolean AS is_bookmarked
FROM tweets t
LEFT JOIN likes l ON l.tweet_id = t.id
LEFT JOIN retweets r ON r.tweet_id = t.id
LEFT JOIN bookmarks b ON b.tweet_id = t.id
WHERE t.id = $2
GROUP BY t.id;

改善したSQLの挙動

このSQLは、実行された瞬間にデータベース内部で以下のような動きをします。

  1. tweetsテーブルを主軸に、likes,retweets,bookmarksを全て横に連結した 「1つの一時的なテーブル」 を作ります。
  2. 連結されたテーブルから、ツイートを取得します。

なぜJOINだと速いのか?(N+1の解消)

データベースへのアクセス回数(クエリ発行数)を劇的に減らせるからです。

  • サブクエリ版: ツイートが20件あれば、裏側で100回以上の追加クエリが走り、通信のオーバーヘッド(往復のムダ)が積み重なります。
  • JOIN版: どんなにデータが多くても、発行されるクエリはたったの1回です。

掛け算現象とDISTINCTによる対策

JOINは強力ですが、1つだけ注意点があります。それが 「データの増殖」 です。
1つのツイートに複数の「いいね」や「リツイート」が紐づいている場合、結合すると行数がそれらの掛け算で増えてしまいます。

  • 例: 「いいね3件」×「リツイート2件」= 内部的に6行(6つのツイート)に増殖

これをそのままCOUNTすると、本来「3」であるいいね数が「6」と表示されるバグに繋がります。
そこで、以下の2つの対策をセットで行うのが鉄則です。

  • GROUP BY t.id: データをツイート単位で1行に集約する。
  • COUNT(DISTINCT l.id): 「重複を除いたIDの数」だけを数えることで、正しい件数を算出する。

まとめ

「複数データ取得には、サブクエリではなくJOINを使おう」

今回の学びを3行でまとめると

  • サブクエリは「N+1」の温床: 1行ごとにデータを見に行くため、件数が増えると急激に重くなる。
  • JOINは「一括処理」: 最初に関連データを結合することで、データベースへのアクセスを最小限(1回)に抑えられる。
  • DISTINCTとJOINはセット: JOINによるデータの増殖(掛け算現象)は、DISTINCTを使い、重複を排除する。

最後に

最後までお読みいただきありがとうございました。

0
0
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
0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?