输出缓存深入
大约 9 分钟约 2828 字
输出缓存深入
简介
输出缓存(Output Cache)缓存的是完整 HTTP 响应,而不是业务对象本身。它特别适合读多写少、响应可复用的接口,例如商品列表、文章详情、地区配置等;但如果缓存粒度、失效策略或变体维度设计不好,也很容易出现脏数据或缓存爆炸。
输出缓存 vs 其他缓存
缓存层次:
1. CDN 缓存(边缘节点)
- 位置:CDN 边缘
- 优势:全球加速,离用户最近
- 控制:Cache-Control / ETag 头
2. 输出缓存(服务端 HTTP 响应)
- 位置:ASP.NET Core 中间件
- 优势:跳过整个管道
- 控制:标签、策略、变体
3. 分布式缓存(Redis — 业务对象)
- 位置:服务端代码
- 优势:灵活,可缓存任意对象
- 控制:TTL、手动删除
4. 内存缓存(IMemoryCache)
- 位置:进程内
- 优势:最快
- 控制:TTL、滑动过期
选择原则:
- 纯静态/公开数据 → CDN + 输出缓存
- 高频 API 响应 → 输出缓存
- 复杂业务对象 → 分布式缓存
- 临时计算结果 → 内存缓存特点
实现
基础策略:过期、变体与标签
// ============================================
// Program.cs — 输出缓存配置
// ============================================
using Microsoft.AspNetCore.OutputCaching;
var builder = WebApplication.CreateBuilder(args);
builder.Services.AddOutputCache(options =>
{
// 基础策略 — 默认 30 秒
options.AddBasePolicy(policy =>
policy.Cache()
.Expire(TimeSpan.FromSeconds(30))
.SetVaryByQuery("culture"));
// 产品列表策略 — 2 分钟,按分类和分页区分
options.AddPolicy("product-list", policy =>
policy.Cache()
.Expire(TimeSpan.FromMinutes(2))
.SetVaryByQuery("page", "pageSize", "categoryId", "keyword")
.Tag("products", "product-list"));
// 产品详情策略 — 10 分钟
options.AddPolicy("product-detail", policy =>
policy.Cache()
.Expire(TimeSpan.FromMinutes(10))
.SetVaryByRouteValue("id")
.Tag("products"));
// 地区配置策略 — 1 小时
options.AddPolicy("regions", policy =>
policy.Cache()
.Expire(TimeSpan.FromHours(1))
.Tag("regions"));
// 禁用缓存策略
options.AddPolicy("no-cache", policy => policy.NoCache());
// 最大缓存条目大小
options.MaximumBodySize = 10 * 1024 * 1024; // 10MB
});
builder.Services.AddControllers();
var app = builder.Build();
app.UseOutputCache();
app.MapControllers();
app.Run();// ============================================
// Controller 使用输出缓存
// ============================================
[ApiController]
[Route("api/products")]
public class ProductsController : ControllerBase
{
private readonly AppDbContext _dbContext;
public ProductsController(AppDbContext dbContext)
{
_dbContext = dbContext;
}
// 产品列表 — 分页 + 分类筛选
[HttpGet]
[OutputCache(PolicyName = "product-list")]
public async Task<IActionResult> GetList(
[FromQuery] int page = 1,
[FromQuery] int pageSize = 20,
[FromQuery] int? categoryId = null,
[FromQuery] string? keyword = null)
{
var query = _dbContext.Products.AsNoTracking().Where(x => x.IsOnline);
if (categoryId.HasValue)
query = query.Where(x => x.CategoryId == categoryId.Value);
if (!string.IsNullOrEmpty(keyword))
query = query.Where(x => x.Name.Contains(keyword));
var total = await query.CountAsync();
var items = await query
.OrderByDescending(x => x.Id)
.Skip((page - 1) * pageSize)
.Take(pageSize)
.Select(x => new { x.Id, x.Name, x.Price })
.ToListAsync();
return Ok(new { items, total, page, pageSize });
}
// 产品详情
[HttpGet("{id:int}")]
[OutputCache(PolicyName = "product-detail")]
public async Task<IActionResult> GetById(int id)
{
var product = await _dbContext.Products
.AsNoTracking()
.Where(x => x.Id == id && x.IsOnline)
.Select(x => new { x.Id, x.Name, x.Price, x.Description })
.FirstOrDefaultAsync();
return product is null ? NotFound() : Ok(product);
}
}数据变更后的主动失效
// ============================================
// 管理端 — 修改后主动失效缓存
// ============================================
[ApiController]
[Route("api/admin/products")]
public class ProductAdminController : ControllerBase
{
private readonly AppDbContext _dbContext;
private readonly IOutputCacheStore _outputCacheStore;
private readonly ILogger<ProductAdminController> _logger;
public ProductAdminController(
AppDbContext dbContext,
IOutputCacheStore outputCacheStore,
ILogger<ProductAdminController> logger)
{
_dbContext = dbContext;
_outputCacheStore = outputCacheStore;
_logger = logger;
}
/// <summary>
/// 更新产品 — 失效产品相关缓存
/// </summary>
[HttpPut("{id:int}")]
public async Task<IActionResult> Update(int id, UpdateProductRequest request, CancellationToken ct)
{
var product = await _dbContext.Products.FindAsync(new object[] { id }, ct);
if (product is null) return NotFound();
product.Name = request.Name;
product.Price = request.Price;
product.Description = request.Description;
product.UpdatedAt = DateTimeOffset.UtcNow;
await _dbContext.SaveChangesAsync(ct);
// 按标签失效
await _outputCacheStore.EvictByTagAsync("products", ct);
_logger.LogInformation("产品已更新,缓存已失效: ProductId={ProductId}", id);
return NoContent();
}
/// <summary>
/// 创建产品 — 失效列表缓存
/// </summary>
[HttpPost]
public async Task<IActionResult> Create(CreateProductRequest request, CancellationToken ct)
{
var product = new Product
{
Name = request.Name,
Price = request.Price,
CategoryId = request.CategoryId,
IsOnline = true
};
_dbContext.Products.Add(product);
await _dbContext.SaveChangesAsync(ct);
// 失效产品列表缓存
await _outputCacheStore.EvictByTagAsync("product-list", ct);
return Created($"/api/products/{product.Id}", new { product.Id });
}
}自定义策略:只缓存匿名 GET 200 响应
// ============================================
// 自定义输出缓存策略 — 精细控制
// ============================================
using Microsoft.AspNetCore.OutputCaching;
/// <summary>
/// 只缓存匿名用户的 GET 200 响应
/// </summary>
public sealed class AnonymousGetOnlyPolicy : IOutputCachePolicy
{
public static readonly AnonymousGetOnlyPolicy Instance = new();
public ValueTask CacheRequestAsync(OutputCacheContext context, CancellationToken ct)
{
var request = context.HttpContext.Request;
var user = context.HttpContext.User;
var isGet = HttpMethods.IsGet(request.Method);
var isAnonymous = user.Identity?.IsAuthenticated != true;
context.EnableOutputCaching = isGet && isAnonymous;
context.AllowCacheLookup = isGet && isAnonymous;
context.AllowCacheStorage = isGet && isAnonymous;
context.AllowLocking = true;
return ValueTask.CompletedTask;
}
public ValueTask ServeFromCacheAsync(OutputCacheContext context, CancellationToken ct)
=> ValueTask.CompletedTask;
public ValueTask ServeResponseAsync(OutputCacheContext context, CancellationToken ct)
{
// 只缓存 200 状态码
if (context.HttpContext.Response.StatusCode != StatusCodes.Status200OK)
{
context.AllowCacheStorage = false;
}
return ValueTask.CompletedTask;
}
}
// 注册
builder.Services.AddOutputCache(options =>
{
options.AddPolicy("anonymous-only", AnonymousGetOnlyPolicy.Instance);
});
// 使用
app.MapGet("/api/articles/{slug}", async (string slug, CmsDbContext db) =>
{
var article = await db.Articles.AsNoTracking()
.Where(x => x.Slug == slug && x.IsPublished)
.Select(x => new { x.Title, x.Summary, x.ContentHtml })
.FirstOrDefaultAsync();
return article is null ? Results.NotFound() : Results.Ok(article);
})
.CacheOutput("anonymous-only");缓存变体维度控制
// ============================================
// 变体维度 — 避免缓存爆炸
// ============================================
// 按查询参数变体(谨慎使用 — 参数越多缓存条目越多)
app.MapGet("/api/search", (string q, int page) => Results.Ok(new { q, page }))
.CacheOutput(p => p
.Expire(TimeSpan.FromMinutes(5))
.SetVaryByQuery("q", "page") // 只按 q 和 page 区分
// 不要加太多参数,否则缓存条目爆炸
.Tag("search"));
// 按请求头变体(多语言场景)
app.MapGet("/api/news", (string lang) => Results.Ok(new { lang, news = "..." }))
.CacheOutput(p => p
.Expire(TimeSpan.FromMinutes(10))
.VaryByHeader("Accept-Language") // 按语言区分
.Tag("news"));
// 按路由值变体
app.MapGet("/api/orders/{orderId}", (int orderId) => Results.Ok(new { orderId }))
.CacheOutput(p => p
.Expire(TimeSpan.FromSeconds(30))
.VaryByRouteValue("orderId")
.Tag($"order:{orderId}"));
// 按用户变体(谨慎 — 用户级缓存条目数 = 用户数 x 接口数)
app.MapGet("/api/profile", (ClaimsPrincipal user) => Results.Ok(new { }))
.CacheOutput(p => p
.Expire(TimeSpan.FromSeconds(10))
.VaryByUserClaim(ClaimTypes.NameIdentifier)
.Tag("profiles"));Redis 分布式输出缓存
// ============================================
// 多实例部署时使用 Redis 作为输出缓存存储
// ============================================
// 安装 NuGet 包
// dotnet add package Microsoft.Extensions.OutputCaching.StackExchangeRedis
var builder = WebApplication.CreateBuilder(args);
// 配置 Redis 输出缓存
builder.Services.AddStackExchangeRedisOutputCache(options =>
{
options.Configuration = builder.Configuration.GetConnectionString("Redis");
options.InstanceName = "outputcache:";
});
builder.Services.AddOutputCache(options =>
{
options.AddBasePolicy(policy =>
policy.Cache()
.Expire(TimeSpan.FromSeconds(30)));
options.AddPolicy("products", policy =>
policy.Cache()
.Expire(TimeSpan.FromMinutes(5))
.SetVaryByQuery("page", "categoryId")
.Tag("products"));
});
var app = builder.Build();
app.UseOutputCache();
// Redis 输出缓存的注意事项:
// 1. 实例名称前缀避免 Key 冲突
// 2. 序列化后的响应体较大,注意 Redis 内存规划
// 3. EvictByTagAsync 在 Redis 模式下使用 SCAN + DEL
// 4. 适合多实例部署,所有节点共享同一份缓存自定义 IOutputCacheStore
// ============================================
// 自定义缓存存储 — 双层缓存(内存 + Redis)
// ============================================
public class TieredOutputCacheStore : IOutputCacheStore
{
private readonly IOutputCacheStore _redisStore;
private readonly MemoryCache _localCache;
private readonly ILogger<TieredOutputCacheStore> _logger;
public TieredOutputCacheStore(
IOutputCacheStore redisStore,
ILogger<TieredOutputCacheStore> logger)
{
_redisStore = redisStore;
_localCache = new MemoryCache(new MemoryCacheOptions { SizeLimit = 1000 });
_logger = logger;
}
public async ValueTask<byte[]?> GetAsync(string key, CancellationToken ct)
{
// 先查本地缓存
if (_localCache.TryGetValue(key, out byte[]? localValue) && localValue != null)
{
_logger.LogDebug("本地缓存命中: {Key}", key);
return localValue;
}
// 再查 Redis
var redisValue = await _redisStore.GetAsync(key, ct);
if (redisValue != null)
{
// 回填本地缓存(10 秒本地过期)
_localCache.Set(key, redisValue, new MemoryCacheEntryOptions
{
Size = 1,
SlidingExpiration = TimeSpan.FromSeconds(10)
});
}
return redisValue;
}
public async ValueTask SetAsync(string key, byte[] value, string[]? tags, TimeSpan validFor, CancellationToken ct)
{
// 写入两层缓存
_localCache.Set(key, value, new MemoryCacheEntryOptions
{
Size = 1,
AbsoluteExpirationRelativeToNow = validFor
});
await _redisStore.SetAsync(key, value, tags, validFor, ct);
}
public async ValueTask EvictByTagAsync(string tag, CancellationToken ct)
{
_logger.LogInformation("按标签失效缓存: {Tag}", tag);
await _redisStore.EvictByTagAsync(tag, ct);
// 本地缓存无法按标签失效,等待自然过期
}
public async ValueTask RemoveAsync(string key, CancellationToken ct)
{
_localCache.Remove(key);
await _redisStore.RemoveAsync(key, ct);
}
}
// 注册自定义存储
builder.Services.AddSingleton<IOutputCacheStore>(sp =>
{
var redisStore = new RedisOutputCacheStore(/* redis config */);
var logger = sp.GetRequiredService<ILogger<TieredOutputCacheStore>>();
return new TieredOutputCacheStore(redisStore, logger);
});缓存预热策略
// ============================================
// 应用启动时预热热点缓存
// ============================================
public class CacheWarmupService : BackgroundService
{
private readonly IServiceProvider _serviceProvider;
private readonly ILogger<CacheWarmupService> _logger;
private readonly IConfiguration _configuration;
public CacheWarmupService(
IServiceProvider serviceProvider,
ILogger<CacheWarmupService> logger,
IConfiguration configuration)
{
_serviceProvider = serviceProvider;
_logger = logger;
_configuration = configuration;
}
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
{
// 等待应用完全启动
await Task.Delay(TimeSpan.FromSeconds(5), stoppingToken);
_logger.LogInformation("开始缓存预热...");
using var scope = _serviceProvider.CreateScope();
var httpClientFactory = scope.ServiceProvider.GetRequiredService<IHttpClientFactory>();
var baseUrl = _configuration["Warmup:BaseUrl"] ?? "http://localhost:5000";
var warmupPaths = _configuration.GetSection("Warmup:Paths").Get<string[]>() ?? Array.Empty<string>();
using var httpClient = httpClientFactory.CreateClient("WarmupClient");
httpClient.Timeout = TimeSpan.FromSeconds(30);
foreach (var path in warmupPaths)
{
try
{
var url = $"{baseUrl}{path}";
var response = await httpClient.GetAsync(url, stoppingToken);
_logger.LogInformation("预热: {Path} -> {StatusCode}", path, response.StatusCode);
}
catch (Exception ex)
{
_logger.LogWarning(ex, "预热失败: {Path}", path);
}
}
_logger.LogInformation("缓存预热完成");
}
}
// appsettings.json 配置
// {
// "Warmup": {
// "BaseUrl": "http://localhost:5000",
// "Paths": [
// "/api/products?page=1&pageSize=20",
// "/api/regions",
// "/api/categories"
// ]
// }
// }
// 注册预热服务
builder.Services.AddHostedService<CacheWarmupService>();缓存锁定 — 防止缓存击穿
// 启用缓存锁定
builder.Services.AddOutputCache(options =>
{
options.AddPolicy("safe-cache", policy =>
{
policy.Cache()
.Expire(TimeSpan.FromMinutes(5))
.SetLockTimeout(TimeSpan.FromSeconds(10)); // 锁定 10 秒
});
});优点
缺点
总结
输出缓存适合放在"高频读取 + 低频变更 + 响应可复用"的 HTTP 接口上。真正决定效果的不是 CacheOutput() 这一行代码,而是变体键、TTL、标签失效和是否允许缓存个性化响应这些边界条件。
关键知识点
- 输出缓存缓存的是 HTTP 响应,不是 EF 查询结果。
- 先设计"哪些接口能缓存",再设计"缓存多久、如何失效"。
- 涉及用户身份、权限、库存、价格的接口要格外谨慎。
- Tag 设计应贴近业务实体,如
products、categories、regions。
项目落地视角
- 商品列表、文章详情、地区字典等接口通常最适合先落地。
- 后台管理修改商品后,统一按
products标签失效。 - 大促期间把热点详情接口 TTL 缩短,降低脏数据窗口。
- 为缓存命中率、失效率、回源耗时建立独立监控面板。
常见误区
- 只配置过期时间,不设计主动失效策略。
- 使用过多查询参数或 Header 作为变体,导致缓存膨胀。
- 把登录后个性化接口也直接缓存,造成数据串号。
- 以为输出缓存能替代对象缓存、查询缓存的所有职责。
进阶路线
- 进一步研究 CDN、反向代理缓存与服务端输出缓存的分层关系。
- 为高流量接口加入缓存预热与热点保护。
- 结合 Redis / 多实例部署设计统一失效策略。
- 为缓存层补充观测:命中率、驱逐率、失效耗时、回源慢查询。
适用场景
- 商品列表、新闻详情、公告页等公开只读接口。
- 配置字典、地区列表、标签列表等低频变化数据。
- CMS 内容页、帮助中心、知识库文档接口。
- 高并发读场景下需要快速缓解数据库压力的接口。
落地建议
- 先从匿名 GET 接口开始,不要一开始覆盖所有 API。
- 变体键只保留真正影响响应内容的维度。
- 每个缓存策略都要有明确的 TTL 和失效来源。
- 把缓存命中日志与业务更新日志串起来,便于追踪脏数据。
排错清单
- 检查
app.UseOutputCache()是否放在正确位置。 - 检查响应是否是 GET/HEAD、状态码是否允许缓存。
- 检查
SetVaryByQuery/ Header 维度是否遗漏或过多。 - 检查写接口是否在数据变更后正确调用
EvictByTagAsync()。
复盘问题
- 这个接口的响应是否真的能被多个请求复用?
- 数据更新后,最迟多久必须让用户看到新结果?
- 如果缓存挂掉,系统是否仍能安全回源?
- 当前缓存策略带来的收益是否值得其一致性成本?
