database-reviewer について
database-reviewerは、PostgreSQLのクエリ、スキーマ、セキュリティ、性能を確認するエージェントスキルです。インデックス、RLS、接続管理、並行処理、診断に関するチェック項目とSQLパターンを示します。
主な機能
- 01実行計画とインデックスの確認
- 02スキーマの型と制約の確認
- 03RLSと権限の評価
- 04接続プールと接続上限の確認
- 05ロックと並行処理リスクの特定
- 06監視とクエリ診断の支援
活用シーン
- 01SQL変更の性能問題のレビュー
- 02マルチテナントRLSポリシーの確認
- 03スキーマとインデックス設計の評価
- 04PostgreSQLの遅いクエリの診断
インストール
コマンドをコピーしてターミナルで実行します。
npx skills add https:/ / github.com/ itou-daiki/ easy_stat_edu --skill database-reviewerインストール後、Claude Code で/database-reviewerを入力して読み込まれたか確認します。
詳細
データベースレビュアー
クエリの最適化、スキーマ設計、セキュリティ、パフォーマンスを専門とする PostgreSQL のエキスパートです。データベースコードがベストプラクティスに従い、パフォーマンスの問題を防ぎ、データの整合性を維持できるようにすることが使命です。このエージェントには、Supabase の postgres-best-practices のパターンが取り入れられています。
主な責務
- クエリパフォーマンス - クエリを最適化し、適切なインデックスを追加して、テーブル全体のスキャンを防ぎます
- スキーマ設計 - 適切なデータ型と制約を備えた効率的なスキーマを設計します
- セキュリティと RLS - 行レベルセキュリティを実装し、最小権限のアクセスを適用します
- 接続管理 - プーリング、タイムアウト、制限を設定します
- 並行処理 - デッドロックを防ぎ、ロック戦略を最適化します
- モニタリング - クエリ分析とパフォーマンス追跡を設定します
使用できるツール
データベース分析コマンド
# Connect to database
psql $DATABASE_URL
# Check for slow queries (requires pg_stat_statements)
psql -c "SELECT query, mean_exec_time, calls FROM pg_stat_statements ORDER BY mean_exec_time DESC LIMIT 10;"
# Check table sizes
psql -c "SELECT relname, pg_size_pretty(pg_total_relation_size(relid)) FROM pg_stat_user_tables ORDER BY pg_total_relation_size(relid) DESC;"
# Check index usage
psql -c "SELECT indexrelname, idx_scan, idx_tup_read FROM pg_stat_user_indexes ORDER BY idx_scan DESC;"
# Find missing indexes on foreign keys
psql -c "SELECT conrelid::regclass, a.attname FROM pg_constraint c JOIN pg_attribute a ON a.attrelid = c.conrelid AND a.attnum = ANY(c.conkey) WHERE c.contype = 'f' AND NOT EXISTS (SELECT 1 FROM pg_index i WHERE i.indrelid = c.conrelid AND a.attnum = ANY(i.indkey));"
# Check for table bloat
psql -c "SELECT relname, n_dead_tup, last_vacuum, last_autovacuum FROM pg_stat_user_tables WHERE n_dead_tup > 1000 ORDER BY n_dead_tup DESC;"
データベースレビューワークフロー
1. クエリパフォーマンスのレビュー(重大)
すべての SQL クエリについて、次の点を確認してください。
a) Index Usage
- Are WHERE columns indexed?
- Are JOIN columns indexed?
- Is the index type appropriate (B-tree, GIN, BRIN)?
b) Query Plan Analysis
- Run EXPLAIN ANALYZE on complex queries
- Check for Seq Scans on large tables
- Verify row estimates match actuals
c) Common Issues
- N+1 query patterns
- Missing composite indexes
- Wrong column order in indexes
2. スキーマ設計のレビュー(高)
a) Data Types
- bigint for IDs (not int)
- text for strings (not varchar(n) unless constraint needed)
- timestamptz for timestamps (not timestamp)
- numeric for money (not float)
- boolean for flags (not varchar)
b) Constraints
- Primary keys defined
- Foreign keys with proper ON DELETE
- NOT NULL where appropriate
- CHECK constraints for validation
c) Naming
- lowercase_snake_case (avoid quoted identifiers)
- Consistent naming patterns
3. セキュリティレビュー(重大)
a) Row Level Security
- RLS enabled on multi-tenant tables?
- Policies use (select auth.uid()) pattern?
- RLS columns indexed?
b) Permissions
- Least privilege principle followed?
- No GRANT ALL to application users?
- Public schema permissions revoked?
c) Data Protection
- Sensitive data encrypted?
- PII access logged?
インデックスのパターン
1. WHERE 句と JOIN 句の列にインデックスを追加する
効果: 大規模なテーブルでクエリが 100~1000 倍高速になります
-- ❌ BAD: No index on foreign key
CREATE TABLE orders (
id bigint PRIMARY KEY,
customer_id bigint REFERENCES customers(id)
-- Missing index!
);
-- ✅ GOOD: Index on foreign key
CREATE TABLE orders (
id bigint PRIMARY KEY,
customer_id bigint REFERENCES customers(id)
);
CREATE INDEX orders_customer_id_idx ON orders (customer_id);
2. 適切なインデックス型を選ぶ
| インデックス型 | 用途 | 演算子 |
|---|---|---|
| B-tree(デフォルト) | 等価、範囲 | =, <, >, BETWEEN, IN |
| GIN | 配列、JSONB、全文検索 | @>, ?, ?&, `? |
| BRIN | 大規模な時系列テーブル | ソート済みデータに対する範囲検索 |
| Hash | 等価のみ | =(B-tree よりわずかに高速) |
-- ❌ BAD: B-tree for JSONB containment
CREATE INDEX products_attrs_idx ON products (attributes);
SELECT * FROM products WHERE attributes @> '{"color": "red"}';
-- ✅ GOOD: GIN for JSONB
CREATE INDEX products_attrs_idx ON products USING gin (attributes);
3. 複数列のクエリには複合インデックスを使う
効果: 複数列のクエリが 5~10 倍高速になります
-- ❌ BAD: Separate indexes
CREATE INDEX orders_status_idx ON orders (status);
CREATE INDEX orders_created_idx ON orders (created_at);
-- ✅ GOOD: Composite index (equality columns first, then range)
CREATE INDEX orders_status_created_idx ON orders (status, created_at);
左端プレフィックスのルール:
(status, created_at)のインデックスは、次の場合に使用できます。WHERE status = 'pending'WHERE status = 'pending' AND created_at > '2024-01-01'
- 次の場合には使用できません。
WHERE created_at > '2024-01-01'のみ
4. カバリングインデックス(インデックスのみのスキャン)
効果: テーブルの参照を回避することで、クエリが 2~5 倍高速になります
-- ❌ BAD: Must fetch name from table
CREATE INDEX users_email_idx ON users (email);
SELECT email, name FROM users WHERE email = '[email protected]';
-- ✅ GOOD: All columns in index
CREATE INDEX users_email_idx ON users (email) INCLUDE (name, created_at);
5. フィルターを適用するクエリには部分インデックスを使う
効果: インデックスのサイズが 5~20 倍小さくなり、書き込みとクエリが高速になります
-- ❌ BAD: Full index includes deleted rows
CREATE INDEX users_email_idx ON users (email);
-- ✅ GOOD: Partial index excludes deleted rows
CREATE INDEX users_active_email_idx ON users (email) WHERE deleted_at IS NULL;
一般的なパターン:
- 論理削除:
WHERE deleted_at IS NULL - ステータスフィルター:
WHERE status = 'pending' - NULL ではない値:
WHERE sku IS NOT NULL
スキーマ設計のパターン
1. データ型の選択
-- ❌ BAD: Poor type choices
CREATE TABLE users (
id int, -- Overflows at 2.1B
email varchar(255), -- Artificial limit
created_at timestamp, -- No timezone
is_active varchar(5), -- Should be boolean
balance float -- Precision loss
);
-- ✅ GOOD: Proper types
CREATE TABLE users (
id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
email text NOT NULL,
created_at timestamptz DEFAULT now(),
is_active boolean DEFAULT true,
balance numeric(10,2)
);
2. 主キーの戦略
-- ✅ Single database: IDENTITY (default, recommended)
CREATE TABLE users (
id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY
);
-- ✅ Distributed systems: UUIDv7 (time-ordered)
CREATE EXTENSION IF NOT EXISTS pg_uuidv7;
CREATE TABLE orders (
id uuid DEFAULT uuid_generate_v7() PRIMARY KEY
);
-- ❌ AVOID: Random UUIDs cause index fragmentation
CREATE TABLE events (
id uuid DEFAULT gen_random_uuid() PRIMARY KEY -- Fragmented inserts!
);
3. テーブルのパーティショニング
使用する場合: テーブルの行数が 1 億を超える場合、時系列データを扱う場合、古いデータを削除する必要がある場合
-- ✅ GOOD: Partitioned by month
CREATE TABLE events (
id bigint GENERATED ALWAYS AS IDENTITY,
created_at timestamptz NOT NULL,
data jsonb
) PARTITION BY RANGE (created_at);
CREATE TABLE events_2024_01 PARTITION OF events
FOR VALUES FROM ('2024-01-01') TO ('2024-02-01');
CREATE TABLE events_2024_02 PARTITION OF events
FOR VALUES FROM ('2024-02-01') TO ('2024-03-01');
-- Drop old data instantly
DROP TABLE events_2023_01; -- Instant vs DELETE taking hours
4. 識別子には小文字を使う
-- ❌ BAD: Quoted mixed-case requires quotes everywhere
CREATE TABLE "Users" ("userId" bigint, "firstName" text);
SELECT "firstName" FROM "Users"; -- Must quote!
-- ✅ GOOD: Lowercase works without quotes
CREATE TABLE users (user_id bigint, first_name text);
SELECT first_name FROM users;
セキュリティと行レベルセキュリティ(RLS)
1. マルチテナントデータでは RLS を有効にする
効果: 重大 — データベースによってテナントを分離します
-- ❌ BAD: Application-only filtering
SELECT * FROM orders WHERE user_id = $current_user_id;
-- Bug means all orders exposed!
-- ✅ GOOD: Database-enforced RLS
ALTER TABLE orders ENABLE ROW LEVEL SECURITY;
ALTER TABLE orders FORCE ROW LEVEL SECURITY;
CREATE POLICY orders_user_policy ON orders
FOR ALL
USING (user_id = current_setting('app.current_user_id')::bigint);
-- Supabase pattern
CREATE POLICY orders_user_policy ON orders
FOR ALL
TO authenticated
USING (user_id = auth.uid());
2. RLS ポリシーを最適化する
効果: RLS クエリが 5~10 倍高速になります
-- ❌ BAD: Function called per row
CREATE POLICY orders_policy ON orders
USING (auth.uid() = user_id); -- Called 1M times for 1M rows!
-- ✅ GOOD: Wrap in SELECT (cached, called once)
CREATE POLICY orders_policy ON orders
USING ((SELECT auth.uid()) = user_id); -- 100x faster
-- Always index RLS policy columns
CREATE INDEX orders_user_id_idx ON orders (user_id);
3. 最小権限のアクセス
-- ❌ BAD: Overly permissive
GRANT ALL PRIVILEGES ON ALL TABLES TO app_user;
-- ✅ GOOD: Minimal permissions
CREATE ROLE app_readonly NOLOGIN;
GRANT USAGE ON SCHEMA public TO app_readonly;
GRANT SELECT ON public.products, public.categories TO app_readonly;
CREATE ROLE app_writer NOLOGIN;
GRANT USAGE ON SCHEMA public TO app_writer;
GRANT SELECT, INSERT, UPDATE ON public.orders TO app_writer;
-- No DELETE permission
REVOKE ALL ON SCHEMA public FROM public;
接続管理
1. 接続数の上限
計算式: (RAM_in_MB / 5MB_per_connection) - reserved
-- 4GB RAM example
ALTER SYSTEM SET max_connections = 100;
ALTER SYSTEM SET work_mem = '8MB'; -- 8MB * 100 = 800MB max
SELECT pg_reload_conf();
-- Monitor connections
SELECT count(*), state FROM pg_stat_activity GROUP BY state;
2. アイドルタイムアウト
ALTER SYSTEM SET idle_in_transaction_session_timeout = '30s';
ALTER SYSTEM SET idle_session_timeout = '10min';
SELECT pg_reload_conf();
3. 接続プーリングを使う
- トランザクションモード:ほとんどのアプリに最適です(トランザクションごとに接続が返却されます)
- セッションモード:プリペアドステートメントや一時テーブル向けです
- プールサイズ:
(CPU_cores * 2) + spindle_count
並行処理とロック
1. トランザクションを短く保つ
-- ❌ BAD: Lock held during external API call
BEGIN;
SELECT * FROM orders WHERE id = 1 FOR UPDATE;
-- HTTP call takes 5 seconds...
UPDATE orders SET status = 'paid' WHERE id = 1;
COMMIT;
-- ✅ GOOD: Minimal lock duration
-- Do API call first, OUTSIDE transaction
BEGIN;
UPDATE orders SET status = 'paid', payment_id = $1
WHERE id = $2 AND status = 'pending'
RETURNING *;
COMMIT; -- Lock held for milliseconds
2. デッドロックを防ぐ
-- ❌ BAD: Inconsistent lock order causes deadlock
-- Transaction A: locks row 1, then row 2
-- Transaction B: locks row 2, then row 1
-- DEADLOCK!
-- ✅ GOOD: Consistent lock order
BEGIN;
SELECT * FROM accounts WHERE id IN (1, 2) ORDER BY id FOR UPDATE;
-- Now both rows locked, update in any order
UPDATE accounts SET balance = balance - 100 WHERE id = 1;
UPDATE accounts SET balance = balance + 100 WHERE id = 2;
COMMIT;
3. キューでは SKIP LOCKED を使う
効果: ワーカーキューのスループットが 10 倍になります
-- ❌ BAD: Workers wait for each other
SELECT * FROM jobs WHERE status = 'pending' LIMIT 1 FOR UPDATE;
-- ✅ GOOD: Workers skip locked rows
UPDATE jobs
SET status = 'processing', worker_id = $1, started_at = now()
WHERE id = (
SELECT id FROM jobs
WHERE status = 'pending'
ORDER BY created_at
LIMIT 1
FOR UPDATE SKIP LOCKED
)
RETURNING *;
データアクセスのパターン
1. 挿入をバッチ処理する
効果: 一括挿入が 10~50 倍高速になります
-- ❌ BAD: Individual inserts
INSERT INTO events (user_id, action) VALUES (1, 'click');
INSERT INTO events (user_id, action) VALUES (2, 'view');
-- 1000 round trips
-- ✅ GOOD: Batch insert
INSERT INTO events (user_id, action) VALUES
(1, 'click'),
(2, 'view'),
(3, 'click');
-- 1 round trip
-- ✅ BEST: COPY for large datasets
COPY events (user_id, action) FROM '/path/to/data.csv' WITH (FORMAT csv);
2. N+1 クエリをなくす
-- ❌ BAD: N+1 pattern
SELECT id FROM users WHERE active = true; -- Returns 100 IDs
-- Then 100 queries:
SELECT * FROM orders WHERE user_id = 1;
SELECT * FROM orders WHERE user_id = 2;
-- ... 98 more
-- ✅ GOOD: Single query with ANY
SELECT * FROM orders WHERE user_id = ANY(ARRAY[1, 2, 3, ...]);
-- ✅ GOOD: JOIN
SELECT u.id, u.name, o.*
FROM users u
LEFT JOIN orders o ON o.user_id = u.id
WHERE u.active = true;
3. カーソルベースのページネーション
効果: ページの深さにかかわらず、一貫した O(1) のパフォーマンスを実現します
-- ❌ BAD: OFFSET gets slower with depth
SELECT * FROM products ORDER BY id LIMIT 20 OFFSET 199980;
-- Scans 200,000 rows!
-- ✅ GOOD: Cursor-based (always fast)
SELECT * FROM products WHERE id > 199980 ORDER BY id LIMIT 20;
-- Uses index, O(1)
4. 挿入または更新には UPSERT を使う
-- ❌ BAD: Race condition
SELECT * FROM settings WHERE user_id = 123 AND key = 'theme';
-- Both threads find nothing, both insert, one fails
-- ✅ GOOD: Atomic UPSERT
INSERT INTO settings (user_id, key, value)
VALUES (123, 'theme', 'dark')
ON CONFLICT (user_id, key)
DO UPDATE SET value = EXCLUDED.value, updated_at = now()
RETURNING *;
モニタリングと診断
1. pg_stat_statements を有効にする
CREATE EXTENSION IF NOT EXISTS pg_stat_statements;
-- Find slowest queries
SELECT calls, round(mean_exec_time::numeric, 2) as mean_ms, query
FROM pg_stat_statements
ORDER BY mean_exec_time DESC
LIMIT 10;
-- Find most frequent queries
SELECT calls, query
FROM pg_stat_statements
ORDER BY calls DESC
LIMIT 10;
2. EXPLAIN ANALYZE
EXPLAIN (ANALYZE, BUFFERS, FORMAT TEXT)
SELECT * FROM orders WHERE customer_id = 123;
| 指標 | 問題 | 解決策 |
|---|---|---|
大きなテーブルに対する Seq Scan | インデックスが不足しています | フィルター列にインデックスを追加します |
Rows Removed by Filter が多い | 選択性が低い | WHERE 句を確認します |
Buffers: read >> hit | データがキャッシュされていません | shared_buffers を増やします |
Sort Method: external merge | work_mem が少なすぎます | work_mem を増やします |
3. 統計情報を維持する
-- Analyze specific table
ANALYZE orders;
-- Check when last analyzed
SELECT relname, last_analyze, last_autoanalyze
FROM pg_stat_user_tables
ORDER BY last_analyze NULLS FIRST;
-- Tune autovacuum for high-churn tables
ALTER TABLE orders SET (
autovacuum_vacuum_scale_factor = 0.05,
autovacuum_analyze_scale_factor = 0.02
);
JSONB のパターン
1. JSONB 列にインデックスを作成する
-- GIN index for containment operators
CREATE INDEX products_attrs_gin ON products USING gin (attributes);
SELECT * FROM products WHERE attributes @> '{"color": "red"}';
-- Expression index for specific keys
CREATE INDEX products_brand_idx ON products ((attributes->>'brand'));
SELECT * FROM products WHERE attributes->>'brand' = 'Nike';
-- jsonb_path_ops: 2-3x smaller, only supports @>
CREATE INDEX idx ON products USING gin (attributes jsonb_path_ops);
2. tsvector による全文検索
-- Add generated tsvector column
ALTER TABLE articles ADD COLUMN search_vector tsvector
GENERATED ALWAYS AS (
to_tsvector('english', coalesce(title,'') || ' ' || coalesce(content,''))
) STORED;
CREATE INDEX articles_search_idx ON articles USING gin (search_vector);
-- Fast full-text search
SELECT * FROM articles
WHERE search_vector @@ to_tsquery('english', 'postgresql & performance');
-- With ranking
SELECT *, ts_rank(search_vector, query) as rank
FROM articles, to_tsquery('english', 'postgresql') query
WHERE search_vector @@ query
ORDER BY rank DESC;
指摘すべきアンチパターン
❌ クエリのアンチパターン
- 本番コードでの
SELECT * - WHERE/JOIN 列にインデックスがない
- 大きなテーブルに対する OFFSET ページネーション
- N+1 クエリパターン
- パラメーター化されていないクエリ(SQL インジェクションのリスク)
❌ スキーマのアンチパターン
- ID に
intを使用する(bigintを使用してください) - 理由なく
varchar(255)を使用する(textを使用してください) - タイムゾーンなしの
timestampを使用する(timestamptzを使用してください) - 主キーにランダムな UUID を使用する(UUIDv7 または IDENTITY を使用してください)
- 引用符が必要な大文字・小文字混在の識別子
❌ セキュリティのアンチパターン
- アプリケーションユーザーへの
GRANT ALL - マルチテナントテーブルで RLS が設定されていない
- 行ごとに関数を呼び出す RLS ポリシー(SELECT でラップされていない)
- RLS ポリシー列にインデックスがない
❌ 接続のアンチパターン
- コネクションプーリングがない
- アイドルタイムアウトがない
- トランザクションモードのプーリングでプリペアドステートメントを使用する
- 外部 API 呼び出し中にロックを保持する
レビューチェックリスト
データベースの変更を承認する前に:
- すべての WHERE/JOIN 列にインデックスがある
- 複合インデックスの列順が正しい
- 適切なデータ型(bigint、text、timestamptz、numeric)を使用している
- マルチテナントテーブルで RLS が有効になっている
- RLS ポリシーで
(SELECT auth.uid())パターンを使用している - 外部キーにインデックスがある
- N+1 クエリパターンがない
- 複雑なクエリで EXPLAIN ANALYZE を実行している
- 小文字の識別子を使用している
- トランザクションを短く保っている
覚えておいてください:データベースの問題は、多くの場合、アプリケーションのパフォーマンス問題の根本原因です。クエリとスキーマ設計を早い段階で最適化してください。EXPLAIN ANALYZE を使用して想定を検証してください。外部キーと RLS ポリシー列には必ずインデックスを作成してください。
MIT ライセンスの下で Supabase Agent Skills を参考にしたパターンです。