Skip to content
Go back

HybridCache in ASP.NET Core .NET 10 完全指南

我在每个需要 Redis 缓存的项目里,都要手写一套 IDistributedCache 扩展方法——GetOrSetAsyncTryGetValueSetAsync,带泛型,几十行样板代码,项目换了再复制一遍。HybridCache 用一个 GetOrCreateAsync 调用让这些代码全部成了历史。但这还不是最重要的。最重要的是防雪崩(stampede protection)。我对着一个冷缓存发了 100 个并发请求,用 IMemoryCache 跑了 100 次数据库查询,用 HybridCache 只跑了 1 次。

这篇文章会带你完整走过 HybridCache 在 ASP.NET Core .NET 10 中的所有内容——L1/L2 架构、完整 API 接口、基于 Tag 的失效、Redis 作为 L2 的配置方式、与 IDistributedCache 的对比迁移,以及附有日志证据的防雪崩演示和 BenchmarkDotNet 数据。

HybridCache 是什么

HybridCache 是一个 .NET 库(.NET 9 GA,.NET 10 稳定),通过单一的 GetOrCreateAsync 方法提供统一的缓存 API,把 L1 内存缓存与可选的 L2 分布式缓存结合在一起,同时内置防雪崩保护和基于 Tag 的失效机制。它通过 Microsoft.Extensions.Caching.Hybrid NuGet 包发布,是微软官方推荐用于新 ASP.NET Core 项目的缓存方案。

L1/L2 架构的工作方式:

L1(内存层):每个应用实例维护自己的进程内内存缓存,和 IMemoryCache 一样。命中 L1 是纳秒级,零序列化。

L2(分布式层):可选的外部缓存(Redis、SQL Server 或任何 IDistributedCache 实现)。L1 未命中时,HybridCache 先检查 L2,再触发数据库查询。L2 层的数据需要序列化,跨实例共享。

工厂执行:L1 和 L2 都未命中时,HybridCache 执行你的工厂委托(数据库查询),把结果写入 L1 和 L2,然后返回。关键在于:同一个 Key 的并发调用中,只有一个调用者执行工厂,其余全部等待结果。

.NET Blog GA 公告里把它描述为 IDistributedCacheIMemoryCache 的”直接替代”,基本准确,后面会补充一些细节。

HybridCache 与手动组合 IMemoryCache + IDistributedCache 的本质区别是:它自动处理两层之间的协作。你不需要写”先检查 L1、再检查 L2、再查数据库、再填两层缓存”的逻辑,一次方法调用搞定一切。防雪崩保护让你永远不会遇到缓存未命中时 100 个并发请求同时打数据库的情况。

什么时候用 HybridCache

下面是三种缓存方案的完整对比:

指标IMemoryCacheIDistributedCacheHybridCache
数据范围单进程跨实例共享L1 本地 + L2 共享
网络延迟1-5msL1 无,L2 1-5ms
重启后存活L2:是
序列化无(存引用)必须(JSON/二进制)L1 无,L2 必须
防雪崩否(手写 SemaphoreSlim)否(手写)是(内置)
Tag 失效否(手写)是(RemoveByTagAsync)
GetOrCreateAsync有(但无防雪崩)否(需扩展方法)有(含防雪崩)
最低版本全版本全版本.NET 9+

不推荐用 HybridCache 的场景

对于新的 .NET 10 项目,即使没配置 L2,HybridCache 也能提供防雪崩保护和 Tag 失效,这两个能力 IMemoryCache 永远不会有。加了第二个 Pod 时,追加 Redis 作为 L2,代码完全不变。

配置 HybridCache

安装包:

dotnet add package Microsoft.Extensions.Caching.Hybrid --version 10.4.0

Program.cs 中注册:

#pragma warning disable EXTEXP0018

var builder = WebApplication.CreateBuilder(args);

builder.Services.AddHybridCache(options =>
{
    options.DefaultEntryOptions = new HybridCacheEntryOptions
    {
        LocalCacheExpiration = TimeSpan.FromMinutes(5),
        Expiration = TimeSpan.FromMinutes(30)
    };
    options.MaximumPayloadBytes = 1024 * 1024; // 1 MB 最大缓存条目
});

几个关键说明:

#pragma warning disable EXTEXP0018 是必需的。HybridCache API 在 .NET 10 中仍标记为 [Experimental],不加这个 pragma 会报编译错误。这不意味着库不稳定——它从 .NET 9 已经 GA,生产可用。Experimental 标志只代表 API 签名在未来小版本中可能调整。核心的 GetOrCreateAsyncRemoveByTagAsync API 从 .NET 9 GA 起就稳定了。也可以在 .csproj 中加 <NoWarn>EXTEXP0018</NoWarn> 全局屏蔽。

DefaultEntryOptions 设置全局默认的过期时间,对没有自定义选项的条目生效。LocalCacheExpiration 控制 L1 生存时间,Expiration 控制 L2 生存时间。

MaximumPayloadBytes 限制单条缓存条目的最大体积。超出限制的条目会被静默跳过(不缓存)。建议始终设置,防止意外缓存一个巨大的对象图把内存打爆。

不配置任何 L2 时,HybridCache 本身就是一个纯内存缓存,但同时具备防雪崩保护和 Tag 失效。

HybridCache 的四个方法

整个 API 只有四个方法。

GetOrCreateAsync(主力方法)

90% 的情况下你用这个。它一次调用完成完整的 cache-aside 模式:

var product = await hybridCache.GetOrCreateAsync(
    $"product:{id}",                              // 缓存 Key
    async ct => await context.Products            // 工厂(未命中时执行)
        .AsNoTracking()
        .FirstOrDefaultAsync(p => p.Id == id, ct),
    new HybridCacheEntryOptions                   // 可选的每条目选项
    {
        LocalCacheExpiration = TimeSpan.FromMinutes(5),
        Expiration = TimeSpan.FromMinutes(30)
    },
    tags: ["products"],                           // 用于批量失效的 Tag
    cancellationToken: cancellationToken);

调用流程:

  1. 检查 L1(内存)。命中则立即返回,零序列化,纳秒级。
  2. L1 未命中且配了 L2,检查 L2(Redis)。命中则反序列化,写入 L1,返回。
  3. 两层都未命中,对该 Key 加锁(防雪崩)。只有一个调用者执行工厂委托,其余并发调用者等待结果。
  4. 把结果写入 L1 和 L2(如配置),返回值。

工厂委托接收一个 CancellationToken 参数 ct,始终用它而不是捕获外层的 cancellation token,因为 HybridCache 内部管理工厂的取消逻辑。

SetAsync(直接写入)

适合在创建或更新后预热缓存,而不是直接失效:

await hybridCache.SetAsync(
    $"product:{product.Id}",
    product,
    new HybridCacheEntryOptions { LocalCacheExpiration = TimeSpan.FromMinutes(5), Expiration = TimeSpan.FromMinutes(30) },
    tags: ["products"],
    cancellationToken: cancellationToken);

RemoveAsync(单条失效)

按 Key 从 L1 和 L2 移除一条缓存:

await hybridCache.RemoveAsync($"product:{id}", cancellationToken);

RemoveByTagAsync(批量失效)

这是 IMemoryCacheIDistributedCache 都没有的杀手级特性。移除所有带指定 Tag 的缓存条目:

await hybridCache.RemoveByTagAsync("products", cancellationToken);

一次调用失效所有带 "products" Tag 的条目,不管单条 Key 是什么。

HybridCacheEntryOptions 详解

var options = new HybridCacheEntryOptions
{
    LocalCacheExpiration = TimeSpan.FromMinutes(5),   // L1 生存时间
    Expiration = TimeSpan.FromMinutes(30),            // L2 生存时间
    Flags = HybridCacheEntryFlags.None                // 默认行为
};

为什么要两个过期时间? 设想三个 API Pod 共用 Redis L2,Pod 1 把商品列表写入 L1。管理员更新了商品并失效了 Redis Key。如果 Pod 2 的 LocalCacheExpiration 是 30 分钟,它会用 L1 的旧数据服务长达 30 分钟;如果是 5 分钟,旧数据窗口就小得多。代价是更短的 L1 过期意味着更多 L2 网络调用,但数据更新鲜。

推荐默认比例:L1 5 分钟,L2 30 分钟(1:6)。变化频繁的数据(库存、订单数)可以把 L1 降到 1-2 分钟;极少变化的数据(权限集、配置)可以把 L1 提到 15 分钟。

Flags 选项:HybridCacheEntryFlags.DisableLocalCacheRead 跳过 L1 直接检查 L2,DisableLocalCacheWrite 阻止写入 L1。适合调试,或需要立即一致性时牺牲性能使用。

完整的 Product API 示例

数据层

public class Product
{
    public Guid Id { get; set; }
    public string Name { get; set; } = default!;
    public string Description { get; set; } = default!;
    public decimal Price { get; set; }
    public string Category { get; set; } = default!;

    private Product() { }

    public Product(string name, string description, decimal price, string category)
    {
        Id = Guid.NewGuid();
        Name = name;
        Description = description;
        Price = price;
        Category = category;
    }
}

public record ProductCreationDto(string Name, string Description, decimal Price, string Category);

Category 字段是关键——它让我们可以按分类打 Tag,从而实现”只失效电子类商品”而不影响服装类缓存。

连接字符串配置(生产环境请用环境变量或 User Secrets):

"ConnectionStrings": {
  "Database": "Host=localhost;Database=hybridcaching;Username=postgres;Password=yourpassword;Include Error Detail=true"
}

ProductService 完整实现

public class ProductService(
    AppDbContext context,
    HybridCache cache,
    ILogger<ProductService> logger) : IProductService
{
    private const string AllProductsCacheKey = "products";

    public async Task<List<Product>> GetAllAsync(CancellationToken cancellationToken = default)
    {
        logger.LogInformation("Fetching data for key: {CacheKey}.", AllProductsCacheKey);

        var products = await cache.GetOrCreateAsync(
            AllProductsCacheKey,
            async ct =>
            {
                logger.LogInformation("Cache miss for key: {CacheKey}. Fetching from database.", AllProductsCacheKey);
                return await context.Products.AsNoTracking().ToListAsync(ct);
            },
            new HybridCacheEntryOptions
            {
                LocalCacheExpiration = TimeSpan.FromMinutes(5),
                Expiration = TimeSpan.FromMinutes(30)
            },
            tags: ["products"],
            cancellationToken: cancellationToken);

        return products ?? [];
    }

    public async Task<Product?> GetByIdAsync(Guid id, CancellationToken cancellationToken = default)
    {
        var cacheKey = $"product:{id}";
        logger.LogInformation("Fetching data for key: {CacheKey}.", cacheKey);

        return await cache.GetOrCreateAsync(
            cacheKey,
            async ct =>
            {
                logger.LogInformation("Cache miss for key: {CacheKey}. Fetching from database.", cacheKey);
                return await context.Products.AsNoTracking()
                    .FirstOrDefaultAsync(p => p.Id == id, ct);
            },
            tags: ["products"],
            cancellationToken: cancellationToken);
    }

    public async Task<List<Product>> GetByCategoryAsync(string category, CancellationToken cancellationToken = default)
    {
        var cacheKey = $"products:category:{category}";

        var products = await cache.GetOrCreateAsync(
            cacheKey,
            async ct => await context.Products.AsNoTracking()
                .Where(p => p.Category == category)
                .ToListAsync(ct),
            tags: ["products", $"category:{category}"],
            cancellationToken: cancellationToken);

        return products ?? [];
    }

    public async Task<Product> CreateAsync(ProductCreationDto request, CancellationToken cancellationToken = default)
    {
        var product = new Product(request.Name, request.Description, request.Price, request.Category);
        await context.Products.AddAsync(product, cancellationToken);
        await context.SaveChangesAsync(cancellationToken);

        logger.LogInformation("Invalidating cache for tags: products, category:{Category}.", request.Category);
        await cache.RemoveByTagAsync("products", cancellationToken);

        return product;
    }

    public async Task<Product?> UpdateAsync(Guid id, ProductCreationDto request, CancellationToken cancellationToken = default)
    {
        var product = await context.Products.FindAsync([id], cancellationToken);
        if (product is null) return null;

        product.Name = request.Name;
        product.Description = request.Description;
        product.Price = request.Price;
        product.Category = request.Category;

        await context.SaveChangesAsync(cancellationToken);

        logger.LogInformation("Invalidating cache for key: product:{ProductId} and tag: products.", id);
        await cache.RemoveAsync($"product:{id}", cancellationToken);
        await cache.RemoveByTagAsync("products", cancellationToken);

        return product;
    }

    public async Task<bool> DeleteAsync(Guid id, CancellationToken cancellationToken = default)
    {
        var product = await context.Products.FindAsync([id], cancellationToken);
        if (product is null) return false;

        context.Products.Remove(product);
        await context.SaveChangesAsync(cancellationToken);

        logger.LogInformation("Invalidating cache for key: product:{ProductId} and tag: products.", id);
        await cache.RemoveAsync($"product:{id}", cancellationToken);
        await cache.RemoveByTagAsync("products", cancellationToken);

        return true;
    }
}

几个关键模式:

Minimal API 注册

var products = app.MapGroup("/products").WithTags("Products");

products.MapGet("/", async (IProductService service, CancellationToken ct) =>
    TypedResults.Ok(await service.GetAllAsync(ct)));

products.MapGet("/{id:guid}", async (Guid id, IProductService service, CancellationToken ct) =>
{
    var product = await service.GetByIdAsync(id, ct);
    return product is not null ? TypedResults.Ok(product) : Results.NotFound();
});

products.MapGet("/category/{category}", async (string category, IProductService service, CancellationToken ct) =>
    TypedResults.Ok(await service.GetByCategoryAsync(category, ct)));

products.MapPost("/", async (ProductCreationDto request, IProductService service, CancellationToken ct) =>
{
    var product = await service.CreateAsync(request, ct);
    return TypedResults.Created($"/products/{product.Id}", product);
});

products.MapPut("/{id:guid}", async (Guid id, ProductCreationDto request, IProductService service, CancellationToken ct) =>
{
    var product = await service.UpdateAsync(id, request, ct);
    return product is not null ? TypedResults.Ok(product) : Results.NotFound();
});

products.MapDelete("/{id:guid}", async (Guid id, IProductService service, CancellationToken ct) =>
{
    var deleted = await service.DeleteAsync(id, ct);
    return deleted ? TypedResults.NoContent() : Results.NotFound();
});

builder.Services.AddScoped<IProductService, ProductService>();

配置 Redis 作为 L2

Docker Compose

services:
  redis:
    image: redis:7.4
    container_name: redis
    ports:
      - "6379:6379"
    command: redis-server --requirepass yourpassword --appendonly yes
    volumes:
      - redis-data:/data

volumes:
  redis-data:

启动:docker-compose up -d

Program.cs 注册

安装包:

dotnet add package Microsoft.Extensions.Caching.StackExchangeRedis --version 10.0.0

添加连接字符串到 appsettings.json(生产环境请用环境变量):

{
  "ConnectionStrings": {
    "Database": "Host=localhost;Database=hybridcaching;Username=postgres;Password=yourpassword;Include Error Detail=true",
    "Redis": "localhost:6379,password=yourpassword,abortConnect=false"
  }
}

注册服务:

#pragma warning disable EXTEXP0018

builder.Services.AddStackExchangeRedisCache(options =>
{
    options.Configuration = builder.Configuration.GetConnectionString("Redis");
    options.InstanceName = "myapp:";
});

builder.Services.AddHybridCache(options =>
{
    options.DefaultEntryOptions = new HybridCacheEntryOptions
    {
        LocalCacheExpiration = TimeSpan.FromMinutes(5),
        Expiration = TimeSpan.FromMinutes(30)
    };
    options.MaximumPayloadBytes = 1024 * 1024;
});

HybridCache 会自动检测 IDistributedCache 的注册(来自 AddStackExchangeRedisCache)并将其用作 L2,不需要手动接线,注册顺序也不影响。

abortConnect=false 的作用:StackExchange.Redis 在 Redis 启动时不抛异常,而是在后台重试连接。生产环境建议始终设置为 false

InstanceName 作为 Redis Key 前缀,避免多个应用共用同一个 Redis 实例时的 Key 冲突。

配置后 HybridCache 工作在完整的 L1+L2 模式:

Tag 失效的工作原理

考虑这个场景:

缓存 KeyTags
products(全量)["products"]
products:category:Electronics["products", "category:Electronics"]
products:category:Clothing["products", "category:Clothing"]
product:{id1}(电子类)["products"]
product:{id2}(服装类)["products"]

场景一:新建商品,调用 RemoveByTagAsync("products"),失效所有带 "products" Tag 的条目。全量列表、所有分类列表、所有单条商品全部失效。这是最保守也最安全的做法,因为新商品同时影响全量列表和分类列表。

场景二:仅失效某分类(例如电子类价格批量更新),调用 RemoveByTagAsync("category:Electronics"),只失效 products:category:Electronics,服装类和全量列表缓存不受影响。

场景三:更新单条商品 可以组合使用:RemoveAsync($"product:{id}") 精确失效单条,再用 RemoveByTagAsync("products") 失效列表缓存。

Tag 失效的真正价值在有 20+ 个缓存 Key 的实际应用中体现。手动跟踪每次写操作要删哪些 Key,极容易漏掉一个。Tags 让你可以用”失效所有商品数据”的思维写代码,而不是罗列一串 Key 祈祷不遗漏。

防雪崩:日志证据

缓存雪崩(也叫 thundering herd):商品目录缓存到期的那一刻,100 个请求同时打来。有了 IMemoryCache,100 个请求全部看到缓存未命中,100 个数据库查询同时发出。如果一次查询需要 500ms,数据库连接池只有 20 个连接,剩余 80 个请求排队,超时开始级联,API 返回 500 错误。

IMemoryCache 的行为(无保护)

// 100 并发请求,各自独立触发工厂
var products = await cache.GetOrCreateAsync("products", async entry =>
{
    var count = Interlocked.Increment(ref _factoryExecutionCount);
    logger.LogWarning("IMemoryCache factory executing. Execution #{Count}", count);
    await Task.Delay(200, cancellationToken); // 模拟慢速数据库查询
    return await context.Products.AsNoTracking().ToListAsync(cancellationToken);
});

预期输出:

warn: IMemoryCache factory executing. Execution #1
warn: IMemoryCache factory executing. Execution #2
...
warn: IMemoryCache factory executing. Execution #97
warn: IMemoryCache factory executing. Execution #98

IMemoryCache.GetOrCreateAsync 不对同一 Key 加锁,所有并发调用者都看到空缓存并执行工厂。

HybridCache 的行为(内置保护)

var products = await cache.GetOrCreateAsync(
    "stampede-test-products",
    async ct =>
    {
        var count = Interlocked.Increment(ref _factoryExecutionCount);
        logger.LogWarning("HybridCache factory executing. Execution #{Count}", count);
        await Task.Delay(200, ct);
        return await context.Products.AsNoTracking().ToListAsync(ct);
    },
    tags: ["stampede-test"],
    cancellationToken: cancellationToken);

预期输出:

warn: HybridCache factory executing. Execution #1

只有一次。只有一条数据库查询。其余 99 个请求等待第一个完成并共享结果。HybridCache 内部使用基于 SemaphoreSlim 的 Key 级加锁机制——第一个调用者持锁执行工厂,后续对同一 Key 的并发调用等锁,不重复执行工厂。

这一个特性就足以让你从 IMemoryCache 切换到 HybridCache。手写 SemaphoreSlim 包装缓存代码既容易出错又容易忘记加。HybridCache 把防雪崩变成了默认行为。

BenchmarkDotNet 性能数据

HybridCache L1 缓存命中约 0.05 微秒,比原始 IMemoryCache(0.02μs)慢 30 纳秒,比 Redis IDistributedCache 调用(1200μs)快 24000 倍。

测试方法平均耗时内存分配
SingleProduct_DatabaseFetch~500 μs~8 KB
SingleProduct_MemoryCacheHit~0.02 μs0 B
SingleProduct_RedisCacheHit~1,200 μs~4 KB
SingleProduct_HybridCache_L1Hit~0.05 μs~200 B
AllProducts_DatabaseFetch (1000)~12,000 μs~650 KB
AllProducts_MemoryCacheHit (1000)~0.02 μs0 B
AllProducts_RedisCacheHit (1000)~3,500 μs~420 KB
AllProducts_HybridCache_L1Hit (1000)~0.08 μs~400 B

测试环境:.NET 10.0.0,PostgreSQL 17 + Redis 7.4 在 Docker Desktop 上运行,Windows 11,10 次迭代取平均值。

分析:HybridCache L1 比原始 IMemoryCache 慢不到 1 微秒,多出的那点分配来自 HybridCache 的内部状态对象。在 HTTP 请求级别完全感知不到这 30 纳秒的差异——仅 HTTP pipeline 就有 50-200μs 开销。真正值得关注的比较是 HybridCache L1(0.05μs)vs Redis(1200μs),前者快了 24000 倍。L1 层的意义在于:你享受 Redis 的跨实例一致性,99% 的请求却以内存速度响应。

从 IDistributedCache 迁移

迁移前:有一个 50+ 行的 DistributedCacheExtensions.cs,包含泛型 SetAsync<T>TryGetValue<T>GetOrSetAsync<T>,每次都要手动序列化/反序列化:

// 旧写法:手写扩展方法 + 手动序列化
var products = await cache.GetOrSetAsync(
    "products",
    async () => await context.Products.AsNoTracking().ToListAsync(cancellationToken),
    new DistributedCacheEntryOptions().SetAbsoluteExpiration(TimeSpan.FromMinutes(20)),
    cancellationToken);

迁移后

// 新写法:删掉扩展方法文件,替换注入类型
var products = await cache.GetOrCreateAsync(
    "products",
    async ct => await context.Products.AsNoTracking().ToListAsync(ct),
    new HybridCacheEntryOptions { LocalCacheExpiration = TimeSpan.FromMinutes(5), Expiration = TimeSpan.FromMinutes(20) },
    tags: ["products"],
    cancellationToken: cancellationToken);

变化的内容

  1. 删除扩展方法文件,GetOrSetAsync/TryGetValue<T>/SetAsync<T> 全部由 HybridCache.GetOrCreateAsyncHybridCache.SetAsync 替代。
  2. 把注入类型从 IDistributedCache 换成 HybridCache,同时在 Program.cs 追加 AddHybridCache()(已有的 AddStackExchangeRedisCache() 保留)。
  3. GetOrSetAsync 换成 GetOrCreateAsync,注意工厂委托现在接收一个 ct 参数。
  4. 把单条 cache.RemoveAsync 替换为 cache.RemoveByTagAsync,获得批量失效能力。
  5. 在直接调用 HybridCache 方法的文件顶部加 #pragma warning disable EXTEXP0018

保持不变的内容:缓存 Key 命名模式不变,过期策略逻辑不变(只是多了 LocalCacheExpiration),Redis 基础设施不变(AddStackExchangeRedisCache 留着)。

如果线上的 IDistributedCache 方案运行良好,没有必要紧急迁移。我的建议是在重大重构时顺带迁移,或者新增服务时直接用 HybridCache。

常见问题与解决方式

问题原因解决方案
多 Pod 服务陈旧数据LocalCacheExpiration 相对 Expiration 太长比例保持 1:6(5 分钟 L1,30 分钟 L2)
L2 反序列化报 JsonException部署后模型变更,Redis 里存的还是旧格式版本化 Key("v2:products")或部署前失效
条目静默不缓存序列化体积超出 MaximumPayloadBytes增大限制或改为缓存 DTO 而非完整实体图
写操作后命中率骤降Tag 粒度太粗,每次删全部用更细化的 Tag("products:list""category:Electronics"
Redis 停服 API 仍可用L1 独立工作,L2 失败优雅降级这是特性,不是 Bug
有 L2 数据但工厂仍然执行Redis 超时或反序列化失败检查 Redis 延迟和模型兼容性

总结

HybridCache 是 ASP.NET Core 一直应该内置的缓存库。它消除了 IDistributedCache 扩展方法的样板代码,解决了 IMemoryCache 无视的雪崩问题,在不需要你写管道代码的前提下提供了 L1+L2 的分层能力。

对于新的 .NET 10 项目,这是我的默认缓存选择。两行注册,零样板,生产级别的缓存能力开箱即用。

参考


Tags


Previous

C# 中为类实现通用 EqualityComparer

Next

Squad:把协调多个 AI 智能体的能力,直接嵌进你的代码仓库