提到响应缓存,很多朋友第一反应就是“给页面加个缓存头,让浏览器别老来烦服务器”。但在实际用ASP.NET Core的时候,会发现事情没那么简单:明明给接口加了响应缓存中间件,结果用户还是看到了旧数据,或者不同设备拿到的内容串了。这背后往往是Cache-Control和Vary头没有配合好,导致缓存生命周期失控。今天就用大白话把这个问题讲透,顺便带大家把常用的配置写对。

一、先把响应缓存中间件的基本用法捋清楚

1.1 中间件是什么,它干了啥

简单说,响缓存中间件就是架在服务器和客户端之间的一个“仓库管理员”。当某个请求第一次到达时,它会跟服务器说“你把结果给我,我记下来”;之后同样的请求再来,它就直接把记下来的结果递给客户端,不再去麻烦后面的MVC代码。这个“记下来”的结果,就是缓存的响应。

在ASP.NET Core里开启这个功能很简单。首先在Program.cs里注册服务:

// 技术栈:ASP.NET Core (C#)

var builder = WebApplication.CreateBuilder(args);

// 注册响应缓存服务(必须在AddControllers之前或之后都行,但必须在Use中间件之前)
builder.Services.AddResponseCaching();

var app = builder.Build();

// 启用响应缓存中间件(这里顺序很关键,尽量放在UseRouting之前)
app.UseResponseCaching();

app.MapGet("/hello", () => "Hello World!");
app.Run();

光这样还不够,我们还需要在接口上标记“这个响应可以被缓存”,否则中间件是不动手的。比如:

// 技术栈:ASP.NET Core (C#)

app.MapGet("/time", () => DateTime.Now.ToString("HH:mm:ss.fff"))
   .RequireResponseCaching(); // 标记允许缓存

但如果你直接跑起来测试,会发现这个接口根本没有被缓存。为什么?因为中间件有个默认规则:响应必须带有Cache-Control头,且值里包含public或private时才会缓存。如果啥头都没有,它就不管。这就引出了第二个关键点。

二、Cache-Control才是缓存生命周期的总控开关

2.1 没有明确的过期时间,缓存就会“赖着不走”

很多开发者在调试时发现,明明改了数据库,页面还是返回老数据。第一反应是“是不是中间件坏了?”其实问题多半出在Cache-Control上。比如我们写一个接口,返回用户信息,但忘记设置Cache-Control,中间件压根不缓存,这反而不出现过期问题。可一旦我们手动加了一个max-age,却没考虑数据什么时候失效,问题就来了。

看个典型的错误示范:

// 技术栈:ASP.NET Core (C#)

app.MapGet("/user/profile", async (HttpContext context) =>
{
    // 模拟从数据库读取用户信息
    var user = await GetUserFromDbAsync();

    // 手动设置Cache-Control:允许公共缓存,缓存1小时
    context.Response.Headers["Cache-Control"] = "public, max-age=3600";

    return Results.Json(user);
});

这里用户资料一个小时不变吗?不一定。如果用户的昵称改了,浏览器和中间件却还抱着一个小时前的数据,用户一看“唉?我改名怎么没生效?”这就是页面返回过期内容的直接原因。Cache-Control里的max-age规定了“新鲜期”,但新鲜期到底多长,得由业务逻辑来决定。你拍脑袋定一个3600秒,可能就拍错了。

2.2 中间件怎么处理Cache-Control

ASP.NET Core中间件内部会判断响应里的Cache-Control,如果存在且包含public或private,它就会把响应存储起来。默认还会加上一个“缓存查询”逻辑。但有一点要注意:如果响应里带了Authorization头,或者Set-Cookie头,默认是不会缓存的。另外,状态码必须是200才能缓存。

还有个容易踩坑的地方:max-age过期后,中间件不会自动去源服务器取新数据,它会把旧的响应直接丢掉,然后让请求重新走一次管线。所以“过期”不是“更新”,而是“作废”。如果你希望过期后还能用旧的,那就要配合must-revalidate之类的指令,这里先不展开。

2.3 用示例理解生命周期

我们做一个带缓存的小接口,打印当前时间,然后故意把max-age设成30秒:

// 技术栈:ASP.NET Core (C#)

app.MapGet("/cache-time", (HttpContext context) =>
{
    // 告诉中间件:这个响应可以被任何人缓存,30秒内有效
    context.Response.Headers["Cache-Control"] = "public, max-age=30";

    // 当前时间精确到秒
    var now = DateTime.Now.ToString("T");

    return Results.Text($"当前时间: {now}");
});

第一次访问,服务器生成时间,中间件存起来。30秒内再访问,中间件直接返回存储的内容,不走后面的代码。30秒后,缓存项过期,中间件再次放请求进去,生成新时间。这个流程大家都懂。但问题来了:如果这个接口是带查询参数的,比如 ?city=beijing?city=shanghai,它们会被区分开吗?这就轮到Vary头登场了。

三、Vary头:决定缓存“认不认人”的身份证

3.1 Vary头是干嘛的

Cache-Control控制的是“缓存多久”,Vary控制的是“缓存按什么区分”。换句话说,Vary告诉缓存中间件:同样一个URL,可能因为某个请求头不同而返回不同内容,你可别把它们混在一起。

最常见的场景就是“用户代理”。同一个网址,手机端和PC端渲染的页面不一样。如果中间件只管URL,不管User-Agent,那手机用户第一次访问后,电脑用户拿到的就是手机版页面。这是典型的“串缓存”,比过期更麻烦——因为数据是新鲜的,但内容完全是别人的。

在ASP.NET Core里,我们可以手动给响应添加Vary头:

// 技术栈:ASP.NET Core (C#)

app.MapGet("/page", (HttpContext context) =>
{
    // 判断是手机还是PC,返回不同视图
    var isMobile = context.Request.Headers["User-Agent"].ToString()
                        .Contains("Mobile");

    // 关键:告诉缓存,要按User-Agent区分缓存项
    context.Response.Headers["Vary"] = "User-Agent";
    context.Response.Headers["Cache-Control"] = "public, max-age=60";

    return isMobile 
        ? Results.Text("手机版页面") 
        : Results.Text("电脑版页面");
});

这样设置后,中间件会把“URL+User-Agent的值”作为缓存键的一部分。即便URL一样,只要User-Agent不一样,就会存两份副本,各看各的,互不干扰。

3.2 多个Vary组合

有时需要按照多个维度区分,比如既要区分设备,又要区分语言。可以用逗号把多个头写在一起:

// 技术栈:ASP.NET Core (C#)

app.MapGet("/multi-vary", (HttpContext context) =>
{
    context.Response.Headers["Vary"] = "User-Agent, Accept-Language";
    context.Response.Headers["Cache-Control"] = "public, max-age=60";

    // 读取请求里的语言
    var lang = context.Request.Headers["Accept-Language"].ToString();

    // 这里简化为直接返回语言信息
    return Results.Text($"语言:{lang}");
});

注意,这里Vary的值是请求头的名称,不是值。中间件在判断缓存键时,会把对应请求头的具体值拿过来拼到键里。如果某个请求头不存在,那就用空字符串。

3.3 Vary: * 的坑

有些人图省事,直接写 Vary: *。意思是“所有请求头都参与区分”,这样做会导致缓存几乎失效。因为任何细微的Header差异都可能产生一个新缓存副本,中间件存储空间很快被占满,缓存命中率骤降。除非你明确知道自己在做什么,否则千万别用星号。

四、Cache-Control与Vary如何协同管理生命周期

4.1 协同的核心:一个管“多久”,一个管“谁的”

打个比方:Cache-Control像是一瓶牛奶的保质期,它告诉你“在这个时间之前可以喝”。Vary像是什么口味的标签,它决定“这瓶是草莓味还是原味”。超市里不能只贴保质期不贴口味,否则顾客随手拿一瓶,喝到的口味可能不是自己想要的。反过来,只贴口味不贴保质期,货架上全是过期牛奶。两者必须同时存在,缓存系统才健康。

在ASP.NET Core里,这两个头是独立添加的,但它们共同组成缓存的“身份+寿命”二元组。身份由URL和Vary决定,寿命由Cache-Control决定。中间件在存储时,键 = URL + 所有Vary指定请求头的值;值 = 响应内容 + 过期时间。每次请求来,先算键,找到后再看有没有过期。过期就丢弃,重新生成。

4.2 协同失效的典型场景

我们写个实际例子:一个接口返回用户个性化推荐,同一个URL,登录用户和游客看到的内容不同。如果只设Cache-Control,不设Vary,会发生什么?

// 技术栈:ASP.NET Core (C#)

app.MapGet("/recommend", (HttpContext context) =>
{
    // 获取当前用户ID(实际可能从JWT或Cookie中解析)
    var userIdClaim = context.User.FindFirst("id")?.Value;
    
    // 根据用户ID生成推荐内容(这里简化处理)
    var content = userIdClaim == null 
        ? "游客推荐:大家都在看" 
        : $"用户{userIdClaim}的专属推荐";

    // 错误示范:只设置了Cache-Control
    context.Response.Headers["Cache-Control"] = "public, max-age=600";

    return Results.Text(content);
});

两个不同用户访问同一个URL,没有Vary头,中间件就认为请求完全一样。第一个用户A访问后,缓存条目里存的是A的专属推荐。接下来用户B访问,中间件一看URL一样,也没过期,直接把A的内容返回给B。这不仅是“串味”,还是严重的数据泄露。更恐怖的是,如果中间件部署在CDN上,全广州的B用户可能都看到A的推荐。

正确的做法是:对于这种个性化内容,要么不缓存,要么用Vary把用户区分开。但用户ID通常在Cookie里,而Cookie不是请求头的一部分(实际上是 Cookie 头)。我们可以这么写:

// 技术栈:ASP.NET Core (C#)

app.MapGet("/recommend-safe", (HttpContext context) =>
{
    // 从Cookie头中提取用户会话ID
    var session = context.Request.Headers["Cookie"].ToString();

    // 把Cookie头作为Vary的区分依据
    context.Response.Headers["Vary"] = "Cookie";
    context.Response.Headers["Cache-Control"] = "public, max-age=600";

    // 下面根据session生成内容,省略具体逻辑
    return Results.Text($"会话标识:{session}");
});

但把Cookie整个作为Vary,粒度太大,因为Cookie里可能有不影响响应内容的项(比如广告追踪ID),导致缓存副本过多。更合理的方式是:如果内容跟登录状态相关,干脆不设 public,改用 privateprivate 意味着只允许客户端缓存,中间件和CDN都不缓存。这样B用户虽然可能命中浏览器本地缓存,但不会拿到A用户的数据。

4.3 演示一个“安全又高效”的写法

假设有一个接口,内容对所有匿名用户一样,但登录用户看到自己的资料。我们可以这样设计:

// 技术栈:ASP.NET Core (C#)

app.MapGet("/profile-cached", (HttpContext context) =>
{
    // 判断是否登录(简化:看有没有token头)
    var hasToken = context.Request.Headers.ContainsKey("X-Token");

    if (hasToken)
    {
        // 登录用户:返回个性化信息,只允许浏览器私有缓存
        context.Response.Headers["Cache-Control"] = "private, max-age=120";
        return Results.Json(new { Name = "张三", RecentOrders = 5 });
    }
    else
    {
        // 匿名用户:所有人都一样,可以用公共缓存,并区分语言
        context.Response.Headers["Vary"] = "Accept-Language";
        context.Response.Headers["Cache-Control"] = "public, max-age=300";
        return Results.Text("欢迎浏览公开资料");
    }
});

这个例子里,对于匿名用户,数据大家都一样,所以放心让中间件缓存,并且用Vary区分语言。对于登录用户,中间件看到private后不会在服务器里存,但浏览器自己会存120秒。这样既不泄露数据,又能减轻服务器压力。

五、常见配置不当的“灾难现场”分析

5.1 忘了设置Cache-Control导致中间件形同虚设

很多新手以为加了 UseResponseCaching 就万事大吉,其实中间件默认要求响应带 Cache-Control 且包含 publicprivate。你忘了设置,中间件直接忽略,根本不会缓存。这不算坏结果,只是性能没提升。但反过来,如果设置了却设错了,麻烦更大。

5.2 把Vary写在代码里,但中间件不认

注意,Vary头必须写在响应头里,而且要在响应开始发送之前设置。有些人喜欢在控制器里通过 [ResponseCache] 特性设置,这是推荐做法。但如果你用中间件管道里的代码设置,务必确保在 next 之前。看个反例:

// 技术栈:ASP.NET Core (C#)

app.Use(async (context, next) =>
{
    // 错误:先调用了next,响应已经生成,再设置头就晚了
    await next();
    context.Response.Headers["Cache-Control"] = "public, max-age=60";
});

next() 执行完,响应可能已经发出去了,再设置头没意义,甚至会抛出异常。正确做法是在调用 next 之前设置:

// 技术栈:ASP.NET Core (C#)

app.Use(async (context, next) =>
{
    // 正确:在next之前设置
    context.Response.Headers["Cache-Control"] = "public, max-age=60";
    context.Response.Headers["Vary"] = "User-Agent";
    await next();
});

但更推荐用 [ResponseCache] 特性,它直接在控制器或接口上声明,清晰且不容易出错。下面展示一个完整的控制器示例。

5.3 教你用[ResponseCache]特性优雅设置

// 技术栈:ASP.NET Core (C#)
using Microsoft.AspNetCore.Mvc;

[ApiController]
[Route("api/items")]
public class ItemsController : ControllerBase
{
    // 给该接口设置缓存:缓存60秒,按User-Agent区分
    [HttpGet]
    [ResponseCache(Duration = 60, VaryByHeader = "User-Agent")]
    public IActionResult GetItems()
    {
        // 模拟耗时查询
        var items = new[] { "商品1", "商品2" };

        // 返回结果,中间件会自动加上Cache-Control和Vary头
        return Ok(items);
    }
}

[ResponseCache] 特性会自动生成 Cache-Control: public, max-age=60Vary: User-Agent,你不必手动拼字符串。它还有 Location 参数,可以用来设置 privateNoStore。比如:

// 技术栈:ASP.NET Core (C#)

// 私有缓存,浏览器存但中间件不存
[ResponseCache(Duration = 120, Location = ResponseCacheLocation.Client)]

这个特性减少了很多手忙脚乱的错误。

六、应用场景、优缺点以及注意事项

6.1 什么场景下该用响应缓存

最典型的是“全网统一、不经常变”的数据:比如网站公告、配置列表、排行榜、饼图数据。这些内容所有用户看到的都一样,且更新频率低,非常适合加 public 缓存,并配合 Vary 区分语言或地区。举个例子,一个天气接口,每个城市的数据都不同,可以用 Vary 按城市编码查询参数来区分。不过查询参数不参与缓存键,需要额外处理。

ASP.NET Core中间件默认只按URL(路径+查询字符串)区分。所以 ?city=beijing?city=shanghai 会作为两个不同的键,不需要Vary来区分查询参数。Vary主要针对请求头。

6.2 优点明显但缺陷也不少

优点是能显著减少服务器计算量。一个耗时500毫秒的报表接口,缓存之后,同一秒内几千个请求都能秒回。缺点是缓存一致性难保障,尤其在多服务器部署时,每台服务器各自维护自己的内存缓存,如果在一台服务器上更新了数据,另一台服务器可能还留着旧内容。中间件默认用内存存储,不太适合分布式场景。想解决就得引入分布式缓存,那就不属于“响应缓存”中间件本身的能力了。

另一个缺点是占用内存。如果Vary设置不当,缓存键维度爆炸,内存里堆满几乎没法再用的副本,反而拖垮服务。要注意控制缓存条目数量,或者设置期限让不常用的项自动淘汰。ASP.NET Core的响应缓存中间件内置了内存限制,默认情况下有一个上限,超过后会按照LRU淘汰。

6.3 注意事项清单(都是过来人踩的坑)

第一,别在需要动态变化的接口上用长max-age。比如购物车数量、未读消息,最好别缓存,或者用 NoStore。第二,一旦用了 Vary,要确保对应请求头的值不会太多样。比如 Accept-Encoding,它的值虽然少,但缓存中间件默认会处理压缩,你不用手动Vary它。第三,设置了 Set-Cookie 的响应,中间件默认不缓存。如果你确实需要缓存带Cookie的响应(比如匿名购物车),要么改成不设Cookie,要么换策略。

再有一个高频问题是:响应缓存中间件和输出缓存(OutputCache)的区别。在ASP.NET Core 7里新增了输出缓存中间件,功能更强大,支持按查询参数和路由值区分缓存。如果你用的是旧版本,建议升级或借助第三方库。但本篇文章讨论的是经典的响应缓存中间件,它的逻辑更简单,适合初学者理解。

6.4 调试技巧:如何确认缓存是否生效

写代码时,可以通过响应头里的 Age 字段判断。如果中间件返回缓存内容,会在响应头里自动加上 Age(从源服务器生成到现在经过的秒数)。你还可以加一个自定义响应头来表示命中了缓存。比如:

// 技术栈:ASP.NET Core (C#)

app.MapGet("/debug-cache", (HttpContext context) =>
{
    // 手动标记缓存命中状态(实际中你可以用中间件或特性)
    context.Response.Headers["X-Cache-Status"] = "MISS"; // 首次访问
    context.Response.Headers["Cache-Control"] = "public, max-age=30";
    return Results.Text(DateTime.Now.Ticks.ToString());
});

但中间件自动处理时,你自己写的X-Cache-Status可能不会自动改为HIT。所以更靠谱的方法是直接用F12刷新看响应头里的 Age。如果缓存命中,Age 会是大于0的数字。

另外,千万别在生产环境用 DateTime.Now 生成内容来测试缓存,因为它会在缓存刷新的一瞬间返回不同值,看起来像“不缓存”,这是正常的,因为缓存周期就是30秒。

七、总结

响应缓存中间件本身不复杂,但用错方式就会让页面返回过期内容或者错乱内容。关键在于理解两个头:Cache-Control 控制缓存能活多久,Vary 控制缓存到底属于谁。它们必须按业务场景协同使用:数据是否对所有人一样?一样就 public 加适当的 max-age;不一样就 Vary 区分请求头,或者使用 private 彻底避免共享。对于不新鲜的代价,宁可不要缓存,也别把错误数据发给用户。

日常开发中,建议优先使用 [ResponseCache] 特性,少手动拼头,能避免很多低级错误。同时,动手验证时多看看响应头里的 Age,观察中间件是否真的在缓存。如果发现缓存不命中,先检查有没有设置 Cache-Control,再检查响应是否带有 AuthorizationSet-Cookie,最后检查状态码是不是200。只要这三关过了,中间件通常不会让你失望。

缓存是性能优化的利器,也是产生诡异Bug的源头。掌握了 Cache-ControlVary 的配合,你就能游刃有余地控制缓存生命周期,让用户在最快的时间内看到正确的内容。