もう全件取得しない!PostgreSQLで行数を賢く指定する完全ガイド

データベース・SQL

「SELECT * FROM users… あれ、画面が固まった…」 「たった最新10件だけ欲しいのに、なんで全データ取ってくるの?」 「ページネーション実装したいけど、どうやって特定の範囲だけ取得するの?」

データベースから必要な行数だけを取得する。これは、パフォーマンスとユーザー体験を劇的に改善する重要なテクニックです。

PostgreSQLには、LIMITOFFSETFETCH FIRSTなど、行数を制御する強力な機能が備わっています。でも、使い方を間違えると、かえってパフォーマンスが悪化することも…

この記事では、PostgreSQLで行数を指定する全ての方法から、高速化のテクニック、よくある落とし穴まで、実例を交えて完全解説します。

読み終わる頃には、数百万件のデータから瞬時に必要な行だけを取り出せるようになっているはずです!

スポンサーリンク

基本:LIMIT句で行数を制限

最もシンプルな使い方

基本構文:

SELECT column1, column2, ...
FROM table_name
LIMIT 行数;

実例:最新の10件を取得

-- usersテーブルから10件だけ取得
SELECT id, name, email, created_at
FROM users
LIMIT 10;

-- 最新の5つの注文を取得
SELECT * 
FROM orders
ORDER BY created_at DESC
LIMIT 5;

なぜLIMITが重要なのか

パフォーマンスの違い:

-- 悪い例:全件取得してからアプリ側で制限
SELECT * FROM products;  -- 100万件全部取得...

-- 良い例:データベース側で制限
SELECT * FROM products LIMIT 100;  -- 100件だけ!

実測例(100万件のテーブル):

  • 全件取得:3.5秒、メモリ500MB使用
  • LIMIT 100:0.003秒、メモリ1MB使用
  • 1000倍以上高速!

OFFSET:特定の位置から取得

スキップして取得する

基本構文:

SELECT column1, column2, ...
FROM table_name
LIMIT 行数 OFFSET スキップする行数;

実例:11件目から20件目を取得

-- 最初の10件をスキップして、次の10件を取得
SELECT id, name, email
FROM users
ORDER BY id
LIMIT 10 OFFSET 10;

-- 価格順で21位から30位の商品
SELECT name, price
FROM products
ORDER BY price DESC
LIMIT 10 OFFSET 20;

ページネーションの実装

ページ番号から自動計算:

-- ページ番号とページサイズから計算
-- ページ2、1ページ20件の場合

-- 変数設定(実際はアプリケーション側で)
-- page_number = 2
-- page_size = 20
-- offset = (page_number - 1) * page_size = 20

SELECT id, title, content, created_at
FROM posts
ORDER BY created_at DESC
LIMIT 20 OFFSET 20;

PHPでの実装例:

$page = $_GET['page'] ?? 1;
$perPage = 20;
$offset = ($page - 1) * $perPage;

$sql = "SELECT * FROM articles 
        ORDER BY id DESC 
        LIMIT $perPage OFFSET $offset";

FETCH FIRST:SQL標準の書き方

より標準的な構文

基本構文:

SELECT column1, column2, ...
FROM table_name
FETCH FIRST n ROWS ONLY;

LIMITとの比較:

-- LIMIT版(PostgreSQL独自)
SELECT * FROM employees LIMIT 5;

-- FETCH FIRST版(SQL標準)
SELECT * FROM employees
FETCH FIRST 5 ROWS ONLY;

-- OFFSETと組み合わせ
SELECT * FROM employees
OFFSET 10 ROWS
FETCH NEXT 5 ROWS ONLY;

なぜFETCH FIRSTを使うのか

メリット:

  • SQL標準準拠(他のDBMSへの移植性)
  • より明示的で読みやすい
  • WITH TIESオプションが使える

WITH TIES:同じ値を含む行も取得

-- 売上トップ3を取得(同額3位も含む)
SELECT name, sales_amount
FROM salespeople
ORDER BY sales_amount DESC
FETCH FIRST 3 ROWS WITH TIES;

-- 結果例:
-- 田中: 1000万
-- 佐藤: 900万
-- 鈴木: 800万  ← 3位
-- 山田: 800万  ← 同額3位も含まれる

ORDER BYとの組み合わせ(超重要!)

順序を指定しないと意味がない

警告:ORDER BYなしのLIMITは危険

-- 悪い例:順序が不定
SELECT * FROM users LIMIT 10;
-- 実行するたびに違う10件が返る可能性!

-- 良い例:順序を明示
SELECT * FROM users 
ORDER BY created_at DESC 
LIMIT 10;
-- 必ず最新の10件が返る

よく使うORDER BYパターン

1. 最新のデータ

-- 最新のコメント10件
SELECT * FROM comments
ORDER BY created_at DESC
LIMIT 10;

2. ランキング系

-- 売れ筋商品トップ10
SELECT product_id, SUM(quantity) as total_sold
FROM order_items
GROUP BY product_id
ORDER BY total_sold DESC
LIMIT 10;

3. ランダム抽出

-- ランダムに5件抽出(重い処理なので注意)
SELECT * FROM questions
ORDER BY RANDOM()
LIMIT 5;

パフォーマンス最適化テクニック

インデックスを活用する

インデックスがある場合とない場合:

-- インデックスを作成
CREATE INDEX idx_users_created_at ON users(created_at DESC);

-- これが高速になる
SELECT * FROM users
ORDER BY created_at DESC
LIMIT 10;

-- 実行計画を確認
EXPLAIN ANALYZE
SELECT * FROM users
ORDER BY created_at DESC
LIMIT 10;

パフォーマンス比較(100万件):

  • インデックスなし:800ms
  • インデックスあり:0.5ms
  • 1600倍高速!

OFFSETの落とし穴と対策

問題:大きなOFFSETは遅い

-- ページ1000(OFFSET 99900)は非常に遅い
SELECT * FROM posts
ORDER BY id
LIMIT 100 OFFSET 99900;
-- PostgreSQLは99900件をスキップしてから100件取得

解決策1:Keyset Pagination(推奨)

-- 前のページの最後のIDを記憶
-- last_id = 12345

SELECT * FROM posts
WHERE id > 12345
ORDER BY id
LIMIT 100;
-- OFFSETを使わないので高速!

解決策2:ROW_NUMBER()を使用

WITH numbered_posts AS (
  SELECT *, ROW_NUMBER() OVER (ORDER BY id) as rn
  FROM posts
)
SELECT * FROM numbered_posts
WHERE rn BETWEEN 99901 AND 100000;

実践的な使用例

1. 無限スクロールの実装

-- 初回読み込み
SELECT id, content, created_at
FROM tweets
ORDER BY created_at DESC
LIMIT 20;

-- 追加読み込み(last_created_atを記憶)
SELECT id, content, created_at
FROM tweets
WHERE created_at < '2024-01-15 10:30:00'
ORDER BY created_at DESC
LIMIT 20;

2. ダッシュボードの要約表示

-- 最近のエラーログ5件
SELECT * FROM error_logs
ORDER BY occurred_at DESC
LIMIT 5;

-- 今日の売上トップ3商品
SELECT product_name, SUM(amount) as total
FROM sales
WHERE date = CURRENT_DATE
GROUP BY product_name
ORDER BY total DESC
LIMIT 3;

3. サンプリングデータの取得

-- 全体の1%をランダムサンプリング(高速版)
SELECT * FROM large_table
WHERE random() < 0.01
LIMIT 1000;

-- 各カテゴリから10件ずつ取得
(SELECT * FROM products WHERE category = 'electronics' LIMIT 10)
UNION ALL
(SELECT * FROM products WHERE category = 'books' LIMIT 10)
UNION ALL
(SELECT * FROM products WHERE category = 'clothing' LIMIT 10);

4. 重複排除での使用

-- 各ユーザーの最新の投稿1件だけ
SELECT DISTINCT ON (user_id) 
  user_id, content, created_at
FROM posts
ORDER BY user_id, created_at DESC;

-- PostgreSQL独自機能DISTINCT ONとLIMITの組み合わせ
SELECT * FROM (
  SELECT DISTINCT ON (user_id) *
  FROM posts
  ORDER BY user_id, created_at DESC
) sub
LIMIT 10;

サブクエリでのLIMIT

IN句での使用

-- 最新10件の投稿へのコメントを取得
SELECT * FROM comments
WHERE post_id IN (
  SELECT id FROM posts
  ORDER BY created_at DESC
  LIMIT 10
);

EXISTS句での効率化

-- アクティブユーザー(最近の投稿がある)を取得
SELECT * FROM users u
WHERE EXISTS (
  SELECT 1 FROM posts p
  WHERE p.user_id = u.id
  AND p.created_at > CURRENT_DATE - INTERVAL '30 days'
  LIMIT 1  -- 1件見つかれば十分
);

CTEとウィンドウ関数での高度な使い方

各グループのトップN

-- 各部署の給与トップ3
WITH ranked_employees AS (
  SELECT 
    name, 
    department, 
    salary,
    ROW_NUMBER() OVER (PARTITION BY department ORDER BY salary DESC) as rank
  FROM employees
)
SELECT name, department, salary
FROM ranked_employees
WHERE rank <= 3
ORDER BY department, rank;

累積和での制限

-- 予算内で買える商品リスト
WITH cumulative AS (
  SELECT 
    name, 
    price,
    SUM(price) OVER (ORDER BY price) as running_total
  FROM products
  WHERE category = 'books'
)
SELECT name, price
FROM cumulative
WHERE running_total <= 10000
ORDER BY price;

よくある間違いと注意点

1. NULLの扱い

-- NULLは最後に来る(PostgreSQLのデフォルト)
SELECT * FROM users
ORDER BY last_login DESC
LIMIT 10;

-- NULLを最初にしたい場合
SELECT * FROM users
ORDER BY last_login DESC NULLS FIRST
LIMIT 10;

2. GROUP BYとの組み合わせ

-- 間違い:LIMITはGROUP BY後に適用される
SELECT category, COUNT(*)
FROM products
GROUP BY category
LIMIT 5;  -- カテゴリが5つだけ、商品5つではない

-- 各カテゴリから5つずつ欲しい場合
SELECT * FROM (
  SELECT *, ROW_NUMBER() OVER (PARTITION BY category ORDER BY id) as rn
  FROM products
) sub
WHERE rn <= 5;

3. UNIONでの使用

-- 括弧が必要
(SELECT * FROM table1 ORDER BY id LIMIT 5)
UNION ALL
(SELECT * FROM table2 ORDER BY id LIMIT 5);

-- 全体に対するLIMIT
SELECT * FROM (
  SELECT * FROM table1
  UNION ALL
  SELECT * FROM table2
) combined
ORDER BY created_at DESC
LIMIT 10;

パフォーマンス計測とチューニング

実行計画の確認

-- 実行計画を見る
EXPLAIN (ANALYZE, BUFFERS) 
SELECT * FROM large_table
ORDER BY created_at DESC
LIMIT 100;

-- コスト確認ポイント:
-- - Limit節のコスト
-- - Sort節の有無(インデックスがあれば不要)
-- - Rows(推定行数)の精度

カウントの最適化

-- 遅い:全件カウント
SELECT COUNT(*) FROM huge_table;

-- 速い:推定値で十分な場合
SELECT reltuples::BIGINT 
FROM pg_class 
WHERE relname = 'huge_table';

-- ページング用の総数取得
SELECT COUNT(*) OVER() as total_count, *
FROM (
  SELECT * FROM posts
  ORDER BY id DESC
  LIMIT 20 OFFSET 40
) sub;

データベース設計での考慮事項

適切なインデックス戦略

-- ページネーション用の複合インデックス
CREATE INDEX idx_posts_user_created 
ON posts(user_id, created_at DESC);

-- カバリングインデックス(取得列も含む)
CREATE INDEX idx_posts_covering 
ON posts(created_at DESC) 
INCLUDE (id, title, user_id);

パーティショニングとの組み合わせ

-- 時系列データのパーティション
CREATE TABLE logs_2024_01 PARTITION OF logs
FOR VALUES FROM ('2024-01-01') TO ('2024-02-01');

-- 最新パーティションからのみ取得(高速)
SELECT * FROM logs_2024_01
ORDER BY created_at DESC
LIMIT 100;

まとめ:賢い行数指定でアプリを高速化

PostgreSQLの行数指定は、単純なLIMITから始まり、非常に奥が深い機能です。

覚えておくべき重要ポイント:

  1. 基本はLIMIT + ORDER BY
    • 順序なしのLIMITは無意味
    • インデックスと組み合わせて高速化
  2. 大きなOFFSETは避ける
    • Keyset Paginationを検討
    • WHERE句での絞り込みを優先
  3. 適切なインデックスは必須
    • ORDER BY列にインデックス
    • 1600倍の高速化も可能

今すぐ実践すべきこと:

  1. 遅いクエリを特定(5分) SELECT query, mean_exec_time FROM pg_stat_statements ORDER BY mean_exec_time DESC LIMIT 10;
  2. LIMITを追加(即効果)
    • 全件取得をやめる
    • 必要な分だけ取得
  3. インデックスを確認(10分)
    • ORDER BY列を確認
    • 必要なインデックスを作成

たったLIMIT 10を追加するだけで、アプリケーションの応答速度が劇的に改善することがあります。

データベースは賢く使えば、驚くほど高速に動作します。この記事の技術を活用して、ユーザーを待たせないアプリケーションを作りましょう!

最後のアドバイス: 「全件取得してからアプリ側で処理」は今日で卒業。データベースに仕事をさせて、ネットワークとメモリを節約しましょう!

コメント

タイトルとURLをコピーしました