1. 概述
CacheHelper
是一个基于 SQLite 的静态缓存工具类,旨在为 .NET 应用程序提供一个简单、高效、持久化且线程安全的缓存解决方案。它将缓存数据存储在应用程序根目录下的 cache.db
文件中,这意味着即使应用程序重启,缓存数据依然存在(只要未过期)。
该助手类封装了常见的缓存操作,并内置了绝对过期 和滑动过期两种策略,通过"Get-Or-Set"模式极大地简化了从数据源(如数据库、API)获取并缓存数据的业务逻辑。
2. 安装与环境准备 (Prerequisites)
要使 CacheHelper
类正常工作,您必须在您的项目中安装以下两个核心的 NuGet 包。
您可以使用 .NET CLI 命令来安装它们:
bash
# 1. 用于操作 SQLite 数据库
dotnet add package Microsoft.Data.Sqlite
# 2. 用于高效的对象序列化/反序列化
dotnet add package MessagePack
或者通过 Visual Studio 的 NuGet 包管理器搜索并安装 Microsoft.Data.Sqlite
和 MessagePack
。
封装好的方法如下:
csharp
using Microsoft.Data.Sqlite;
using System;
using System.IO;
using System.Threading.Tasks;
using System.Collections.Concurrent;
using pei_repspark_admin_webapi.Entities.Constants;
using MessagePack.Resolvers;
using MessagePack; // 请确保您的常量命名空间正确
namespace YOU_PROJECT.Utils
{
public static class CacheHelper
{
private static readonly string _dbPath;
private static readonly string _connectionString;
private static readonly MessagePackSerializerOptions _serializerOptions = ContractlessStandardResolver.Options;
private static readonly int default_limit = CacheConstants.FILTER_LIMIT;
// 用于线程安全的锁管理器,确保每个 key 都有一个独立的锁对象
private static readonly ConcurrentDictionary<string, object> _locks = new ConcurrentDictionary<string, object>();
/// <summary>
/// 静态构造函数,在类第一次被访问时自动运行一次,用于初始化数据库。
/// </summary>
static CacheHelper()
{
// 将数据库文件放在应用程序的根目录下,确保路径一致性
_dbPath = Path.Combine(AppContext.BaseDirectory, "cache.db");
_connectionString = $"Data Source={_dbPath}";
InitializeDatabase();
}
/// <summary>
/// 初始化数据库:如果数据库文件或表不存在,则创建它们。
/// </summary>
private static void InitializeDatabase()
{
try
{
using var connection = new SqliteConnection(_connectionString);
connection.Open();
// 创建一个可重用的 command 对象
var command = connection.CreateCommand();
// 1. 执行 PRAGMA 指令以优化并发性能
command.CommandText = "PRAGMA journal_mode=WAL;";
command.ExecuteNonQuery();
// 2. 重用同一个 command 对象,执行 CREATE TABLE 指令
command.CommandText = @"
CREATE TABLE IF NOT EXISTS CacheStore (
Key TEXT NOT NULL PRIMARY KEY,
Value BLOB NOT NULL,
InsertionTimeUtc INTEGER NOT NULL,
ExpirationTimeUtc INTEGER NOT NULL,
SlidingExpirationMinutes INTEGER NOT NULL
);";
command.ExecuteNonQuery();
}
catch (Exception ex)
{
// 如果数据库初始化失败,这是个严重问题,需要记录下来
// 在生产环境中,应使用专业的日志库(如 Serilog, NLog)
($"[CACHE CRITICAL] Database initialization failed. Error: {ex.Message}").LogErr();
// 抛出异常,因为如果数据库无法初始化,整个缓存服务都无法工作
throw;
}
}
#region Public Cache Modification Methods (Add, Clean)
public static void AddCacheWithAbsolute(string key, object value, int minute)
{
Set(key, value, TimeSpan.FromMinutes(minute), isSliding: false);
}
public static void AddCacheWithRelative(string key, object value, int minute)
{
Set(key, value, TimeSpan.FromMinutes(minute), isSliding: true);
}
public static void AddCache(string key, object value)
{
// 对于永久缓存,我们设置一个极大的过期时间
var longLivedTimeSpan = TimeSpan.FromDays(30); // 30 days
Set(key, value, longLivedTimeSpan, isSliding: false);
}
public static void CleanCache(string key)
{
try
{
using var connection = new SqliteConnection(_connectionString);
connection.Open();
var command = connection.CreateCommand();
command.CommandText = "DELETE FROM CacheStore WHERE Key = $key;";
command.Parameters.AddWithValue("$key", key);
command.ExecuteNonQuery();
}
catch (Exception ex)
{
($"[CACHE ERROR] Failed to clean cache for key '{key}'. Error: {ex.Message}").LogErr();
}
}
#endregion
#region Public Cache Retrieval Methods (Get, GetOrQuery)
public static object? GetCache(string key)
{
var (found, value) = TryGetCache<object>(key);
return found ? value : null;
}
public static T GetCacheOrQuery<T>(string key, Func<T> myFunc)
{
return GetCacheOrQuery<T>(key, default_limit, myFunc);
}
public static T GetCacheOrQuery<T>(string key, int minute, Func<T> myFunc)
{
return GetOrSet(key, minute, () => myFunc());
}
public static r GetCacheOrQuery<p, r>(string key, Func<p, r> myFunc, p param1)
{
return GetCacheOrQuery<p, r>(key, default_limit, myFunc, param1);
}
public static r GetCacheOrQuery<p, r>(string key, int minter, Func<p, r> myFunc, p param1)
{
return GetOrSet(key, minter, () => myFunc(param1));
}
// --- 为了简洁,这里省略了剩余的 GetCacheOrQuery 重载 ---
// --- 您可以按照下面的 `GetOrSet` 模式轻松地实现它们 ---
// 示例:
public static r GetCacheOrQuery<p1, p2, r>(string key, int minter, Func<p1, p2, r> myFunc, p1 param1, p2 param2)
{
return GetOrSet(key, minter, () => myFunc(param1, param2));
}
public static r GetCacheOrQuery<p1, p2, r>(string key, Func<p1, p2, r> myFunc, p1 param1, p2 param2)
{
return GetCacheOrQuery(key, default_limit, myFunc, param1, param2);
}
// ... 请为其他所有重载方法应用相同的模式 ...
#endregion
#region Core Logic (Private Methods)
/// <summary>
/// 核心的 "Get-Or-Set" 方法,实现了双重检查锁定以确保线程安全。
/// </summary>
private static T GetOrSet<T>(string key, int minute, Func<T> queryFunc)
{
// 第一次检查(在锁之外),这是为了在缓存命中的情况下获得最高性能
var (found, value) = TryGetCache<T>(key);
if (found)
{
Console.WriteLine($"Get [{key}] cache, survival time is [{minute}] minutes");
return value!;
}
// 获取或为当前 key 创建一个唯一的锁对象
var lockObject = _locks.GetOrAdd(key, k => new object());
// 进入锁代码块,确保同一时间只有一个线程能为这个 key 生成缓存
lock (lockObject)
{
// 第二次检查(在锁之内),防止在等待锁的过程中,其他线程已经生成了缓存
(found, value) = TryGetCache<T>(key);
if (found)
{
return value!;
}
// 执行昂贵的数据查询操作
var result = queryFunc();
// 将查询结果存入缓存
if (result != null)
{
Set(key, result, TimeSpan.FromMinutes(minute), isSliding: false);
}
Console.WriteLine($"Added [{key}] cache, survival time is [{minute}] minutes");
return result;
}
}
/// <summary>
/// 统一的缓存写入方法。
/// </summary>
private static void Set(string key, object value, TimeSpan expiration, bool isSliding)
{
if (value == null) return;
try
{
var serializedValue = MessagePackSerializer.Serialize(value, _serializerOptions);
var now = DateTime.UtcNow;
using var connection = new SqliteConnection(_connectionString);
connection.Open();
var command = connection.CreateCommand();
command.CommandText = @"
INSERT OR REPLACE INTO CacheStore (Key, Value, InsertionTimeUtc, ExpirationTimeUtc, SlidingExpirationMinutes)
VALUES ($key, $value, $insertion, $expiration, $sliding);";
command.Parameters.AddWithValue("$key", key);
command.Parameters.AddWithValue("$value", serializedValue);
command.Parameters.AddWithValue("$insertion", now.Ticks);
command.Parameters.AddWithValue("$expiration", now.Add(expiration).Ticks);
command.Parameters.AddWithValue("$sliding", isSliding ? expiration.TotalMinutes : 0);
command.ExecuteNonQuery();
}
catch (Exception ex)
{
($"[CACHE ERROR] Failed to set cache for key '{key}'. Error: {ex.Message}").LogErr();
// 吞掉异常,保证主程序继续运行
}
}
/// <summary>
/// 尝试从缓存中获取数据,并处理滑动过期的更新逻辑。
/// </summary>
private static (bool found, T? value) TryGetCache<T>(string key)
{
try
{
using var connection = new SqliteConnection(_connectionString);
connection.Open();
var command = connection.CreateCommand();
command.CommandText = @"
SELECT Value, SlidingExpirationMinutes FROM CacheStore
WHERE Key = $key AND ExpirationTimeUtc > $now;";
command.Parameters.AddWithValue("$key", key);
command.Parameters.AddWithValue("$now", DateTime.UtcNow.Ticks);
using var reader = command.ExecuteReader();
if (reader.Read())
{
var blob = reader.GetFieldValue<byte[]>(0);
var slidingMinutes = reader.GetInt64(1);
// 如果是滑动过期项,则更新其过期时间
if (slidingMinutes > 0)
{
try
{
var updateCmd = connection.CreateCommand();
updateCmd.CommandText = "UPDATE CacheStore SET ExpirationTimeUtc = $newExpiration WHERE Key = $key;";
updateCmd.Parameters.AddWithValue("$key", key);
updateCmd.Parameters.AddWithValue("$newExpiration", DateTime.UtcNow.AddMinutes(slidingMinutes).Ticks);
updateCmd.ExecuteNonQuery();
}
catch (Exception updateEx)
{
// 滑动过期更新失败不是致命错误,只记录警告
($"[CACHE WARNING] Failed to update sliding expiration for key '{key}'. Error: {updateEx.Message}").LogErr();
}
}
var deserializedValue = MessagePackSerializer.Deserialize<T>(blob, _serializerOptions);
return (true, deserializedValue);
}
}
catch (Exception ex)
{
($"[CACHE ERROR] Failed to get cache for key '{key}'. Error: {ex.Message}").LogErr();
}
// 如果发生任何错误或未找到,都返回"未命中"
return (false, default);
}
#endregion
}
}
3. 公共 API 参考 (封装方法说明)
这是与 CacheHelper
交互的公共方法列表。
3.1 核心模式:获取或查询 (Get-Or-Set)
这是最推荐的使用方式。它将"检查缓存、执行查询、设置缓存"的逻辑封装为一步,确保了代码的简洁和线程安全。
GetCacheOrQuery<...>(...)
-
描述 : 尝试根据
key
从缓存中获取数据。如果缓存存在且未过期,则直接返回缓存数据;否则,执行您提供的查询方法 (myFunc
) 来获取最新数据,然后将结果存入缓存,并最终返回该结果。 -
重载 (Overloads): 该方法提供多个重载版本,以支持无参、单参数、双参数等不同签名的查询方法。
-
参数:
string key
: 缓存的唯一标识符。int minute
(可选): 缓存的有效期(分钟)。如果未提供,将使用一个默认值(例如60分钟)。Func<...> myFunc
: 一个委托或 Lambda 表达式。当缓存未命中时,此函数将被调用以获取数据。p1, p2, ...
(可选): 传递给myFunc
的参数。
-
示例:
csharp// 示例1: 无参数的查询 string allProductsKey = "products:all"; var products = CacheHelper.GetCacheOrQuery(allProductsKey, 30, () => { // 这段代码只会在缓存未命中时执行 Console.WriteLine("从数据库获取所有产品..."); return database.GetAllProducts(); }); // 示例2: 带一个参数的查询 int userId = 123; string userKey = $"user:{userId}"; var user = CacheHelper.GetCacheOrQuery(userKey, 60, (id) => { // 这段代码只会在缓存未命中时执行 Console.WriteLine($"从数据库获取ID为 {id} 的用户..."); return database.GetUserById(id); }, userId);
3.2 直接缓存管理
这些方法允许您更直接地控制缓存的添加和更新。
AddCache(string key, object value)
- 描述: 添加一个"永久"缓存(内部设置为10年有效期)。适用于极少变动的基础数据。
- 示例 :
CacheHelper.AddCache("global_settings", siteSettings);
AddCacheWithAbsolute(string key, object value, int minute)
- 描述 : 添加一个具有绝对过期 策略的缓存。缓存将在
minute
分钟后过期,无论期间是否被访问。 - 示例 :
CacheHelper.AddCacheWithAbsolute("daily_report", reportData, 1440); // 缓存24小时
AddCacheWithRelative(string key, object value, int minute)
- 描述 : 添加一个具有滑动过期 策略的缓存。如果在
minute
分钟内没有被访问,缓存将过期。每次访问都会重置其生命周期。常用于用户会话等场景。 - 示例 :
CacheHelper.AddCacheWithRelative("user_session:xyz", sessionData, 20); // 20分钟不活动则过期
3.3 缓存移除
CleanCache(string key)
-
描述: 从缓存中手动移除一个指定的项。这在底层数据更新后,需要强制让缓存失效时非常有用。
-
示例 :
csharp// 更新了用户ID为123的个人信息 database.UpdateUser(updatedUser); // 立即清除旧的缓存,确保下次请求获取的是最新数据 CacheHelper.CleanCache("user:123");
4. 核心特性
- 持久化存储: 使用 SQLite 文件数据库,缓存内容在应用程序重启后依然保留。
- 线程安全: 采用双重检查锁定(Double-Checked Locking)模式和基于Key的锁,有效防止在高并发场景下的"缓存击穿"问题。
- 高效序列化 : 使用
MessagePack
对缓存对象进行二进制序列化,相比 JSON 序列化,性能更高,占用空间更小。 - 两种过期策略 :
- 绝对过期 (Absolute Expiration): 缓存项在设定的固定时间点后失效。
- 滑动过期 (Sliding Expiration): 缓存项在一段时间内未被访问则失效;每次访问都会重置其生命周期。
- 简洁的 API : 提供了简单易用的
GetCacheOrQuery
方法,将"检查缓存、获取数据、存入缓存"的逻辑封装为原子操作。
5. 核心概念深入解析
5.1 数据库结构 (cache.db
)
CacheHelper
会自动创建名为 CacheStore
的表,其结构如下:
字段名 | 类型 | 描述 |
---|---|---|
Key |
TEXT |
主键。缓存项的唯一标识符。 |
Value |
BLOB |
存储经 MessagePack 序列化后的二进制数据。 |
InsertionTimeUtc |
INTEGER |
缓存项的创建时间 (UTC Ticks)。 |
ExpirationTimeUtc |
INTEGER |
缓存项的过期时间点 (UTC Ticks)。这是判断缓存是否有效的核心字段。 |
SlidingExpirationMinutes |
INTEGER |
滑动过期策略的关键 。0 表示绝对过期;>0 的值表示这是一个滑动过期的项,其值为滑动的分钟数。 |
5.2 滑动过期 (SlidingExpirationMinutes
) 的工作原理
SlidingExpirationMinutes
字段的设计非常巧妙,它同时扮演了**"标记"和"时长"**两个角色。
-
设置缓存时:
- 调用
AddCacheWithAbsolute
时,SlidingExpirationMinutes
被设为0
。 - 调用
AddCacheWithRelative(key, value, 30)
时,SlidingExpirationMinutes
被设为30
。
- 调用
-
获取缓存时 (
TryGetCache
内部逻辑):- 系统首先检查
ExpirationTimeUtc
是否已过期。 - 如果未过期且成功读取数据,系统会检查
SlidingExpirationMinutes
字段的值。 - 如果值为
0
,则不执行任何额外操作。 - 如果值大于 0 (例如
30
),系统识别出这是一个滑动缓存项,会立即执行一个UPDATE
命令,将该项的ExpirationTimeUtc
更新为当前时间 + 30分钟
。
- 系统首先检查
这个"读取并续期"的原子操作,完美地实现了滑动过期的逻辑:只要你在它过期前访问它,它的生命就在不断延续。
6. 并发安全机制
在高并发环境下,多个线程可能同时请求同一个不存在的缓存项。如果没有锁定机制,这些线程会全部穿透缓存去执行昂贵的数据查询,这就是"缓存击穿"。
CacheHelper
通过 GetOrSet
方法中的 双重检查锁定模式 解决了这个问题:
- 第一次检查 (无锁) : 在进入
lock
之前快速检查缓存是否存在。对于绝大多数缓存命中的情况,可以无锁返回,性能极高。 - 获取Key专用锁 : 如果第一次检查未命中,系统会从一个
ConcurrentDictionary
中为当前key
获取一个专用的锁对象。这确保了对不同key
的请求不会互相阻塞。 - 第二次检查 (有锁): 在获得锁之后,再次检查缓存。这是为了防止在等待锁的过程中,已有其他线程完成了数据查询和缓存设置。
- 执行查询与设置: 只有当第二次检查仍然未命中时,当前线程才会去执行数据查询,并将结果写入缓存。
这个机制确保了对于任意一个key
,在同一时刻最多只有一个线程在执行数据源的查询操作。
7. 注意事项与最佳实践
- 缓存键 (Key) 的命名 : 缓存键应具有唯一性和良好的描述性。推荐使用如
object_type:id
的格式,例如user:123
或products:all
。 - 缓存失效 : 当底层数据发生变化时(例如,用户信息被修改),应主动调用
CacheHelper.CleanCache("user:123")
来清除旧缓存,以避免数据不一致。 - 可序列化对象 : 存入缓存的对象必须能被
MessagePack
序列化。绝大多数 POCO (Plain Old C# Object) 对象都没有问题。 - 异常处理 :
CacheHelper
内部已对数据库操作和序列化等步骤进行了try-catch
封装。缓存操作失败时会向控制台输出错误日志,但不会抛出异常中断主程序流程,保证了系统的稳定性。
8. 参考
.NET SQLite: juejin.cn/post/756095...
内存缓存: juejin.cn/post/728516...