本文へ移動
SQL道場 更新日: 2026年5月2日 約16分で読めます

大規模テーブル集計をSQLで高速化する方法|パーティションとフィルタ戦略


データが増えるほど、クエリ設計が経営に直結する

通販のデータは時間とともに積み上がる。注文テーブルが1億行を超えると、不用意に書いたクエリが「数十分かかる」「課金コストが跳ね上がる」という問題を引き起こすようになる。

Treasure Data は従量課金モデルだ。厳密にいうとprestoの消費量によってコストが変わる。契約上限以内なら追加金額は発生しないがオーバーすると請求されてしまう。そのためスキャンしたデータ量が課金に直結するといってよい。つまりクエリの書き方そのものがコスト削減に繋がる。月次の分析コストを半分に下げることが、SQL の最適化で実現できる。

この記事では Treasure Data(Presto)を主眼に、大規模テーブルを高速かつ低コストで集計するための設計パターンを体系的に解説する。


Treasure Data のアーキテクチャを理解する

最適化の前に、Treasure Data がどうデータを保存しているかを理解しておく必要がある。

列指向ストレージ(カラムストア)

Treasure Data はカラムストアを採用している。同じカラムのデータが物理的にまとまって保存されているため、必要なカラムだけを読めばよい。SELECT * は全カラムを読むため非効率で、必要なカラムだけを指定することでスキャン量が大幅に減る。

時間パーティション(time カラム)

Treasure Data のすべてのテーブルは time カラム(Unix タイムスタンプ)によって自動的にパーティション分割されている。WHERE time >= ... の条件を付けることで、対象パーティションだけを読む「パーティションプルーニング」が働く。

プルーニングが効く条件と効かない条件

-- ✅ パーティションプルーニングが効く
WHERE time >= 1704067200              -- Unixタイムスタンプで直接比較
WHERE TD_TIME_RANGE(time, '2024-01-01', '2024-12-31')  -- TD固有の関数
WHERE time >= TD_TIME('2024-01-01')   -- TD_TIME関数で変換

-- ❌ プルーニングが効かない(関数でラップしている)
WHERE FROM_UNIXTIME(time) >= '2024-01-01'  -- timeを変換してから比較
WHERE YEAR(FROM_UNIXTIME(time)) = 2024     -- timeに関数を適用

time カラムを関数でラップすると、パーティションキーとして認識されなくなる。必ず time を裸で比較する。


STEP 1 ― パーティションプルーニングを正しく使う

-- ❌ 遅い・コストが高い:FROM_UNIXTIME で time をラップしている
SELECT
    customer_id,
    SUM(total_amount)  AS total_sales
FROM orders
WHERE FROM_UNIXTIME(time) >= '2024-01-01'
AND   FROM_UNIXTIME(time) <  '2025-01-01'
GROUP BY customer_id;


-- ✅ 速い・安い:TD_TIME_RANGE でパーティションを直接指定
SELECT
    customer_id,
    SUM(total_amount)  AS total_sales
FROM orders
WHERE TD_TIME_RANGE(time, '2024-01-01', '2025-01-01', 'JST')
GROUP BY customer_id;

TD_TIME_RANGE(time, start, end, timezone) は Treasure Data 専用のパーティションプルーニング対応関数だ。start 以上 end 未満のデータのみを読む。タイムゾーンを指定することで JST(日本標準時)基準での絞り込みができる。

TD_TIME 関数で日付文字列を Unix タイムスタンプに変換することもできる。

-- TD_TIME を使ったパーティション絞り込み
WHERE time >= TD_TIME('2024-01-01', 'JST')
AND   time <  TD_TIME('2025-01-01', 'JST')

STEP 2 ― SELECT するカラムを最小限に絞る

カラムストアの特性を最大限活かすために、使わないカラムは SELECT しない。

-- ❌ 全カラムを読む(スキャン量が最大)
SELECT *
FROM orders
WHERE TD_TIME_RANGE(time, '2024-01-01', '2025-01-01', 'JST')
AND status = 'completed';


-- ✅ 必要なカラムだけ読む(スキャン量を削減)
SELECT
    customer_id,
    order_date,
    total_amount
FROM orders
WHERE TD_TIME_RANGE(time, '2024-01-01', '2025-01-01', 'JST')
AND status = 'completed';

顧客IDと金額だけが必要なのに、商品説明・画像URL・メモなど大きいテキストカラムまで読んでいると、スキャン量が数倍になる。カラムを絞るだけでコストが大幅に下がる。


STEP 3 ― WHERE 句の絞り込みを「スキャン前」に行う

絞り込みはできるだけ早い段階(スキャン時)で行う。JOIN の前後で絞り込むタイミングを誤ると、不要なデータを大量に処理してから捨てる非効率が生まれる。

-- ❌ JOINしてからWHEREで絞る(大量データを結合してから捨てる)
SELECT
    o.customer_id,
    c.rank,
    SUM(o.total_amount)  AS total_sales
FROM orders     o
JOIN customers  c  ON o.customer_id = c.customer_id
WHERE o.status = 'completed'
AND   TD_TIME_RANGE(o.time, '2024-01-01', '2025-01-01', 'JST')
AND   c.rank = 'gold';


-- ✅ サブクエリまたはCTEで先に絞ってからJOINする
WITH completed_orders AS (
    -- まず orders を絞り込む
    SELECT customer_id, total_amount
    FROM orders
    WHERE status = 'completed'
    AND TD_TIME_RANGE(time, '2024-01-01', '2025-01-01', 'JST')
),
gold_customers AS (
    -- customers も先に絞り込む
    SELECT customer_id, rank
    FROM customers
    WHERE rank = 'gold'
)
SELECT
    o.customer_id,
    c.rank,
    SUM(o.total_amount)  AS total_sales
FROM completed_orders   o
JOIN gold_customers     c  ON o.customer_id = c.customer_id
GROUP BY o.customer_id, c.rank;

各テーブルをCTEで先に絞り込んでからJOINすることで、結合する行数が減り処理が速くなる。


STEP 4 ― 集計の中間テーブルを活用する

毎回重い集計を実行するのではなく、「よく使う中間集計結果」をテーブルとして保存しておき、後続のクエリはその軽量テーブルを参照する設計だ。

-- ① 日次バッチで「月次顧客別集計テーブル」を作成する(重い処理を1回だけ)
CREATE TABLE monthly_customer_summary AS
SELECT
    DATE_TRUNC('month', order_date)  AS sales_month,
    customer_id,
    COUNT(order_id)                  AS order_count,
    SUM(total_amount)                AS total_spent,
    AVG(total_amount)                AS avg_order_value,
    MAX(order_date)                  AS last_order_date
FROM orders
WHERE status = 'completed'
GROUP BY
    DATE_TRUNC('month', order_date),
    customer_id;

-- ② 後続の分析クエリは軽量な monthly_customer_summary を参照する
-- RFM分析
SELECT
    customer_id,
    DATE_DIFF('day', MAX(last_order_date), CURRENT_DATE)  AS recency,
    SUM(order_count)                                       AS frequency,
    SUM(total_spent)                                       AS monetary
FROM monthly_customer_summary
WHERE sales_month >= DATE_ADD('month', -12, DATE_TRUNC('month', CURRENT_DATE))
GROUP BY customer_id;

生データ(数億行)から毎回RFMを計算するのではなく、月次集計テーブル(数百万行)から計算することで、クエリの速度とコストが劇的に改善する。


STEP 5 ― APPROXIMATE 関数でコストを下げる

完全な正確性が不要な探索的分析では、近似集計関数を使うと処理が大幅に速くなる。

-- COUNT DISTINCTの正確値(重い)
SELECT COUNT(DISTINCT customer_id) AS unique_customers
FROM orders
WHERE TD_TIME_RANGE(time, '2024-01-01', '2025-01-01', 'JST');

-- APPROX_DISTINCT(HyperLogLog)で近似値(速い・誤差は1〜2%程度)
SELECT APPROX_DISTINCT(customer_id) AS approx_unique_customers
FROM orders
WHERE TD_TIME_RANGE(time, '2024-01-01', '2025-01-01', 'JST');

APPROX_DISTINCT は HyperLogLog アルゴリズムを使った近似カウントで、誤差は通常2%以内だ。「ユニーク顧客数のおおよその規模感を把握したい」という探索的分析では十分な精度だ。

同様に、パーセンタイルの計算も近似版が速い。

-- 正確なパーセンタイル(重い)
SELECT PERCENTILE_CONT(0.5) WITHIN GROUP (ORDER BY total_amount) AS median
FROM orders;

-- APPROX_PERCENTILE(速い)
SELECT APPROX_PERCENTILE(total_amount, 0.5) AS approx_median
FROM orders
WHERE TD_TIME_RANGE(time, '2024-01-01', '2025-01-01', 'JST');

STEP 6 ― 巨大な JOIN を分割して処理する

巨大テーブル同士の JOIN はメモリ使用量が爆発することがある。「ブロードキャスト JOIN」と「シャッフル JOIN」を意識して設計する。

-- ✅ 小さいテーブルを broadcast してシャッフルコストを削減
SELECT /*+ broadcast(c) */
    o.customer_id,
    c.rank,
    SUM(o.total_amount) AS total_sales
FROM orders     o
JOIN customers  c  ON o.customer_id = c.customer_id
WHERE TD_TIME_RANGE(o.time, '2024-01-01', '2025-01-01', 'JST')
AND o.status = 'completed'
GROUP BY o.customer_id, c.rank;

/*+ broadcast(c) */ ヒントは customers テーブル(小テーブル)を全ノードにブロードキャストし、大きな orders テーブルをシャッフルするコストを避ける。顧客マスタ・商品マスタなど数万〜数十万行程度のテーブルとのJOINで特に効果的だ。
ちなみにtreasuredata のprestoではできなかった。


最適化の優先順位

すべての最適化を同時に適用する必要はない。効果の大きい順に優先度をつけると次のようになる。

優先度最適化手法改善効果
1TD_TIME_RANGE でパーティションを絞る★★★★★(スキャン量が激減)
2SELECT カラムを必要最小限にする★★★★(スキャン量が大幅減)
3中間集計テーブルを使う★★★★(クエリ頻度が高い場合)
4JOIN 前に絞り込みを行う★★★(結合行数が減る)
5broadcast ヒントで小テーブルを展開★★★(JOINコスト削減)
6APPROX 関数で近似集計★★(探索的分析のみ)

まず「どの期間のデータを使うか」のパーティション設計と「何のカラムが必要か」のカラム設計を正しく行うだけで、多くの場合コストが80%以上削減できる。


実務での運用ヒント

TD_TIME_RANGEorder_date の混在に注意

Treasure Data では time(Unixタイムスタンプ)と order_date(DATE型など)が別カラムとして存在することがある。パーティションプルーニングが効くのは time カラムだけだ。order_date をWHEREで絞ってもスキャン量は減らない。必ず TD_TIME_RANGE(time, ...) を使う。

② 本番実行前にスキャン量を見積もる

Treasure Data の UI や API では、クエリを実行する前にスキャン量の見積もりが確認できる場合がある。重いクエリは本番実行前に見積もりを確認し、想定外のスキャン量になっていないかをチェックする習慣をつける。

③ 集計粒度の設計が長期的なコストを決める

「全期間の生データに毎回クエリをかける」設計は、データが増えるほどコストが線形に増加する。「日次バッチで集計してから分析クエリは集計テーブルを参照する」設計にすることで、データが増えても分析コストが一定に保てる。この設計選択が長期的な運用コストを大きく左右する。


まとめ

Treasure Data における大規模テーブル最適化の核心は2点だ。

  1. TD_TIME_RANGE で必ずパーティションを絞る ― time カラムを関数でラップせず直接比較する
  2. 必要なカラムだけを SELECT する ― カラムストアの特性を活かす

この2点だけで多くの重いクエリが劇的に改善する。さらに中間集計テーブルの設計・JOIN前の絞り込み・broadcast ヒントを組み合わせることで、大規模データを扱う通販分析基盤のコストと速度を同時に最適化できる。

クエリの最適化は「正しい結果を出す」の次のステップだ。 結果が合っていてもコストが高いクエリは、データが増えるほど問題が顕在化する。今日のうちに最適化の習慣を身につけておくことが、明日の分析基盤の健全性を守ることに直結する。


次のアクション

SQLやデータ活用を、手元で試しながら理解する

記事で読んだ考え方を、SQL練習場や関連カテゴリの記事でさらに深掘りできます。相談やご依頼もお問い合わせページから受け付けています。

SQL練習場で試す お問い合わせ

この記事を書いた人:martechfarm

Treasure Data Top Lapidarist Award受賞。

SQL / Digdag / Python / CDP設計 / CRM設計を横断し、企業のデータ活用を支援。

実績・支援内容を見る →

MarTech Farmをもっと見る

今すぐ購読し、続きを読んで、すべてのアーカイブにアクセスしましょう。

続きを読む