# 如何在 Entity Framework 中增加 WITH (NOLOCK) 和 Parameter Sniffing 的處理 [![hackmd-github-sync-badge](https://hackmd.io/C_kRZTF-QTibZHUtN44AJQ/badge)](https://hackmd.io/C_kRZTF-QTibZHUtN44AJQ) 前幾天在面試時被問到 SQL Server 的 `WITH (NOLOCK)`,因為太久沒用,一時想不起來,導致回答錯誤。明明一年前的筆記中有相關內容「[SQL Server 效能調教](/EC-RlPMtTI25VsCJK22uPA)」。 為什麼在 SQL Server 中 `WITH (NOLOCK)` 很重要,卻很久沒用了呢?主要原因是現在大部分開發都直接使用 Entity Framework,而不再像過去手刻 Library 產生 SQL 語句,直接讓 Library 在執行 Command 前修正 SQL 就好。 現在我們來看看如何在 Entity Framework 中實現相同的行為。 ## Interceptor(攔截器) Microsoft.EntityFrameworkCore 的 Interceptor 在 3.0 版中加入,而 .NET Framework 的 EntityFramework 則是在 6.0 版中加入。其主要功能是允許在 Entity Framework 執行低階資料庫操作或 `SaveChanges()` 時,修改或攔截正在進行的操作。更具體的內容請參考 MSDN 的「[攔截器](https://learn.microsoft.com/zh-tw/ef/core/logging-events-diagnostics/interceptors#database-interception)」。本篇文章以 Microsoft.EntityFrameworkCore 版本作為範例。 ### Interceptor 介面 * IDbCommandInterceptor:處理 Command 的相關方法,本文將使用此介面。 * IDbConnectionInterceptor:處理連線與關閉連線的相關方法。 * IDbTransactionInterceptor:處理交易相關的方法。 * ISaveChangesInterceptor:處理 `SaveChanges()` 相關方法。 ## 實作方法 關於 `WITH (NOLOCK)` 的處理,網路上大部分的解法都無法處理子查詢。不過,有一篇文章「[给 EF Core 查询增加 With NoLock](https://blog.csdn.net/puzi0315/article/details/133018791)」有更一步處理,所以我就參考這篇來修改。 ```csharp public class FixDbCommandInterceptor : DbCommandInterceptor { private static readonly RegexOptions regexOptions = RegexOptions.Multiline | RegexOptions.IgnoreCase; private static readonly Regex cudRegex = new(@"\b(INSERT|UPDATE|DELETE)\b", regexOptions); private static readonly Regex tableAliasRegex = new( @"(?<tableAlias>(FROM|JOIN)\s+\[\w+\]\s+AS\s+\[\w+\])", regexOptions ); public override InterceptionResult<DbDataReader> ReaderExecuting( DbCommand command, CommandEventData eventData, InterceptionResult<DbDataReader> result) { FixCommand(command); return base.ReaderExecuting(command, eventData, result); } public override async ValueTask<InterceptionResult<DbDataReader>> ReaderExecutingAsync( DbCommand command, CommandEventData eventData, InterceptionResult<DbDataReader> result, CancellationToken cancellationToken = default ) { FixCommand(command); return await base.ReaderExecutingAsync(command, eventData, result, cancellationToken); } public override InterceptionResult<object> ScalarExecuting( DbCommand command, CommandEventData eventData, InterceptionResult<object> result ) { FixCommand(command); return base.ScalarExecuting(command, eventData, result); } public override ValueTask<InterceptionResult<object>> ScalarExecutingAsync( DbCommand command, CommandEventData eventData, InterceptionResult<object> result, CancellationToken cancellationToken = default ) { FixCommand(command); return base.ScalarExecutingAsync(command, eventData, result, cancellationToken); } private static void FixCommand(IDbCommand command) { string commandText = command.CommandText; // 部分異動情境,例如先查詢後異動,EF 也有可能是呼叫 ExecuteReader,而非 ExecuteNonQuery // 所以要排除此情況 if (cudRegex.IsMatch(commandText)) { return; } // 如果是呼叫 Single 或是 First,那可能是追求比較精確的資料,例如取得資料做異動,就不該加 NOLOCK if (!commandText.Contains("TOP(1)") && !commandText.Contains("TOP(2)")) { commandText = tableAliasRegex.Replace(commandText, "${tableAlias} WITH (NOLOCK)"); } // 雖然 EF 產生的 Select 語句沒有分號結尾,只有異動語句有,但為了預防萬一,還是需要處理一下 commandText = commandText.TrimEnd(';') + " OPTION (OPTIMIZE FOR UNKNOWN);"; command.CommandText = commandText; } } ``` ### 程式碼說明 1. `DbCommandInterceptor` 已經實作了 `IDbCommandInterceptor` 的所有方法,所以只需繼承它並覆寫需要的方法。 2. 與查詢相關的方法有 `ExecuteReader()` 和 `ExecuteScalar()`,因此針對這兩個方法的同步與非同步版本來覆寫 `IDbCommandInterceptor` 所對應的執行前方法。 3. `CommandText` 修正: * 部分包含回傳值的異動語法,可能會使用 `ExecuteScalar()` 執行,因此遇到 `INSERT`、`UPDATE` 和 `DELETE` 的語法不處理。 * `WITH (NOLOCK)` 是為了在資料被鎖定時不被阻塞,但如果是撈取資料來做異動,就不適合使用,因此遇到包含 `TOP(1)`(例如:`First()` 或 `Find()`)和 `TOP(2)`(例如:`Single()`)的語法不處理。 * 增加 `OPTION (OPTIMIZE FOR UNKNOWN);` 來處理 Parameter Sniffing。 :::warning 以上處理缺乏實際使用的驗證,請依照自身實際情況調整。 ::: ## 加入 Interceptor 可以使用以下兩種方法加入 Interceptor: * 在 DbContext 中加入以下程式碼: ```csharp protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder) => optionsBuilder.AddInterceptors(new FixDbCommandInterceptor()); ``` * 在 DI 中注入 `DbContext` 時,從 `DbContextOptionsBuilder` 設定: ```csharp services.AddDbContext<TestDbContext>(options => { options .UseSqlServer(DbConnectionString) .AddInterceptors(new FixDbCommandInterceptor()); }); ``` ## 實際執行結果 使用以下 SQL 建立資料表,並用反向工程建立 EF: ```sql CREATE TABLE [dbo].[Test]( [Id] [int] IDENTITY(1,1) NOT NULL, [TestInt] [int] NOT NULL, [TestBit] [bit] NOT NULL, [TestDateTime] [datetime2](7) NOT NULL, [TestGuid] [uniqueidentifier] NOT NULL, CONSTRAINT [PK_Test] PRIMARY KEY CLUSTERED ( [Id] ASC ) WITH ( PAD_INDEX = OFF, STATISTICS_NORECOMPUTE = OFF, IGNORE_DUP_KEY = OFF, ALLOW_ROW_LOCKS = ON, ALLOW_PAGE_LOCKS = ON, OPTIMIZE_FOR_SEQUENTIAL_KEY = OFF) ON [PRIMARY] ) ON [PRIMARY] GO CREATE TABLE [dbo].[SubTest]( [Id] [int] IDENTITY(1,1) NOT NULL, [TestId] [int] NOT NULL, CONSTRAINT [PK_SubTest] PRIMARY KEY CLUSTERED ( [Id] ASC ) WITH ( PAD_INDEX = OFF, STATISTICS_NORECOMPUTE = OFF, IGNORE_DUP_KEY = OFF, ALLOW_ROW_LOCKS = ON, ALLOW_PAGE_LOCKS = ON, OPTIMIZE_FOR_SEQUENTIAL_KEY = OFF) ON [PRIMARY] ) ON [PRIMARY] GO ALTER TABLE [dbo].[SubTest] WITH CHECK ADD CONSTRAINT [FK_SubTest_Test] FOREIGN KEY([TestId]) REFERENCES [dbo].[Test] ([Id]) GO ``` 執行以下程式: ```csharp context.Tests.Find(1); context.Tests .Include(x => x.SubTests) .SingleOrDefault(x => x.Id == 1); context.Tests .Include(x => x.SubTests) .ToList(); ``` 產生的 SQL 語法如下: ```sql -- Find() SELECT TOP(1) [t].[Id], [t].[TestBit], [t].[TestDateTime], [t].[TestGuid], [t].[TestInt] FROM [Test] AS [t] WHERE [t].[Id] = @__p_0 OPTION (OPTIMIZE FOR UNKNOWN); -- SingleOrDefault() SELECT [t0].[Id], [t0].[TestBit], [t0].[TestDateTime], [t0].[TestGuid], [t0].[TestInt], [s].[Id], [s].[TestId] FROM ( SELECT TOP(2) [t].[Id], [t].[TestBit], [t].[TestDateTime], [t].[TestGuid], [t].[TestInt] FROM [Test] AS [t] WHERE [t].[Id] = 1 ) AS [t0] LEFT JOIN [SubTest] AS [s] ON [t0].[Id] = [s].[TestId] ORDER BY [t0].[Id] OPTION (OPTIMIZE FOR UNKNOWN); -- ToList() SELECT [t].[Id], [t].[TestBit], [t].[TestDateTime], [t].[TestGuid], [t].[TestInt], [s].[Id], [s].[TestId] FROM [Test] AS [t] WITH (NOLOCK) LEFT JOIN [SubTest] AS [s] WITH (NOLOCK) ON [t].[Id] = [s].[TestId] ORDER BY [t].[Id] OPTION (OPTIMIZE FOR UNKNOWN); ``` ###### tags: `.NET` `.NET Core & .NET 5+` `Entity Framework` `Microsoft SQL Server`