ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

ASP.NET Core面试核心知识点:中间件、依赖注入、缓存与性能优化

ASP.NET Core面试核心知识点:中间件、依赖注入、缓存与性能优化

1. 请求管道与中间件:面试官真正想考察什么

1.1 中间件执行顺序为什么是面试必考题

中间件在 ASP.NET Core 里本质是一个委托链。形象点说,你可以把管道想象成一条流水线,HttpContext 是一个不断被传递的工作对象,每个中间件是一个工位。一个工位要么自己处理完直接产出(短路返回),要么调用 next 把工作交给下一个工位。这听起来简单,但面试时最常暴露的一个问题是:候选人能把 Use、Map、Run 背得滚瓜烂熟,却讲不清为什么执行顺序会成为线上事故的根源。

如果让我来出题,我会先给一个场景:一个请求进来,需要记录耗时、处理异常、校验访问令牌,这三个中间件应该按什么顺序注册?普通人往往先答“异常最外层”然后卡住。其实背后逻辑是:异常中间件必须最先注册,它包住后面所有委托才能 catch 到管道中任何位置抛出的异常;耗时中间件次之,这样计时范围涵盖了后续全部逻辑;令牌校验要放在耗时中间件内部、业务处理之前,这样校验不通过的请求也会被计时,且不会影响后续业务。如果不按这个顺序,比如把异常中间件放到最后,前面的中间件抛异常就只能被框架默认处理,无法统一返回友好错误。

面试官接着通常会追问一个概念:管道是洋葱模型,请求从外层进入,响应从内层退出。实际沿着 Use 的注册顺序执行,但响应阶段是逆序返回。这意味着你在 next 前写的代码先执行,在 next 后写的代码在响应返回时才执行。我要提醒准备面试的朋友,不要只记住“先注册先执行”,要能举出“响应日志”的例子:下一个中间件返回后,当前中间件在 await next() 之后打印的信息,记录的是响应阶段的状态码和耗时,而不是请求阶段。这个细节能答出来,说明你真的调试过请求管道,而不是背答案。

1.2 自定义中间件的正确姿势与常见误区

自定义中间件有三种常见写法:直接用 lambda 写在 app.Use 中、定义类式中间件通过 app.UseMiddleware 注册、自己封装工厂方法动态创建。很多候选人只会第一种,遇到需要在中间件里使用依赖注入的场景就卡住。类式中间件通常在构造函数里接收 RequestDelegate next,也可以注入一些 Singleton 或 Transient 服务,但要注意:构造函数注入的服务生命周期不能是 Scoped,因为中间件是启动时创建的单例对象,从根容器解析 Scoped 会报错。

如果要注入 Scoped 服务,正确做法是在 InvokeAsync 方法的参数里声明它。中间件的 Invoke 方法由容器动态调用,支持按请求作用域解析服务,所以这样既能使用 DbContext 或业务服务,又不会出现捕捉作用域的问题。我见过很多候选人不知道这一点,在构造函数里注入了 IRepository 就再也跑不起来。实际上 ASP.NET Core 内置的一些中间件(例如 UseDbContext)采用的就是这种参数注入模式。

还有一个常见误区:只记得 try-catch 包裹 next,却不考虑响应头是否已经发出。如果后续中间件已经写入了响应体,前面的 catch 再去修改状态码就会抛异常。正确做法是先判断 response.HasStarted,如果还没开始,可以安全地改写状态码和响应体;如果已经开始,则只能选择终止连接或者记录日志。另外,需要读取响应体的时候一定要做缓冲——用 MemoryStream 替换 Response.Body,处理完再把内容拷贝回去,否则日志工具无法看到响应内容,还会导致大响应占额外内存。面试时主动讲到这几个点,面试官基本就会把你归到“有实战经验”那类。

1.3 分支管道与短路:Map/UseWhen/When 的适用边界

Map、MapWhen、UseWhen 这几个 API 经常被混为一谈。Map 是按路径前缀直接分叉,进入分支后原管道后续的中间件不会再执行,而且分支里可以继续注册自己的中间件。MapWhen 和 Map 的区别在于它不只看路径前缀,而是接受一个自定义判断委托,比如按 Header、QueryString、Host 来分叉。UseWhen 则完全不同,它只是条件性进入分支管道,分支执行完之后还会回到主管道继续往下走,相当于在主管道里临时插入了一个子流程。

面试题“如何让 /health 端点不受业务认证中间件影响”,答案思路是 Map 一个健康检查分支,把需要跳过的中间件注册在分支之前、或者用分支替换整体管道。使用 UseWhen 就不合适,因为它分支执行后还会回到主管道,令牌校验依然会被执行。另一个常见场景是静态文件加速:希望通过 UseWhen 先检查文件是否存在,存在就直接短路,不存在再走后续管道处理。这种用 UseWhen 合理。

有一个细节很多人不清楚:Map 创建分支管道时,并不是从空管道开始,而是继承了调用 Map 之前已经注册的中间件状态。因为 Map 本质上是在当前管道里插入一个分支节点,分支内部后续处理逻辑是新的中间件集合,但之前的中间件已经包裹在外层。理解这一点可以解释为什么分支里异常还是会被外层异常中间件捕获。面试答到这个层面,说明你不是只照着文档敲过代码。

2. 依赖注入:生命周期选错,线上事故离你不远

2.1 Singleton、Scoped、Transient 背后的容器原理

ASP.NET Core 依赖注入容器的核心职责是管理对象的创建和释放,三个生命周期不是随便定的,它们对应不同的复用语义。Singleton 是在根容器里只创建一个实例,所有地方共享;Scoped 是在每个作用域内创建一个实例,不同作用域各自独立;Transient 则每次解析都创建一个新实例。默认情况下,每个 HTTP 请求会创建一个 IServiceScope,所以注册为 Scoped 的服务在同一个请求内始终是同一个实例,跨请求则是不同实例。

面试中考得最多的一个问题是“Singleton 能不能注入 Scoped”。答案是不能,因为单例服务是从根容器创建的,如果它依赖了一个 Scoped 服务,容器会尝试从根容器解析这个 Scoped,从而把它提升为单例,造成所谓“捕获作用域”。一旦被捕获,这个 Scoped 就失去了按请求隔离的语义,多个请求会共用同一个实例,导致数据串台或线程安全问题。反过来,Scoped 可以注入 Singleton,Singleton 可以注入 Transient,Transient 不要注入 Scoped。简单的记忆方式是:依赖的生命周期不能比被依赖方更短。

更细一层,容器创建对象时会自动处理构造函数的依赖链。如果构造函数里同时存在值类型、字符串、接口,值类型和字符串必须有明确注册,否则会在启动或请求时抛异常。面试官如果问“容器都做了哪些事情”,可以把“依赖图的构建、实例的缓存、IDisposable 的自动释放”三个点都答全。特别提到释放,因为很多人不知道容器默认会跟踪它创建的所有 IDisposable 对象,并在作用域结束或容器释放时逐一调用 Dispose,但如果对象不是容器创建的,容器不会负责释放。

2.2 捕获依赖与作用域泄漏的经典陷阱

实际开发中最容易踩的坑是在后台任务里使用 Scoped 服务。很多同事在写 BackgroundService 时,把某个 Scoped 的仓储服务直接注入到单例后台服务的构造函数中,看起来启动没问题,一旦调用就会抛异常:Cannot resolve scoped service from root provider。更隐蔽的情况是,通过 ScopeFactory.CreateScope() 创建了作用域,但忘记释放,导致 DbContext 实例不被释放,底层连接也不会归还连接池,时间一长连接池就被耗尽。

正确做法是用 IServiceScopeFactory 每次创建独立的 scope,在 scope 内解析需要的 Scoped 服务,处理完立即释放。通常我们会在 BackgroundService 的主循环里使用 try-finally 或 using 块来确保 scope 被释放。我印象很深的一个线上问题:某个订单扫描服务把 ApplicationDbContext 注册成了 Singleton,结果多个 worker 线程并发操作同一个上下文,时不时抛出“DbContext 不是线程安全”的异常。后来查依赖图才发现,某个人为了图省事把 DbContext 直接注入了单例服务。

这个问题的排查思路也可以用来说给面试官听:查看启动日志中容器构建是否报错、检查服务注册的类型、画出依赖链,重点看有没有从根容器解析 Scoped 的情况。如果候选人能主动提到“Captive Dependency”这个概念,以及在 OWIN/Kestrel 时代和现在的差异,面试官大概率会觉得你踩过坑也总结过。

2.3 面试加分项:验证范围与容器销毁顺序

ASP.NET Core 默认只在开发环境开启 DI 验证,生产环境默认不会在 Build 时报错,很多生命周期问题要到运行期才炸。实际上 Host Builder 提供 ConfigureServices 时我们可以在 serviceCollection 上配置 ValidateOnBuild 和 ValidateScopes。开发环境下框架已经默认开启,但最好在 Program 里显式写入,并把它作为团队规范固定下来。

再往深了讲,容器销毁顺序也值得掌握。当 scope 结束时,容器会按创建顺序的反序释放它创建的对象,这意味着后创建的对象先释放。假设 A 依赖 B 和 C,容器创建 A 时先创建 B 再创建 C 最后创建 A,释放时先 A,再 C,再 B。如果 B 的 Dispose 中要使用 A 的资源,就会出问题。这个顺序问题不常见,但在日志组件、消息消费者、DbContext 组合场景里会翻车。

另一个面试加分项是手动作用域的生命周期控制。比如在使用 EF Core 的 Disconnected 场景,为了确保上下文在同一个请求内复用,可以在管道中手动创建 scope。如果在中间件里 new 了一个 service scope 却交给其他组件释放,这段生命周期变得不可控。因此最佳实践是:谁创建谁释放,作用域必须显式 using。能把这些细节讲清楚,不仅面试加分,线上排查能力也会提升不少。

3. 配置体系:从 appsettings 到强类型配置的演进

3.1 配置源与优先级:为什么说配置是分层的

ASP.NET Core 的配置系统是一个可插拔 provider 集合。默认构建器会依次加载 appsettings.json、appsettings.{Environment}.json、环境变量、命令行参数,用户机密在开发环境也会参与。后面的 provider 会覆盖前面的 provider,这就是为什么生产连接字符串通过环境变量设置之后会覆盖文件里的默认值。理解这一点比背顺序强,因为只要你需要自己实现配置源,就必须明白 provider 的加载顺序决定了最终值。

很多候选人不知道 JSON 配置的 key 是大小写不敏感的,而环境变量映射时使用双下划线作为层级分隔符。例如 ConnectionStrings__DefaultConnection 会被解析成 ConnectionStrings:DefaultConnection。在 Linux 部署时环境变量的名称规则和 Windows 不同,大小写约定也可能不一致,所以尽量用双下划线格式并避免特殊字符。面试官经常从“怎么在不同环境使用不同连接字符串”切入,能答出环境变量优先级和 provider 顺序的候选人,通常是真的部署过项目的。

另一个容易忽略的点是:IConfiguration 不是强类型。直接通过 configuration["Section:Key"] 拿值需要处理类型转换、默认值和空字符串。更推荐把配置绑定到强类型 POCO,做法是 services.Configure (configuration.GetSection("SectionName")),然后在构造函数里注入 IOptions 等接口读取。这样既可以享受配置热更新,又避免了到处都是字符串 key 的魔法值,重构时编译器也能帮上忙。

3.2 IOptions、IOptionsSnapshot、IOptionsMonitor 该怎么选

这三个选项接口是配置绑定之后读取数据的关键入口,面试几乎必问。IOptions 是 Singleton 形式,容器第一次解析后缓存实例,之后配置变化不会自动更新。IOptionsSnapshot 是 Scoped 形式,每个作用域第一次解析时从 IConfiguration 重新读取,所以同一个请求内保持一致,不同请求可能拿到新值。IOptionsMonitor 是 Singleton 形式,但内部监听了配置 reload 事件,配置变化时重新构造 T,并且可以通过 OnChange 注册回调。

在实际项目里,普通 Web 请求中用 IOptionsSnapshot 最顺手,因为每个请求都是一次新的作用域,拿到的配置和当前配置一致,又不会创建过多对象。如果是后台长时间运行的服务,比如消息消费者或者 Worker,IOptionsSnapshot 就不合适,因为它的作用域可能长时间不释放,永远拿不到更新值。这时候应该用 IOptionsMonitor,或者通过监听配置变更事件主动刷新自己的字段。

我记得自己踩过一个坑:做一个灰度开关服务,刚开始在单例的配置服务里注入 IOptionsSnapshot ,结果所有开关都只在进程启动时生效,上线后改配置不生效,排查半天才意识到后台任务的作用域不随 HTTP 请求变化。换成 IOptionsMonitor 之后,开关功能一切正常。面试时如果能把这个“后台任务用错接口导致配置不更新”的经历讲出来,比单纯背概念有说服力得多。

3.3 敏感数据保护与配置热更新实践

连接字符串、API Key、证书密钥这些东西放在 appsettings.json 里提交到代码库是个坏习惯。开发环境可以使用 dotnet user-secrets 把敏感配置放在用户目录;生产环境用环境变量、密钥管理平台、或者容器编排的 secret 对象。ASP.NET Core 的配置源设计允许我们扩展自定义 provider,所以企业内部可以写一个从远程配置中心拉取配置的 provider,把它加在默认 provider 之后,实现中心化配置管理。

热更新默认由 FileConfigurationProvider 通过 reloadOnChange 支持。appsettings.json 变化时,配置系统会重新加载文件并触发 ChangeToken。但配置绑定的对象不会自动重建,除非你使用 IOptionsMonitor 或 IOptionsSnapshot。如果自己写配置中心,一般做法是:后台轮询远端接口获取最新配置,写入 ConcurrentDictionary,再触发一次 reload,让所有订阅了 ChangeToken 的组件重新读取。这个机制理解之后,面试官问“配置中心怎么实现”,你就可以从 IConfigurationBuilder、ConfigurationProvider、ChangeToken 三个角度展开,而不是只会说“连数据库读配置”。

同时要提示一点:不要把所有配置都热更新,像数据库连接字符串这种如果被频繁换掉,可能导致连接池不稳定。通常做法是配置内容分为“静态启动项”和“动态变更项”,动态项走配置中心,静态项保留在环境变量或启动参数中。做架构设计时提前区分好,能减少很多上线事故。

4. 认证与授权:从 Cookie 到 JWT 的完整心智模型

4.1 认证与授权分离的设计逻辑

认证解决“你是谁”,授权解决“你能做什么”。ASP.NET Core 中 AuthenticationMiddleware 在请求管道中把凭据解析成 ClaimsPrincipal 并放到 HttpContext.User;AuthorizationMiddleware 再通过授权策略判断当前用户是否满足条件。两个中间件是独立的,授权需要认证,但认证不一定要授权。这个分离很多人讲不清,甚至有人以为加了 [Authorize] 就是认证,其实它管的是授权,认证方案是另一个注册配置。

AddAuthentication 和 AddAuthorization 是两个独立的扩展方法。AddAuthentication 注册认证方案的 handler,例如 CookieHandler、JwtBearerHandler;AddAuthorization 注册授权策略和 handler。请求进入时,认证中间件会根据既定方案尝试认证,如果失败不会直接拒绝,而是让 User 保持未认证状态;授权中间件到了之后发现未认证,才会引导跳转或返回 401。这个机制意味着如果你想实现“某些接口允许匿名,某些必须登录”,只需要在控制器或端点上加 [AllowAnonymous] 或 [Authorize],不需要写大量分支代码。

面试高频追问是“多个认证方案并存怎么处理”。答案是可以为 [Authorize] 传 AuthenticationSchemes 属性,或者在 AddAuthentication 里设置 DefaultAuthenticateScheme,也可以在策略里指定。如果同一个接口既支持 JWT 又支持 Cookie,需要明确指定方案,否则授权中间件只会用默认方案去认证,导致一种方案永远无法通过。能讲到这里,说明你不是只会按模板搭一个 Swagger 加 JWT 的 Demo。

4.2 JWT 与 Cookie 方案的适用场景对比

JWT 适合无状态 API、移动端、跨域调用,Cookie 适合传统浏览器 MVC 应用。很多人默认 JWT 更现代更安全,其实要看风险模型。JWT 的 token 一旦发到前端,常见做法是存在 localStorage 或内存中,如果 XSS 攻击者能执行脚本,就可以偷走 token 模拟请求。Cookie 配合 HttpOnly、SameSite、Secure 属性,浏览器会限制 JavaScript 读取 Cookie,从而降低这类风险。另一方面,Cookie Session 由服务端保存会话状态,可以随时登出失效;JWT 的 token 本身难以做到服务端主动失效。

但这不代表 JWT 不能用。在无状态 API 场景,JWT 避免了下游服务每次都要查询 Session 存储,适合水平扩展。真正要解决的是泄露与失效问题,通常引入短期 accessToken 加长期 refreshToken,服务端保存 refreshToken 的白名单或黑名单,实现可控失效。面试时如果问“JWT 泄露了怎么处理”,不要只说“等它过期”,要提出刷新令牌轮换、异常检测、设备指纹、黑名单机制等组合方案。

我一般建议的原则是:面向浏览器的应用优先 Cookie;纯前后端分离且对 XSS 防护有信心的团队可以用 JWT,但必须做好刷新和黑名单。面试官往往不是要一个标准答案,而是看你会不会针对不同场景取舍。你把 Cookie Secure 和 HttpOnly 的特性讲清楚,再把 JWT 的无状态特点讲清楚,就是一个很好的对比式回答。

4.3 基于策略与自定义授权过滤器的常见扩展

角色授权最简单但有时不够用。比如管理员和运营都算“可编辑”,但运营不能删除;或者权限需要按租户区分,同一角色的人在不同租户下拥有不同的权限范围。这时候就需要自定义授权策略。ASP.NET Core 提供 IAuthorizationRequirement 和 IAuthorizationHandler,可以定义 HasPermissionRequirement,并实现一个 handler 根据用户的 Claim 或数据库中的权限集合来判断。策略注册时可以把 requirements 组合起来,多个 requirement 之间是 AND 关系,都满足才通过。

面试中如果被问“按钮级权限怎么做”,不要陷入过滤器或中间件的思路。最正统的方式是授权策略:前端根据接口返回的权限码决定显示哪些按钮;后端通过 [Authorize(Policy = "CanEditOrder")] 校验按钮对应的行为是否被允许。如果需要在多个地方复用权限逻辑,策略模式比在每个控制器里手写 if 判断要干净得多。同时要把授权过滤器与授权中间件的区别讲清楚:AuthorizationFilter 运行在 MVC 管道中,是对已经通过授权中间件的用户做更面向资源的校验,属于应用层逻辑;而授权中间件是做全局策略判断。

一个加分细节是 IAuthorizationPolicyProvider 的用途。默认 policy 只能通过名称查找,但如果你有几千个权限点,把每个权限点都注册成 policy 不现实。可以通过自定义 policy provider,把“权限名:操作”字符串动态解析成 requirement。我见过有团队这么做后,扩展新权限点只需要改权限名,不用动 Startup。这个设计非常值得在面试里展开。

5. 异步编程与性能攻坚:Task、线程池与连接池

5.1 async/await 只是语法糖?面试官追问的底层原理

说 async/await 是语法糖没有错,但没有回答本质。它编译后是一个异步状态机,编译器会把方法拆成若干状态,每个 await 是一个挂起点。当 await 的操作完成后,状态机会在合适的线程上继续执行。这个模型保证了不阻塞调用线程,但对于 I/O 操作来说,真正快不是因为谁同时处理多个操作,而是因为线程在等待 I/O 时可以去做别的请求。所以异步的本质是资源释放,不是并行。

对 Web 服务来说,把控制器方法写成 async 能显著提高吞吐,因为线程池线程不需要在数据库查询期间被白白占着。但 CPU 密集型操作使用 async 没有收益,反而增加状态机开销。这时候可以用 Task.Run 把它丢到线程池,但也不需要每个 CPU 任务都包一层,因为请求本身就由线程池线程处理,直接同步执行也占用线程。面试官如果问“你知道线程池饥饿吗”,可以这样答:线程池线程处理请求时如果同步阻塞,等待异步操作的完成回调却又要从线程池中取出新线程才能继续,线程池为了快速补充线程会短暂注入新线程,高并发下会导致吞吐骤降和延迟上升。

在代码里,一个常见的反模式是 async void 事件处理器只用于 UI 事件,不要用于后端服务。另一个是使用 .Result 或 .Wait() 阻塞并等待异步方法,这不但浪费线程,还可能死锁。回答时要强调:从控制器到数据库访问,整个调用链尽量保持 async,避免任何 Sync-over-Async 的转折。

5.2 阻塞与死锁:ConfigureAwait 什么时候必须用

很多人一说异步死锁就提 ConfigureAwait(false),但在 ASP.NET Core 请求管道中,默认没有传统的 SynchronizationContext,所以不写也不会因上下文缺失而死锁。实际上是桌面应用里因为有 UI SynchronizationContext,.Result 会导致死锁。如果类库需要被 WinForms、WPF、或旧 ASP.NET Framework 使用,必须留意捕获上下文的行为,用 ConfigureAwait(false) 可以让后续代码不需要回到特定上下文。

面试官经常会设置一个陷阱:让你讲 ConfigureAwait(false) 在 ASP.NET Core 里是不是必须。如果回答“所有 await 后面都要加”,说明你没理解机制。正确回答是先说上下文捕获的基本原理,再区分平台:ASP.NET Core 请求中不必加,类库为了健壮可以考虑,但加了之后反而会影响需要 HttpContext 的场景。这个细致区分会让面试官刮目相看。

还有一个相关概念是 TaskCompletionSource 和 ValueTask。ValueTask 能减少无等待完成时的分配,适合高性能路径,但约束是不能多次 await。如果候选人能自动提到 ValueTask 的使用场景,通常说明对异步性能比较敏感。不过面试中不必展开太深,把核心模型讲对最重要。

5.3 性能排查:从 ThreadPool 饥饿到连接池耗尽

线上高并发时,请求大量超时往往不一定是代码逻辑慢,而可能是线程池或连接池被耗尽。ThreadPool 饥饿的表现是 CPU 不高但请求延迟越来越长,因为大量线程阻塞等待异步完成,而完成回调又需要线程池分配线程。检查时可以看 ThreadPool 队列长度、线程注入速率,以及有没有大量同步阻塞调用。缓解方式包括避免 .Result、合理调大最小线程数,但根本还是依赖代码保持全异步。

连接池耗尽在数据库或 Redis 场景也很常见。如果一个操作获取连接后没有及时关闭,池内连接被耗尽,后续操作就会排队或超时。排查时看活动连接数和池上限,重点关注有没有未释放的 DbContext、Connection 或 Scope。连接字符串里 Max Pool Size 可以调,但不能掩盖泄漏问题。

我自己的排查套路是:先拉取性能计数器看 CPU、线程数、内存、连接数;再抓取两个不同时间点的 dump 对比线程栈,找出大量阻塞在哪个调用上;最后根据栈信息回到代码定位。面试时不必把工具说得太细,但要展示这种“先看指标再抓证据”的思维。如果还能说出“用 dotnet-counters 看 ThreadPool Queue Length,用 dotnet-dump 分析阻塞线程”,就更有实战感。

6. 缓存与分布式场景:本地缓存、分布式缓存怎么选

6.1 IMemoryCache 的过期策略与缓存穿透

IMemoryCache 是进程内缓存,速度快、延迟低,适合高频读取且基本不变的配置数据、字典数据和热点搜索结果。但它有几个众所周知的问题:内存有限,需要设计合理的过期策略;多实例部署时每个实例缓存独立,数据可能不一致。面试常问的是绝对过期和滑动过期的区别。绝对过期是到时间就删,滑动过期是每次访问后重置过期时间,但为了防止极端情况,通常两者结合使用,设置一个滑动上限。

缓存穿透是查询不存在的数据,缓存和数据库都没有,请求每次都穿透到数据库。解决办法有:缓存空值并设置较短过期时间;在请求入口做参数校验;使用布隆过滤器快速判断数据是否可能存在。缓存击穿是某个热点 key 失效的瞬间,大量请求同时打到数据库。可以用锁或 GetOrCreateAsync 的原子操作,但要注意 IMemoryCache 的 GetOrCreateAsync 并发时 factory 可能会执行多次,不同版本行为不完全一致,最好自己加 Double-Check 锁。能把这个细节说出来,说明你不仅用过缓存,还读过一些源码。

缓存雪崩则是指大量 key 同时过期,或者缓存节点整体宕机,导致大量请求打进数据库。方案包括:key 过期时间加随机抖动、多级缓存、服务降级、数据库限流。回答缓存问题时,不建议只背三个术语,最好结合场景说明取舍。比如某些业务能接受短暂空窗,就不需要过度设计。

6.2 Redis 分布式缓存实战要点

分布式缓存通常用 Redis,通过 IDistributedCache 接口集成。StackExchange.Redis 默认会复用连接,但要注意连接超时、断线重连、命令超时这些参数。生产环境一般要设置一个合理的 connectTimeout 和 syncTimeout,并实现对缓存故障的降级——不能因为 Redis 抖动导致主业务流程失败。

Redis 本身是单线程处理命令,所以千万别存大对象、大列表,避免 O(N) 命令阻塞所有请求。key 的命名要规范,最好带业务前缀、版本号,比如 order:cache:v1:12345。分布式缓存存储的是字节数组,通常会配一个序列化器,如果使用 JSON,模型字段变更后反序列化可能出错,建议在存储对象中加版本字段,或者保留旧类型兼容。

除了缓存,Redis 还常做分布式锁和计数器。在分布式缓存面试题中,如果被追问“缓存和数据库一致性怎么保证”,可以自然过渡到下面的章节。能区分本地缓存与分布式缓存的适用边界也很重要:本地缓存减小网络开销但数据不一致,分布式缓存一致性更好但延迟高,实际系统往往是两级缓存组合使用,把最热的数据放本地,其余放 Redis。

6.3 缓存一致性:更新数据库后缓存怎么失效

最常被问到的经典问题:先更新数据库还是先删缓存?先删缓存后更新数据库,读请求可能把旧数据重新写回缓存;先更新数据库再删缓存,在删除完成前有短暂旧缓存窗口。这两种都会造成短时间不一致,只是概率和窗口不同。业界常见的做法是 Cache-Aside:读时先查缓存,未命中查库回填;写时更新成功后删除缓存。对于不一致窗口,可以通过延迟双删(更新后延迟几百毫秒再删一次)缓解。

更可靠但更重的方案是订阅数据库变更日志(如 binlog)异步删除缓存,或者使用本地消息表保证最终一致性。这不一定适合所有项目,但面试时如果能从“简单的 Cache-Aside”到“延迟双删/消息驱动”分层介绍,会让面试官觉得你有完整的知识体系。同时要强调:绝对一致性很难做到,也不一定需要做到。如果业务要求强一致,最简单的方案是不缓存或串行化读写,而不是引入复杂的一致性协议。

在实践层面,我建议在缓存值里塞一个时间戳或版本,当数据库更新后,缓存删除失败时至少可以通过版本对比拒绝旧数据。面试官如果听说你处理过删除缓存失败的消息重试,基本就能确认你有真实项目经验。

7. 常见综合连环题与答题框架

7.1 “你的项目遇到性能瓶颈怎么排查”这类开放题的答法

这是一道综合性很高的开放题,没有标准答案,面试官要看你的排查思路是否系统。我的答题框架是:先确定问题现象和影响范围(QPS、延迟、错误率、成功率),然后沿着请求链路逐层定位,最后针对性优化并回归。不要一上来就说“加缓存、加机器”,那是没有分析过程的暴力方案。

具体链路可以从入口开始:负载均衡、中间件耗时、控制器/业务层、数据库访问、下游调用。每个环节都可能成为瓶颈。通过日志或 trace 把各环节耗时百分比算出来,就能确定大头在哪。比如数据库查询占总耗时 80%,那就先看索引、执行计划、缓存命中;如果是下游调用超时占大头,就要看超时配置、重试策略、连接池设置。

另一个维度是资源分类:CPU 瓶颈、内存瓶颈、IO 瓶颈、锁竞争。CPU 高抓 dump 找热点;内存高看 GC 堆大小和对象存活;IO 高看磁盘队列和网络带宽;锁竞争看线程上下文切换和等待时间。面试时只要能把这些思路分层次说出来,再附上一个自己遇到过的简单例子,就是不错的表现。

7.2 涉及微服务时 ASP.NET Core 的定位与边界

在微服务架构里,ASP.NET Core 更多是提供服务宿主能力。它通过中间件来承载网关、服务发现、配置刷新、认证、日志追踪等横切关注点。比如每个服务启动时从配置中心拉取配置,请求进入时从请求头透传 trace id,调用下游时把当前 trace id 注入到 HttpClient。这些功能在 ASP.NET Core 里通常实现为一个中间件或 DelegatingHandler,而不是在业务代码里到处复制。

很多候选人对“服务间调用要不要走 API 网关”这类问题没有明确观点。我的建议是:外部流量进业务集群走网关做统一认证、限流、路由;内部服务调用可以直接走服务发现或内部负载均衡,避免所有流量都绕经网关增加延迟。ASP.NET Core 提供了内置的健康检查端点、gRPC、SignalR 等多种能力,在微服务中分别适合不同场景:健康检查用于探针,gRPC 适合低延迟高吞吐的内部通信,SignalR 适合实时推送。能把这些组件串起来,说明你不是只把微服务理解成“拆几个 Web API 项目”。

还要知道 ASP.NET Core 配置、DI、日志是跨项目共享的基础设施。微服务团队可以创建公共基础包,封装统一的中间件管道和配置约定,既保证各服务行为一致,又不必重复造轮子。面试时说清楚这一点,比单纯背微服务八股更有价值。

7.3 高频进阶问题清单速查

为了让读者在面试前一天快速过一遍,我把本文涉及的高频问题整理成速查表。每一个问题都可以用“结论+原理+实战案例”三段式回答,时间控制在一分钟以内。

  • 请求管道的执行顺序、Map/UseWhen/Use 的区别是什么?实际项目中如何选择?
  • Singleton、Scoped、Transient 的生命周期边界在哪里?为什么 Singleton 不能注入 Scoped?
  • IOptions、IOptionsSnapshot、IOptionsMonitor 分别适合什么场景?后台服务中该怎么选?
  • JWT 与 Cookie 的优缺点是什么?JWT 如何应对泄露和失效?
  • 异步方法为什么能提升吞吐?.Result 会造成什么后果?
  • 缓存穿透、击穿、雪崩分别怎么解决?本地缓存和分布式缓存怎么搭配?
  • 更新数据库后缓存怎么保持一致性?

这些问题的答案在文中都有展开。如果面试官继续深挖,就往源码层面走:看一下中间件源码里 next 是如何调用的、DefaultServiceProvider 是如何解析生命周期的、认证 handler 的内部流程。把这些源码阅读经验写进博客,是我觉得最有效的方式。

最后再分享一点我自己的体会:面试中遇到没准备过的问题不要慌。ASP.NET Core 的很多设计都围绕着“请求管道、容器、配置、中间件”这几个抽象,即使不知道具体 API,也可以从这几个抽象推出合理答案。我在准备面试时,习惯把每篇文章里的知识点都投影到这几个抽象上,形成一个自己能推导的知识网络。你能把这里面的逻辑讲通,技术深度自然就体现出来了。

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进