supabase-performance-tuning について
supabase-performance-tuning は、PostgreSQL、インフラ、クライアント SDK の各面から Supabase プロジェクトを改善するスキルです。診断とクエリプランの比較でボトルネックを特定し、変更の効果を確認します。
主な機能
- 01遅いクエリと DB 指標の診断
- 02クエリプランの分析とインデックス作成
- 03クライアントクエリとページネーションの最適化
- 04RPC 関数とマテリアライズドビューの活用
- 05プーリングとリードレプリカの設定
- 06Edge Functions への計算処理のデプロイ
活用シーン
- 01遅いクエリとインデックス利用の調査
- 02クライアントクエリの取得データ量の削減
- 03反復的な集計処理の高速化
- 04サーバーアプリの接続プール負荷の緩和
インストール
コマンドをコピーしてターミナルで実行します。
npx skills add https:/ / github.com/ jeremylongshore/ tons-of-skills-marketplace --skill supabase-performance-tuningインストール後、Claude Code で/supabase-performance-tuningを入力して読み込まれたか確認します。
同梱ファイル
SKILL.md と一緒にインストールされる参考資料やスクリプト。
references/caching-strategy.mderrors.mdexamples.md詳細
Supabase のパフォーマンスチューニング
概要
3 つのレイヤーにわたって、Supabase のクエリとデータベースのパフォーマンスを体系的に向上させます。PostgreSQL エンジン(インデックス、クエリプラン、マテリアライズドビュー)、Supabase インフラストラクチャ(Supavisor のコネクションプーリング、Edge Functions、リードレプリカ)、クライアント SDK のパターン(カラム選択、ページネーション、RPC 関数)です。ここで紹介する手法はすべて測定可能です。改善を確認するため、実行前と実行後に EXPLAIN ANALYZE を実行してください。
前提条件
@supabase/supabase-jsv2+ がインストールされた Supabase プロジェクト(ローカルまたはホステッド)- Supabase CLI がインストールされていること(確認するには
npx supabase --versionを実行) - Supabase ダッシュボードの SQL Editor または Postgres への直接接続にアクセスできること
pg_stat_statements拡張機能が有効であること(手順 1 で説明します)
手順
手順 1:診断 — 遅い処理を特定する
パフォーマンス改善には、必ずデータに基づいて取り組みます。pg_stat_statements を有効にし、Supabase CLI の診断を実行して、最適化の前にボトルネックを特定します。
統計情報の拡張機能を有効にします:
CREATE EXTENSION IF NOT EXISTS pg_stat_statements;
平均実行時間が最も長いクエリを特定します:
SELECT
query,
calls,
mean_exec_time::numeric(10,2) AS avg_ms,
total_exec_time::numeric(10,2) AS total_ms,
rows
FROM pg_stat_statements
ORDER BY mean_exec_time DESC
LIMIT 10;
Supabase CLI でインデックスの使用状況とキャッシュヒット率を確認します:
# Which indexes are actually being used?
npx supabase inspect db index-usage
# What percentage of queries are served from cache vs disk?
npx supabase inspect db cache-hit
# Tables consuming the most space
npx supabase inspect db table-sizes
アクティブな接続を調べ、プーリングに関する問題を確認します:
SELECT state, count(*), max(age(now(), state_change)) AS max_age
FROM pg_stat_activity
WHERE datname = current_database()
GROUP BY state;
idle 接続がプランの上限を超えている場合や、active クエリの max_age が大きい場合は、コネクションプーリング(手順 2)とクエリの最適化(手順 3)を優先してください。
手順 2:インデックスとクエリプラン
インデックスは、最も効果の大きい最適化です。EXPLAIN ANALYZE でクエリプランを確認してから、対象を絞ったインデックスを作成します。
クエリプランを確認します:
EXPLAIN (ANALYZE, BUFFERS, FORMAT TEXT)
SELECT * FROM users WHERE email = '[email protected]';
大きなテーブルで Seq Scan が発生していないか確認してください。これはインデックスが使われていないことを意味します。インデックスを追加した後は、プランに Index Scan または Index Only Scan が表示されるはずです。
基本的なインデックスを作成します:
CREATE INDEX idx_users_email ON users(email);
複数カラムのフィルター用に複合インデックスを作成します:
-- Optimizes: WHERE user_id = ? AND created_at > ? ORDER BY created_at DESC
CREATE INDEX idx_orders_user_created
ON orders(user_id, created_at DESC);
一般的なフィルターパターンを対象に部分インデックスを作成します:
-- Only indexes incomplete todos — much smaller and faster than full-table index
CREATE INDEX idx_todos_user_incomplete
ON todos(user_id, inserted_at DESC)
WHERE is_complete = false;
外部キーに不足しているインデックスを特定します(JOIN が遅くなる一般的な原因です):
SELECT
tc.table_name,
kcu.column_name AS fk_column,
'CREATE INDEX idx_' || tc.table_name || '_' || kcu.column_name
|| ' ON public.' || tc.table_name || '(' || kcu.column_name || ');' AS fix
FROM information_schema.table_constraints tc
JOIN information_schema.key_column_usage kcu
ON tc.constraint_name = kcu.constraint_name
LEFT JOIN pg_indexes i
ON i.tablename = tc.table_name
AND i.indexdef LIKE '%' || kcu.column_name || '%'
WHERE tc.constraint_type = 'FOREIGN KEY'
AND tc.table_schema = 'public'
AND i.indexname IS NULL;
未使用のインデックスを特定します(書き込みのオーバーヘッドを減らすため、削除候補となります):
SELECT schemaname, relname, indexrelname, idx_scan
FROM pg_stat_user_indexes
WHERE idx_scan = 0 AND schemaname = 'public'
ORDER BY pg_relation_size(indexrelid) DESC;
インデックス作成中の書き込みロックを避けるため、本番環境のテーブルでは必ず CREATE INDEX CONCURRENTLY を使用してください。
手順 3:クライアント SDK とインフラストラクチャの最適化
まず Supabase JS クライアントの呼び出しを最適化し、次にインフラストラクチャの機能を活用してスケールに対応します。
必要なカラムだけを選択し、select('*') は避けます:
import { createClient } from '@supabase/supabase-js'
const supabase = createClient(
process.env.SUPABASE_URL!,
process.env.SUPABASE_ANON_KEY!
)
// BAD: fetches every column, large payloads
const { data } = await supabase.from('users').select('*')
// GOOD: only the columns you need
const { data } = await supabase.from('users').select('id, name, avatar_url')
すべての行を読み込む代わりに、.range() でページネーションします:
// Page 1: rows 0-49
const { data: page1 } = await supabase
.from('products')
.select('id, name, price')
.order('created_at', { ascending: false })
.range(0, 49)
// Page 2: rows 50-99
const { data: page2 } = await supabase
.from('products')
.select('id, name, price')
.order('created_at', { ascending: false })
.range(50, 99)
複雑なロジックを Postgres に移すには、RPC 関数を使用します:
-- Create a server-side function for an expensive aggregation
CREATE OR REPLACE FUNCTION get_dashboard_stats(org_id uuid)
RETURNS json AS $$
SELECT json_build_object(
'total_users', (SELECT count(*) FROM users WHERE organization_id = org_id),
'active_projects', (SELECT count(*) FROM projects WHERE organization_id = org_id AND status = 'active'),
'tasks_completed_30d', (SELECT count(*) FROM tasks t
JOIN projects p ON p.id = t.project_id
WHERE p.organization_id = org_id
AND t.completed_at > now() - interval '30 days')
);
$$ LANGUAGE sql STABLE;
// One network call instead of three separate queries
const { data } = await supabase.rpc('get_dashboard_stats', {
org_id: 'your-org-uuid'
})
負荷の高い集計処理には、マテリアライズドビューを作成します:
-- Precompute a leaderboard instead of recalculating on every request
CREATE MATERIALIZED VIEW leaderboard AS
SELECT
u.id,
u.username,
count(t.id) AS tasks_completed,
rank() OVER (ORDER BY count(t.id) DESC) AS rank
FROM users u
LEFT JOIN tasks t ON t.assignee_id = u.id AND t.status = 'done'
GROUP BY u.id, u.username;
-- Create an index on the materialized view
CREATE UNIQUE INDEX idx_leaderboard_user ON leaderboard(id);
-- Refresh on a schedule (e.g., via pg_cron or a cron Edge Function)
REFRESH MATERIALIZED VIEW CONCURRENTLY leaderboard;
Supavisor でコネクションプーリングを設定します:
// For serverless environments (Vercel, Netlify, Cloudflare Workers):
// Use the pooled connection string with transaction mode
// Dashboard → Settings → Database → Connection string → "Transaction mode"
// The JS SDK uses PostgREST (HTTP) which has its own pooling — no config needed.
// Direct Postgres clients (Prisma, Drizzle, pg) need the pooled string:
import { Pool } from 'pg'
const pool = new Pool({
connectionString: process.env.SUPABASE_DATABASE_URL,
max: 5, // Keep low in serverless — Supavisor manages the upstream pool
idleTimeoutMillis: 10000,
})
データに近い場所で負荷の高い計算処理を行うには、Edge Functions を使用します:
// supabase/functions/generate-report/index.ts
// Edge Functions run in the same region as your database — low latency
import { createClient } from 'https://esm.sh/@supabase/supabase-js@2'
Deno.serve(async (req) => {
const supabase = createClient(
Deno.env.get('SUPABASE_URL')!,
Deno.env.get('SUPABASE_SERVICE_ROLE_KEY')!
)
// Heavy aggregation runs next to the database, not in the user's browser
const { data } = await supabase.rpc('get_dashboard_stats', {
org_id: (await req.json()).org_id
})
return new Response(JSON.stringify(data), {
headers: { 'Content-Type': 'application/json' }
})
})
読み取り負荷の高いワークロードでは、Pro+ プランでリードレプリカを有効にし、分析やレポートのクエリをレプリカに振り分けて、プライマリの負荷を軽減します。
出力
これらの手順を完了すると、次の状態になります。
pg_stat_statements、index-usage、cache-hitによる診断の基準データ- 遅いクエリのカラム、外部キー、一般的なフィルターパターンを対象としたインデックス
EXPLAIN ANALYZEで検証され、Seq Scan ではなく Index Scan が表示されるクエリプラン- カラム選択、ページネーション、結合クエリによって最適化されたクライアントクエリ
- サーバー側の負荷の高い集計処理に対応する RPC 関数とマテリアライズドビュー
- サーバーレスデプロイ向けに Supavisor 経由で設定されたコネクションプーリング
- データベースの近くで負荷の高い計算処理を行うためにデプロイされた Edge Functions
エラー処理
| 症状 | 原因 | 対処方法 |
|---|---|---|
大きなテーブルの EXPLAIN 出力に Seq Scan が表示される | 絞り込みまたは並べ替え対象の列にインデックスがない | WHERE/ORDER BY 句で指定している列に CREATE INDEX を実行します |
PGRST000: could not connect to server | コネクションプールが枯渇している | Supavisor のプール接続文字列に切り替えます。サーバーレス環境ではプールの max サイズを小さくします |
RLS ポリシーが遅い(pg_stat_statements で確認可能) | ポリシー内のサブクエリが行ごとに評価される | security definer 関数にリファクタリングするか、IN の代わりに EXISTS を使います |
| レスポンスペイロードが 1MB を超える | select('*') がすべての列と行を返している | .select('col1, col2') と .range() を使ってページネーションします |
| マテリアライズドビューのデータが古い | 書き込み後にビューが更新されていない | REFRESH MATERIALIZED VIEW CONCURRENTLY を実行するよう、pg_cron または cron Edge Function を設定します |
cache-hit 率が 99% 未満 | ワーキングセットが RAM(shared_buffers)を超えている | コンピュートアドオンをアップグレードするか、アクセスするページ数が少なくなるようクエリを最適化します |
| 集計エンドポイントのレイテンシーが高い | リクエストごとに集計をリアルタイムで計算している | マテリアライズドビューまたは RPC 関数に移行し、Edge Function 層でキャッシュします |
例
インデックス最適化の前後:
-- Before: 450ms, Seq Scan
EXPLAIN (ANALYZE) SELECT * FROM orders WHERE customer_id = 'abc-123';
-- Seq Scan on orders (cost=0.00..15234.00 rows=50 width=128) (actual time=0.015..450.123 rows=50 loops=1)
CREATE INDEX idx_orders_customer ON orders(customer_id);
-- After: 0.8ms, Index Scan
EXPLAIN (ANALYZE) SELECT * FROM orders WHERE customer_id = 'abc-123';
-- Index Scan using idx_orders_customer on orders (cost=0.42..8.44 rows=50 width=128) (actual time=0.025..0.812 rows=50 loops=1)
クエリの最適化 — N+1 の解消:
// BAD: N+1 — one query per project (10 projects = 11 queries)
const { data: projects } = await supabase.from('projects').select('id, name')
for (const project of projects!) {
const { data: tasks } = await supabase
.from('tasks').select('*').eq('project_id', project.id)
}
// GOOD: Single query with embedded join (1 query total)
const { data } = await supabase
.from('projects')
.select('id, name, tasks(id, title, status)')
.eq('organization_id', orgId)
リソース
- Supabase Performance Advisor — 組み込みの CLI 診断
- PostgreSQL Index Types — B-tree、GIN、GiST と、それぞれの使いどころ
- Connection Pooling with Supavisor — トランザクションモードとセッションモード
- Supabase Edge Functions — データベースの隣にサーバーレス関数をデプロイします
- Read Replicas — Pro+ プランで読み取り負荷の高いクエリを分散します
- RLS Performance Best Practices — 行ごとのサブクエリを回避します
次のステップ
- RLS ポリシーの設計については、
supabase-rls-policiesを参照してください - コストの最適化については、
supabase-cost-tuningを参照してください - リアルタイムサブスクリプションについては、
supabase-realtimeを参照してください