RCSI 簡介與改良版 Entity Framework 鎖定提示攔截器
TLDR
- RCSI (Read Committed Snapshot Isolation) 是目前解決 SQL Server 讀寫鎖定衝突的最佳實踐,建議優先於
WITH (NOLOCK)使用。 - RCSI 為資料庫層級設定,開啟後可避免讀取被寫入阻塞,且不會讀到髒資料。
- 若需在 EF Core 中精細控制鎖定提示(如
NOLOCK),建議使用TagWith搭配DbCommandInterceptor進行顯式宣告,而非全域強制替換。 - 針對參數嗅探 (Parameter Sniffing) 問題,建議優先考慮 SQL Server 的 Query Store 機制。
- 框架層級的全域處理應以「降低團隊認知負荷」為核心目標,並根據專案的風險容忍度進行權衡。
RCSI 是什麼?
RCSI (Read Committed Snapshot Isolation) 透過「版本控制」解決鎖定問題。當資料正在寫入時,讀取操作不會被阻塞,也不會讀到未提交的髒資料,而是讀取寫入前的最後一個快照版本。
RCSI 的運作特性
- 資料庫層級設定:開啟後(
ALTER DATABASE ... SET READ_COMMITTED_SNAPSHOT ON),全域生效,所有未指定隔離層級的查詢都會自動進入快照模式。 - 無法局部開啟:若需針對特定查詢進行精細控制,應改用
Snapshot Isolation並在 Transaction 中顯式指定。 - 不取代樂觀鎖:RCSI 僅解決查詢阻塞問題,若要處理資料更新時的併發衝突,仍須搭配
RowVersion機制。
RCSI vs. NOLOCK vs. READPAST
- WITH (NOLOCK):允許讀取髒資料,風險最高。
- WITH (READPAST):遇到鎖定直接跳過,適用於 Queue 處理,不適用於報表。
- RCSI:讀取一致的快照資料,無需加 Hint 即可達成非阻塞讀取。
TIP
在現代環境(SSD 普及、雲端預設開啟)下,RCSI 的 TempDB 負擔已不再是致命傷,是比到處加 NOLOCK 更乾淨的解法。
TagWith + Interceptor 改良實作
在無法全面開啟 RCSI 或有特殊需求的情境下,可透過 TagWith 進行顯式宣告,僅在特定查詢介入鎖定提示。
1. 定義擴充方法
透過 Fluent API 語法,讓程式碼意圖更清晰:
csharp
public static class EfHintExtensions {
public const string TagNoLock = "SQL_HINT: NOLOCK";
public const string TagReadPast = "SQL_HINT: READPAST";
public const string TagOptionUnknown = "SQL_OPTION: OPTIMIZE FOR UNKNOWN";
public const string TagRecompile = "SQL_OPTION: RECOMPILE";
public static IQueryable<T> WithNoLock<T>(this IQueryable<T> query) => query.TagWith(TagNoLock);
public static IQueryable<T> WithReadPast<T>(this IQueryable<T> query) => query.TagWith(TagReadPast);
public static IQueryable<T> WithOptionUnknown<T>(this IQueryable<T> query) => query.TagWith(TagOptionUnknown);
public static IQueryable<T> WithRecompile<T>(this IQueryable<T> query) => query.TagWith(TagRecompile);
}2. 實作攔截器 (Interceptor)
此攔截器支援 Schema 格式(如 [dbo].[Table])並自動合併 SQL Option,避免語法錯誤。
csharp
public class SqlTaggingInterceptor : DbCommandInterceptor {
private static readonly RegexOptions regexOptions = RegexOptions.Multiline | RegexOptions.IgnoreCase;
private static readonly Regex tableAliasRegex = new(
@"(?<tableAlias>(?:FROM|JOIN)\s+(?:\[[^\]]+\]\.)?\[[^\]]+\]\s+AS\s+\[[^\]]+\])(?!\s+WITH\s*\()",
regexOptions
);
public override InterceptionResult<DbDataReader> ReaderExecuting(
DbCommand command, CommandEventData eventData, InterceptionResult<DbDataReader> result
) {
FixCommand(command);
return base.ReaderExecuting(command, eventData, result);
}
private static void FixCommand(DbCommand command) {
if (string.IsNullOrWhiteSpace(command.CommandText)) return;
string text = command.CommandText;
bool isChanged = false;
string hintToApply = null;
if (text.Contains(EfHintExtensions.TagNoLock)) hintToApply = "WITH (NOLOCK)";
else if (text.Contains(EfHintExtensions.TagReadPast)) hintToApply = "WITH (READPAST)";
if (hintToApply != null) {
text = tableAliasRegex.Replace(text, $"${{tableAlias}} {hintToApply}");
isChanged = true;
}
List<string> options = new ();
if (text.Contains(EfHintExtensions.TagOptionUnknown)) options.Add("OPTIMIZE FOR UNKNOWN");
if (text.Contains(EfHintExtensions.TagRecompile)) options.Add("RECOMPILE");
if (options.Count > 0) {
text = text.TrimEnd().TrimEnd(';');
text += $" OPTION ({string.Join(", ", options)});";
isChanged = true;
}
if (isChanged) command.CommandText = text;
}
}實際使用範例
csharp
// 報表:允許髒讀 + 解決參數嗅探
List<Order> orders = context.Orders
.WithNoLock()
.WithOptionUnknown()
.ToList();
// Queue:跳過鎖定行
Job job = context.Jobs
.WithReadPast()
.FirstOrDefault();關於全域攔截與隱形機制的思辨
在評估是否採用全域處理(如自動注入 AsNoTracking 或攔截器)時,建議從以下三個維度進行權衡:
- 情境頻率:若 90% 的查詢都屬於同一類型(如報表系統),全域預設處理能大幅簡化開發。
- 辨別難度:若「高風險操作」比「低風險操作」更容易識別,則預設開啟全域處理反而能降低誤判風險。
- 風險容忍度:需評估誤判造成的後果(如資料不一致 vs. 查詢變慢),並選擇最符合專案需求的架構。
結論:技術手段的選擇應以「降低團隊認知負荷」為優先,而非盲目遵循教條式的 Anti-Pattern 定義。
異動歷程
- 2026-02-05 初版文件建立。
