使用 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...

相关推荐
北冥you鱼16 小时前
Go 语言读取链上数据:从基础到实战
开发语言·后端·golang
程序员cxuan16 小时前
Claude Code 为了封禁中国用户,竟然在代码里下毒
人工智能·后端·程序员
郡杰16 小时前
Git基础与开发平台搭建
git·后端
ServBay17 小时前
MCP (Model Context Protocol) 深度指南,协议原理、Python 实现与生产级安全实践
后端·ai编程·mcp
道友可好17 小时前
Claude Code 泄露源码里的 89 个秘密
前端·人工智能·后端
用户83562907805118 小时前
使用 Python 保护和取消保护 Excel 工作表和工作簿
后端·python
Java面试题总结18 小时前
CubeSandbox 实战:从零部署到快照/克隆/回滚全体验
后端
Csvn18 小时前
Python 开发技巧:函数式编程与 lambda 进阶
后端
考虑考虑18 小时前
nginx配置ssl
运维·后端·nginx
uhakadotcom18 小时前
Python 中,`None` 不等于 `False`导致的问题
后端·面试·github