d

建立一個 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 → 重設
  • 查詢狀態 → 全部清掉

d

🔹 LINQ → SQL 的轉換不一定最佳

EF Core 的 Query Provider 會把 LINQ 轉成 SQL,但它不一定能轉出最優化的 SQL,有時候會產生多餘的 JOIN、Subquery

例如,抓出有大額訂單的活躍用戶

EF

1
2
3
4
var users = context.Users
.Include(u => u.Orders)
.Where(u => u.IsActive && u.Orders.Any(o => o.Amount > 100))
.ToList();

SQL轉譯

1
2
3
4
5
SELECT [u].[Id], [u].[Name], [u].[IsActive], [o].[Id], [o].[Amount]
FROM [Users] AS [u]
LEFT JOIN [Orders] AS [o] ON [u].[Id] = [o].[UserId]
WHERE [u].[IsActive] = 1
ORDER BY [u].[Id]

EF Core 為了支援 Include,把整個 Orders 都 JOIN 出來,即使你只想檢查 o.Amount > 100,它還是把所有 Orders 都拉回來,再來記憶體中判斷

如果手寫 SQL,可以直接用 EXISTS

1
2
3
4
5
6
7
8
SELECT *
FROM Users u
WHERE u.IsActive = 1
AND EXISTS (
SELECT 1 FROM Orders o
WHERE o.UserId = u.Id
AND o.Amount > 100
)

d

🔹 查詢複雜時,SQL Optimizer 難以最佳化

例如多層 GroupBy、Window Function、CTE (Common Table Expression),EF Core 難以精確轉譯,可能會生成巨大的 SQL

🔹 難以調校 (Tuning)

在 EF Core 中,要調整 SQL 結構不容易,但如果是自己寫 SQL 或 SP,就能手動加 Hint、Index、Rewrite


🔹 大量 JOIN / 多表關聯

1
2
3
4
5
6
7
SELECT u.Id, u.Name, SUM(o.Amount)
FROM Users u
JOIN Orders o ON u.Id = o.UserId
JOIN OrderDetails od ON o.Id = od.OrderId
JOIN Products p ON od.ProductId = p.Id
WHERE p.CategoryId = 10
GROUP BY u.Id, u.Name

這種多表聚合查詢,如果用 EF Core LINQ,可能會轉出非常複雜又沒效率的 SQL

🔹 統計 / 報表 (Aggregation, GroupBy, Window Function)

排名、分頁、Top-N per group

1
2
3
4
5
SELECT u.Id, u.Name, 
RANK() OVER (PARTITION BY u.Region ORDER BY SUM(o.Amount) DESC) AS RankInRegion
FROM Users u
JOIN Orders o ON u.Id = o.UserId
GROUP BY u.Id, u.Name, u.Region;

d

假設你要查 5000 個 ID

🔹 Optimizer 的困境

1
2
3
SELECT * 
FROM CrmMember
WHERE CrmMemberId IN (1,2,3,4,...,5000)
  • 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 可能會「想很久」或「做出笨決定」。

d

🔹 為什麼 JOIN TVF 比較穩定?

1
2
3
4
SELECT cm.*
FROM CrmMember cm
JOIN dbo.cfn_SplitToBigintTableV2('1,2,3,...,5000', ',') ids
ON cm.CrmMemberId = ids.Id

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

d

🔹 Nested Loop Join / Hash Join / Merge Join

這些是 SQL Server 比對兩張表的方法

Nested Loop Join

1
2
3
SELECT * 
FROM Orders
WHERE CustomerId IN (1,2,3,4,...5000);

SQL Optimizer 的典型作法

  • 把 IN(…) 裡的值當成「小清單」
  • 每個值都去 Orders 裡 重新走一次索引 (Index Seek)

這就是 Nested Loop Join 的思維,外圈迴圈:5000 個 Id,內圈:每次都去 Orders 找一次

👉 結果:會做 5000 次索引查詢。這在 Id 很少時快,但 Id 多時就爆炸

d

Hash Join

1
2
3
4
SELECT o.*
FROM Orders o
JOIN dbo.cfn_SplitToBigintTableV2('1,2,3,...5000', ',') ids
ON o.CustomerId = ids.Id;
  • 先把 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 從頭比到尾

d

🔹 EF Core 查詢怎麼運作

1
var user1 = await _db.Users.Where(c => c.Account == "admin").FirstOrDefaultAsync();

EF Core 會做這幾件事:

  • 建立「運算式樹狀結構 (Expression Tree)」
  • 把這個樹狀結構編譯成 SQL
  • 把 SQL 丟給 SQL Server 執行

d

1
2
3
SELECT TOP(1) [u].[Id], [u].[Account], ...
FROM [Users] AS [u]
WHERE [u].[Account] = N'admin'

🔹 常數會讓查詢結構不一樣

1
var user2 = await _db.Users.Where(c => c.Account == "allen").FirstOrDefaultAsync();

雖然只是改了 “allen”,但 EF Core 看到的「樹狀結構」不一樣了,EF Core 認為這是 兩個不同查詢,所以會各自編譯一次 LINQ → SQL、建立兩個 SQL Execution Plan,這會浪費效能,因為這兩個查詢本質上一樣,只差參數

d

🔹 參數化 (Parameterization)

1
2
3
4
5
var account1 = "admin";
var user1 = await _db.Users.Where(c => c.Account == account1).FirstOrDefaultAsync();

var account2 = "rico";
var user2 = await _db.Users.Where(c => c.Account == account2).FirstOrDefaultAsync();

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
2
var now = DateTime.Now;
var result = _db.Users.Where(u => u.CreatedDate < now).ToList();

d

1
2
3
4
5
foreach (var user in users)
{
_db.Add(user);
_db.SaveChanges(); // 每次迴圈都丟一次 SQL
}

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

d

🔹 一次性批次處理的做法

1
2
_db.AddRange(users);   // 一次性追蹤全部要新增的 User
_db.SaveChanges(); // 一次性產生 SQL,丟給資料庫

先把 10 筆資料都加進 DbContext。然後一次 SaveChanges(),EF Core 會產生一批 SQL,丟給資料庫一次處理。10 筆資料 = 1 次網路往返。效能差異:少了 10 倍的網路延遲。

d