R
RAY'S NOTES

用 BenchmarkDotNet 連真實資料庫驗證 EF Core 查詢效能

程式開發 19 分鐘閱讀

BenchmarkDotNet 連 InMemory provider 跑出來的數字根本不可信。這篇記錄我怎麼改連真實 UAT 資料庫、踩了哪些坑、以及怎麼用統計顯著性判讀結果。

我在做一個 GraphQL 查詢優化,要在既有查詢裡加兩個 subquery 去撈「通知訂閱狀態」。擔心效能會被拖垮,決定跑 BenchmarkDotNet 來量化差異。第一次直覺反應是用 EF Core InMemory provider — 快、不需要基礎建設、CI 友好。但跑完之後回頭想,這個結果根本沒有意義。

為什麼 InMemory Benchmark 不可信

InMemory provider 的存在意義是跑 unit test,不是模擬真實資料庫行為。它有幾個關鍵差異:

  • LINQ 不會編譯成 SQL:InMemory 直接在記憶體裡做 LINQ 查詢,跳過 SQL 生成、query planner、JOIN 策略
  • 沒有索引選擇:SQL Server 根據統計資訊決定用哪個索引,InMemory 完全不管這件事
  • 沒有網路延遲:真實 DB 有連線成本、查詢封包往返,InMemory 全在 process 內

LINQ 能在 InMemory 快速跑過,不代表在 SQL Server 上跑起來一樣。我加了兩個 subquery,其實想驗證的是 SQL Server 的執行計畫會怎麼處理 — 用 InMemory 根本量不到這件事。

結論很簡單:InMemory benchmark 只能測試應用層邏輯,不能測資料庫效能。

改連真實 UAT 資料庫

決定改連真實 UAT 環境。這裡有幾個實務問題要處理。

加密連線字串的解密

連線字串放在 appsettings.UAT.json,是加密過的,不能直接貼明碼到環境變數或 hardcode 進程式碼。

服務本身已有一個 CryptoHelper 做對稱加密解密,用相同的解密流程在 [GlobalSetup] 裡處理 — 這樣和正式部署走的是同一套路,不需要另外搭基礎建設。

[GlobalSetup]
public async Task Setup()
{
    var config = new ConfigurationBuilder()
        .AddJsonFile("appsettings.UAT.json")
        .Build();

    var encryptedConnStr = config.GetConnectionString("Default");
    var connStr = CryptoHelper.Decrypt(encryptedConnStr);

    var options = new DbContextOptionsBuilder<AppDbContext>()
        .UseSqlServer(connStr)
        .UseQueryTrackingBehavior(QueryTrackingBehavior.NoTracking)
        .Options;

    _dbContext = new AppDbContext(options);

    // 健康檢查:確認測試資料存在
    var testMember = await _dbContext.Members
        .FirstOrDefaultAsync(m => m.Id == TestMemberId);

    if (testMember == null)
        throw new InvalidOperationException(
            $"測試會員 {TestMemberId} 不存在,請確認 UAT 資料庫的測試資料");
}

UseQueryTrackingBehavior(NoTracking) 是一道額外的安全網。我們的查詢本來就是 read-only,但多加這一行,萬一有人不小心在 benchmark 裡塞了 SaveChanges(),也不會真的寫進 UAT 資料庫。

選測試資料

要找「最能代表真實負載」的測試會員。我另外寫了個小工具 QueryTool 連 UAT 查詢,QueryTool 只允許 SELECT / WITH 開頭的語句(用 prefix 檢查擋掉 DELETEUPDATEDROP),讓我可以在 UAT 上安全跑任意查詢,不用開 SSMS。

查下來找到一個綁了 40 台車的會員 — 這是資料庫裡關聯數量最多的案例,最能代表最壞情況的負載。

GlobalSetup 加健康檢查的原因

第一次跑的時候,[GlobalSetup] 裡的 SaveChanges() 拋了這個錯誤:

Microsoft.EntityFrameworkCore.DbUpdateException: Required properties '{MgmCode}' are missing for the instance of entity type 'Member'.

BenchmarkDotNet 不會把 GlobalSetup 的例外好好 bubble 上來,只顯示「benchmark failed」。真正的 stack trace 藏在 log 檔案裡,要翻才找得到。

學到的防守方式:[GlobalSetup] 開頭明確 assert 測試資料存在,找不到就直接 throw,而不是讓 EF Core 拋那種難讀的 Required properties missing 錯誤。這樣失敗時訊息清楚,也不用翻 log 才知道問題在哪。

DisableOptimizationsValidator 的坑

設定好 DbContext 之後,第一次執行就爆了:

Assembly 'InternalPackage, Version=1.0.0.0' which defines benchmarks
references non-optimized 'SomeCore, Version=2.0.0.0'.

BenchmarkDotNet 預設會驗證所有 referenced assembly 都是 Release(optimized)build。我的專案依賴的幾個內部套件是 Debug 版,直接過不了。

解法是建一個自訂的 ManualConfig

public class BenchmarkConfig : ManualConfig
{
    public BenchmarkConfig()
    {
        AddJob(Job.Default);
        AddExporter(MarkdownExporter.Default);
        AddLogger(ConsoleLogger.Default);
        AddColumnProvider(DefaultColumnProviders.Instance);

        // 告訴 BenchmarkDotNet 跳過 Debug reference 的驗證
        WithOptions(ConfigOptions.DisableOptimizationsValidator);
    }
}

[Config(typeof(BenchmarkConfig))]
public class MemberQueryBenchmark
{
    // ...
}

DisableOptimizationsValidator 的語意是「我知道我在做什麼,跳過這個檢查」。在 CI 正式流程裡不應該這樣用,但本地驗證連真實 DB 這個場景完全合理。

怎麼判讀統計顯著性

跑出來的結果:

| Method         |     Mean |    Error |  StdDev |
|--------------- |---------:|---------:|--------:|
| Original       | 241.3 ms | ±12.4 ms | 8.6 ms  |
| WithSubqueries | 249.4 ms | ±14.1 ms | 9.8 ms  |

Mean 差了 8.1 ms,相對差異 +3.3%。看起來新版慢了一點。但這樣能下結論嗎?

不行。 關鍵在 Error 欄位。BenchmarkDotNet 的 Error 是信賴區間半徑(通常是 99.9% CI)。

  • Original 的範圍:241.3 ± 12.4 ms → 228.9 ms ~ 253.7 ms
  • WithSubqueries 的範圍:249.4 ± 14.1 ms → 235.3 ms ~ 263.5 ms

這兩個區間重疊非常大。也就是說,我們無法統計上確定 WithSubqueries 比 Original 慢 — 這個 8 ms 的差異,完全可能是量測噪音造成的。

只看 Mean 下結論是常見誤判。 如果兩組 error bar 有明顯重疊,不能宣稱 A 比 B 快或慢。必須要信賴區間不重疊(或至少重疊很少),差異才算統計上顯著。

我的判讀:+3.3% 的差異在可接受範圍內,統計上不顯著,兩個 subquery 可以上。

結語

InMemory benchmark 快速但沒有參考價值;連真實 DB 有成本但數字才有意義。整個流程下來有幾個可複用的 pattern:

  • CryptoHelper 在 GlobalSetup 解密:和正式部署同一套邏輯,不需要額外基礎建設
  • NoTracking 做安全網:即使查詢本來就是 read-only,明確設定防止意外寫入
  • GlobalSetup 加 assert:讓失敗訊息清楚,而不是讓 EF Core 拋難讀的例外
  • DisableOptimizationsValidator:本地驗證場景合理,CI 流程要謹慎使用
  • 看 error bar,不只看 Mean:信賴區間重疊才是判讀顯著性的正確方式

這套流程現在變成我做 EF Core 效能驗證的標準做法。QueryTool 也順便沉澱成日常查 UAT 資料的工具,省去開 SSMS 的麻煩。

留言