Skip to content

缓存

本页将帮助你快速了解 Ape‑Volo‑Admin 的缓存使用与配置。

缓存实现一览

系统内置两种缓存实现:

  • DistributedCache:进程内内存缓存(本地缓存),部署单实例或开发调试场景下更轻量。
  • RedisCache:基于 Redis 的分布式缓存,适用于多实例/横向扩展与跨进程共享缓存。

选择开关:当 SystemOptions.UseRedisCache = true 时启用 RedisCache,否则使用 DistributedCache。

  • 系统同时使用两种缓存方案扩展了对SqlSugar的二级扩展,按需使用。

AOP 缓存 开关

  • 在配置中开启:Aop.Cache.Enabled = true(参见“配置文档 > AOP 配置”)。
  • 开启后,被缓存特性标记的方法会被拦截并自动进行缓存处理。

🧱 缓存实现与选择

  • 单机开发/功能调试:优先使用 DistributedCache,零依赖,成本低。
  • 多节点部署/需要共享会话或跨进程复用数据:使用 RedisCache,具备更强的可扩展性与可观测性。
  • 对一致性要求高的强一致写场景:建议缩短 TTL + 走“读穿透”策略,或仅在读多写少的接口开启缓存。

🔧 Redis 配置

配置文件设置

建议在 appsettings.Development.json 中配置 Redis 相关参数(生产环境在对应环境文件覆盖):

yaml
Redis:
  Name: "" # Redis 连接名称
  Host: "localhost" # Redis 主机地址
  Port: 6379 # Redis 端口
  Password: "" # Redis 密码
  Index: 0 # 默认数据库索引
  ConnectTimeout: 10000 # 连接超时时间(毫秒)
  SyncTimeout: 10000 # 同步操作超时(毫秒)
  KeepAlive: 20 # 保持活动时间(秒)
  ConnectRetry: 10 # 连接重试次数
  AbortOnConnectFail: true # 连接失败是否中止
  AllowAdmin: true # 是否允许执行管理命令
  SuspendTime: 10000 # 挂起时间(毫秒)
  IntervalTime: 0 # 调度间隔时间(毫秒)
  MaxQueueConsumption: 100 # 最大队列消费数量
  ShowLog: false # 是否显示日志

缓存注册

位置:Ape.Volo.Infrastructure/Extensions/CacheExtensions.cs

csharp
/// <summary>
/// 缓存启动器
/// </summary>
public static class CacheExtensions
{
public static void AddCacheService(this IServiceCollection services)
{
if (services.IsNull())
throw new ArgumentNullException(nameof(services));
services.AddDistributedMemoryCache(); //session 需要

if (App.GetOptions<SystemOptions>().UseRedisCache)
    {
        services.AddSingleton<ICache, RedisCache>();
        return;
    }

    services.AddSingleton<ICache, DistributedCache>();
}
}

说明:

  • 始终注册内存缓存(支持会话/短期缓存)。
  • 根据 UseRedisCache 选择 ICache 的具体实现,业务代码通过 ICache 抽象无感知切换。

🧩 键设计与过期策略

合理的 Key 设计与过期策略是缓存命中与稳定性的关键:

  • Key 规范:模块:资源:维度,如:dict:byName:{name};尽量使用短、稳定且可读的分隔符(:)。
  • KeyPrefix:通过 UseCacheAttribute.KeyPrefix 统一前缀,便于批量定位与清理。
  • TTL(过期时间):根据数据的“更新频率 × 容忍滞后度”确定;可引入抖动(随机 ±10%)避免雪崩。
  • 过期模型:
    • 绝对过期(默认):到时即失效,最简单;
    • 滑动过期:活跃数据持续续期,适合短期热点但需注意长期不失效的问题。

缓存 AOP

位置:Ape.Volo.Core/Aop/CacheAop.cs

csharp
/// <summary>
/// Redis特性  AOP拦截使用
/// </summary>
[AttributeUsage(AttributeTargets.Method)]
public class UseCacheAttribute : Attribute
{
    /// <summary>
    /// 过期时间(分钟)
    /// </summary>
    public int Expiration { get; set; } = 20;

    /// <summary>
    /// 缓存key前缀
    /// </summary>
    public string KeyPrefix { get; set; } = "";

    /// <summary>
    /// 缓存类型(默认绝对过期)
    /// </summary>
    public CacheExpireType CacheExpireType { get; set; } = CacheExpireType.Absolute;
}


/// <summary>
/// Redis缓存拦截器
/// </summary>
public class CacheAop : IInterceptor
{
    private readonly ICache _cache;

    public CacheAop(ICache cache)
    {
        _cache = cache;
    }

    public void Intercept(IInvocation invocation)
    {
        var method = invocation.MethodInvocationTarget ?? invocation.Method;
        if (method.ReturnType == typeof(void) || method.ReturnType == typeof(Task))
        {
            invocation.Proceed();
            return;
        }


        if (method.GetCustomAttributes(true).FirstOrDefault(x => x.GetType() == typeof(UseCacheAttribute)) is
            UseCacheAttribute useCacheAttribute)
        {
            var cacheKey = CreateCacheKey(invocation, useCacheAttribute);

            var cacheValue = _cache.Get<dynamic>(cacheKey);
            if (cacheValue != null)
            {
                dynamic result = cacheValue;
                invocation.ReturnValue = typeof(Task).IsAssignableFrom(method.ReturnType)
                    ? Task.FromResult(result)
                    : result;
                return;
            }


            invocation.Proceed();

            if (!string.IsNullOrWhiteSpace(cacheKey))
            {
                object response = null;

                var type = invocation.Method.ReturnType;
                if (typeof(Task).IsAssignableFrom(type))
                {
                    var resultProperty = type.GetProperty("Result");
                    if (resultProperty != null) response = resultProperty.GetValue(invocation.ReturnValue);
                }
                else
                {
                    response = invocation.ReturnValue;
                }

                if (response.IsNullOrEmpty())
                {
                    return;
                }
                _cache.Set(cacheKey, response, TimeSpan.FromMinutes(useCacheAttribute.Expiration),
                    null);
            }
        }
        else
        {
            invocation.Proceed();
        }
    }

    /// <summary>
    /// 构建Redis Key
    /// </summary>
    /// <param name="invocation"></param>
    /// <param name="useCacheAttribute"></param>
    /// <returns></returns>
    private static string CreateCacheKey(IInvocation invocation, UseCacheAttribute useCacheAttribute)
    {
        var typeName = invocation.TargetType.Name;
        var methodName = invocation.Method.Name;

        //支持多参数包括实体类,建议不要超过三个, 避免产生的redis key过长
        var methodArguments = invocation.Arguments.Select(GetArgumentValue).ToList();

        var key = useCacheAttribute.KeyPrefix.IsNullOrEmpty()
            ? $"{typeName}:{methodName}:"
            : useCacheAttribute.KeyPrefix;

        methodArguments.ForEach(arg => { key = $"{key}{arg}:"; });

        return key.TrimEnd(':');
    }

    /// <summary>
    /// 获取参数值类型值
    /// </summary>
    /// <param name="arg"></param>
    /// <returns></returns>
    private static string GetArgumentValue(object arg)
    {
        if (arg == null) return string.Empty;

        switch (arg)
        {
            case string:
            case long:
                return arg.ToString().ToMd5String16();
            case DateTime:
                return arg.ToString("yyyyMMddHHmmss").ToMd5String16();
            default:
            {
                if (arg.GetType().IsClass)
                {
                    return arg.ToJson().ToMd5String16();
                }

                return string.Empty;
            }
        }
    }
}

缓存使用

在需要缓存的方法上添加 [UseCache] 特性:

csharp
[UseCache(Expiration = 30, KeyPrefix = GlobalConstants.CachePrefix.LoadDictByName)]
public async Task<DictVo> QueryByNameAsync(string name)
{
        var dict = await Table.Where(x => x.Name == name).Includes(x => x.DictDetails.OrderBy(y => y.DictSort)
        .ToList()).FirstAsync();
        return App.Mapper.MapTo<DictVo>(dict);
}

失效与刷新示例

当写操作导致数据变化时,应及时失效相关 Key,避免读到旧数据:

csharp
// 更新字典后,移除相关缓存
await _cache.RemoveAsync($"{GlobalConstants.CachePrefix.LoadDictByName}:{name}");

// 或采用“写后刷新”的方式:更新成功后,重新设置最新值(小心并发与事务一致性)

🧭 何时适合使用缓存

  • 读多写少的接口:例如配置、字典、热门列表、统计汇总等。
  • 计算/聚合成本高:复杂查询、跨服务聚合、第三方接口结果等。
  • 跨请求复用:同一数据被多个接口或多个用户频繁访问。

不建议使用的场景:

  • 对实时性/一致性要求极高的强一致写读链路(例如支付扣款后的余额实时展示)。
  • 数据体量巨大且低复用、或个别超大 Value(>1MB)频繁写入。

⚠️ 常见风险与规避

  • 缓存击穿(热点 Key 过期瞬间被大量并发请求穿透)
    • 规避:热点 Key 设置较长 TTL + 提前异步刷新;用互斥锁/单飞阀(SingleFlight)保护回源。
  • 缓存穿透(请求的数据不存在,持续打到 DB)
    • 规避:对不存在的结果也缓存短 TTL 的空值;布隆过滤器预判非法 Key。
  • 缓存雪崩(大量 Key 在同一时刻同时过期)
    • 规避:TTL 加随机抖动;错峰失效;必要时分批预热。
  • 数据不一致(写成功但缓存未失效或失效慢)
    • 规避:写操作后先删缓存再写库或使用可靠地“写后删/写后刷新”策略;缩短 TTL;必要时引入消息驱动的失效。
  • 热点 Key 与大 Value
    • 规避:拆分热点 Key 或采用本地 + 远端两级缓存;对大对象做字段级缓存或分页缓存。
  • 序列化与兼容
    • 规避:统一序列化协议(如 Newtonsoft.Json),避免不同版本模型不兼容。
  • 连接与资源
    • 规避:合理配置连接池与超时,监控 QPS、慢查询、内存压力;避免在热点路径执行阻塞命令。

✅ 小结

  • 通过 ICache 抽象无缝切换 DistributedCache/RedisCache。
  • 用好 UseCacheAttribute + CacheAop,配合合理 Key/TTL 设计,能显著提升读性能。
  • 关注击穿/穿透/雪崩与一致性问题,按需引入抖动、互斥与空值缓存等手段。

版权所有 © 2021-2026 ApeVolo-Team