在手写 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
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 缓存的核心设计就四句话:
- record struct CacheKey(connStr, cmdText) — 值类型组合键,零 GC 分配,编译器自动生成高质量 Equals/GetHashCode
- 缓存命中必须 Clone — DbParameter、DataTable、List
都是可变对象,不克隆一定会被污染 - ConnectionString 进键 — 多数据库环境下的硬性要求,防止串库
- static 缓存 + 实例 Database — 全进程共享一套缓存,最大化命中率
这四句话单独拿出来可能都不算复杂,但放在一起就构成了一个生产级 ORM 的缓存基础设施。SwitchData 项目上线 5 年多,这套方案在 SQL Server 和 MySQL 上都稳定运行,没有因为缓存出过一次线上问题。
适用场景:手写 ORM、数据库抽象层、多环境数据访问封装。如果你的项目已经用 EF Core / Dapper,那这些底层细节框架已经帮你做了;但如果你想深入理解 ORM 内部的缓存原理,或者需要在无 ORM 环境下手写 ADO.NET 抽象层,这套方案可以直接抄过去用。