☕ NEW! 完成新手任務即可參加抽獎!LINE 星巴克禮券等你拿,名額有限!        🎉 推廣活動:邀請好友註冊 DevLearn,累積推薦抽 LINE 星巴克禮券! 活動詳情 →        🔥 活動期間 2026/4/1 - 5/31 |已有 0 人參加       
資料庫 進階

資料庫效能優化

為什麼效能很重要?

想像你在一個巨大的圖書館找一本書:

  • 沒有索引:從第一本書開始,一本一本翻,直到找到為止(全表掃描)
  • 有索引:查目錄,直接走到正確的書架(索引查詢)

當資料量從 100 筆變成 100 萬筆,差別就是一秒和一小時的區別。


索引(Index)

叢集索引(Clustered Index)

每張表只能有一個叢集索引,決定資料的實際儲存順序

-- 主鍵預設就是叢集索引
CREATE TABLE Students (
    Id INT PRIMARY KEY CLUSTERED,          -- 主鍵 = 叢集索引,資料按 Id 排序儲存
    Name NVARCHAR(100),                    -- 姓名
    Email NVARCHAR(200)                    -- 信箱
);

非叢集索引(Non-Clustered Index)

可以有多個,像書的目錄一樣指向實際資料。

-- 在 Email 欄位建立非叢集索引
CREATE NONCLUSTERED INDEX IX_Students_Email -- 建立非叢集索引
ON Students(Email);                        -- 在 Email 欄位上

-- 複合索引(多欄位)
CREATE INDEX IX_Students_Name_Score        -- 建立複合索引
ON Students(Name ASC, Score DESC);         -- 先按姓名升序,再按分數降序
-- 注意:複合索引的欄位順序很重要!
-- WHERE Name = 'xxx' → ✅ 會用到索引
-- WHERE Score > 90   → ❌ 不會用到索引(必須從最左邊的欄位開始匹配)

在 EF Core 中建立索引

// Fluent API 建立索引
modelBuilder.Entity<Student>(entity =>     // 設定 Student Entity
{
    entity.HasIndex(s => s.Email)           // 在 Email 上建索引
        .IsUnique();                       // 唯一索引

    entity.HasIndex(s => new { s.Name, s.Score }) // 複合索引
        .HasDatabaseName("IX_Students_Name_Score"); // 指定索引名稱
});

查詢執行計畫(Execution Plan)

-- 查看查詢的執行計畫
SET STATISTICS IO ON;                      -- 開啟 IO 統計
SET STATISTICS TIME ON;                    -- 開啟時間統計

-- 使用 EXPLAIN 查看執行計畫(MSSQL 用法)
SET SHOWPLAN_TEXT ON;                      -- 開啟文字格式的執行計畫
GO
SELECT * FROM Students WHERE Email = 'test@test.com'; -- 要分析的查詢
GO
SET SHOWPLAN_TEXT OFF;                     -- 關閉執行計畫顯示
-- 執行計畫結果範例
-- Index Seek(好!用到索引)→ 直接跳到對的位置
-- Table Scan(不好!全表掃描)→ 逐列搜尋,很慢
-- Index Scan(普通)→ 掃描整個索引,比 Table Scan 快一點

N+1 問題與解決方案

// ❌ N+1 問題:查 1 次取得清單 + N 次取得關聯資料
var students = await db.Students.ToListAsync(); // 查詢 1:取得所有學生
foreach (var student in students)               // 迴圈 N 次
{
    // 每次迴圈都產生一個 SQL 查詢
    var enrollments = await db.Enrollments      // 查詢 2~N+1
        .Where(e => e.StudentId == student.Id)  // 篩選該學生的選課
        .ToListAsync();                         // 執行查詢
}
// 100 個學生 = 101 次查詢 😱

// ✅ 解法 1:Eager Loading
var students = await db.Students               // 查詢學生
    .Include(s => s.Enrollments)               // 一次載入所有選課記錄
    .ToListAsync();                            // 只有 1 次查詢 ✅

// ✅ 解法 2:投影(最佳效能)
var result = await db.Students                 // 查詢學生
    .Select(s => new                           // 投影成需要的形狀
    {
        s.Name,                                // 只選需要的欄位
        EnrollmentCount = s.Enrollments.Count  // 在 SQL 端計算
    })
    .ToListAsync();                            // 一次查詢搞定 ✅

// ✅ 解法 3:分批載入
var studentIds = students.Select(s => s.Id).ToList(); // 先取得所有學生 ID
var allEnrollments = await db.Enrollments              // 一次查詢所有相關選課
    .Where(e => studentIds.Contains(e.StudentId))      // 用 IN 查詢
    .ToListAsync();                                    // 只有 2 次查詢 ✅

批次操作

// ❌ 逐筆更新(慢)
foreach (var student in students)              // 遍歷每個學生
{
    student.Score += 5;                        // 加分
    await db.SaveChangesAsync();               // 每次都送一個 UPDATE 語句
}

// ✅ 批次更新(EF Core 7+ 支援 ExecuteUpdate)
await db.Students                              // 查詢學生
    .Where(s => s.Score < 60)                  // 篩選不及格的
    .ExecuteUpdateAsync(s =>                   // 批次更新
        s.SetProperty(x => x.Score, x => x.Score + 5) // 加 5 分
    );                                         // 只產生一個 UPDATE 語句 ✅

// ✅ 批次刪除(EF Core 7+)
await db.Students                              // 查詢學生
    .Where(s => s.IsDeleted)                   // 篩選已刪除的
    .ExecuteDeleteAsync();                     // 一個 DELETE 語句搞定 ✅

Compiled Queries(編譯查詢)

// 預先編譯常用查詢,避免每次都重新產生 SQL
private static readonly Func<AppDbContext, int, Task<Student?>> // 宣告編譯查詢
    GetStudentById = EF.CompileAsyncQuery(     // 編譯查詢
        (AppDbContext db, int id) =>           // 參數:DbContext 和 ID
            db.Students.FirstOrDefault(s => s.Id == id) // 查詢邏輯
    );

// 使用編譯查詢
var student = await GetStudentById(db, 1);     // 直接呼叫,不需要重新產生 SQL

快取策略

// MemoryCache — 記憶體快取(單機)
using Microsoft.Extensions.Caching.Memory;     // 引用快取命名空間

public class StudentService                    // 學生服務
{
    private readonly IMemoryCache _cache;       // 注入快取服務
    private readonly AppDbContext _db;          // 注入 DbContext

    public async Task<Student?> GetById(int id) // 查詢學生(帶快取)
    {
        var cacheKey = $"student_{id}";       // 快取鍵
        if (_cache.TryGetValue(cacheKey, out Student? student)) // 嘗試從快取取得
        {
            return student;                    // 快取命中,直接回傳
        }

        student = await _db.Students           // 快取未命中,查詢資料庫
            .FirstOrDefaultAsync(s => s.Id == id); // 依 ID 查詢

        if (student is not null)               // 如果找到學生
        {
            _cache.Set(cacheKey, student,       // 存入快取
                TimeSpan.FromMinutes(5));       // 5 分鐘後過期
        }
        return student;                        // 回傳結果
    }
}

💡 Redis 是分散式快取方案,適合多台伺服器共用快取的情境。概念類似 MemoryCache,但資料存在獨立的 Redis 伺服器上。


🤔 我這樣寫為什麼會錯?

❌ 錯誤 1:SELECT *(選取所有欄位)

// ❌ 只需要姓名,卻把所有欄位都選出來
var students = await db.Students.ToListAsync(); // SELECT * FROM Students
var names = students.Select(s => s.Name);       // 在 C# 端才篩選欄位
// 如果 Student 有大型的 ProfileImage 欄位 → 浪費大量頻寬和記憶體
// ✅ 只選取需要的欄位
var names = await db.Students                  // 查詢學生
    .Select(s => s.Name)                       // 只選 Name 欄位
    .ToListAsync();                            // SQL: SELECT Name FROM Students ✅

❌ 錯誤 2:缺少索引

-- ❌ 每次查詢都要全表掃描
SELECT * FROM Orders                           -- 查詢訂單
WHERE CustomerId = 12345                       -- 按客戶 ID 篩選
AND Status = 'Pending';                        -- 按狀態篩選
-- 如果沒有索引,100 萬筆資料每次都要掃描全部 😱
-- ✅ 建立適當的索引
CREATE INDEX IX_Orders_CustomerId_Status       -- 建立複合索引
ON Orders(CustomerId, Status);                 -- 按查詢條件建索引
-- 現在查詢只需要毫秒等級 ✅

❌ 錯誤 3:過度索引

-- ❌ 在每個欄位上都建索引
CREATE INDEX IX_1 ON Students(Name);           -- 索引 1
CREATE INDEX IX_2 ON Students(Email);          -- 索引 2
CREATE INDEX IX_3 ON Students(Score);          -- 索引 3
CREATE INDEX IX_4 ON Students(Name, Email);    -- 索引 4
CREATE INDEX IX_5 ON Students(Name, Score);    -- 索引 5
CREATE INDEX IX_6 ON Students(Email, Score);   -- 索引 6
-- 每次 INSERT/UPDATE/DELETE 都要更新所有索引 → 寫入效能暴降!
-- ✅ 只在常用查詢條件上建索引
-- 分析查詢模式後,只建必要的索引
CREATE INDEX IX_Students_Email ON Students(Email); -- 經常用 Email 查詢
CREATE INDEX IX_Students_Name_Score                -- 經常用姓名+分數排序
ON Students(Name, Score DESC);

💡 重點整理

概念 說明
Clustered Index 決定資料實際儲存順序,每表只有一個
Non-Clustered Index 像書的目錄,可以有多個
N+1 問題 1 次查主表 + N 次查關聯表
ExecuteUpdate EF Core 7+ 批次更新
Compiled Query 預先編譯查詢,提高效能
MemoryCache 記憶體快取,減少資料庫查詢

💡 大家的想法 · 0

載入中...
💬 即時聊天室 🟢 0 人在線
😀 😎 🤓 💻 🎮 🎸 🔥
➕ 新問題
📋 我的工單
💬 LINE 社群
🔒
需要註冊才能使用此功能
註冊帳號即可解鎖測驗、遊戲、簽到、筆記下載等所有功能,完全免費!
免費註冊