PostgreSQLで行数を取得する全方法!COUNT関数からパフォーマンス最適化まで【完全版】

データベース・SQL

「テーブルの行数を数えたいだけなのに、なんでこんなに遅いの?」 「COUNT(*)とCOUNT(1)の違いって何?」 「大規模テーブルの行数を高速に取得する方法は?」

PostgreSQLで行数を取得する方法は、実は状況によって最適な手法が異なります

単純にCOUNT(*)を使えばいいと思っていませんか?実は、テーブルのサイズやインデックス、精度の要求によって、もっと効率的な方法があるんです。

この記事では、基本的なCOUNT関数から、数億行のテーブルでも高速に動作する上級テクニックまで、すべてお伝えします!


スポンサーリンク

基本編:COUNT関数の使い方

📊 COUNT(*):最も基本的な行数取得

全行数を取得

-- テーブルの全行数を取得
SELECT COUNT(*) FROM users;

-- 結果例
 count 
-------
  1234
(1 row)

特徴:

  • NULL値も含めてすべての行をカウント
  • 最も一般的で確実な方法
  • インデックスがあっても全件スキャンすることがある

🎯 条件付きカウント:WHERE句と組み合わせ

-- アクティブユーザーの数を取得
SELECT COUNT(*) FROM users WHERE status = 'active';

-- 複数条件
SELECT COUNT(*) 
FROM orders 
WHERE created_at >= '2025-01-01' 
  AND status = 'completed';

-- 結果を分かりやすく表示
SELECT COUNT(*) AS active_user_count 
FROM users 
WHERE status = 'active';

🔍 COUNT関数のバリエーション

COUNT(*) vs COUNT(1) vs COUNT(列名)

-- すべて同じ結果?いいえ、違います!

-- COUNT(*):すべての行をカウント
SELECT COUNT(*) FROM products;  -- 結果:1000

-- COUNT(1):すべての行をカウント(*と同じ)
SELECT COUNT(1) FROM products;  -- 結果:1000

-- COUNT(column):NULLを除外してカウント
SELECT COUNT(description) FROM products;  -- 結果:950(NULL除外)

-- COUNT(DISTINCT):重複を除外
SELECT COUNT(DISTINCT category_id) FROM products;  -- 結果:15

使い分けの指針:

方法用途NULL値パフォーマンス
COUNT(*)全行数含む標準
COUNT(1)全行数含む*と同じ
COUNT(列名)特定列の非NULL数除外列による
COUNT(DISTINCT 列)ユニーク値の数除外遅い

グループ別の行数取得

📈 GROUP BYで集計する

基本的なグループ集計

-- カテゴリー別の商品数
SELECT 
    category_id,
    COUNT(*) AS product_count
FROM products
GROUP BY category_id
ORDER BY product_count DESC;

-- 結果例
 category_id | product_count 
-------------+---------------
           1 |           234
           5 |           189
           3 |           156

複数列でのグループ化

-- 年月別の注文数
SELECT 
    DATE_TRUNC('month', created_at) AS month,
    status,
    COUNT(*) AS order_count
FROM orders
GROUP BY DATE_TRUNC('month', created_at), status
ORDER BY month DESC, status;

-- 結果例
        month        |   status   | order_count 
--------------------+------------+-------------
 2025-01-01 00:00:00 | completed  |        1234
 2025-01-01 00:00:00 | pending    |         456
 2024-12-01 00:00:00 | completed  |        1189

🎨 HAVING句で集計結果をフィルタ

-- 100件以上の注文があるユーザーのみ表示
SELECT 
    user_id,
    COUNT(*) AS order_count
FROM orders
GROUP BY user_id
HAVING COUNT(*) >= 100
ORDER BY order_count DESC;

-- 複数条件のHAVING
SELECT 
    category_id,
    COUNT(*) AS product_count,
    AVG(price) AS avg_price
FROM products
GROUP BY category_id
HAVING COUNT(*) > 10 
   AND AVG(price) > 1000;

パフォーマンスを考慮した行数取得

⚡ 大規模テーブルでの高速化テクニック

方法1:システムカタログを使用(概算値)

-- pg_stat_user_tablesから概算値を取得(超高速)
SELECT 
    schemaname,
    tablename,
    n_live_tup AS approximate_row_count
FROM pg_stat_user_tables
WHERE tablename = 'large_table';

-- より正確な概算値(pg_class)
SELECT 
    reltuples::BIGINT AS approximate_row_count
FROM pg_class
WHERE relname = 'large_table';

-- 結果例(実際:10,234,567行)
 approximate_row_count 
----------------------
            10234000

メリット:

  • 瞬時に結果が返る(数ミリ秒)
  • CPU/IO負荷がほぼゼロ

デメリット:

  • あくまで概算値(誤差あり)
  • VACUUM/ANALYZE実行後の値

方法2:インデックスオンリースキャン

-- インデックスがある列でカウント
CREATE INDEX idx_users_status ON users(status);

-- インデックスを使った高速カウント
EXPLAIN (ANALYZE, BUFFERS) 
SELECT COUNT(*) FROM users WHERE status = 'active';

-- カバリングインデックスの活用
CREATE INDEX idx_orders_covering 
ON orders(user_id) 
INCLUDE (created_at, status);

方法3:パーティションテーブルの並列カウント

-- パーティションごとに並列でカウント
SELECT 
    SUM(cnt) AS total_count
FROM (
    SELECT COUNT(*) AS cnt FROM orders_2025_01
    UNION ALL
    SELECT COUNT(*) AS cnt FROM orders_2025_02
    UNION ALL
    SELECT COUNT(*) AS cnt FROM orders_2024_12
) AS partition_counts;

高度なカウントテクニック

🚀 ウィンドウ関数を使った累積カウント

-- 累積カウント(ランニングトータル)
SELECT 
    created_at::DATE AS date,
    COUNT(*) AS daily_count,
    SUM(COUNT(*)) OVER (ORDER BY created_at::DATE) AS cumulative_count
FROM orders
GROUP BY created_at::DATE
ORDER BY date;

-- 結果例
    date    | daily_count | cumulative_count 
------------+-------------+------------------
 2025-01-01 |         100 |              100
 2025-01-02 |         150 |              250
 2025-01-03 |         200 |              450

📊 条件別カウント(CASE文使用)

-- 複数の条件を一度にカウント
SELECT 
    COUNT(*) AS total_orders,
    COUNT(CASE WHEN status = 'completed' THEN 1 END) AS completed_orders,
    COUNT(CASE WHEN status = 'pending' THEN 1 END) AS pending_orders,
    COUNT(CASE WHEN status = 'cancelled' THEN 1 END) AS cancelled_orders,
    COUNT(CASE WHEN amount > 10000 THEN 1 END) AS high_value_orders
FROM orders
WHERE created_at >= CURRENT_DATE - INTERVAL '30 days';

-- FILTER句を使った方法(PostgreSQL 9.4以降)
SELECT 
    COUNT(*) AS total_orders,
    COUNT(*) FILTER (WHERE status = 'completed') AS completed_orders,
    COUNT(*) FILTER (WHERE status = 'pending') AS pending_orders,
    COUNT(*) FILTER (WHERE amount > 10000) AS high_value_orders
FROM orders
WHERE created_at >= CURRENT_DATE - INTERVAL '30 days';

🔄 CTE(共通テーブル式)を使った複雑な集計

-- 複数段階の集計
WITH user_orders AS (
    SELECT 
        user_id,
        COUNT(*) AS order_count,
        SUM(amount) AS total_amount
    FROM orders
    WHERE created_at >= '2025-01-01'
    GROUP BY user_id
),
user_categories AS (
    SELECT 
        CASE 
            WHEN order_count >= 10 THEN 'heavy_user'
            WHEN order_count >= 5 THEN 'regular_user'
            ELSE 'light_user'
        END AS user_type,
        COUNT(*) AS user_count,
        AVG(order_count) AS avg_orders
    FROM user_orders
    GROUP BY user_type
)
SELECT * FROM user_categories
ORDER BY 
    CASE user_type
        WHEN 'heavy_user' THEN 1
        WHEN 'regular_user' THEN 2
        WHEN 'light_user' THEN 3
    END;

リアルタイム行数カウントの実装

📈 カウンターテーブルを使った高速化

カウンターテーブルの設計

-- カウンターテーブルの作成
CREATE TABLE row_counters (
    table_name TEXT PRIMARY KEY,
    row_count BIGINT NOT NULL DEFAULT 0,
    last_updated TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);

-- トリガー関数の作成
CREATE OR REPLACE FUNCTION update_row_counter()
RETURNS TRIGGER AS $$
BEGIN
    IF TG_OP = 'INSERT' THEN
        UPDATE row_counters 
        SET row_count = row_count + 1,
            last_updated = CURRENT_TIMESTAMP
        WHERE table_name = TG_TABLE_NAME;
    ELSIF TG_OP = 'DELETE' THEN
        UPDATE row_counters 
        SET row_count = row_count - 1,
            last_updated = CURRENT_TIMESTAMP
        WHERE table_name = TG_TABLE_NAME;
    END IF;
    RETURN NULL;
END;
$$ LANGUAGE plpgsql;

-- トリガーの設定
CREATE TRIGGER update_users_counter
AFTER INSERT OR DELETE ON users
FOR EACH ROW EXECUTE FUNCTION update_row_counter();

-- 初期値の設定
INSERT INTO row_counters (table_name, row_count)
SELECT 'users', COUNT(*) FROM users;

-- 使用例(超高速!)
SELECT row_count FROM row_counters WHERE table_name = 'users';

🔄 マテリアライズドビューで集計を事前計算

-- 集計結果を事前計算
CREATE MATERIALIZED VIEW order_summary AS
SELECT 
    DATE_TRUNC('day', created_at) AS date,
    status,
    COUNT(*) AS order_count,
    SUM(amount) AS total_amount
FROM orders
GROUP BY DATE_TRUNC('day', created_at), status;

-- インデックスの作成
CREATE INDEX idx_order_summary_date ON order_summary(date);

-- 定期的な更新(cronで実行)
REFRESH MATERIALIZED VIEW CONCURRENTLY order_summary;

-- 高速クエリ
SELECT SUM(order_count) AS total_orders
FROM order_summary
WHERE date >= '2025-01-01';

実行計画の確認と最適化

🔍 EXPLAINで実行計画を確認

-- 基本的なEXPLAIN
EXPLAIN SELECT COUNT(*) FROM large_table;

-- 詳細な分析
EXPLAIN (ANALYZE, BUFFERS, VERBOSE)
SELECT COUNT(*) FROM large_table WHERE status = 'active';

-- 実行計画の読み方
                                QUERY PLAN
--------------------------------------------------------------------------
 Aggregate  (cost=12345.67..12345.68 rows=1 width=8)
           (actual time=234.567..234.568 rows=1 loops=1)
   Buffers: shared hit=1234 read=5678
   ->  Seq Scan on large_table  (cost=0.00..11234.56 rows=444444 width=0)
                                 (actual time=0.123..198.765 rows=445678 loops=1)
         Filter: (status = 'active'::text)
         Rows Removed by Filter: 554322
         Buffers: shared hit=1234 read=5678

重要な指標:

  • cost:推定コスト(低いほど良い)
  • rows:推定行数
  • actual time:実際の実行時間
  • Seq Scan vs Index Scan:スキャン方式

⚙️ 最適化のヒント

-- 統計情報の更新
ANALYZE large_table;

-- 自動バキュームの設定確認
SELECT 
    schemaname,
    tablename,
    last_vacuum,
    last_autovacuum,
    last_analyze,
    last_autoanalyze,
    vacuum_count,
    autovacuum_count
FROM pg_stat_user_tables
WHERE tablename = 'large_table';

-- work_memの調整(セッション単位)
SET work_mem = '256MB';

-- 並列処理の有効化
SET max_parallel_workers_per_gather = 4;

よくあるパターンと解決策

💡 ケース1:日次レポート用の行数取得

-- 日別のアクティビティサマリー
WITH daily_stats AS (
    SELECT 
        created_at::DATE AS date,
        COUNT(*) AS total_actions,
        COUNT(DISTINCT user_id) AS unique_users,
        COUNT(*) FILTER (WHERE action_type = 'login') AS login_count,
        COUNT(*) FILTER (WHERE action_type = 'purchase') AS purchase_count
    FROM user_activities
    WHERE created_at >= CURRENT_DATE - INTERVAL '30 days'
    GROUP BY created_at::DATE
)
SELECT 
    date,
    total_actions,
    unique_users,
    login_count,
    purchase_count,
    ROUND(100.0 * purchase_count / NULLIF(unique_users, 0), 2) AS conversion_rate
FROM daily_stats
ORDER BY date DESC;

📊 ケース2:ページネーション用の総件数取得

-- 効率的なページネーション
-- 方法1:概算値で十分な場合
WITH count_estimate AS (
    SELECT reltuples::BIGINT AS total_count
    FROM pg_class
    WHERE relname = 'products'
)
SELECT 
    p.*,
    ce.total_count
FROM products p
CROSS JOIN count_estimate ce
WHERE p.category_id = 5
ORDER BY p.created_at DESC
LIMIT 20 OFFSET 0;

-- 方法2:正確な件数が必要な場合(キャッシュ推奨)
SELECT 
    json_build_object(
        'total_count', (SELECT COUNT(*) FROM products WHERE category_id = 5),
        'page_data', json_agg(p.*)
    ) AS result
FROM (
    SELECT * FROM products 
    WHERE category_id = 5
    ORDER BY created_at DESC
    LIMIT 20 OFFSET 0
) p;

🔥 ケース3:ホットデータの行数監視

-- リアルタイムモニタリング用ビュー
CREATE VIEW table_row_counts AS
SELECT 
    schemaname,
    tablename,
    pg_size_pretty(pg_total_relation_size(schemaname||'.'||tablename)) AS total_size,
    n_live_tup AS live_rows,
    n_dead_tup AS dead_rows,
    ROUND(100.0 * n_dead_tup / NULLIF(n_live_tup + n_dead_tup, 0), 2) AS dead_ratio,
    last_vacuum,
    last_autovacuum
FROM pg_stat_user_tables
ORDER BY n_live_tup DESC;

-- 使用例
SELECT * FROM table_row_counts WHERE dead_ratio > 10;

パフォーマンス比較表

📊 手法別の実行時間比較

手法1万行100万行1億行精度用途
COUNT(*)1ms100ms10秒100%正確な値が必要
pg_stat_user_tables<1ms<1ms<1ms95-99%概算で十分
カウンターテーブル<1ms<1ms<1ms100%リアルタイム
マテリアライズドビュー<1ms<1ms1ms99.9%定期更新
サンプリング(TABLESAMPLE)1ms10ms100ms90-95%統計用

よくある質問と回答

Q:COUNT(*)とCOUNT(1)はどちらが速い?

A: PostgreSQLではパフォーマンスに差はありません。オプティマイザが同じ実行計画を生成します。可読性の観点からCOUNT(*)を推奨します。

Q:数億行のテーブルで正確な行数が必要な場合は?

A: カウンターテーブルマテリアライズドビューを使用してください。リアルタイム性が必要ならトリガーベースのカウンター、多少の遅延が許容できるなら定期更新のマテリアライズドビューが最適です。

Q:DELETEした行もカウントされる?

A: COUNT(*)では削除済み行はカウントされません。ただし、VACUUMが実行されるまで物理的には存在するため、pg_stat_user_tablesのn_dead_tupには含まれます。

Q:パーティションテーブルの全行数を効率的に取得するには?

A: 各パーティションを並列でカウントして合計するか、pg_classから各パーティションのreltuplesを合計する方法が高速です。

Q:行数が0かどうかだけを確認したい

A: EXISTSを使用してください。SELECT EXISTS(SELECT 1 FROM table_name LIMIT 1)が最速です。COUNT(*)は全件スキャンするので非効率です。


まとめ:状況に応じた最適な行数取得を!

PostgreSQLでの行数取得は、要求される精度とパフォーマンスのバランスを考慮して手法を選択することが重要です。

手法選択のガイドライン:

  1. 正確な値が必要 → COUNT(*)を使用
  2. 概算で十分 → pg_stat_user_tablesを活用
  3. リアルタイム監視 → カウンターテーブル実装
  4. 定期レポート → マテリアライズドビュー
  5. 大規模テーブル → サンプリングやパーティション分割

パフォーマンス改善のポイント:

  • 適切なインデックスの作成
  • 統計情報の定期更新(ANALYZE)
  • 並列処理の活用
  • キャッシュ戦略の検討

用途に応じて最適な手法を選択し、効率的なデータベース運用を実現しましょう!

高速で正確な行数取得を! 🚀📊

コメント

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