データが増えるほど、クエリ設計が経営に直結する
通販のデータは時間とともに積み上がる。注文テーブルが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ではできなかった。
最適化の優先順位
すべての最適化を同時に適用する必要はない。効果の大きい順に優先度をつけると次のようになる。
| 優先度 | 最適化手法 | 改善効果 |
|---|---|---|
| 1 | TD_TIME_RANGE でパーティションを絞る | ★★★★★(スキャン量が激減) |
| 2 | SELECT カラムを必要最小限にする | ★★★★(スキャン量が大幅減) |
| 3 | 中間集計テーブルを使う | ★★★★(クエリ頻度が高い場合) |
| 4 | JOIN 前に絞り込みを行う | ★★★(結合行数が減る) |
| 5 | broadcast ヒントで小テーブルを展開 | ★★★(JOINコスト削減) |
| 6 | APPROX 関数で近似集計 | ★★(探索的分析のみ) |
まず「どの期間のデータを使うか」のパーティション設計と「何のカラムが必要か」のカラム設計を正しく行うだけで、多くの場合コストが80%以上削減できる。
実務での運用ヒント
① TD_TIME_RANGE と order_date の混在に注意
Treasure Data では time(Unixタイムスタンプ)と order_date(DATE型など)が別カラムとして存在することがある。パーティションプルーニングが効くのは time カラムだけだ。order_date をWHEREで絞ってもスキャン量は減らない。必ず TD_TIME_RANGE(time, ...) を使う。
② 本番実行前にスキャン量を見積もる
Treasure Data の UI や API では、クエリを実行する前にスキャン量の見積もりが確認できる場合がある。重いクエリは本番実行前に見積もりを確認し、想定外のスキャン量になっていないかをチェックする習慣をつける。
③ 集計粒度の設計が長期的なコストを決める
「全期間の生データに毎回クエリをかける」設計は、データが増えるほどコストが線形に増加する。「日次バッチで集計してから分析クエリは集計テーブルを参照する」設計にすることで、データが増えても分析コストが一定に保てる。この設計選択が長期的な運用コストを大きく左右する。
まとめ
Treasure Data における大規模テーブル最適化の核心は2点だ。
TD_TIME_RANGEで必ずパーティションを絞る ― time カラムを関数でラップせず直接比較する- 必要なカラムだけを SELECT する ― カラムストアの特性を活かす
この2点だけで多くの重いクエリが劇的に改善する。さらに中間集計テーブルの設計・JOIN前の絞り込み・broadcast ヒントを組み合わせることで、大規模データを扱う通販分析基盤のコストと速度を同時に最適化できる。
クエリの最適化は「正しい結果を出す」の次のステップだ。 結果が合っていてもコストが高いクエリは、データが増えるほど問題が顕在化する。今日のうちに最適化の習慣を身につけておくことが、明日の分析基盤の健全性を守ることに直結する。