應用程式把資料放進記憶體快取後,常會誤以為每次讀取都能得到獨立副本。然而,若快取值是 C# 參考型別,讀取端通常拿到的是同一份物件圖。任何看似只影響本次查詢的屬性指派、集合刪減或巢狀元素修改,都可能污染後續請求所見的資料。真正可靠的設計不是提醒開發者「小心別改」,而是建立清楚的所有權、投影與序列化邊界。

本文以 IMemoryCache、投影副本與 System.Text.Json 為核心,說明如何辨識共享參考、選擇隔離策略,並設計能確實抓出污染的回歸測試。文中的驗證環境固定為 net9.0 與 Microsoft.Extensions.Caching.Memory 9.0.8,只討論功能正確性,不推導或宣稱任何效能數字。

問題本質:快取命中不等於取得副本

IMemoryCache 儲存參考型別時,重新讀取通常仍指向同一個實例;讀取只是取得參考,不會自動複製物件或其集合。

C# 的 class、陣列、委派與介面等屬於 reference type。變數承載的是物件參考,因此兩個變數可以指向相同物件;透過其中一個參考修改狀態,另一個參考隨即能觀察到變化。官方說明可參考 C# reference types。

IMemoryCache 提供建立、讀取與移除快取項目的介面,但其契約並未承諾在 TryGetValue 時深層複製物件。官方 API 文件見 IMemoryCache。因此,下列程式碼中的 first 與 second 很可能是同一個參考:

using Microsoft.Extensions.Caching.Memory;

using var cache = new MemoryCache(new MemoryCacheOptions());
var cached = new Catalog
{
    Items = [new Item("A"), new Item("B"), new Item("C")]
};

cache.Set("catalog", cached);
var first = cache.Get("catalog")!;
var second = cache.Get("catalog")!;

Console.WriteLine(ReferenceEquals(first, second)); // True
first.Items.RemoveAt(0);
Console.WriteLine(second.Items.Count);             // 已受影響

public sealed class Catalog
{
    public List Items { get; set; } = [];
}

public sealed record Item(string Code);

危險點不只在 cached.Items = filtered 這類明顯指派。Remove、Clear、Sort、索引賦值,以及修改 cached.Items[0].Status,都屬於就地變更。即使外層屬性沒有重新指派,共用集合或巢狀物件仍已被改寫。

污染如何跨越一次查詢

當過濾邏輯直接改寫快取物件,第一次查詢可能回傳正確子集合,卻把完整資料永久縮減,讓後續無條件查詢也取得殘缺內容。

這類錯誤之所以難察覺,是因為單次測試通常只檢查「過濾結果是否正確」。若輸入條件要求一筆資料,而程式把共用清單裁成一筆,當次斷言仍會通過;真正的失敗要到下一個讀取者要求完整資料時才出現。資料污染也可能與請求順序有關,使問題在測試、開發與正式流量下呈現不同表象。

典型反模式如下:

var cached = memoryCache.Get("catalog")!;

if (request.Codes is { Count: > 0 })
{
    cached.Items = cached.Items
        .Where(x => request.Codes.Contains(x.Code))
        .ToList(); // 新 List,卻仍指派回共用 Catalog
}

return cached;

Where(...).ToList() 的確建立了新清單,但問題不在右側是否為新清單,而在左側仍是快取中的共用 Catalog。把新清單指派回共用物件,等同修改快取內容。若元素本身可變,即使不回寫清單,只把同一批元素放入新清單後再修改元素,也仍可能穿透到原始物件圖。

判斷原則可以簡化為:從快取讀出的物件預設視為唯讀快照;任何呼叫端專屬的篩選、排序、遮蔽或狀態標記,都必須作用於隔離後的結果,而不是快取擁有的物件圖。

投影隔離:只建立呼叫端需要的結果

投影隔離會建立新的外層物件與集合,讓查詢條件只改變回傳結果,不改動快取所持有的來源物件,是最直接的預設解法。

安全寫法是從快取資料投影到輸出模型。投影同時表達資料所有權:快取模型由快取管理,查詢結果由本次呼叫管理。若輸出只需要少數欄位,更應使用專用 DTO,避免整張物件圖在不同層之間流動。

public sealed record ItemView(string Code, string DisplayName);

public sealed class CatalogView
{
    public List Items { get; init; } = [];
}

public static CatalogView QueryCatalog(
    Catalog cached,
    IReadOnlyCollection? codes)
{
    IEnumerable query = cached.Items;

    if (codes is { Count: > 0 })
    {
        query = query.Where(x => codes.Contains(x.Code));
    }

    return new CatalogView
    {
        Items = query
            .Select(x => new ItemView(x.Code, x.DisplayName))
            .ToList()
    };
}

建議依下列順序落實:

  1. 先界定快取擁有權。 將從快取取得的根物件、集合與巢狀元素都視為不可變來源,不把它們直接交給會修改資料的程式。
  2. 再組合所有條件。 只有在條件集合非 null 且含有元素時才套用限制;多個獨立條件通常應逐步疊加,而不是每次都從原始集合重新查詢。
  3. 最後投影新物件圖。 建立新的輸出根物件、新集合與必要的新元素;回傳前不要把結果指派回快取來源。
  4. 補上跨次讀取測試。 先執行帶條件查詢,再重新從快取讀取完整資料,確認來源數量與內容沒有改變。

投影不代表每個欄位都必須盲目複製。不可變值、字串與 immutable record 可以安全重用;可變 class、List、Dictionary、陣列及其可變元素則要依後續操作決定是否建立新實例。關鍵不是語法上的 new 數量,而是回傳物件圖中是否仍存在可被修改、且同時由快取持有的節點。

淺拷貝與深層集合的隱藏風險

淺拷貝只切斷外層物件參考,內部集合與集合元素仍可能共用;只要後續會修改巢狀節點,污染就可能從內層穿透回快取。

例如 MemberwiseClone、record 的 with,或 new List(source) 都可能只複製某一層。新清單與舊清單雖然不是同一個 List,其中的 T 若為可變 class,兩邊仍指向相同元素。修改元素屬性時,快取內容照樣改變。

var copiedList = new List(cached.Items);

Console.WriteLine(ReferenceEquals(copiedList, cached.Items));       // False
Console.WriteLine(ReferenceEquals(copiedList[0], cached.Items[0])); // True

copiedList[0].DisplayName = "Changed"; // 快取中的元素也被修改

因此,複製策略應配合變更深度:

  • 只枚舉、不修改:可直接查詢,但不要把可變實體洩漏給未知呼叫端。
  • 只調整集合成員:建立新集合通常足夠,前提是元素不會被修改。
  • 會修改元素屬性:元素也要投影成新物件。
  • 會修改多層導覽或不確定消費端行為:需要完整深拷貝、不可變模型,或明確的序列化邊界。

最容易漏掉的是 Dictionary>、樹狀節點與雙向關聯。外層字典複製後,值仍可能是原清單;清單複製後,節點仍可能共用;節點複製後,其子節點又可能共用。審查時應沿著「哪些節點會被修改」逐層追蹤,而不是看到 ToList() 就認定已隔離。

JSON round-trip:建立明確的序列化邊界

JSON round-trip 透過序列化再反序列化建立另一張物件圖,適合需要通用深層隔離且模型可完整序列化的邊界,但必須接受其語意限制。

使用 System.Text.Json 時,可以把快取值序列化為 JSON,再反序列化成新的實例。官方操作方式與選項說明見 How to serialize and deserialize JSON in .NET。

using System.Text.Json;

public static T DeepClone(T source, JsonSerializerOptions? options = null)
{
    ArgumentNullException.ThrowIfNull(source);

    var json = JsonSerializer.Serialize(source, options);
    return JsonSerializer.Deserialize(json, options)
        ?? throw new JsonException("反序列化未產生物件。");
}

var isolated = DeepClone(cached);
isolated.Items[0].DisplayName = "Changed";
// cached.Items[0].DisplayName 維持原值

這個方法提供的是「依 JSON 表示法重建」的深層隔離,不保證保留所有執行期語意。未公開成員、被忽略的屬性、介面或抽象型別、多型資訊、循環參考、唯讀集合、特殊建構流程與自訂轉換器,都可能影響結果。若物件圖含有共享子節點,反序列化後的參考拓撲也未必與來源完全相同。

因此,JSON round-trip 應被視為可測試的架構邊界,而不是萬用複製魔法。穩定且高頻的核心路徑可優先採用明確投影或不可變模型;跨層傳輸、背景工作輸入或需要徹底隔離的通用模型,則可評估序列化邊界。選擇時應以正確性、模型契約與維護成本為依據,不應在沒有 benchmark 的情況下宣稱速度優劣。

null、空集合與多條件語意

null 與空集合若都代表未指定條件,就只在集合含有元素時套用過濾;多個條件則應明確定義為交集、聯集或其他規則。

常見錯誤是只判斷 codes != null。當呼叫端傳入空集合,Where(x => codes.Contains(x.Code)) 會把所有資料排除,形成非預期的空結果。若 API 契約將 null 與空集合都定義為「不設限」,可使用模式 codes is { Count: > 0 },或建立語意清楚的 HasAny 輔助方法。

另一個風險是多個條件彼此覆寫:第一段從來源依代碼過濾,第二段又重新從原始來源依狀態過濾,最後只剩第二個條件生效。安全方式是持續在同一個 IEnumerable 或 IQueryable 上疊加條件。若需求是 AND,每個有效條件都呼叫一次 Where;若需求是 OR,則應明確組合述詞,而不是依賴指派順序。

輸入條件也應在 API 邊界正規化。例如把空白字串移除、去除重複值,並使用符合業務需求的字串比較器。這些操作應施加於輸入副本或新集合,不應為了方便而排序、清除或改寫呼叫端提供的集合。

驗證與重現

有效的重現必須同時證明共享參考確實存在、就地修改能被重讀看見,以及兩種隔離方法都能保住原始快取內容。

固定驗證環境為 net9.0 與 Microsoft.Extensions.Caching.Memory 9.0.8。測試資料應使用少量通用物件,避免依賴資料庫、外部服務或私有環境。驗證流程如下:

  1. 建立含多個可變元素的根物件,放入 IMemoryCache,連續讀取兩次並檢查參考相等。
  2. 對第一次讀取結果做就地集合變更,再次讀取,確認變更可見,以證明污染路徑。
  3. 重新建立乾淨快取,透過投影取得條件結果,確認回傳根物件不是同一參考,且快取集合數量不變。
  4. 重新建立乾淨快取,以 JSON round-trip 產生副本,確認物件圖不同;修改副本後,再確認快取內容完整。

真實執行結果共六項,全部為 PASS:

PASS  IMemoryCache re-read ReferenceEquals true
PASS  in-place mutation visible
PASS  projection different reference
PASS  cached count intact
PASS  JSON clone different graph
PASS  mutating clone leaves cache intact

回歸測試不應只驗證「投影後有幾筆」。更重要的是測試時間順序:帶條件查詢完成後,再進行一次不帶條件的快取重讀,並核對原始集合與巢狀元素。若程式可能排序、遮蔽欄位或設定狀態,還要分別斷言順序、欄位值與深層集合,才能攔住只污染部分物件圖的變形錯誤。

測試也應覆蓋 null、空集合、單一條件、多條件與找不到資料等情境。若採用 JSON round-trip,需加入多型、特殊轉換器、循環參考或唯讀成員等模型特性測試;若採用手寫投影,則要在模型新增可變欄位時讓測試提醒維護者更新映射。

設計與程式審查 checklist

將快取值視為唯讀來源,再以投影、不可變模型或序列化建立所有權邊界,可以把依賴提醒的慣例轉成可驗證的工程規則。

提交程式前可逐項檢查:

  • 從 IMemoryCache 取出的值是否可能被多個呼叫端共享?
  • 是否存在屬性指派、Remove、Clear、Sort、索引賦值或巢狀元素修改?
  • ToList() 是否只複製集合,卻仍共用可變元素?
  • 回傳模型是否投影成新的根物件、集合與必要元素?
  • null 與空集合的條件語意是否已有明確契約?
  • 多個條件是否依需求正確疊加,而非由最後一次指派覆寫?
  • JSON round-trip 是否涵蓋模型的多型、循環參考與自訂轉換需求?
  • 測試是否先執行帶條件查詢,再重讀未過濾快取?
  • 是否檢查 ReferenceEquals、集合數量與巢狀值,而非只檢查表面結果?
  • 對外文件是否避免把功能驗證誤寫成效能結論?

共用快取的核心風險不是快取 API 本身,而是物件所有權模糊。IMemoryCache 讓參考可被重複讀取,C# reference type 讓修改可沿共享物件圖傳播;投影隔離能建立精準輸出,JSON round-trip 能建立明確序列化邊界。只要測試同時涵蓋污染路徑與隔離後的重讀,就能把這類順序相依問題轉成穩定的回歸保護。本文列出的六項結果屬於功能驗證,不是 benchmark,也不代表任何效能比較。