はじめに
SQLを書きながら「速いSQLってどう書くんだろう」と考え始めたとき、最初に腹落ちした気づきの共有です。
「タスクが全部完了したか」を確認する処理で、最初はこう考えました。
完了したレコードを全部数えて、件数が想定どおりなら「全部完了」とみなす
でも、完了を全部数えなくても「未完了が0件か」さえ見れば済む。
この小さな発想の転換で、SQLもコードもシンプルで速くなりました。
この記事で分かること
- 「全部完了したか」を、完了件数ではなく未完了の不在で判定する考え方
-
NOT EXISTSがCOUNT(*)より速くなりやすい理由 - Python には
true/falseだけ返す書き方 - そこから見える「速いSQL」の一般原則
前提: PostgreSQL + psycopg2 を例にします。
MySQL など他の行指向RDBでも接続部分を差し替えればそのまま使えます
(考え方自体はRedshiftのような列指向DBでも通用しますが、インデックス前提の話は当てはまらない点に注意)。
1. 困りごと:完了を全部数えるのは重い
「タスクが全部完了したら次へ進みたい」。よくある要件です。
最初に考えたのは「完了を全部数えて、件数が想定どおりなら完了とみなす」でした。
でもこれ、完了が増えるほど数える対象が増えます。
1万件完了していたら1万件を数える。「全部終わったか知りたいだけ」なのに重い。
2. 発想の転換:未完了を探すほうがラク
「全部完了した」を裏返すと、こうです。
未完了(とエラー)の行が1件も存在しない
完了を数えなくても、未完了が0件だと確認できれば「全部完了」と言える。
「全部できたか?」を「やり残しはないか?」に変えるだけです。
3. SQL:NOT EXISTS で判定する
「存在しないこと」を確認する構文が NOT EXISTS です。
-- 未完了(pending)・エラー(error)が1件もなければ true(=全部完了)
SELECT NOT EXISTS (
SELECT 1
FROM tasks
WHERE status IN ('pending', 'error')
) AS all_completed;
該当が1件もなければ true、1件でもあれば false。これだけです。
なぜ COUNT(*) ではなく NOT EXISTS か
同じ判定は COUNT(*) でも書けます。
SELECT COUNT(*) = 0 AS all_completed
FROM tasks
WHERE status IN ('pending', 'error');
違いはやる仕事の量です。
| 書き方 | 挙動 |
|---|---|
COUNT(*) |
該当行を最後まで全部数える |
NOT EXISTS |
該当行を1件見つけた時点で打ち切れる |
EXISTS は「1件でもあるか?」を問うので、最初の1件が見つかった瞬間に探索をやめられます。
これが速さの正体です。
補足: 実際の最適化はオプティマイザとインデックス次第で、
COUNTがうまく処理されることもあります。
ただ「あるか無いかだけ知りたい」意図が明確なNOT EXISTSのほうがDBの挙動と噛み合いやすい、と理解しておくと安全です。
statusにインデックスがあるとさらに効きます。気になるときはEXPLAIN ANALYZEで実行計画を確認してください。
4. Python連携:判定はSQLに任せる
ポイントは未完了の行そのものをPythonに持ってこないこと。
判定はSQL側で済ませ、Pythonは true / false だけ受け取ります。
転送量が最小になり、ロジックもシンプルになります。
import psycopg2
def is_all_completed(conn) -> bool:
"""未完了・エラーが0件なら True(=全部完了)を返す。"""
sql = """
SELECT NOT EXISTS (
SELECT 1 FROM tasks WHERE status IN ('pending', 'error')
)
"""
with conn.cursor() as cur:
cur.execute(sql)
(all_completed,) = cur.fetchone() # (True,) または (False,)
return all_completed
使う側:
if is_all_completed(conn):
run_finalize() # 0件 → 完了処理へ
else:
logger.info("未完了あり")
追記:ポーリングして完了を待つ
import time
def wait_until_completed(conn, interval_sec=30, max_tries=20) -> bool:
for _ in range(max_tries):
if is_all_completed(conn):
return True
time.sleep(interval_sec)
return False # タイムアウト
毎回「未完了を1件探す」だけなので、何度呼んでも軽いのがポイントです。
5. 一般化:速いSQLとは「処理しなくて済むように書いたSQL」
NOT EXISTS が速かったのは、処理を頑張って速くしたからではなく、
そもそもやる仕事を減らしたからです。「全部数える」をやめて「1件探す」に変えただけ。
この視点で見ると、バラバラなテクニックが同じ原則の現れだと分かります。
| テクニック | 減らしているもの |
|---|---|
NOT EXISTS / LIMIT 1
|
答えが出たら残りを処理しない |
WHERE で早く絞り込む |
対象の行数を減らす |
| インデックス | 全行走査せず狙った行だけ読む |
必要な列だけ SELECT(SELECT * を避ける) |
読むデータ量を減らす |
どれも「速く処理する」ではなく「処理する量・読む量を減らす」方向を向いています。
速いSQLとは、速く処理するSQLではなく、処理しなくて済むように書いたSQLである。
まとめ
- 「全部完了したか」は、未完了の不在(
NOT EXISTS)で判定できる -
NOT EXISTSは1件見つけたら打ち切れるため、全件数えるCOUNTより速くなりやすい - Pythonには判定結果だけ返し、行そのものは転送しない
- 速さの本質は「速く処理する」ではなく「処理する量を減らす」こと
参考:同じ原則で読める記事
「処理する量を減らす」視点で面白かった記事です。
-
【SQL】ちょっとしたパフォーマンスチューニングまとめ …
UNION ALLで重複排除のソートを省くなど、「暗黙のソートを減らす」話。 -
SQLが爆速になる!「WHERE句最適化」知らないと損する5つの技 …
WHERE句で列を関数で包むとインデックスが効かなくなる(SARGable)話。 - ページネーションの速度比較(OFFSET vs キーセット) … OFFSETは読み飛ばす行も一度読む。「捨てるために読む」をやめる話。