输出缓存
大约 14 分钟约 4080 字
输出缓存
简介
ASP.NET Core 7 引入了输出缓存(Output Cache)中间件,在服务端缓存完整的 HTTP 响应内容。与响应缓存(Response Cache)不同,输出缓存在中间件层工作,支持更灵活的缓存策略、缓存标签(Cache Tags)、按查询参数和请求头区分缓存,以及基于 Redis 的分布式缓存后端。
输出缓存在请求管道中的位置
HTTP 请求
|
v
[Authorization] -- 认证中间件
|
v
[Output Cache] -- 输出缓存中间件(命中则直接返回,不经过后续管道)
|
v
[Endpoints] -- 路由和控制器
|
v
[Action Filter] -- 过滤器
|
v
[Business Logic] -- 业务逻辑
|
v
[Database/External] -- 数据库/外部调用
|
v
HTTP 响应 (同时存入缓存)输出缓存 vs 响应缓存
| 特性 | 输出缓存 (Output Cache) | 响应缓存 (Response Cache) |
|---|---|---|
| 工作位置 | 服务端内存/Redis | 客户端/CDN 代理 |
| 缓存控制 | 服务端完全控制 | 依赖 Cache-Control 头 |
| 缓存标签 | 支持(批量失效) | 不支持 |
| 缓存变化 | VaryByQuery/Header/Route | VaryByQuery/Header |
| .NET 版本 | 7+ | 2+ |
| 适用场景 | API 响应缓存 | 静态资源/CDN 加速 |
特点
基本配置
全局配置
var builder = WebApplication.CreateBuilder(args);
builder.Services.AddOutputCache(options =>
{
// 基础策略 — 所有未显式指定策略的端点都使用此策略
options.AddBasePolicy(builder => builder.Expire(TimeSpan.FromMinutes(10)));
// 最大缓存条目数(默认 100)
options.MaximumBodySize = 64 * 1024 * 1024; // 64MB
options.SizeLimit = 100 * 1024 * 1024; // 总缓存大小 100MB
// 命名策略
options.AddPolicy("NoCache", builder => builder.NoCache());
options.AddPolicy("ShortCache", builder => builder.Expire(TimeSpan.FromMinutes(1)));
options.AddPolicy("LongCache", builder => builder.Expire(TimeSpan.FromHours(1)));
options.AddPolicy("VeryLongCache", builder => builder.Expire(TimeSpan.FromDays(7)));
});
var app = builder.Build();
// 必须在 UseRouting/UseEndpoints 之前注册
app.UseOutputCache();
app.MapControllers();
app.Run();控制器使用
[ApiController]
[Route("api/[controller]")]
public class ProductsController : ControllerBase
{
/// <summary>
/// 默认缓存策略 — 5 分钟
/// </summary>
[HttpGet]
[OutputCache(Duration = 300)]
public IActionResult GetAll()
{
return Ok(new { Products = "cached list" });
}
/// <summary>
/// 按查询参数区分缓存
/// VaryByQueryKeys 确保不同的查询参数组合有独立的缓存
/// </summary>
[HttpGet("search")]
[OutputCache(Duration = 600, VaryByQueryKeys = new[] { "keyword", "category", "page" })]
public IActionResult Search(
[FromQuery] string? keyword,
[FromQuery] string? category,
[FromQuery] int page = 1)
{
return Ok(new { Keyword = keyword, Category = category, Page = page });
}
/// <summary>
/// 按路由参数区分缓存
/// </summary>
[HttpGet("{id}")]
[OutputCache(Duration = 600, VaryByRouteValueNames = new[] { "id" })]
public IActionResult GetById(int id)
{
return Ok(new { Id = id, Name = "Product" });
}
/// <summary>
/// 按请求头区分缓存(多语言场景)
/// 不同 Accept-Language 头的请求有独立缓存
/// </summary>
[HttpGet("localized")]
[OutputCache(Duration = 300, VaryByHeaderNames = new[] { "Accept-Language" })]
public IActionResult GetLocalized()
{
var lang = Request.Headers.AcceptLanguage.ToString();
return Ok(new { Language = lang, Message = "本地化内容" });
}
/// <summary>
/// 使用命名策略
/// </summary>
[HttpGet("realtime")]
[OutputCache(PolicyName = "NoCache")]
public IActionResult GetRealtime()
{
return Ok(new { Timestamp = DateTime.UtcNow });
}
/// <summary>
/// 组合使用 VaryBy 和 PolicyName
/// </summary>
[HttpGet("profile/{userId}")]
[OutputCache(
Duration = 120,
VaryByRouteValueNames = new[] { "userId" },
VaryByHeaderNames = new[] { "Accept-Language" })]
public IActionResult GetUserProfile(int userId)
{
return Ok(new { UserId = userId, Name = "User" });
}
}Minimal API
// 基本缓存
app.MapGet("/api/time", () => DateTime.UtcNow)
.CacheOutput(x => x.Expire(TimeSpan.FromSeconds(10)).Tag("time"));
// 带查询参数变化
app.MapGet("/api/products", (AppDbContext db,
[FromQuery] string? category,
[FromQuery] int page = 1) =>
db.Products
.Where(p => category == null || p.Category == category)
.Skip((page - 1) * 20)
.Take(20)
.ToList())
.CacheOutput(x => x.Expire(TimeSpan.FromMinutes(5))
.VaryByQuery("category", "page")
.Tag("products", "list"));
// 禁用缓存
app.MapGet("/api/health", () => "ok")
.CacheOutput(x => x.NoCache());
// 使用命名策略
app.MapGet("/api/config", () => new { Version = "1.0" })
.CacheOutput("LongCache");
// 按请求头缓存
app.MapGet("/api/news", (string lang) => new { Lang = lang, News = "..." })
.CacheOutput(x => x
.Expire(TimeSpan.FromMinutes(10))
.VaryByHeader("Accept-Language")
.Tag("news"));
// 按路由值缓存
app.MapGet("/api/orders/{orderId}", (int orderId) => new { OrderId = orderId })
.CacheOutput(x => x
.Expire(TimeSpan.FromMinutes(30))
.VaryByRouteValue("orderId")
.Tag($"order:{orderId}"));缓存标签
按标签失效
缓存标签是输出缓存最强大的特性之一。通过为缓存条目打标签,可以在数据变更时批量失效相关缓存。
// ============================================
// 为端点添加缓存标签
// ============================================
// 产品列表 — 标记为 "products" 和 "list"
app.MapGet("/api/products", () => /* 产品列表 */)
.CacheOutput(x => x.Expire(TimeSpan.FromMinutes(10))
.Tag("products", "list"));
// 产品详情 — 标记为 "product:{id}" 和 "detail"
app.MapGet("/api/products/{id}", (int id) => /* 产品详情 */)
.CacheOutput(x => x.Expire(TimeSpan.FromMinutes(10))
.Tag($"product:{id}", "detail"));
// 分类下的产品 — 同时标记分类和产品
app.MapGet("/api/categories/{categoryId}/products",
(int categoryId) => /* 分类产品 */)
.CacheOutput(x => x.Expire(TimeSpan.FromMinutes(10))
.Tag("products", $"category:{categoryId}"));
// ============================================
// 手动清除缓存
// ============================================
// 清除特定产品的所有缓存
app.MapPost("/api/products/{id}/evict",
async (int id, IOutputCacheStore cache) =>
{
// 清除标记为 "product:{id}" 的所有缓存
await cache.EvictByTagAsync($"product:{id}", default);
return Results.Ok(new { Message = $"产品 {id} 的缓存已清除" });
});
// 清除所有产品相关缓存
app.MapPost("/api/products/evict-all",
async (IOutputCacheStore cache) =>
{
await cache.EvictByTagAsync("products", default);
return Results.Ok(new { Message = "所有产品缓存已清除" });
});
// 清除分类下的产品缓存
app.MapPost("/api/categories/{categoryId}/evict",
async (int categoryId, IOutputCacheStore cache) =>
{
await cache.EvictByTagAsync($"category:{categoryId}", default);
return Results.Ok(new { Message = $"分类 {categoryId} 的缓存已清除" });
});自动失效 — 服务层集成
/// <summary>
/// 在服务层自动清除缓存
/// 当数据变更时,同步清除相关缓存
/// </summary>
public class ProductService
{
private readonly AppDbContext _context;
private readonly IOutputCacheStore _cacheStore;
public ProductService(AppDbContext context, IOutputCacheStore cacheStore)
{
_context = context;
_cacheStore = cacheStore;
}
public async Task<Product> UpdateProductAsync(int id, ProductDto dto)
{
var product = await _context.Products.FindAsync(id);
if (product == null)
throw new NotFoundException($"Product {id} not found");
product.Name = dto.Name;
product.Price = dto.Price;
await _context.SaveChangesAsync();
// 清除相关缓存
await EvictProductCacheAsync(id, product.CategoryId);
return product;
}
public async Task DeleteProductAsync(int id)
{
var product = await _context.Products.FindAsync(id);
if (product == null) return;
_context.Products.Remove(product);
await _context.SaveChangesAsync();
// 清除相关缓存
await EvictProductCacheAsync(id, product.CategoryId);
}
private async Task EvictProductCacheAsync(int productId, int categoryId)
{
// 清除产品详情缓存
await _cacheStore.EvictByTagAsync($"product:{productId}", default);
// 清除产品列表缓存
await _cacheStore.EvictByTagAsync("products", default);
// 清除分类缓存
await _cacheStore.EvictByTagAsync($"category:{categoryId}", default);
}
}缓存键设计最佳实践
标签命名规范:
1. 实体级别:entity:{id}
示例:product:123, user:456, order:789
2. 集合级别:entity:collection
示例:products, users, orders
3. 分类级别:category:{id}
示例:category:electronics, category:books
4. 功能级别:feature:name
示例:navigation:main, settings:global
失效规则:
- 更新实体 -> 清除 entity:{id} + entity:collection
- 删除实体 -> 清除 entity:{id} + entity:collection + category:{id}
- 批量操作 -> 清除 entity:collection自定义策略
动态缓存策略
builder.Services.AddOutputCache(options =>
{
// ============================================
// 策略 1:基于认证状态的动态缓存
// ============================================
options.AddPolicy("AuthAware", context =>
{
// 已认证用户缓存时间短,匿名用户缓存时间长
var isAuthenticated = context.HttpContext.User.Identity?.IsAuthenticated ?? false;
var ttl = isAuthenticated
? TimeSpan.FromMinutes(1)
: TimeSpan.FromMinutes(30);
context.ExpirationTimeSpan = ttl;
context.Tags = new[] { "auth-aware" };
});
// ============================================
// 策略 2:条件缓存 — 只缓存 GET 请求
// ============================================
options.AddPolicy("GetOnly", context =>
{
if (context.HttpContext.Request.Method != HttpMethods.Get)
{
context.EnableOutputCaching = false;
return;
}
context.ExpirationTimeSpan = TimeSpan.FromMinutes(5);
});
// ============================================
// 策略 3:基于路径的动态过期
// ============================================
options.AddPolicy("PathAware", context =>
{
var path = context.HttpContext.Request.Path.Value;
// 首页数据变化慢,缓存时间长
if (path == "/api/home")
{
context.ExpirationTimeSpan = TimeSpan.FromHours(1);
}
// 配置数据变化更慢
else if (path?.StartsWith("/api/config") == true)
{
context.ExpirationTimeSpan = TimeSpan.FromDays(1);
}
// 其他接口默认 5 分钟
else
{
context.ExpirationTimeSpan = TimeSpan.FromMinutes(5);
}
});
// ============================================
// 策略 4:基于角色的缓存
// ============================================
options.AddPolicy("RoleAware", context =>
{
var user = context.HttpContext.User;
var isAdmin = user.IsInRole("Admin");
// 管理员不缓存(数据实时性要求高)
context.EnableOutputCaching = !isAdmin;
context.ExpirationTimeSpan = TimeSpan.FromMinutes(5);
context.Tags = new[] { "role-aware" };
});
// ============================================
// 策略 5:带锁定的缓存(防止缓存击穿)
// ============================================
options.AddPolicy("WithLock", builder =>
{
builder
.Expire(TimeSpan.FromMinutes(5))
.SetLockTimeout(TimeSpan.FromSeconds(10)); // 锁定超时
});
});自定义 IOutputCachePolicy
/// <summary>
/// 自定义缓存策略 — 实现更复杂的缓存逻辑
/// </summary>
public class TenantAwareCachePolicy : IOutputCachePolicy
{
public async ValueTask CacheRequestAsync(
OutputCacheContext context,
CancellationToken cancellation)
{
// 只缓存 GET 和 HEAD 请求
if (context.HttpContext.Request.Method != "GET" &&
context.HttpContext.Request.Method != "HEAD")
{
context.EnableOutputCaching = false;
return;
}
// 根据租户决定缓存时间
var tenantId = context.HttpContext.Request.Headers["X-Tenant-Id"].ToString();
context.ExpirationTimeSpan = string.IsNullOrEmpty(tenantId)
? TimeSpan.FromMinutes(30) // 默认租户缓存时间长
: TimeSpan.FromMinutes(5); // 自定义租户缓存时间短
// 设置缓存键变化因子
context.CacheVaryByRules.QueryKeys.Add("page");
context.CacheVaryByRules.QueryKeys.Add("pageSize");
context.CacheVaryByRules.RouteValueNames.Add("id");
// 设置缓存标签
context.Tags.Add($"tenant:{tenantId ?? "default"}");
context.Tags.Add("tenant-aware");
// 缓存大小限制(超过此大小不缓存)
context.MaximumBodySize = 10 * 1024 * 1024; // 10MB
}
public async ValueTask ServeFromCacheAsync(
OutputCacheContext context,
CancellationToken cancellation)
{
// 可在此处添加缓存服务逻辑(如记录缓存命中)
}
public async ValueTask ServeResponseAsync(
OutputCacheContext context,
CancellationToken cancellation)
{
// 只缓存 200 状态码的响应
if (context.HttpContext.Response.StatusCode != 200)
{
context.EnableOutputCaching = false;
}
}
}
// 注册自定义策略
builder.Services.AddOutputCache(options =>
{
options.AddPolicy<TenantAwareCachePolicy>("TenantAware");
});Redis 分布式缓存
配置 Redis 后端
// ============================================
// NuGet: Microsoft.Extensions.OutputCaching.StackExchangeRedis
// ============================================
// 方式 A:使用连接字符串
builder.Services.AddStackExchangeRedisOutputCache(options =>
{
options.Configuration = "localhost:6379,abortConnect=false";
options.InstanceName = "OutputCache:"; // Redis Key 前缀
});
// 方式 B:使用 ConfigurationOptions(更细粒度的控制)
builder.Services.AddStackExchangeRedisOutputCache(options =>
{
options.ConfigurationOptions = new ConfigurationOptions
{
EndPoints = { "redis-host:6379" },
Password = "your-redis-password",
AllowAdmin = false,
ConnectTimeout = 5000,
SyncTimeout = 5000,
AbortOnConnectFail = false,
ReconnectRetryPolicy = new ExponentialRetry(5000)
};
options.InstanceName = "myapp:output:";
});
// 使用方式完全不变
app.UseOutputCache();Redis 缓存的 Key 结构
Redis 中的 Key 格式:
{InstanceName}{Route}_{QueryStringHash}_{HeaderHash}
示例:
OutputCache:/api/products_abc123_def456
OutputCache:/api/products/42_ghi789_jkl012Redis 连接故障处理
/// <summary>
/// 自定义 Redis 不可用时的降级策略
/// </summary>
public class FallbackOutputCacheStore : IOutputCacheStore
{
private readonly IOutputCacheStore _redisStore;
private readonly IDistributedCache _memoryCache;
private readonly ILogger<FallbackOutputCacheStore> _logger;
public FallbackOutputCacheStore(
IOutputCacheStore redisStore,
IDistributedCache memoryCache,
ILogger<FallbackOutputCacheStore> logger)
{
_redisStore = redisStore;
_memoryCache = memoryCache;
_logger = logger;
}
public async ValueTask<byte[]?> GetAsync(string key, CancellationToken token)
{
try
{
return await _redisStore.GetAsync(key, token);
}
catch (Exception ex)
{
_logger.LogWarning(ex, "Redis 缓存获取失败,降级到内存缓存");
var cached = await _memoryCache.GetStringAsync(key, token);
return cached == null ? null : Convert.FromBase64String(cached);
}
}
public async ValueTask SetAsync(
string key, byte[] value, TimeSpan validFor,
string[]? varyByRules, CancellationToken token)
{
try
{
await _redisStore.SetAsync(key, value, validFor, varyByRules, token);
}
catch (Exception ex)
{
_logger.LogWarning(ex, "Redis 缓存设置失败,降级到内存缓存");
await _memoryCache.SetStringAsync(
key, Convert.ToBase64String(value),
new DistributedCacheEntryOptions { AbsoluteExpirationRelativeToNow = validFor },
token);
}
}
public async ValueTask EvictByTagAsync(string tag, CancellationToken token)
{
try
{
await _redisStore.EvictByTagAsync(tag, token);
}
catch (Exception ex)
{
_logger.LogWarning(ex, "Redis 缓存失效失败");
}
}
}缓存锁定(Cache Locking)
缓存锁定防止缓存过期时大量请求同时回源(缓存击穿/Cache Stampede):
// 启用缓存锁定
builder.Services.AddOutputCache(options =>
{
options.AddPolicy("SafeCache", builder =>
{
builder
.Expire(TimeSpan.FromMinutes(5))
.SetLockTimeout(TimeSpan.FromSeconds(30)); // 锁定超时时间
});
});
// 在 Minimal API 中使用
app.MapGet("/api/expensive", () =>
{
// 模拟耗时操作
Thread.Sleep(2000);
return new { Data = "expensive computation result" };
})
.CacheOutput(x => x
.Expire(TimeSpan.FromMinutes(1))
.SetLockTimeout(TimeSpan.FromSeconds(10)));缓存锁定的工作原理
时间线:
T0: 缓存过期
T1: 请求 A 到达 -> 获取锁 -> 回源查询
T2: 请求 B 到达 -> 等待锁(不回源)
T3: 请求 C 到达 -> 等待锁(不回源)
T4: 请求 A 完成 -> 写入缓存 -> 释放锁
T5: 请求 B 从缓存获取结果
T6: 请求 C 从缓存获取结果
没有锁定:
T0: 缓存过期
T1: 1000 个请求同时到达 -> 1000 个回源查询 -> 数据库过载可观测性
缓存命中率监控
/// <summary>
/// 缓存命中率监控中间件
/// </summary>
public class CacheMonitoringMiddleware
{
private readonly RequestDelegate _next;
private static long _totalRequests;
private static long _cacheHits;
public CacheMonitoringMiddleware(RequestDelegate next)
{
_next = next;
}
public async Task InvokeAsync(HttpContext context)
{
Interlocked.Increment(ref _totalRequests);
// 输出缓存中间件会设置此响应头
var cacheHitBefore = context.Response.Headers.ContainsKey("X-Cache");
await _next(context);
if (context.Response.Headers.TryGetValue("X-Cache", out var cacheStatus))
{
if (cacheStatus == "HIT")
{
Interlocked.Increment(ref _cacheHits);
}
}
}
public static (long Total, long Hits, double HitRate) GetStats()
{
var total = Interlocked.Read(ref _totalRequests);
var hits = Interlocked.Read(ref _cacheHits);
var rate = total > 0 ? (double)hits / total * 100 : 0;
return (total, hits, rate);
}
}
// 暴露缓存统计端点
app.MapGet("/api/cache/stats", () =>
{
var (total, hits, rate) = CacheMonitoringMiddleware.GetStats();
return new
{
TotalRequests = total,
CacheHits = hits,
HitRate = $"{rate:F2}%",
CacheMisses = total - hits
};
});结构化日志
// 配置输出缓存的日志级别
builder.Logging.AddFilter("Microsoft.AspNetCore.OutputCaching",
LogLevel.Information);
// 在缓存失效时记录日志
public class CacheEvictionLoggingService
{
private readonly IOutputCacheStore _cacheStore;
private readonly ILogger<CacheEvictionLoggingService> _logger;
public CacheEvictionLoggingService(
IOutputCacheStore cacheStore,
ILogger<CacheEvictionLoggingService> logger)
{
_cacheStore = cacheStore;
_logger = logger;
}
public async Task EvictWithLoggingAsync(string tag, string reason)
{
_logger.LogInformation(
"清除缓存标签: {Tag}, 原因: {Reason}, 时间: {Time}",
tag, reason, DateTime.UtcNow);
var sw = Stopwatch.StartNew();
await _cacheStore.EvictByTagAsync(tag, CancellationToken.None);
sw.Stop();
_logger.LogInformation(
"缓存清除完成: {Tag}, 耗时: {ElapsedMs}ms",
tag, sw.ElapsedMilliseconds);
}
}缓存策略对比
| 策略 | 位置 | 适用场景 | 失效方式 |
|---|---|---|---|
| 输出缓存 | 服务端内存/Redis | API 响应缓存 | 标签失效/过期 |
| 响应缓存 | 客户端/代理 | 静态内容 | HTTP 头控制 |
| 内存缓存 | 服务端内部 | 数据库查询缓存 | 过期/手动移除 |
| 分布式缓存 | Redis/共享 | 多实例共享 | 过期/手动删除 |
| CDN 缓存 | 边缘节点 | 全球加速 | API 调用/过期 |
优点
缺点
总结
输出缓存核心:services.AddOutputCache 注册策略,app.UseOutputCache 启用中间件。控制器用 [OutputCache] 特性,Minimal API 用 .CacheOutput()。缓存标签(Tag)实现精准失效,通过 IOutputCacheStore.EvictByTagAsync 清除。VaryByQueryKeys/VaryByHeaderNames 区分不同请求的缓存。生产环境用 Redis 后端支持多实例共享。适合读多写少、数据变化不频繁的接口。务必配合缓存锁定防止击穿,配合监控观察命中率。
关键知识点
- 先分清这个主题位于请求链路、后台任务链路还是基础设施链路。
- 服务端主题通常不只关心功能正确,还关心稳定性、性能和可观测性。
- 任何框架能力都要结合配置、生命周期、异常传播和外部依赖一起看。
- 缓存与开关类主题都在处理"配置/数据与运行时行为之间的解耦"。
项目落地视角
- 画清请求进入、业务执行、外部调用、日志记录和错误返回的完整路径。
- 为关键链路补齐超时、重试、熔断、追踪和结构化日志。
- 把配置与敏感信息分离,并明确不同环境的差异来源。
- 明确 Key 设计、过期策略、回源逻辑和降级方案。
常见误区
- 只会堆中间件或组件,不知道它们在链路中的执行顺序。
- 忽略生命周期和线程池、连接池等运行时资源约束。
- 没有监控和测试就对性能或可靠性下结论。
- 只加缓存,不设计失效与一致性策略。
- 忽略缓存锁定,在高并发场景下遭受缓存击穿。
- 缓存过大的响应体(如大文件下载),导致内存溢出。
- 忘记 VaryBy 配置,导致不同参数的请求返回相同的缓存。
进阶路线
- 继续向运行时行为、可观测性、发布治理和微服务协同深入。
- 把主题和数据库、缓存、消息队列、认证授权联动起来理解。
- 沉淀团队级模板,包括统一异常处理、配置约定和基础设施封装。
- 继续补齐多级缓存、缓存预热、分布式缓存治理和旗标管理平台。
适用场景
- 当你准备把《输出缓存》真正落到项目里时,最适合先在一个独立模块或最小样例里验证关键路径。
- 适合 API 服务、后台任务、实时通信、认证授权和微服务协作场景。
- 当需求开始涉及稳定性、性能、可观测性和发布流程时,这类主题会成为基础设施能力。
落地建议
- 先定义请求链路与失败路径,再决定中间件、过滤器、服务边界和依赖方式。
- 为关键链路补日志、指标、追踪、超时与重试策略。
- 环境配置与敏感信息分离,避免把生产参数写死在代码或镜像里。
- 为缓存添加命中率监控,作为性能优化的依据。
排错清单
- 先确认问题发生在路由、模型绑定、中间件、业务层还是基础设施层。
- 检查 DI 生命周期、配置来源、序列化规则和认证上下文。
- 查看线程池、连接池、缓存命中率和外部依赖超时。
- 检查
app.UseOutputCache()是否在app.MapControllers()之前注册。 - 检查
MaximumBodySize是否足够容纳响应体。 - 检查 VaryBy 配置是否正确,不同参数是否生成了不同的缓存键。
复盘问题
- 如果把《输出缓存》放进你的当前项目,最先要验证的输入、输出和失败路径分别是什么?
- 《输出缓存》最容易在什么规模、什么边界条件下暴露问题?你会用什么指标或日志去确认?
- 相比默认实现或替代方案,采用《输出缓存》最大的收益和代价分别是什么?
