1. 请求(Request)是如何到达你的业务代码的
我在带团队做 ASP.NET 项目时,发现很多刚接触框架的开发者,上来就写Request.QueryString,但问他Request背后究竟是哪个对象、数据从哪来,他得愣半天。这其实不怪你,因为框架替你消化了太多底层细节。但如果你写复杂的中间件、做接口鉴权、排查莫名其妙的Server.UrlDecode乱码问题,不理解请求管线就得靠猜。
1.1 从 TCP 连接开始的一次 HTTP 访问
任何一次 HTTP 请求,本质都是客户端通过 TCP 连接,向 Web 服务器发了一段符合 HTTP 协议格式的文本。对于 ASP.NET(这里指 .NET Framework 时代的 ASP.NET Web Forms / MVC,以及 .NET Core 的 ASP.NET Core),浏览器发送的这段文本到了服务器后,第一步先由服务器进程(IIS 或 Kestrel)接收并解析,再把它们包装成一个HttpRequest对象。
HttpRequest不是凭空变出来的,它在管线的早期就被构造。微软在这个环节做了一层很经典的分层:服务器层负责处理底层 socket,然后把“已解析的 HTTP 上下文”托管给 ASP.NET 运行时。换句话讲,你代码里拿到的Request,已经是经过第一轮解析的“结构化数据”,否则你没法直接通过Request["id"]拿到查询字符串的值。
1.2 管线中 Request 真正可用的时机
很多人误以为Request在整个请求生命周期都是“可随便读”的。其实不是。
ASP.NET 的生命周期事件顺序里,BeginRequest触发时,Request对象已经存在,但部分数据(比如表单Form)可能还没有被完整读取。真正到了AcquireRequestState事件(也就是 Session 状态被加载之后),Request里的各集合才进入“正经可用”的状态。我们在 MVC 中写 Action 方法时,模型绑定框架才去遍历Request.Form、Request.QueryString、Request.Files。
有位朋友曾经在自己实现的IHttpModule的BeginRequest里直接读取Request.Form,结果发现第一次访问没问题,第二次访问表单里全是空的。原因很简单:表单数据的解析是延迟的,而且和请求实体流(InputStream)只能被读取一次有关。他在BeginRequest里读了一次,之后模型绑定再读,就发现输出了。这属于典型的对“Request 对象创建不等于数据就绪”的认知误区。
1.3 Request 的各大集合到底怎么取值
很多初学者都会问:Request["id"]和Request.QueryString["id"]有什么区别?
Request["id"]是一个便捷索引器,它内部按 QueryString、Form、Cookies、ServerVariables 的顺序去查找。这类设计方便是方便,但有一个隐蔽的问题:如果有同一个键名同时出现在 URL 查询字符串和表单里,Request["id"]拿到的到底是谁?答案是——以它的查找顺序为准,QueryString 优先。这个优先级是固定的,写代码时不能依赖“后提交覆盖前面的”这种直觉。
所以在正式项目里,我的习惯是:宁可用Request.QueryString["id"]、Request.Form["name"],也不图省事用Request["id"]。意图明确,后续代码审查的人一眼就看懂。
// 明确从查询字符串取参数 string id = Request.QueryString["id"]; // 明确从表单取参数 string name = Request.Form["name"]; // 读取请求头 string token = Request.Headers["Authorization"];还有一点容易被忽略的是Request.Url和Request.RawUrl的区别。Url是完整的经过解析的Uri对象,比如https://example.com/page?id=1;RawUrl则是原始字符串,比如/page?id=1。如果你要做 URL 重写判断,通常用的是RawUrl,因为它是“进入管线时原本的样子”。
1.4 请求数据的几种常见来源
| 数据来源 | 典型场景 | 读取方式 |
|---|---|---|
| URL 查询字符串 | GET 请求传参 | Request.QueryString["key"] |
| 表单数据 | POST 提交 | Request.Form["key"] |
| 请求头 | 认证、自定义标记 | Request.Headers["key"] |
| Cookie | 客户端状态 | Request.Cookies["key"] |
| 请求体原始流 | 文件上传、JSON 报文 | Request.InputStream |
| 路由数据 | MVC / API 路由传参 | RouteData.Values["key"] |
建议所有请求参数的获取,都先确认来源,再决定读取方式。框架虽然给了你Request[key]这种“一锅端”,但真正规范的写法,永远是显式指定集合。这是多年实战下来我觉得最能减少团队无效 bug 的习惯之一。
2. 响应(Response)的每一个细节都值得深挖
你要问一个刚入行的开发者怎么给页面输出一句话,他肯定说:Response.Write("Hello")。但要继续问:Response.End()之后为何try-catch捕获不到业务异常?很多人就答不上来了。这背后的原因在于Response.End()会抛出ThreadAbortException,而且这个异常在 .NET Framework 下比较特殊,它会中断当前线程。你在catch里写log.Debug都可能执行不到。这是一种极其“野”的退出方式,只在极少数场景(比如文件下载被强制中断)才值得考虑。
2.1 Response 对象是怎么控制页面输出的
Response是一个专门负责“服务器向客户端发送数据”的对象。它内部维护一个缓冲区,你调用Response.Write时,内容一般是先写进这个缓冲区,等当前页面(或当前请求处理)执行完之后,框架再统一把缓冲区内容发送给客户端。
默认情况下,Response.BufferOutput是true,意味着响应内容可以整体缓冲。好处是:你可以在输出的最后一刻修改响应头,因为头还没发出去。最早写 ASP 时代的开发者可能还记得,那时要手动Response.Buffer = True。到了 ASP.NET,这个行为默认就是开着的,但你需要注意一个大坑:如果已经往响应流里写了大量数据,再试图修改状态码或自定义响应头,会抛出HttpException(“Server cannot append header after HTTP headers have been sent.”)。
下面这段是我排查过的真实案例。某同事在Application_Error里写了一个日志,日志里把Response.StatusCode改了,意图是让出错页面变成 500。单测时好好的,但压测时偶尔会崩:因为异常发生时,页面很可能已经向缓冲区写入了部分内容,此时改状态码就会触发异常。
// 不建议的写法:可能已经写入部分内容后再改头 protected void Application_Error(object sender, EventArgs e) { // 某些情况下这里会炸,因为响应头可能已经发送 Response.StatusCode = 500; } // 建议的写法:先清空缓冲,再设置状态码和内容 protected void Application_Error(object sender, EventArgs e) { Response.ClearContent(); Response.StatusCode = 500; Response.Write("未处理的异常发生"); }2.2 重定向家族的隐藏差异
说到Response.Redirect,有个细节我每次面试都喜欢问:Redirect和RedirectPermanent有什么区别?
前者是 302 临时重定向,搜索引擎会认为这个地址只是临时跳走,原来的地址仍在索引中。后者是 301 永久重定向,搜索引擎会更新索引,把权重转移到新地址。对于网站改版这种场景,如果你用了 302,旧页面会长期停留在索引里,用户搜到旧地址,点进去又被临时转到新地址,SEO 则始终没有真正合并权重。正确姿势是旧页面 URL 改成RedirectPermanent。
另一件事是Response.Redirect默认会终止当前线程吗?标准写法是Response.Redirect(url),它内部会调用End,从而触发ThreadAbortException。这个问题会导致try-catch捕不到、后续代码不执行。所以很多资深开发者都会显式传第二个参数false:
Response.Redirect("/login?ReturnUrl=" + Server.UrlEncode(Request.RawUrl), false); // 这里还能继续执行一些收尾工作但传false也有代价:方法不会立即终止,后面的代码还会继续跑。如果你希望“跳转后绝不执行后续任何逻辑”,需要手动return。务必记住这个权衡。
2.3 输出文件时最容易踩的坑
文件下载也是 Response 用得最多的地方。最标准的写法是设置ContentType、Content-Disposition,然后通过WriteFile或BinaryWrite输出。
有个非常典型的坑:文件名是中文时,浏览器经常出现乱码或直接被截断。原因是 HTTP 头默认只接受 ASCII 字符,你直接往Content-Disposition里塞中文文件名,不同浏览器解析不一样。现代浏览器的处理方式是:文件名走filename*加上UTF-8''前缀进行 RFC 2231 编码,或者单独UrlEncode。
string fileName = Server.UrlEncode("中文报表.xlsx"); Response.AddHeader("Content-Disposition", $"attachment; filename*=UTF-8''{fileName}");这样处理的兼容性会好很多。早期只写filename="中文.xlsx"的老代码,在 Chrome 里偶尔能用,但换到某些国产浏览器或旧版 Edge 就毁了。所以别嫌麻烦,统一按 RFC 2231 规范来。
2.4 请求生命周期各阶段的 Response 可用状态
理解响应,不能脱离请求生命周期。我把几个关键阶段的行为整理了一下:
| 阶段事件 | Response 可用状态 | 说明 |
|---|---|---|
| BeginRequest | 可以写状态码和头 | 缓冲未开始,基本随便写 |
| AuthenticateRequest | 可以使用 Response 做 401 | 注意不要写 Body,容易被冲掉 |
| AcquireRequestState | 可以正常输出 | 但尽量别在模块里写页面内容 |
| PreRequestHandlerExecute | 可修改响应头 | 但改了要确保业务逻辑不冲突 |
| 页面 OnLoad | 可写内容 | 如果在此处 clear 缓冲,要小心之前的模块输出丢失 |
| 页面 Unload | 谨慎写内容 | 这时响应可能已部分发送 |
| Application_EndRequest | 不建议写内容 | 此时做日志记录可以,改输出为时已晚 |
这表格不是让你背,而是让你调试时有个方向。比如你有一次发现浏览器里输出的内容末尾多了一截空白,多半是某个模块在EndRequest里又Response.Write了内容。因为此时页面内容已经发送完,再追加就是“尾巴”。
3. 状态管理全景图:每种手段都有它的“脾气”
状态管理是 ASP.NET 面试里必问、项目里必用的核心话题。它是一个大杂烩:ViewState、Session、Cookie、Application、Cache,甚至HiddenField、QueryString。这么多手段,各有各的生存周期、存储位置、安全级别。选错了,轻则体验差,重则数据泄漏。
3.1 客户端状态与服务器端状态的本质区别
先理清一个根本性问题:状态存哪?
- 客户端状态:数据放在页面里(ViewState)、浏览器里(Cookie)、或者 URL 上(QueryString)。服务器不占内存,但数据会被用户看到、篡改或伪造。
- 服务器端状态:数据放在服务器内存(Session、Application、Cache)里。安全性和可控性高,但会占用服务器资源,分布式环境下还需要额外处理。
我见过很多人把“用户登录后的基本信息”塞到 Cookie 里,理由是“不想用 Session”。结果用户在浏览器里打开开发者工具,把 Cookie 里的role=user改成role=admin,刷新页面权限就变了。如果你做的是后台管理系统,这种设计就是给服务器开大门。
注意:任何涉及权限、金额、用户标识的核心状态,绝对不能只放在客户端。即使要做“记住登录状态”,也应该用服务端 Session 或令牌机制,Cookie 只存一个不可预测的 Session ID。
3.2 ViewState:页面回传的“记忆罐头”
ViewState 是我见过新手误解最多的状态管理工具。很多人以为 ViewState 是“服务器保存的页面状态”,其实它默认是放在页面里的一个隐藏字段__VIEWSTATE,表现为 Base64 字符串。
它是为了保证 ASP.NET Web Forms 的“回传”(PostBack)机制能工作:你在页面上改了某个 Label 的 Text,第一次点击按钮提交,服务器怎么能知道这个 Label 被改成什么了?因为控件状态在服务端渲染时被序列化进了 ViewState。提交时,框架又从 ViewState 里恢复控件状态,再触发相应事件。
这个机制最大的问题是“页面体积膨胀”。一个复杂的 GridView 页面,ViewState 可能有几十 KB,甚至几百 KB。用户每次请求都要上传下载这个字段,很浪费流量。所以优化手段就是:如果确定某个控件在回传后不需要恢复状态,就关掉它的 ViewState(EnableViewState="false")。
<asp:Label ID="lblMessage" runat="server" EnableViewState="false" />另外,ViewState 默认不加密(只有签名防篡改),所以页面里包含敏感信息时会直接以 Base64 形式“半裸奔”在 HTML 里。开启ViewStateEncryptionMode="Always"会让性能下降,但能加密内容。我的建议是:敏感信息别存在 ViewState,老老实实走 Session。
3.3 Cookie:小而实用,但别指望它绝对可靠
Cookie 是最常见的客户端状态。默认情况下,每个域名有数量和大小的限制(经典限制是 4KB 左右,不同浏览器不完全一样)。你把一个UserDetails对象序列化成 JSON 塞 Cookie,一个 8KB 就直接超出限制,之后会时灵时不灵。
我在排障时发现一个典型情况:用户在 IE 下登录失败,但在 Chrome 下正常。排查半天,发现代码里往 Cookie 塞了用户头像的 Base64 字符串,Chrome 比较宽容,IE 直接拒绝写入超长 Cookie。本来就应当只存userId,头像信息应该通过请求实时获取或缓存,而不是塞 Cookie。
注意 Cookie 跨域。默认情况下,Cookie 不能跨域名读取。你用example-a.com种下的 Cookie,example-b.com是拿不到的。想实现跨子域共享时,可以设置Cookie.Domain为.example.com,这样a.example.com和b.example.com都能读取,但也带来了一个安全边界:攻击者在任意一个子域下种了同名 Cookie,另一个子域也会把它当成合法 Cookie。所以凡是涉及跨子域共享 Cookie 的场景,都要做额外的校验字段(比如用户ID+签名)。
3.4 Session:服务端状态的“主力军”,但分布式才是分水岭
说到 Session,脑子里蹦出两大类实现:进程内 Session(InProc)和进程外 Session(StateServer、SQL Server、Redis)。这是 ASP.NET 状态管理的核心分水岭。
进程内 Session 默认使用,快,部署简单。但随着站点扩展为多台服务器,问题立刻暴露:用户第一次请求落在服务器 A,Session 存在 A 的内存;第二次请求被负载均衡器转到服务器 B,B 的内存里没有这个 Session,用户又重新变成匿名状态。
解决方式有几种:
- 使用
StateServer:把 Session 放在独立进程中,但序列化有一定成本,并且 StateServer 本身是单点。 - 使用 SQL Server:Session 持久化到数据库,可靠但慢。
- 使用 Redis:目前最主流,性能好且可以集群化。
一般在 .NET Framework 时代,我们用Custom模式接 Redis,需要引入第三方 Session Provider。到了 .NET Core,微软官方的IDistributedCache就支持AddStackExchangeRedisCache这种扩展,SQL Server 也有现成的实现。
// ASP.NET Core 示例:配置 Redis 作为 Session 存储 builder.Services.AddStackExchangeRedisCache(options => { options.Configuration = "127.0.0.1:6379"; options.InstanceName = "MyApp.Session"; }); builder.Services.AddSession(options => { options.IdleTimeout = TimeSpan.FromMinutes(20); options.Cookie.HttpOnly = true; options.Cookie.IsEssential = true; });Session 必须设置合理的过期时间。我见过一个系统把 Session 超时设成 24 小时,运维反馈内存使用率居高不下。原因很简单:每个登录用户都有一份 Session,长时间不释放,内存自然越堆越高。常规建议是 15~30 分钟,安全敏感系统(网银、后台)甚至设到 10 分钟。
3.5 Application 与 Cache:别把大对象随便丢进程内存
Application对象是应用程序域的全局容器,和Cache一样都存活于进程内。区别在于Application没什么高级策略,而Cache支持过期、依赖、回调。
用 Cache 时需要考虑清除策略。比如缓存的数据依赖数据库表,表更新时你必须主动移除缓存,否则会读到脏数据。常见做法是:写操作里同步删除缓存 Key,下次读的时候再重建。
还有一种更稳的做法是CacheDependency(文件依赖或数据库依赖),但数据库依赖需要开启相关配置,折腾起来成本不小。实际项目里,我认为“显式删除 + 按需重建”已经足够覆盖绝大多数场景,依赖机制留给特殊需求。
4. 实战:封装一个结合 Request、Response 与 Session 的登录防刷模块
纸上谈兵这么久,下面用一个比较综合的例子把这些知识点串起来。场景:一个后台管理系统的登录接口,要求同一 IP 在 10 分钟内失败 5 次就锁定 30 分钟,同时登录成功后把用户信息写入 Session,并写入安全 Cookie。
4.1 需求拆解
- 用 Request 获取客户端 IP 与提交的账号密码。
- 用 Session 记录该 IP 的连续失败次数。
- 失败达到阈值后,即使密码对了,也禁止登录,直至锁定时间结束。
- 登录成功,创建 Session,同时种一个 HttpOnly 的会话 Cookie。
- 用 Response 输出相应状态码与提示信息。
这需求里的难点是:Session 默认是“按会话”来的,不同浏览器的 Session 不同;但我想限制的是“同一 IP”,所以不能简单把失败次数存在当前用户的 Session 里。一种常见变通方案:用 IP 拼 Key 存到 Cache 里,而不是 Session,因为 Cache 是全局共享的。这里我为了演示状态管理的混合使用,依然让 Session 参与流程,但真正做防刷的计数器放 Cache。
4.2 实现代码(Web Forms 示例)
protected void btnLogin_Click(object sender, EventArgs e) { string ip = Request.UserHostAddress; // 获取客户端 IP string username = Request.Form["txtUsername"]; string password = Request.Form["txtPassword"]; // 1. 防刷检查:同一 IP 10 分钟内失败超过 5 次则锁定 string failKey = "LoginFail_" + ip; string lockKey = "LoginLock_" + ip; object lockObj = HttpRuntime.Cache.Get(lockKey); if (lockObj != null) { Response.StatusCode = 429; // Too Many Requests Response.Write("尝试过于频繁,请 30 分钟后再试"); return; } int failCount = 0; object failData = HttpRuntime.Cache.Get(failKey); if (failData != null) { failCount = (int)failData; } // 2. 模拟密码校验 bool isValid = (username == "admin" && password == "123456"); if (!isValid) { failCount++; // 缓存策略:过期时间 10 分钟 HttpRuntime.Cache.Insert(failKey, failCount, null, DateTime.Now.AddMinutes(10), Cache.NoSlidingExpiration); if (failCount >= 5) { // 锁定 30 分钟,并且移除失败计数,避免解锁后立刻再次达到阈值 HttpRuntime.Cache.Insert(lockKey, true, null, DateTime.Now.AddMinutes(30), Cache.NoSlidingExpiration); HttpRuntime.Cache.Remove(failKey); Response.StatusCode = 429; Response.Write("失败次数过多,账号已锁定 30 分钟"); } else { Response.StatusCode = 401; Response.Write($"账号或密码错误,剩余尝试次数:{5 - failCount}"); } return; } // 3. 登录成功:清除失败记录 HttpRuntime.Cache.Remove(failKey); HttpRuntime.Cache.Remove(lockKey); // 4. Session 保存用户信息 Session["UserId"] = 10001; Session["Username"] = username; // 5. Cookie 写入一个票据,HttpOnly 防止 XSS 读取 HttpCookie authCookie = new HttpCookie("AuthTicket") { Value = Guid.NewGuid().ToString("N"), HttpOnly = true, Secure = true, // 仅在 HTTPS 下传输 Expires = DateTime.Now.AddHours(2) }; Response.Cookies.Add(authCookie); Response.StatusCode = 200; Response.Write("登录成功"); }4.3 这段代码里的关键决策
为什么防刷计数器放 Cache 而不是 Session?因为攻击者可以不断换浏览器,甚至清除 Cookie 来重置 Session。如果计数只存在某次会话里,防刷直接形同虚设。用 IP 维度存全局缓存,才能覆盖“同一来源但不同会话”的攻击。
为什么锁定之后要移除 failKey?这是个细节。如果不移除,30 分钟锁定期满后,failCount 还是 5,用户再错一次立刻又触发锁定,体验非常差。而且原始失败时间也过了 10 分钟,旧记录早该清零了。
为什么登录成功后要清理失败缓存?避免用户正常操作时被“残留的失败次数”影响。试想用户 2 分钟前错了一次,现在输入正确密码登录成功,如果不清理,下次输错密码,failCount 从 1 开始累加,很快就又锁定了,这显然不合理。
4.4 登录成功后的 Session 存储设计
登录后 Session 里建议不要存大对象,尤其不要存整个用户实体。实体类可能在版本迭代中增加属性,导致旧 Session 反序列化失败。更稳的做法是只存UserId和必要的角色标识,每次请求再按需取用户详情(配合 Cache 提高性能)。
同时要设置 Session 的HttpOnlyCookie 属性。ASP.NET 默认会把 ASP.NET_SessionId 种到浏览器,这个 Cookie 默认是 HttpOnly 的,但你要确认网站的配置里没有把它覆盖成非 HttpOnly。一旦 SessionId 可以被脚本读取,攻击者通过 XSS 拿到之后,就能冒充用户会话,是非常典型的会话劫持路径。
5. 常见问题与排查技巧实录
这部分我直接以一个“问题速查”的方式写出来,内容基本来自现场救火的记忆。
5.1 Request 取值取不到
- 症状:
Request["key"]返回 null,但浏览器地址栏里明明有?key=abc。 - 原因:可能不是 GET 请求,而是 POST,
Request["key"]会依次查 QueryString 和 Form,但如果 key 既不在 QueryString 也不在 Form,就返回 null。这种情况往往是传参名字拼错,或者前端提交的是 JSON 数据(JSON 不在 Form 里)。 - 解决:确认提交方式,用抓包工具(开发者工具的 Network)看“Query String Parameters”和“Request Payload”。如果是 JSON,应该在服务端读取
Request.InputStream然后反序列化。
5.2 输出响应时页面莫名多出空格或换行
- 症状:Web Forms 的页面顶部多一个空行,或者 JSON 接口返回内容前面多了几个空格,导致前端解析失败。
- 原因:页面前面有
Response.Write,或者 ASPX 文件里有隐藏的空格/换行在渲染时被输出了。 - 解决:对于普通页面,检查
Page指令的ResponseEncoding;对于 API,强烈建议不要手动Response.Write,而是设置返回内容后通过Response.End或直接由框架序列化输出。
5.3 Session 在 Web Farm 环境下频繁丢失
- 症状:一个 .NET Framework 系统部署两台服务器,用户登录后,请求偶尔出现“未登录”状态。
- 原因:负载均衡无粘性会话,Session 依然默认存在各服务器进程内。
- 解决:要么在负载均衡器上开启“会话粘滞(Sticky Session)”,要么改用集中式 Session 存储(Redis / SQL Server)。前者配置简单,但负载分担不均衡;后者更适合高可用。
5.4 修改 Response 头时抛错
- 症状:设置了状态码后,又调用
Response.Redirect报错:Cannot redirect after HTTP headers have been sent。 - 原因:响应头已经随第一批内容发送,无法再追加或修改。
- 解决:尽量把所有需要设置的 Header、StatusCode、Redirect 逻辑放在输出内容之前;如果无法保证,先
Response.ClearHeaders()或Response.ClearContent()再重设。但注意Clear是清全部,别误伤了业务需要的自定义头。
5.5 Cookie 写入后刷新没了
- 症状:设置
Response.Cookies["name"].Value = "xx",刷新后Request.Cookies["name"]读不到。 - 原因:Cookie 没有设置
Expires,导致它成为会话级 Cookie,浏览器关闭就清掉。或者你在写 Cookie 时设了Expires,但用户机器时间不对,可能性也存在。 - 解决:确认要“持久化”还是“仅本次会话有效”。持久化 Cookie 必须设置明确的
Expires,而且要使用 UTC 时间,避免时区问题。
HttpCookie cookie = new HttpCookie("name", "value"); cookie.Expires = DateTime.UtcNow.AddDays(7); Response.Cookies.Add(cookie);还有一个特别隐蔽的坑:Cookie 写入用的是Response.Cookies,读取用的是Request.Cookies,这两个对象名称很像,但一个是发出去的、一个是收回来的,别搞混。新手经常在写完之后立刻去读Request.Cookies,发现是空,以为写入失败,其实是正常的——Cookie 要在下一次请求时才会被浏览器带回来。
5.6 状态管理选型的“一张图”判断
我在评审新人的代码时,经常拿一个简单问题来定状态管理方案:这份状态被用户修改后,其他人是否需要马上看到?
- 和用户无关的全局配置:用 Cache,改配置时主动移除。
- 用户私有的临时数据:用 Session。
- 用户公开展示的个性化信息:用 Cookie + 服务端校验,但不要只信 Cookie。
- 真正要“记住我”的登录态:用独立的认证票据(Token、Ticket),配合 HttpOnly Cookie 或 Authorization 头。
6. 自己动手压测时的一些体会
写这个主题的过程中,我自己在本地搭了一个测试环境,用几行代码验证了 Session 与 Cookie 的过期行为,踩了一个很有意思的小坑:你在同一个浏览器标签里连续登录登出,再刷新页面,有时会发现 Session 里还有旧值。原因不是缓存,而是浏览器对同一 Cookie 的覆盖时机和页面回发的顺序问题。调试的时候,请务必先清空浏览器站点数据,再用“无痕窗口”验证,省得把问题归咎到代码上。
还有一点,微软在 .NET Core 里的命名空间和 API 与 .NET Framework 有不少差异,比如HttpContext.Current在 Core 里不再存在,需要用构造函数注入IHttpContextAccessor。如果你是从老项目迁移到新项目,这块要重点适应,不能按记忆硬套。
最后分享一个调优细节:如果你的站是 HTTPS,务必确保写 Cookie 时设了Secure属性,同时把 Session Cookie 也设置成SameSite。我遇到过一个线上问题,用户在局域网 HTTP 环境下一切正常,切到公司强制 HTTPS 后,登录状态一直保持不住,最后发现就是 Cookie 没有Secure属性,浏览器的严格安全策略直接丢弃了非加密通道下的问题。写状态管理代码,安全属性永远要在功能测试通过后立刻补上,别留到上线再说。