EFCORE - 効率の詩 2

建立一個 DbContext → 用完 → Dispose 掉,一般效能影響不大。但是如果你在一個高併發服務(例如 Web API,每個 Request 都要建一個 DbContext),那麼
- 每次都要 new 一個 DbContext
- EF Core 需要做初始化動作
- CPU 與 GC 負擔增加
👉 在大量 Request 下就會有「明顯效能影響」。
🔹 DbContext Pooling (快取池)
EF Core 提供 DbContext Pooling 功能,可以「重複利用」DbContext 實例,而不是每次都重新 new
,當你 Dispose() 一個 DbContext 時,它並不會真的銷毀,而是放回 Pool (池子),下次需要時,直接拿出來重用 → 減少建立成本,AddDbContextPool() 確實會 重複使用同一個 DbContext 實例,但 EF Core 在把它丟回池子前,會做 重置 (reset)
- Change Tracker → 清空 (不會保留任何追蹤的 Entity)
- 交易 (Transaction) → 自動 Dispose
- Scoped Service Provider → 重設
- 查詢狀態 → 全部清掉

🔹 LINQ → SQL 的轉換不一定最佳
EF Core 的 Query Provider 會把 LINQ 轉成 SQL,但它不一定能轉出最優化的 SQL,有時候會產生多餘的 JOIN、Subquery
例如,抓出有大額訂單的活躍用戶
EF
1 | var users = context.Users |
SQL轉譯
1 | SELECT [u].[Id], [u].[Name], [u].[IsActive], [o].[Id], [o].[Amount] |
EF Core 為了支援 Include,把整個 Orders 都 JOIN 出來,即使你只想檢查 o.Amount > 100,它還是把所有 Orders 都拉回來,再來記憶體中判斷
如果手寫 SQL,可以直接用 EXISTS
1 | SELECT * |

🔹 查詢複雜時,SQL Optimizer 難以最佳化
例如多層 GroupBy、Window Function、CTE (Common Table Expression),EF Core 難以精確轉譯,可能會生成巨大的 SQL
🔹 難以調校 (Tuning)
在 EF Core 中,要調整 SQL 結構不容易,但如果是自己寫 SQL 或 SP,就能手動加 Hint、Index、Rewrite
🔹 大量 JOIN / 多表關聯
1 | SELECT u.Id, u.Name, SUM(o.Amount) |
這種多表聚合查詢,如果用 EF Core LINQ,可能會轉出非常複雜又沒效率的 SQL
🔹 統計 / 報表 (Aggregation, GroupBy, Window Function)
排名、分頁、Top-N per group
1 | SELECT u.Id, u.Name, |

假設你要查 5000 個 ID
🔹 Optimizer 的困境
1 | SELECT * |
- SQL Server 要先解析 (parse) 這段 SQL,字串很長,解析時間變久
- 編譯 Execution Plan (執行計劃) 也會變慢,因為 Optimizer 要想辦法決定「怎麼處理 5000 個條件」。
- 每次 IN 的值不同,SQL Server 會認為是「不同的 SQL」,計劃快取用不了,等於每次都要重新編譯。
SQL Server 在執行查詢前,會先經過 Query Optimizer,決定「怎麼跑這個查詢最省」。
- 用 Index Seek 還是 Table Scan?
- 要不要用 Nested Loop / Hash Join?
👉 如果 SQL 太長(例如 5000 個 IN 值),Optimizer 可能會「想很久」或「做出笨決定」。

🔹 為什麼 JOIN TVF 比較穩定?
1 | SELECT cm.* |
Optimizer 視角來看,看到的就是「CrmMember JOIN 一個小 Table (ids)」,這樣 Optimizer 可以用 Hash Join 或 Merge Join(一次比對整個 Table),比起 5000 個 IN 條件更有效率,計劃還可以重複使用,不會因為值不同就重編譯

🔹 Nested Loop Join / Hash Join / Merge Join
這些是 SQL Server 比對兩張表的方法
Nested Loop Join
1 | SELECT * |
SQL Optimizer 的典型作法
- 把 IN(…) 裡的值當成「小清單」
- 每個值都去 Orders 裡 重新走一次索引 (Index Seek)
這就是 Nested Loop Join 的思維,外圈迴圈:5000 個 Id,內圈:每次都去 Orders 找一次
👉 結果:會做 5000 次索引查詢。這在 Id 很少時快,但 Id 多時就爆炸

Hash Join
1 | SELECT o.* |
- 先把 ids(5000 筆)讀出來
- 建立一個 Hash Table (類似字典/Set),Key = Id,Value = 位置(方便比對)
- 掃描 Orders (100 萬筆),把 CustomerId 丟進 Hash Function 算 key,看 Hash Table 裡有沒有對應 → O(1) 查找
所以只會掃一次 Orders,每筆比對用 Hash Table O(1) 判斷,整體只做 100 萬次比對 + 建 Hash Table,比 Nested Loop 快很多
說明一下 Hash Table ≠ 一個普通清單,它是 Key → Slot (桶子) 的映射結構
| Id | Hash(Key) | Slot (桶子) |
|---|---|---|
| 101 | 101 % 128 = 101 | 桶 101 |
| 205 | 205 % 128 = 77 | 桶 77 |
| 999 | 999 % 128 = 103 | 桶 103 |
例如當 Orders.CustomerId = 205 時
→ SQL Server 算 205 % 128 = 77
→ 直接跳到「桶 77」
→ 在桶子裡找到 Id = 205 → 比對成功
👉 它不需要從 5000 個 Id 從頭比到尾

🔹 EF Core 查詢怎麼運作
1 | var user1 = await _db.Users.Where(c => c.Account == "admin").FirstOrDefaultAsync(); |
EF Core 會做這幾件事:
- 建立「運算式樹狀結構 (Expression Tree)」
- 把這個樹狀結構編譯成 SQL
- 把 SQL 丟給 SQL Server 執行

1 | SELECT TOP(1) [u].[Id], [u].[Account], ... |
🔹 常數會讓查詢結構不一樣
1 | var user2 = await _db.Users.Where(c => c.Account == "allen").FirstOrDefaultAsync(); |
雖然只是改了 “allen”,但 EF Core 看到的「樹狀結構」不一樣了,EF Core 認為這是 兩個不同查詢,所以會各自編譯一次 LINQ → SQL、建立兩個 SQL Execution Plan,這會浪費效能,因為這兩個查詢本質上一樣,只差參數

🔹 參數化 (Parameterization)
1 | var account1 = "admin"; |
EEF Core 會認定「這是同一個查詢結構」,所以不用重新編譯一次 LINQ Tree,省掉 EF Core 的編譯成本,SQL Server 計劃快取 (Execution Plan Cache),SQL Server 看到「結構相同的 SQL + 不同參數」,就能重複使用同一個執行計劃 (Execution Plan),避免 Plan Cache 變得很大 (因為每個不同常數都會生成新計劃)
🔹 以直接帶上時間為例
1 | var result = _db.Users.Where(u => u.CreatedDate < DateTime.Now).ToList(); |
1 | var now = DateTime.Now; |

1 | foreach (var user in users) |
每新增一筆 User,EF Core 就會馬上呼叫一次 SaveChanges()。SaveChanges() → 會產生 SQL → 打一次網路請求給資料庫。10 筆資料 = 打 10 次網路請求。每次 SaveChanges() = 一次「網路往返 (Round Trip)」。網路往返比「執行 SQL」本身更耗時。所以這樣效率超差。

🔹 一次性批次處理的做法
1 | _db.AddRange(users); // 一次性追蹤全部要新增的 User |
先把 10 筆資料都加進 DbContext。然後一次 SaveChanges(),EF Core 會產生一批 SQL,丟給資料庫一次處理。10 筆資料 = 1 次網路往返。效能差異:少了 10 倍的網路延遲。











