使用 SQLite 实现 CacheHelper

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.SqliteMessagePack

封装好的方法如下:

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 字段的设计非常巧妙,它同时扮演了**"标记""时长"**两个角色。

  1. 设置缓存时:

    • 调用 AddCacheWithAbsolute 时,SlidingExpirationMinutes 被设为 0
    • 调用 AddCacheWithRelative(key, value, 30) 时,SlidingExpirationMinutes 被设为 30
  2. 获取缓存时 (TryGetCache 内部逻辑):

    • 系统首先检查 ExpirationTimeUtc 是否已过期。
    • 如果未过期且成功读取数据,系统会检查 SlidingExpirationMinutes 字段的值
    • 如果值为 0,则不执行任何额外操作。
    • 如果值大于 0 (例如 30),系统识别出这是一个滑动缓存项,会立即执行一个 UPDATE 命令,将该项的 ExpirationTimeUtc 更新为 当前时间 + 30分钟

这个"读取并续期"的原子操作,完美地实现了滑动过期的逻辑:只要你在它过期前访问它,它的生命就在不断延续。

6. 并发安全机制

在高并发环境下,多个线程可能同时请求同一个不存在的缓存项。如果没有锁定机制,这些线程会全部穿透缓存去执行昂贵的数据查询,这就是"缓存击穿"。

CacheHelper 通过 GetOrSet 方法中的 双重检查锁定模式 解决了这个问题:

  1. 第一次检查 (无锁) : 在进入 lock 之前快速检查缓存是否存在。对于绝大多数缓存命中的情况,可以无锁返回,性能极高。
  2. 获取Key专用锁 : 如果第一次检查未命中,系统会从一个 ConcurrentDictionary 中为当前 key 获取一个专用的锁对象。这确保了对不同 key 的请求不会互相阻塞。
  3. 第二次检查 (有锁): 在获得锁之后,再次检查缓存。这是为了防止在等待锁的过程中,已有其他线程完成了数据查询和缓存设置。
  4. 执行查询与设置: 只有当第二次检查仍然未命中时,当前线程才会去执行数据查询,并将结果写入缓存。

这个机制确保了对于任意一个key,在同一时刻最多只有一个线程在执行数据源的查询操作。

7. 注意事项与最佳实践

  • 缓存键 (Key) 的命名 : 缓存键应具有唯一性和良好的描述性。推荐使用如 object_type:id 的格式,例如 user:123products: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...

相关推荐
Lear5 小时前
SpringBoot启动流程分析
后端
Lear5 小时前
SpringMVC之拦截器(Interceptor)
后端
Lear5 小时前
SpringBoot之自动装配
后端
Lear5 小时前
SpringMVC之监听器(Listener)
后端
karry_k5 小时前
Redis如何搭建搭建一主多从?
后端·面试
用户5975653371105 小时前
【Java多线程与高并发系列】第2讲:核心概念扫盲:进程 vs. 线程
后端
Lear5 小时前
SpringBoot异步编程
后端
间彧5 小时前
Java LongAdder详解与应用实战
后端
Lear5 小时前
Spring MVC 拦截器与过滤器的区别及执行顺序
后端