😁

SQLに立ち返って学ぶ、本当に効率的なDB設計

に公開

記事の概要

対象読者: Web開発経験者(ORM中心の開発経験あり)
記事の価値: SQLを直接書くことで見えてくる、真のパフォーマンス最適化
技術スタック: Rust + SQLx + TiDB(しかし言語非依存の内容)


📝 記事構成案

1. 導入:ORMから生SQLへの転換点

なぜORMからSQLに立ち返る必要があるのか

Web開発を始めたとき、多くの人がそうであるように、僕もPrismaやTypeORMといったORMに頼り切っていました。

「SQLなんて古い技術でしょ?」
「ORMがあれば型安全だし、SQLインジェクションの心配もない」

そんな風に思っていた僕が、なぜ生SQLに立ち返ることになったのか。
それは、あるパフォーマンス問題との出会いからでした。

きっかけ:AIとの対話で気づいた衝撃的事実

Web開発を始めて以来、僕はずっとPrismaのようなORMを使ってきました。
「便利だし、型安全だし、これで十分でしょ?」

そんな僕が、ふとAIに質問してみたんです。

「Prismaで書いたコードと、生SQLで書いたコードって、どのくらい性能差があるの?」

返ってきた答えは、衝撃的でした。

// 僕がいつも書いているPrismaコード
async function getDashboardData(userId: number) {
  const user = await prisma.user.findUnique({
    where: { id: userId }
  });
  
  const orders = await prisma.order.findMany({
    where: { userId: userId }
  });
  
  const stats = {
    totalOrders: orders.length,
    totalAmount: orders.reduce((sum, order) => sum + order.amount, 0),
    avgOrderValue: 0
  };
  
  stats.avgOrderValue = stats.totalAmount / stats.totalOrders;
  
  return { user, stats };
}

「このコードは、データが増えると何倍、何十倍も遅くなる可能性があります」

え?何十倍って...まじで?

2. パフォーマンス問題の深堀り

理論値で知る、驚愕のパフォーマンス差

AIに教えてもらった内容は、理論値ではありますが、僕の認識を根本から覆すものでした。

何が起こっていたのか:隠れたパフォーマンス問題

僕が「便利だから」という理由で使っていたPrismaコードに、こんな問題が潜んでいたんです:

1. 不要なデータ転送

-- Prismaが実際に発行するクエリ
SELECT * FROM orders WHERE user_id = 1;  -- 全フィールドを取得
-- でも実際に使うのは amount フィールドだけ

-- 10,000件のレコード × 平均500バイト = 5MB の転送
-- 実際に必要なのは集計結果の数バイトだけ

2. アプリケーション側での集計

// JavaScriptエンジンで10,000件を処理
const totalAmount = orders.reduce((sum, order) => sum + order.amount, 0);

// これをデータベースにやらせると...
// 最適化されたC言語のコードで瞬時に計算完了

3. 理論的なパフォーマンス比較

処理 Prisma(アプリ側処理) 生SQL(DB側処理)
データ転送 5MB(10,000件×500B) 数十バイト(集計結果のみ) 100倍以上
集計処理 JavaScript(遅い) データベースエンジン(高速) 10-50倍
メモリ使用量 全データをメモリに展開 結果のみ 数百倍

理論上、数百倍の性能差が生まれる可能性があることが判明したのです。

さらに驚いたのは...

「え、じゃあSQLで直接書けば、めちゃくちゃ速くなるってこと?」

「はい。特に集計処理においては、データベースに任せた方が圧倒的に効率的です」

この時の僕の衝撃といったら...
今まで「便利だから」という理由でPrismaを使っていたけれど、実はパフォーマンスの足を引っ張る可能性があったなんて。

3. SQLに立ち返る:DB側でのデータ成形

発想の転換:「データベースに仕事をさせる」

問題の根本原因は、データベースという強力な計算エンジンを単なるデータ置き場として使っていたことでした。

Before: アプリケーション側で処理

// ❌ 非効率なパターン
const orders = await getAll(); // 全件取得
const filtered = orders.filter(o => o.amount > 100); // アプリでフィルタ
const grouped = groupBy(filtered, 'category'); // アプリでグループ化
const aggregated = map(grouped, g => ({ // アプリで集計
  category: g.key,
  total: sum(g.values, 'amount'),
  count: g.values.length
}));

After: データベース側で処理

-- ✅ 効率的なパターン
SELECT 
    category,
    COUNT(*) as count,
    SUM(amount) as total,
    AVG(amount) as average,
    MIN(created_at) as first_order,
    MAX(created_at) as last_order
FROM orders 
WHERE amount > 100
GROUP BY category
ORDER BY total DESC;

この変更により、処理時間が 5秒 → 0.05秒 に短縮されました。

4. 具体的な改善事例

実例で学ぶ:DB側データ成形の威力

事例1: 売上ランキング画面

要件: 月別商品売上TOP10を表示

🔴 ORMでの実装(僕がいつも書いてたコード)

async function getMonthlyTopProducts(year: number, month: number) {
  // 1. 該当月の全注文を取得(便利だから全部includeしちゃう)
  const orders = await prisma.order.findMany({
    where: {
      createdAt: {
        gte: new Date(year, month - 1, 1),
        lt: new Date(year, month, 1)
      }
    },
    include: {
      items: {
        include: {
          product: true
        }
      }
    }
  });

  // 2. JavaScript で集計処理(普通にやってた)
  const productSales = new Map();
  orders.forEach(order => {
    order.items.forEach(item => {
      const productId = item.product.id;
      const current = productSales.get(productId) || {
        name: item.product.name,
        totalSales: 0,
        totalQuantity: 0
      };
      
      current.totalSales += item.totalPrice;
      current.totalQuantity += item.quantity;
      productSales.set(productId, current);
    });
  });

  // 3. ソートして上位10件
  return Array.from(productSales.entries())
    .sort((a, b) => b[1].totalSales - a[1].totalSales)
    .slice(0, 10);
}

// 理論上の実行時間: データ量が多いと数秒〜数十秒

🟢 SQLでの実装(AIに教えてもらった最適解)

-- DB側で一発計算、結果だけ受け取る
WITH monthly_sales AS (
    SELECT 
        p.id,
        p.name,
        SUM(oi.total_price) as total_sales,
        SUM(oi.quantity) as total_quantity,
        COUNT(DISTINCT o.id) as order_count,
        AVG(oi.total_price) as avg_order_value
    FROM products p
    JOIN order_items oi ON p.id = oi.product_id
    JOIN orders o ON oi.order_id = o.id
    WHERE 
        o.created_at >= $1 
        AND o.created_at < $2
        AND o.status = 'completed'
    GROUP BY p.id, p.name
)
SELECT 
    *,
    RANK() OVER (ORDER BY total_sales DESC) as rank
FROM monthly_sales
ORDER BY total_sales DESC
LIMIT 10;

-- 理論上の実行時間: インデックスが効けば数十ミリ秒

この違いを知った時の衝撃ったらありませんでした。
「便利だから」という理由だけでツールを選んでいた自分が恥ずかしくなりました。

事例2: ユーザーダッシュボード

要件: ユーザーの活動サマリー(注文統計、最近の活動など)

🔴 従来の実装

// 複数回のクエリ + アプリ側計算
const user = await getUser(userId);
const orders = await getOrdersByUser(userId);
const recentOrders = orders.slice(0, 5);
const totalSpent = orders.reduce((sum, o) => sum + o.amount, 0);
const avgOrderValue = totalSpent / orders.length;
const monthlyStats = calculateMonthlyTrends(orders);

// 実行時間: 800ms

🟢 最適化後の実装

-- 1つのクエリで全ての情報を取得
SELECT 
    u.id,
    u.name,
    u.email,
    
    -- 注文統計
    COUNT(o.id) as total_orders,
    COALESCE(SUM(o.amount), 0) as total_spent,
    COALESCE(AVG(o.amount), 0) as avg_order_value,
    MIN(o.created_at) as first_order_date,
    MAX(o.created_at) as last_order_date,
    
    -- 最近30日の活動
    COUNT(CASE 
        WHEN o.created_at >= CURRENT_DATE - INTERVAL '30 days' 
        THEN 1 
    END) as recent_orders,
    
    COALESCE(SUM(CASE 
        WHEN o.created_at >= CURRENT_DATE - INTERVAL '30 days' 
        THEN o.amount 
    END), 0) as recent_spent,
    
    -- 最頻購入カテゴリ
    MODE() WITHIN GROUP (
        ORDER BY p.category_id
    ) as favorite_category_id

FROM users u
LEFT JOIN orders o ON u.id = o.user_id
LEFT JOIN order_items oi ON o.id = oi.order_id  
LEFT JOIN products p ON oi.product_id = p.id
WHERE u.id = $1
GROUP BY u.id, u.name, u.email;

-- 実行時間: 25ms(30倍高速化!)

5. 設計パターンとベストプラクティス

学んだDB設計パターン

パターン1: 集約をDBに任せる

-- ❌ アプリ側での集計
const stats = data.reduce((acc, item) => {
  acc.total += item.amount;
  acc.count += 1;
  return acc;
}, { total: 0, count: 0 });

-- ✅ DB側での集計
SELECT 
    COUNT(*) as count,
    SUM(amount) as total,
    AVG(amount) as average,
    STDDEV(amount) as standard_deviation
FROM orders;

パターン2: ウィンドウ関数の活用

-- 前月比較、ランキング、累積値などを一発で
SELECT 
    month,
    revenue,
    LAG(revenue) OVER (ORDER BY month) as prev_month,
    revenue - LAG(revenue) OVER (ORDER BY month) as growth,
    RANK() OVER (ORDER BY revenue DESC) as rank,
    SUM(revenue) OVER (ORDER BY month) as cumulative_revenue
FROM monthly_sales;

パターン3: CTE(Common Table Expressions)で可読性向上

-- 複雑な処理をステップ分けして読みやすく
WITH user_segments AS (
    -- ステップ1: ユーザーをセグメント分け
    SELECT 
        user_id,
        CASE 
            WHEN total_orders >= 10 THEN 'VIP'
            WHEN total_orders >= 5 THEN 'Regular'  
            ELSE 'New'
        END as segment
    FROM user_order_stats
),
segment_analysis AS (
    -- ステップ2: セグメント別分析
    SELECT 
        segment,
        COUNT(*) as user_count,
        AVG(total_spent) as avg_spent
    FROM user_segments us
    JOIN user_order_stats uos ON us.user_id = uos.user_id
    GROUP BY segment
)
-- ステップ3: 最終結果
SELECT * FROM segment_analysis
ORDER BY avg_spent DESC;

パターン4: インデックス戦略

-- 適切なインデックス設計で更なる高速化
CREATE INDEX idx_orders_user_date ON orders(user_id, created_at);
CREATE INDEX idx_order_items_product ON order_items(product_id);

-- 部分インデックスで効率化
CREATE INDEX idx_orders_pending ON orders(user_id) 
WHERE status = 'pending';

6. 開発体験と実装のコツ

SQLx + Rustでの実装体験

生SQLを書くことの懸念点として「型安全性」がありましたが、RustのSQLxを使うことで解決できました。

型安全な生SQL

// コンパイル時に型チェックされる生SQL
let user_stats = sqlx::query_as!(
    UserStats,
    r#"
    SELECT 
        u.id,
        u.name,
        COUNT(o.id) as "total_orders!",
        COALESCE(SUM(o.amount), 0) as "total_spent!",
        COALESCE(AVG(o.amount), 0) as "avg_order_value!"
    FROM users u
    LEFT JOIN orders o ON u.id = o.user_id
    WHERE u.id = $1
    GROUP BY u.id, u.name
    "#,
    user_id
)
.fetch_one(&pool)
.await?;

開発の流れ

  1. 要件を分析 → どんなデータが必要か整理
  2. SQLで設計 → DB側で可能な限り処理
  3. Rustで型定義 → 型安全な構造体を定義
  4. SQLxで実装 → コンパイル時型チェックで安全性確保

この流れにより、パフォーマンス・安全性・保守性を全て両立できました。

実際の開発効率

観点 ORM時代 生SQL時代
初期実装速度 速い やや遅い
デバッグ効率 遅い(何が起きてるか不明) 速い(SQLが見える)
パフォーマンス調査 困難 容易
複雑な処理 無理ゲー 自然に書ける
チューニング 不可能 自由自在

結果: 長期的には圧倒的にSQL直書きの方が効率的でした。

7. 他の開発者へのアドバイス

これからSQL学習を始める人へ

学習ステップ

  1. 基本のCRUDをマスター

    SELECT, INSERT, UPDATE, DELETE
    
  2. JOINを理解する

    INNER JOIN, LEFT JOIN, RIGHT JOIN
    
  3. 集約関数を覚える

    COUNT, SUM, AVG, GROUP BY, HAVING
    
  4. ウィンドウ関数で差をつける

    ROW_NUMBER(), RANK(), LAG(), LEAD()
    
  5. インデックスでパフォーマンスを理解

    CREATE INDEX, EXPLAIN ANALYZE
    

実践的なアドバイス

🎯 まずは既存ORMクエリのSQL化から

-- PrismaのfindManyを...
const users = await prisma.user.findMany({
  where: { status: 'active' },
  include: { orders: true }
});

-- SQLに置き換える
SELECT 
    u.*,
    COUNT(o.id) as order_count,
    COALESCE(SUM(o.amount), 0) as total_spent
FROM users u
LEFT JOIN orders o ON u.id = o.user_id
WHERE u.status = 'active'
GROUP BY u.id;

🔍 EXPLAIN ANALYZEでパフォーマンス検証

EXPLAIN ANALYZE
SELECT ...
-- 実行計画を見てボトルネックを特定

📊 段階的に最適化

  1. 動くものを作る(シンプルなSQL)
  2. 計測する(EXPLAIN ANALYZE)
  3. 最適化する(インデックス、クエリ改善)
  4. 再計測する

注意点

  • SQLインジェクション対策は必須(パラメータ化クエリ)
  • データベース固有の機能に依存しすぎない
  • 可読性も重要(適切にフォーマット、コメント)

8. まとめ:新たなフロンティア

SQLに立ち返って得られたもの

技術的な収穫

  1. 圧倒的なパフォーマンス向上

    • 平均して10-100倍の高速化を実現
    • ユーザー体験の劇的改善
  2. より深いデータベース理解

    • インデックスの重要性
    • 実行計画の読み方
    • ボトルネックの特定方法
  3. 設計力の向上

    • データの流れを意識した設計
    • 責務の適切な分離(DBの仕事はDBに)

思考の変化

Before: データベースは単なるデータ置き場
After: データベースは強力な計算エンジン

この発想の転換により、アプリケーション全体のアーキテクチャが根本的に改善されました。

今後の展望

SQLの習得により、以下のような新しい可能性が見えてきました:

  • リアルタイム分析:ウィンドウ関数による高速集計
  • データドリブン開発:DBからの洞察でプロダクト改善
  • スケーラビリティ:適切な設計による自然な拡張性

最後に

ORMが悪いわけではありません。適材適所です。

しかし、SQLを理解せずにORMだけに頼るのは、車の運転でアクセルとブレーキを知らないようなものです。

一度SQLに立ち返って学ぶことで、より良いエンジニアになれると確信しています。

皆さんも、ぜひ一度SQLと真剣に向き合ってみてください。
きっと新たな発見があるはずです。


参考リンク

この記事が役に立ったら、ぜひ「いいね」や「保存」をお願いします!

Discussion