在手写 ORM 或数据库抽象层时,缓存是绕不开的核心问题:同样的存储过程参数不应该每次都重新 DiscoverParameters,同样的查询 Schema 也不应该每次都 ExecuteReader(CommandBehavior.SchemaOnly)。

但缓存写不好反而会出问题——缓存键碰撞导致数据串库、缓存对象被外部修改导致参数错乱、多线程读写不加锁导致集合损坏——都是真实踩过的坑。

本文就从 SwitchData 项目中提取了一套生产环境跑了多年的 ORM 缓存方案,核心技巧只有三个:record struct 组合键 + ICloneable 防御性克隆 + ConcurrentDictionary 分层三级缓存。

graph TD
    A[Database 抽象基类] --> B[ParameterCache 参数缓存]
    A --> C[SchemaCache Schema缓存]
    A --> D[MetadataCache 元数据缓存]
    B --> E[CachingMechanism 底层容器]
    E --> F[(ConcurrentDictionary CacheKey → IDataParameter[])]
    C --> G[(ConcurrentDictionary CacheKey → DataTable[])]
    C --> H[(ConcurrentDictionary CacheKey → List<string>)]
    D --> I[(ConcurrentDictionary Type → TableMetadata)]
    
    style F fill:#4a90d9,stroke:#2c5f8a
    style G fill:#4a90d9,stroke:#2c5f8a
    style H fill:#4a90d9,stroke:#2c5f8a
    style I fill:#4a90d9,stroke:#2c5f8a

问题背景:为什么需要组合键?

假设你正在实现一个 Database 抽象基类,支持多数据库,业务代码这样调用:

// 业务代码 A
var cmdA = database.GetStoredProcCommand("sp_GetUser", userId);
database.ExecuteReader(cmdA);

// 业务代码 B(不同的数据库实例)
var cmdB = anotherDatabase.GetStoredProcCommand("sp_GetUser", userId);
anotherDatabase.ExecuteReader(cmdB);

关键问题:cmdA 和 cmdB 的 CommandText 完全相同(都是 “sp_GetUser”),但它们属于不同的数据库实例(不同的 ConnectionString)。如果缓存键只用 CommandText,A 实例 Discover 出来的参数会被错误地复用到 B 实例上,轻则报参数类型不匹配,重则直接连不上数据库。

结论:缓存键必须包含 ConnectionString。

第一层:CacheKey 用 record struct 做组合键

代码位置:SwitchData.Common.Db.CachingMechanism

internal readonly record struct CacheKey(string ConnectionString, string CommandText);

就这一行,解决了以下几个问题:

为什么用 record struct 而不是普通 struct?

特性 普通 struct record struct class
内存分配 栈上(零 GC) 栈上(零 GC) 堆上(一次 GC)
自动 Equals/GetHashCode 需要手写 编译器生成 默认引用比较
不可变性 可写 readonly 自动不可变 可写(需手动)
解构支持 不支持 var (cs, ct) = key 不支持

编译器为 record struct 自动生成的 Equals 和 GetHashCode 是逐字段比较 + 组合哈希算法,恰好满足 ConcurrentDictionary 对键的要求——不需要手写任何比较逻辑。

为什么加 readonly?

readonly record struct 告诉编译器这个类型永远不会被修改,可以启用更多优化(比如不必要的防御性拷贝、方法内联等)。同时防止业务代码意外修改缓存键导致键值对无法被再次命中。

完整的 CacheKey 使用示例

internal class CachingMechanism
{
    // ConcurrentDictionary 的键类型直接是 record struct
    private readonly ConcurrentDictionary<CacheKey, IDataParameter[]> paramCache = new();

    public void AddParameterSetToCache(Database database, IDbCommand command, IDataParameter[] parameters)
    {
        var key = new CacheKey(database.ConnectionString, command.CommandText);
        paramCache[key] = parameters;
    }

    public IDataParameter[] GetCachedParameterSet(Database database, IDbCommand command)
    {
        var key = new CacheKey(database.ConnectionString, command.CommandText);
        return paramCache.TryGetValue(key, out var cachedParameters)
            ? CloneParameters(cachedParameters)
            : null;
    }
}

第二层:为什么参数缓存必须克隆?

这是一个容易忽视的细节。先看缓存读写的完整调用链:

internal class ParameterCache
{
    private readonly CachingMechanism cache = new();

    public void SetParameters(DbCommand command, Database database)
    {
        var parameters = cache.GetCachedParameterSet(database, command);
        if (parameters == null)
        {
            database.DiscoverParameters(command);
            parameters = CreateParameterCopy(command);
            cache.AddParameterSetToCache(database, command, parameters);
        }
        else
        {
            foreach (var p in parameters)
            {
                command.Parameters.Add(p);
            }
        }
    }
}

注意 CreateParameterCopy 和 CloneParameters 这两步。为什么不能直接把缓存里的 IDataParameter[] 加进去?

DbParameter 是可变对象

// DbParameter 有这些可写属性:
public DbType DbType { get; set; }
public object? Value { get; set; }     // 业务代码执行时会修改这个!
public ParameterDirection Direction { get; set; }

业务代码执行时一定会设置 Value:

cmd.Parameters[0].Value = userId;

如果我们直接复用缓存里的数组,那: 1. 第一次执行:参数 A.Value = “user_001” → 缓存被污染! 2. 第二次执行(不同用户):从缓存取出的参数 A.Value 还是 “user_001” → 严重的业务 Bug

CloneParameters 的实现

public static IDataParameter[] CloneParameters(IDataParameter[] originalParameters)
{
    var clonedParameters = new IDataParameter[originalParameters.Length];

    for (int i = 0; i < originalParameters.Length; i++)
    {
        // DbParameter 实现了 ICloneable
        clonedParameters[i] = (IDataParameter)((ICloneable)originalParameters[i]).Clone();
    }

    return clonedParameters;
}

每个 DbParameter(SqlParameter / OracleParameter / MySqlParameter)都实现了 ICloneable.Clone(),返回一个新实例。克隆出来的数组和里面每个元素都是全新的,业务代码修改 Value 不会影响缓存。

ICloneable 的历史遗留

ICloneable 在 .NET 社区名声不好(容易产生浅拷贝 vs 深拷贝混淆),但这里场景很单纯——我们知道 DbParameter.Clone() 返回的是深拷贝(独立的新实例,包含独立的属性值)。用它没问题。

第三层:SchemaCache 的克隆策略

Schema 缓存和参数缓存略有不同。DataTable 用于描述数据库表结构,它比 DbParameter[] 更重,也有自己的可变风险。

internal sealed class SchemaCache
{
    private readonly ConcurrentDictionary<CacheKey, DataTable[]> schemaCache = new();
    private readonly ConcurrentDictionary<CacheKey, List<string>> primaryKeyCache = new();

    public DataTable[] GetSchema(Database database, IDbCommand command)
    {
        var key = new CacheKey(database.ConnectionString, command.CommandText);
        if (schemaCache.TryGetValue(key, out var tables))
        {
            // DataTable.Clone() 只复制 Schema(列结构),不复制数据——正好是我们需要的
            var cloneTables = new DataTable[tables.Length];
            for (int i = 0; i < tables.Length; i++)
            {
                cloneTables[i] = tables[i].Clone();
            }
            return cloneTables;
        }
        return null;
    }

    public List<string> GetPrimaryKeys(Database database, string tableName)
    {
        var key = new CacheKey(database.ConnectionString, tableName);
        if (primaryKeyCache.TryGetValue(key, out var primaryKeys))
        {
            // List<string> 本身也需要克隆,因为调用方可能 Add/Remove
            return new List<string>(primaryKeys);
        }
        return null;
    }
}

不同的克隆方式对应不同的可变风险: - DataTable.Clone() — 克隆表结构(Columns、Constraints),不克隆数据行 - new List(primaryKeys) — 浅拷贝列表容器(元素是 string 不可变,所以安全) - CloneParameters — 深拷贝每个 DbParameter

ConcurrentDictionary vs 其他缓存

你可能会问:为什么不用 MemoryCache?为什么不用第三方缓存库?

方案 线程安全 零依赖 零分配键 多实例隔离
ConcurrentDictionary 自带 BCL record struct 每个缓存独立
IMemoryCache 需要加锁 有 object boxed 有
LazyCache 有 NuGet 有 有
Redis 分布式 额外服务 N/A 跨进程共享

这里的缓存只需要进程内 + 多线程安全,ConcurrentDictionary 是最轻量、最透明的选择。

注意 Database 基类里的缓存实例声明:

public abstract class Database
{
    // static:全局共享(进程内只有一套缓存)
    private static readonly ParameterCache parameterCache = new();
    private static readonly SchemaCache schemaCache = new();
    private static readonly MetadataCache metadataCache = new();

    // 实例字段:每个 Database 实例持有自己的连接字符串和 DbProviderFactory
    private readonly DbProviderFactory dbProviderFactory;
    private readonly string connectionString;
}

设计权衡:缓存是 static 的(全进程共享),CacheKey 里已经包含 ConnectionString 做隔离;而 Database 实例本身是 transient 的(用完就 Dispose)。这样业务代码无论 new 多少个 Database 实例,底层只维护一套缓存,命中几率最大化。

性能对比实测

做一个简单的 Benchmark:执行同一个存储过程 1000 次,对比有缓存和没缓存的耗时。

// 无缓存:每次都 DiscoverParameters + 执行
var sw1 = Stopwatch.StartNew();
for (int i = 0; i < 1000; i++)
{
    using var cmd = database.GetStoredProcCommand("sp_GetUser");
    database.DiscoverParameters(cmd);  // 打开连接查询 sys.parameters
    cmd.Parameters[0].Value = i;
    database.ExecuteNonQuery(cmd);
}
sw1.Stop();
// 约 12.3 秒

// 有缓存:第一次 Discover,后续直接 Clone + Add
var sw2 = Stopwatch.StartNew();
for (int i = 0; i < 1000; i++)
{
    using var cmd = database.GetStoredProcCommand("sp_GetUser");
    parameterCache.SetParameters(cmd, database);  // 第一次 Discover,后续 Clone
    cmd.Parameters[0].Value = i;
    database.ExecuteNonQuery(cmd);
}
sw2.Stop();
// 约 2.1 秒

950 次命中缓存,耗时下降 83%。对于高频使用的存储过程,这个收益非常可观。

踩过的坑

坑 1:CacheKey 只放了 CommandText

// 错误:没有 ConnectionString
internal readonly record struct CacheKey(string CommandText);

后果:开发环境和生产环境(或不同的数据库实例)的参数互相污染。SQL Server 的 @param 和 Oracle 的 :param 参数名格式也不同,会直接抛 IndexOutOfRangeException。

坑 2:直接复用 DbParameter[],不克隆

// 错误:直接返回缓存引用
public IDataParameter[] GetCachedParameterSet(...)
{
    return paramCache.TryGetValue(key, out var p) ? p : null;
}

后果:上一次执行设置的 Value 残留到下一次,业务查询的数据全是错的(而且这种 Bug 极难复现,因为依赖线程调度顺序)。

坑 3:Database 实例字段而非 static

// 错误:每个 Database 实例维护独立缓存
private readonly ParameterCache parameterCache = new();

后果:业务代码 new SqlDatabase(connStr) 和 new SqlDatabase(connStr) 创建的两个实例,明明连接串相同,却各自 DiscoverParameters 一次,缓存完全隔离,命中率为零。

坑 4:用 class 做 CacheKey

// 问题:堆分配 + 引用比较
internal sealed class CacheKey
{
    public string ConnectionString { get; init; }
    public string CommandText { get; init; }
}

后果:虽然手写了 Equals/GetHashCode 可以正确比较,但每次创建都是堆分配(一次 GC 压力),而 record struct 直接在栈上分配(零 GC)。高并发场景下这个差异会被放大。

总结

这套 ORM 缓存的核心设计就四句话:

  1. record struct CacheKey(connStr, cmdText) — 值类型组合键,零 GC 分配,编译器自动生成高质量 Equals/GetHashCode
  2. 缓存命中必须 Clone — DbParameter、DataTable、List 都是可变对象,不克隆一定会被污染
  3. ConnectionString 进键 — 多数据库环境下的硬性要求,防止串库
  4. static 缓存 + 实例 Database — 全进程共享一套缓存,最大化命中率

这四句话单独拿出来可能都不算复杂,但放在一起就构成了一个生产级 ORM 的缓存基础设施。SwitchData 项目上线 5 年多,这套方案在 SQL Server 和 MySQL 上都稳定运行,没有因为缓存出过一次线上问题。

适用场景:手写 ORM、数据库抽象层、多环境数据访问封装。如果你的项目已经用 EF Core / Dapper,那这些底层细节框架已经帮你做了;但如果你想深入理解 ORM 内部的缓存原理,或者需要在无 ORM 环境下手写 ADO.NET 抽象层,这套方案可以直接抄过去用。