RCSI 簡介與改良版 Entity Framework 鎖定提示攔截器
前幾天無意間發現 Google 的 NotebookLM 其實早在 2025 年 4 月 30 日的 Podcast 就支援中文語系(好笑的是,直到 2026 年 1 月時,Gemini 還信誓旦旦地騙我說不支援)。於是我一時興起,把手邊的一些技術筆記丟進去,想說轉成 Podcast 通勤時可以聽聽。
因為 Antigravity 認為我的筆記內容充滿了決策過程,建議我將風格設定為「辯論」。畢竟我也不認為自己寫的筆記絕對正確,所以也順便下 Prompt 請它幫我檢查是否有錯誤或過時的內容。
結果產生出來的內容讓我有點傻眼,感覺很多評論是為了批評而批評:
- 為辯論而辯論:我在筆記中提到習慣使用
DateTimeOffset,當時遇到同事在 Entity Framework 使用DateTime搭配 UTC 時遇到的雷點(我找出原因並提供解法)。結果評論結論竟然是:我應該讓同事直接改用DateTimeOffset,而不是在DateTime上花時間糾結? - 畫錯重點:這兩位 AI 主持人花了大量篇幅在攻防我的「前言」和「背景介紹」,卻忽略了主文的技術細節。
- 指鹿為馬:我明明只是做語法示範或探討某些機制的筆記,文中也明確寫了不建議使用或有哪些潛在問題,結果它竟然說我在「推廣」這些做法?
好吧,我承認我是玻璃心,後來把這些 Podcast 全部刪除了。
不過,也不能說完全沒收穫,我從中聽到了一個關鍵詞:RCSI (Read Committed Snapshot Isolation)。
還記得 2018 年左右面試某些 高流量或高併發系統 的公司時,懂得在 SQL Server 使用 WITH (NOLOCK) 幾乎是必考題,這代表你知道如何避免查詢被寫入鎖住。但最近查了資料才發現,現在這做法在某些情境下被視為一種 Anti-Pattern(反模式)。
因為最近比較忙,也有點懶,所以這篇就簡單筆記一下重點:
RCSI 是什麼?
全名是 Read Committed Snapshot Isolation。簡單來說,它透過「版本控制」的概念來解決鎖定問題。當有人在寫入資料時,讀取的操作不會被擋住,也不會讀到寫到一半的髒資料,而是讀取「寫入前的最後一個版本(Snapshot)」。
RCSI 是全域開關嗎?
是的,RCSI (READ_COMMITTED_SNAPSHOT) 是 資料庫層級 (Database Level) 的設定。
一旦開啟(ALTER DATABASE ... SET READ_COMMITTED_SNAPSHOT ON),它會改變整個資料庫中所有使用 Read Committed 隔離層級(預設值)的行為。
- 無法針對「部分資料表」開啟。
- 這是全域生效的,所有未特別指定隔離層級的查詢都會自動變成「讀取快照」模式。
TIP
若要「選擇性」使用快照隔離,需改用 Snapshot Isolation,並在 EF 建立 Transaction 時顯式指定 IsolationLevel.Snapshot。 但這種精細控制對團隊的開發要求相當高。若是交易頻繁的專案,老實說,連我自己都沒把握能花時間去一一釐清並做出正確的層級判斷。
WARNING
請注意,RCSI 主要是解決查詢方面的鎖定問題(避免讀取被寫入阻塞)。 若要處理資料更新時的併發衝突(例如兩人同時修改同一筆資料),仍然必須搭配 RowVersion (樂觀鎖) 機制來確保資料一致性,RCSI 並不能取代 RowVersion。
RCSI vs. NOLOCK vs. READPAST
WITH (NOLOCK): 為了不被鎖,我願意讀取「髒資料 (Dirty Read)」。
WITH (READPAST): 遇到被鎖住的行就直接「跳過」。適用於 Queue 的處理,但不適合一般報表(因為資料會少)。
RCSI: 讀取一致的「快照資料」。不用加 Hint 就能達成非阻塞讀取。
TIP
不論是髒讀還是快照讀,在高併發情境下都不保證拿到「最新狀態」的資料,因為資料本來就處於持續異動中。差別在於:快照讀只會讀到已提交的歷史版本,而髒讀可能讀到尚未完成的中間狀態,等同破壞原子性,因此帶來的風險層級明顯更高。
為什麼以前不用,現在變主流?
RCSI 的代價是它會大量使用 TempDB 來儲存資料版本。在傳統機械硬碟 (HDD) 時代,這會導致嚴重的 I/O 瓶頸。 但現在主流環境變了:
- SSD 普及:I/O 速度大幅提升,TempDB 的負擔不再是致命傷。
- 雲端預設開啟:例如 Azure SQL Database 預設就是開啟 RCSI 的。
所以,如果硬體撐得住(空間與 I/O),直接開 RCSI 是比到處加 NOLOCK 更乾淨的解法(主要是 Entity Framework 若要處理 NOLOCK,通常得用攔截器,比較麻煩)。
TagWith + Interceptor 改良實作
當然以現在來說 RCSI 應該是最佳解法,但也不是全部的專案都適合,我們還是需要控制鎖定層級。
以前我寫過一篇 如何在 Entity Framework 中增加 WITH (NOLOCK) 和 Parameter Sniffing 的處理 的筆記,當時的解法是用 DbCommandInterceptor 搭配正規式(Regex),把所有的 SELECT 都強制加上 NOLOCK。
說實話,我對那個解法一直不滿意。因為在跑清單報表時,資料精確性或許不用那麼高;但當你在取出資料進行異動時,讀到髒資料是很危險的。當時我只能利用「排除 TOP 2」這種旁門左道來處理,但如果是批次資料處理(必須撈清單資料),依然會出問題。
TagWith 的發現
早在 2024 年,有同事在處理 Parameter Sniffing 問題時,就有找到 TagWith 的用法。但我當時誤以為這只是加個靜態註解,然後再用攔截器去字串搜尋做替換。最近才發現,TagWith 是 EF Core 內建的功能,它能將註解完整帶入產生的 SQL 中,這給了我們一個完美的「開關」。
改良後的解法
這範例選擇預設不加 WITH (NOLOCK) 和 OPTION,而是透過 TagWith 進行顯式宣告。只有當我明確說這條 Query 要 NOLOCK 時,攔截器才介入。
1. 定義擴充方法 (Extension Methods)
讓程式碼寫起來像 Fluent API,語意更清晰。
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]這種帶有 Schema 的格式(舊版正規式會失效)。 - Option 合併:自動處理多個 Option 的合併,避免 SQL 語法錯誤。
public class SqlTaggingInterceptor : DbCommandInterceptor {
private static readonly RegexOptions regexOptions = RegexOptions.Multiline | RegexOptions.IgnoreCase;
// 改良版 Regex:支援 Schema (例如 [dbo].[Table])
// 理論上資料表名稱不該有空白,但是技術上允許,所以這邊也增加處理
private static readonly Regex tableAliasRegex = new(
@"(?<tableAlias>(?:FROM|JOIN)\s+(?:\[[^\]]+\]\.)?\[[^\]]+\]\s+AS\s+\[[^\]]+\])(?!\s+WITH\s*\()",
regexOptions
);
// 攔截 Reader 與 Scalar (Count/Any 也要處理!)
public override InterceptionResult<DbDataReader> ReaderExecuting(
DbCommand command, CommandEventData eventData, InterceptionResult<DbDataReader> result
) {
FixCommand(command);
return base.ReaderExecuting(command, eventData, result);
}
// ... (Async 與 Scalar 方法省略,記得都要呼叫 FixCommand)
private static void FixCommand(DbCommand command) {
if (string.IsNullOrWhiteSpace(command.CommandText)) {
return;
}
string text = command.CommandText;
bool isChanged = false;
// 1. 處理 Table Hints (NOLOCK / READPAST)
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;
}
// 2. 處理 Query Options
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;
}
}
}實際使用
現在,可以控制每一條查詢的行為,而不必擔心改壞其他的 SQL:
// 報表:允許髒讀 + 解決參數嗅探
List<Order> orders = context.Orders
.WithNoLock()
.WithOptionUnknown()
.ToList();
// Queue:跳過鎖定行
Job job = context.Jobs
.WithReadPast()
.FirstOrDefault();結論
不論 WITH (NOLOCK) 還是 Option 處理參數嗅探,目前主流都有更適合從資料庫處理的作法,例如開啟 RCSI 或使用 Query Store 進行處理。如果在硬體與維運跟得上(I/O 效能與 TempDB 空間監控),尤其有 DBA 的情況下,建議使用。在多數讀多寫少、以查詢為主的系統中,RCSI 是合理預設。它能從根本上解決讀寫衝突,而不需要在程式碼中到處加 Hint。 而如果是為了解決 Parameter Sniffing,從 SQL Server 2016 開始引入的 Query Store 也能提供更好的執行計畫管理與強制機制。
當然,這一切的前提是硬體資源(TempDB 空間與 I/O)要跟得上,且最好有 DBA 協助評估與監控。但在現代化的雲端環境或 SSD 設備下,這些成本通常是值得投資的。
延伸探討:關於全域攔截與隱形機制的思辨
在討論 RCSI 與 TagWith 的過程中,不可避免地會遇到一個爭議:到底該不該用框架做全域處理?
對於 AI 主持人認為「不該從全域著手」、「不該進行隱形的黑魔法」,我則抱持不同的看法。
不論是 Entity Framework 還是 ASP.NET Core,框架本身就做了很多類似的事情。當有人批評「從攔截器改 SQL 會讓產生的 SQL 與開發者預期不同」時,難道開發者真的覺得自己能完全掌握 EF 產生出來的每一句 SQL 嗎?
事實上,更多人可能只了解當中一部分的機制(包含我在內),甚至有人完全不知道框架背後幫忙處理了什麼(例如 Parameter Sniffing 的影響)。
舉例來說:
ASP.NET Core 的大小寫轉換: 早期 ASP.NET Framework 的 JSON 屬性名稱預設與 C# DTO 一致(PascalCase),這讓習慣 camelCase 的前端開發者感到彆扭。因此 ASP.NET Core 改為預設強制轉換為 camelCase。這就是一個典型的 框架全域處理 例子。
Model Validation: 過往我們需要在 Action 中手動判斷
ModelState.IsValid,但後來 ASP.NET Core Web API 甚至預設直接攔截驗證失敗的請求並回傳 400,完全不需要開發者寫一行程式碼。
所以,很多情況下從框架進行全域處理反而是正確的選擇。問題不在於「是否全域」,而在於哪種方式更能降低開發團隊的認知負荷 (Cognitive Load)。
認知負荷與預設行為的權衡
AsNoTracking() 也是一個很好的例子。它取出來的資料因為少了一些追蹤資訊,效能較佳。理論上我們會建議團隊在「沒有要異動的查詢」一律加上 AsNoTracking()。
但在實務上,我們很容易看到兩種混亂的情況:
- 該加沒加:導致後續要 Update 時失敗(因為被追蹤了)。
- 不該加卻加了:導致無法異動,結果沒搞清楚狀況的人去手動變更
EntityState硬要讓它可以異動,造成更混亂的程式碼。
與其依賴開發者的「自律」或「記憶力」,不如在架構上做切分:把 Query Service 和 Repository 分開。只要是從 Query Service 出來的資料,預設就帶有 AsNoTracking()。這樣團隊在使用 Query Service 時,完全不用思考「要不要加 NoTracking」,因為架構已經幫你做出了最安全的預設決定。
這就是我想強調的重點:看哪個情境更常用、更容易辨別情境、判斷錯誤造成的風險最低。
我們可以從三個維度來評估「要不要做全域處理」:
哪個情境更常用? 這取決於專案特性。如果是報表系統,可能 90% 的查詢都該加
NOLOCK;如果是交易系統,可能 90% 都不該加。哪個更容易辨別情境? 有時候,全域預設開啟反而更好辨別。
- 預設不加 開發者每次寫 Query 都要判斷「這個查詢可以髒讀嗎?」這意味著必須深刻理解業務邏輯,判斷成本較高。
- 預設加 開發者只需判斷「這個查詢絕對不能髒讀嗎(例如扣庫存)?」相對來說,這種高風險操作更容易識別,誤判門檻較低。
判斷錯誤造成的風險最低?
- 預設不加:通常風險最低(這點大家都同意)。
- 預設加 + 誤判:資料不一致、交易錯誤(嚴重)。
- 預設不加 + 誤判:查詢變慢、可能鎖住 (Deadlock)(通常可忍受)。
雖然從第 3 點來看,「預設不加」在大部分情況下風險最低,也是最保守的選擇。但如果結合第 1 點(業務特性)與第 2 點(辨別難度)來看,某些專案或許「預設全域處理」反而能大幅降低團隊的開發負擔與心智成本。
對於要使用「顯式宣告」還是「隱式黑魔法」,重點不在於技術手段本身,而在於它是否能有效降低團隊的認知負荷,並符合專案的風險容忍度。脫離具體情境去談 Anti-Pattern,往往只是另一種形式的教條主義。
異動歷程
- 2026-02-05 初版文件建立。
