Skip to content
Go back

2026 高级 .NET 面试:35 道真实考题拆解

高级 .NET 面试不是中级面试的加难版,而是一场完全不同的面试。没有人会问你依赖注入是什么;他们会描述一个「CPU 只有 15% 但服务在超时」的场景,不给你任何日志,然后观察你怎么思考。

这一点淘汰了很多人:你可以有八年稳定交付功能的经验,仍然在高级轮次折戟——因为问题不再问「某样东西是什么」,而是问「这东西着火了你又不知道原因时,你会怎么做」。能赢的回答有一个共同点:说出一个具体机制、承认自己还不知道什么、描述下一步要做的测量而不是下一个猜测

本文是 Mukesh Murugan(Solutions Architect、Microsoft MVP)整理的 35 道高级 .NET 面试题,按真实面试的格式组织:真实场景、示范回答、会被悄悄淘汰的「雷区回答」、面试官紧接着会追问的问题。内容基于 .NET 10 与 C# 14,所有版本相关的说法我都对照官方文档核对过。这不是 REST 设计、LINQ 语法或中间件顺序那种知识点清单——这是运行时、生产现场和判断力。

高级层级到底变在哪

三件事变了,理解它们比背下任何单题答案都值钱。

问题不再有唯一正确答案。 中级问题有标准答案;高级问题考的是正确的过程。「p99 是 6 秒而 p50 只有 40ms」没有单一原因。面试官在看你是缩小问题空间,还是开始瞎猜修复方案。

你应该主动说「我会先测量」。 中级阶段承认不确定会被当成短板;高级阶段没有证据就断言原因才是更大的短板。最强的回答会点名工具和第一个要看的指标。

判断力比记忆重要。 知道 CQRS 存在只是入场券;能说出什么时候拒绝引入它、为什么,才是被考察的东西。面试官在判断你会让代码库变好,还是只是变得更时髦。

全程还有一个「时效性检查」:如果你谈起 BinaryFormatter、.NET Upgrade Assistant 或 Server GC 调优时表现得好像 DATAS 不存在,面试官会默默得出结论——你已经好几个版本没看发布说明。这些具体的坑都在下文。

01 运行时、内存与 GC(6 题)

这一节把「跑过 .NET 生产环境的人」和「写过 .NET 的人」区分开。几乎没有任何面试准备资料覆盖它,这正是它被问的原因。

Q1. API 内存一整天持续爬升、重启就回落。找出泄漏。 先确认这到底是不是泄漏:.NET 不会急切地把内存还给操作系统,工作集增长但机器内存充足,可能完全健康。要回答的问题是存活对象是否在增长,而不是已提交内存是否增长。用 dotnet-counters monitor -n MyApidotnet.gc.last_collection.heap.size 分代观察几小时:如果 gen 2 在回收后持续攀升,说明有引用被持有;如果只有工作集涨而 gen 2 平稳,那是碎片化或原生内存,不是托管泄漏。确认是托管问题后,用 dotnet-gcdump collect -p <pid> 相隔一小时取两份堆快照做 diff,看哪个类型涨了,再找根:静态集合、从不退订的长期事件处理器、没有大小限制或过期策略的 IMemoryCache、注册成 singleton 的闭包捕获。 雷区回答:「我给容器加内存再配个自动重启。」——这是缓解措施,有时是正确的短期方案,但把它当诊断说出口,等于承认你从没真正找到过泄漏。 追问:diff 显示有一百万个 byte[] 实例,你怎么找谁持有它们?

Q2. 你的 API 跑的是 Server GC 还是 Workstation GC?有关系吗? ASP.NET Core 应用默认 Server GC。托管应用的宿主替运行时选择,Web 宿主选 Server:每个逻辑 CPU 一个堆和专用回收线程、并行回收,同样堆大小下更快,但显著更耗内存和 CPU。关键知识点:只有单逻辑 CPU 的机器无论如何都强制 Workstation GC——ServerGarbageCollection 配成 true 也会被单核容器无视。所以一个在 4 核节点上表现正常的服务,别人收紧 CPU 限制后行为可能完全不同,而你的配置什么都没改。另一面是密度:一台机器上跑很多小服务实例时,Server GC 的每核线程会互相打架,禁用并发回收的 Workstation GC 可能真的更快。 雷区回答:「Server GC 对服务器总是更好。」——它是正确的默认,但在资源受限或高密度容器里常常是错的,而且在单核上根本不生效。 追问:怎么验证运行中的进程实际选的是哪种?

Q3. DATAS 是什么,为什么 .NET 10 里要关心? DATAS 即 Dynamic Adaptation To Application Sizes(按应用规模动态适应)。它让 GC 按应用的存活数据量决定堆大小,而不是因为机器内存大就维持大堆。它在 .NET 8 以开关引入,.NET 9 起默认开启(仅 Server GC,Workstation 不受影响)。面试里出现它的原因:.NET 10 是 LTS,大量团队从 .NET 8 升级时第一次遇见它。行为变化是真实的:内存占用显著下降,代价是少量吞吐损耗——DATAS 默认目标是把吞吐成本控制在 2%(官方文档的 Throughput Cost Percentage);它还会让进程从一个堆起步再增长,所以启动和流量爬坡的头几分钟可能显得比以前慢。如果服务在升级 .NET 9/10 后吞吐回退,第一个要验证的就是 DATAS:把 System.GC.DynamicAdaptationMode 设为 0 重跑压测——不是要关掉它,而是确认原因。 雷区回答:「DATAS 是 .NET 10 的新 GC。」——它不是新收集器,也不是 .NET 10 才有的;它是 .NET 9 起默认的 Server GC 适应模式。 追问:升级后内存降了但 p99 涨了,你怎么判断这个交换值不值?

Q4. 什么对象上大对象堆(LOH),为什么它让你难受? 85,000 字节及以上的对象进 LOH:大数组、大字符串、每请求分配的缓冲区、内存里拼出来的序列化负载。两个痛点:LOH 只随 gen 2 回收,LOH 压力会拖出完整回收;它默认不压缩,不同大小的连续大缓冲区分配会把它碎片化——堆里全是洞,已提交内存付着钱却用不上。一个容易暴露「读过还是调过」细节的 caveat:.NET Core 3.0 起的容器环境会自动压缩 LOH。所以在 Kubernetes 里碎片化基本不是你的问题,gen 2 压力才是剩下的痛点。修法几乎从不是调 GC,而是停止分配:ArrayPool<T>.Shared 复用缓冲区、用流代替整包物化、大请求体用 RecyclableMemoryStream 替代 MemoryStream。调 System.GC.LOHThreshold 存在且少数情况正确,但第一个就想到它是坏味道。 雷区回答:「可以用 GCSettings.LargeObjectHeapCompactionMode 强制压缩 LOH。」——可以,而且偶尔是正确的应急杠杆,但它触发的是阻塞式全量压缩回收。把它当常规答案,说明你宁可配置也不修问题。 追问:怎么定位是哪条代码路径在 LOH 上分配?

Q5. Gen 0 回收本该很便宜,为什么你的 p99 在飙? 因为「很便宜」的回收如果频繁发生就不便宜,而且 gen 0 回收不是唯一会停线程的东西。常见机制是晋升(promotion):gen 0 便宜的前提是对象在回收时已经死了。如果请求处理器分配的对象活到响应写完——这很正常——它们逃过 gen 0、升到 gen 1 再到 gen 2,你就在为没预料到的完整回收付钱。分配速率直接变成 gen 2 压力。要看 dotnet.gc.pause.time 与请求延迟的对应关系,加上各代回收次数:负载下 gen 2 次数爬升,p99 尖峰几乎必然对齐那些暂停。另外进程接近内存上限时 GC 会更激进地做完整压缩回收——物理内存负载约 90% 触发,容器里指容器限额的 90%,不是宿主机的。 雷区回答:「Gen 0 很便宜,所以 GC 不是问题。」——这正是题目的陷阱,考的就是你知道不知道晋升。 追问:不重写一切的情况下,怎么降低热点请求路径的分配?

Q6. 进程占 8 GB,堆快照只算得出 2 GB,其余在哪? 托管堆大小和进程工作集是两回事,差额的候选很短。碎片化:.NET 7 起 64 位 Windows 和 Linux 上 GC 用 region 而不是 segment 组织堆,但已提交未使用的空间仍算进程占用,dotnet.gc.last_collection.heap.fragmentation.size 告诉你有多少。原生内存(实际服务里更常见):原生数据库驱动、图片/PDF 库、gRPC 与 HTTP/2 缓冲区、任何 SafeHandle 包裹的资源都在托管堆之外,这里的泄漏对 gcdump 完全隐形。第三就是GC 还没被要求归还:已释放回堆但未解除提交的内存仍计入工作集。拆解方法:对比 dotnet.process.memory.working_set 和托管堆大小随时间的变化——托管平稳而工作集爬升,就别再看 gcdump,去查原生句柄和非托管库。 雷区回答:「就是 GC 偷懒,它自己会回来的。」——有时是真的,但不看碎片计数、不考虑原生内存就下这个结论,是没被容器 OOM kill 过的人的回答。 追问:怎么确认是原生泄漏而不是托管泄漏?

02 并发与线程池(5 题)

async/await 的机制属于 C# 那页。这里问的是线程池本身成为瓶颈时怎么办——这是生产问题,不是语言问题。

Q7. API 负载下超时,但 CPU 只有 15%。发生了什么? 「响应慢 + CPU 闲着」是**线程池饥饿(thread pool starvation)**的标志性组合:工作项排着队但没有空闲线程跑,请求在队列里等,机器看起来无所事事。机制几乎总是 sync-over-async:请求路径上有人对 task 调 .Result.Wait().GetAwaiter().GetResult(),阻塞池线程而不是释放它。负载下每个并发请求吞一个线程并握住不放,池被抽干,运行时补线程又很慢——初始爆发后大约每秒只加一两个。确认而非猜测:dotnet-counters monitor -n MyApidotnet.thread_pool.thread.count——饥饿的典型形态是线程数爬到核数的两三倍还在慢慢往上走,而 CPU 保持低位,同时 dotnet.thread_pool.queue.length 很大、work_item.count 很低。值得知道:.NET 6 改了池的启发式,对某些阻塞型 task API 会更快加线程,缩短了这类事件——现代 .NET 上饥饿常表现为爬坡期的延迟尖峰而不是永久停摆,但底层问题没消失。 雷区回答:「我把 ThreadPool.SetMinThreads 调高。」——经典创可贴:掩盖爬坡期的症状,对阻塞线程毫无帮助。可以说,但要说清只是稳住局面、同时去修阻塞调用。 追问:确认了饥饿,怎么定位是哪一行在阻塞?

Q8. 已确认饥饿,怎么找阻塞调用? 两条路,取决于问题是持续还是间歇。每请求都发生:dotnet-stack report -n MyApi 把所有线程栈直接打到控制台,找以 ThreadPoolWorkQueue.Dispatch()PortableThreadPool+WorkerThread.WorkerThreadStart() 结尾的栈(这两个帧标识池线程),再读栈顶看它停在什么上——被阻塞的 sync-over-async 表现为 Task.SpinThenBlockingWaitManualResetEventSlim.Wait 压在你自己的 controller 方法上面。几分钟才一次:栈是抽奖,改抓 trace:

dotnet-trace collect -n MyApi --clrevents waithandle --clreventlevel verbose --duration 00:00:30

这捕获 WaitHandleWait 事件——.NET 9 专门为这个场景新增的:线程一阻塞就触发,覆盖 Task.ResultTask.WaitlockMonitor.EnterSemaphoreSlim.Wait 等。把生成的 .nettrace 用 PerfView 或社区 .NET Events Viewer 打开、按栈分组,罪魁调用路径通常是第一项。 雷区回答:「我挂个调试器单步跟。」——生产通常不允许挂调试器,而且单步复现不了负载相关的问题。 追问:阻塞调用在改不了的第三方库里,然后呢?

Q9. 共享计数器用 lock、SemaphoreSlim 还是 Interlocked?为选择辩护。 纯计数器:Interlocked.Increment。单条原子 CPU 指令,无内核切换、无阻塞风险,这个场景没有更近的选项。lock 是「多个操作要保持一致」时的默认选择——读值、判断、写回——无争用时便宜、可读性最好,硬限制是里面不能 awaitSemaphoreSlim 用于临界区里有异步工作(WaitAsync 会释放线程而不是阻塞),或想放行 N 个并发持有者;代价是比 lock 重,且必须在 finally 里释放。考的是你会不会选最便宜够用的工具:用 SemaphoreSlim 保护一个 int 不算错,只是贵了几个数量级。 雷区回答:「在 await 外面包 lock。」——编译器直接拒绝。说出这句通常意味着从没写过有争用的异步代码。 追问:计数器变成 counter 字典,答案变吗?

Q10. ConcurrentDictionary.GetOrAdd 的 factory 跑了两次,为什么,要紧吗? 因为 GetOrAdd插入是原子的,对 factory 的执行不是。两个线程同时 miss 同一个 key 时,两个都会执行 value factory;结果只有一个胜出被存下,另一个被丢弃。要不要紧完全取决于 factory 干什么:算个值,浪费点 CPU 没人注意;打开连接、启动后台任务、向外部系统注册——你就制造了一个永远没人 dispose 的对象,真·资源泄漏,只在并发下出现,能逃过你所有测试套件。修法是让存进去的值便宜且惰性:字典里存 Lazy<T>,让 Lazy<T> 提供 run-once 保证——GetOrAdd 可能仍创建两个 Lazy<T> 包装,但只有赢家的 Value 会被访问,昂贵工作只发生一次。 雷区回答:「ConcurrentDictionary 是线程安全的,所以不可能。」——线程安全指数据结构不会损坏,不代表你的回调只跑一次。 追问:同样的问题问 IMemoryCache.GetOrCreate,它也有这个毛病吗?

Q11. 要调下游 API 500 次,怎么不把它打挂? 朴素答案是 500 个 task 上 Task.WhenAll——500 个请求同时射出,是让自己被限流、或打垮一个按正常流量规划的服务的好办法。正确工具是 Parallel.ForEachAsync 配显式 MaxDegreeOfParallelism:它就是为这个场景设计的,且正确处理异步。显式设置是关键——默认值是 Environment.ProcessorCount,而核数对 I/O 密集型工作恰好是错误基准,正确值来自下游能承受多少。需要更多控制时,用按并发上限初始化的 SemaphoreSlim + 每个 task 内 WaitAsync,效果相同且更容易和重试策略组合。两点要主动提:传 CancellationToken——500 个不能取消的调用会活得比发起它的请求还久;以及小心「有界并发 × 重试」的组合——下游开始失败时,如果你所有在途调用都在重试,你就是在给一个已经挣扎的服务造负载放大器。 雷区回答:「Task.WhenAll 就行。」——它只是并发跑,不设边界。这题问的就是边界。 追问:重试策略放在并发限制的哪一侧?

03 生产诊断(6 题)

这一节与写代码无关。它关于凌晨三点、一个不能挂调试器的服务,以及高级面试比候选人预期花更多时间的地方。

Q12. 生产变慢,不能挂调试器也不能部署。你伸手拿什么? .NET 诊断 CLI 工具,按从便宜到侵入的顺序:dotnet-counters 永远第一——几乎零成本、对活进程生效,回答分诊问题:这是 CPU、内存、GC 暂停、线程池还是锁争用?这五个桶覆盖大多数事故,知道自己在哪个桶里,后面每一步都不同。计数器指向线程时用 dotnet-stack report——控制台直接输出,不用搬文件,告诉你每个线程此刻在干什么。问题间歇、需要一段历史窗口时用 dotnet-trace——追阻塞时找 WaitHandleWait 事件也在这。内存问题用 dotnet-gcdump——给你一个可和后续快照 diff 的堆图。dotnet-dump 最后——完整进程转储是最全的产物也最扰民,离线分析。框架是每一步都由上一步的结果来论证;上来就全量 dump「因为它什么都有」,你会得到 8 GB 文件和零假设。 雷区回答:「我先看日志。」——作为第一直觉没错,但如果日志里有答案,你根本不会在这个对话里。这题问的是日志没有答案时怎么办。

Q13. 一个 .NET API 的仪表盘上只能放四个数,放哪四个? 请求速率、错误率、p99 延迟、饱和度——RED/USE 框架。这四个组合能探测几乎所有事故形态,且每个都提供其他三个给不了的信息。高级点在于两个延伸:延迟必须是百分位,永远不是平均值——均值会漂亮地藏住 1% 正在超时的用户,而这 1% 恰是发邮件给客服的那群;饱和度是 .NET 专属的那一个——我要线程池队列长度和 GC 暂停时间,因为这两个资源是 CPU、内存看起来都正常时悄悄进入危急状态的。允许第五个的话:按依赖拆分的依赖延迟——大多数「我们的 API 慢」事故其实是「我们调用的某个东西慢」,这个拆分每次都能省下调查的前二十分钟。 雷区回答:「CPU、内存、磁盘、网络。」——那是主机指标,告诉你机器的情况而不是服务是否在履行职责,而且线程池饥饿在这四个里全部隐形。 追问:有了 p99,怎么定告警阈值?

Q14. 一个 Bug 每周只在生产出现一次,本地从不复现。怎么抓它? 停止尝试复现,开始尝试捕获——每周一次的问题用交互方式追会耗掉你一个月。思路是让失败留下证据:correlation ID 贯穿每条日志和每次下游调用,事后能把一次失败的请求重新拼起来;采样偏向错误——失败 100% 采样、成功只采 1%——尾部采样(tail-based sampling)决定了你需要 trace 时手里有没有,还是有一百万条成功请求的 trace。然后加定向捕获:失败有可检测的特征(特定异常、超阈值延迟)时,安排它一触发就自动抓 dump 或 trace,而不是指望有人恰好在场。另一半是等待时收窄空间:每周一次本身就是线索——调度任务、缓存过期、token 刷新、证书轮换、部署窗口、某天跑一次的批处理。先把失败时间戳和系统里所有周期性事件对齐,再假设它是随机的。 雷区回答:「多加日志等着。」——对了一半,也是多数人的做法,但没有 correlation、没有错误偏向采样,你通常只会得到更多数据量和同一个盲区。 追问:时间戳和你的夜间任务对齐了,怎么证明因果而非巧合?

Q15. 日志一天 40 GB,还是回答不了「这个请求为什么慢」。 因为体量不等于信号,40 GB 的错误内容回答不了任何问题。三种典型失败:非结构化——只能 grep 不能聚合,$"Order {id} took {ms}ms" 是字符串,你没法问它「按端点看 p99」;结构化日志把同一行变成可查询的。无关联——一次请求的旅程散落在各服务和实例间,没有 trace 或 correlation ID 就拼不回来。记录高度错误——每个方法的进出都记,40 GB 大多来自这里,真正变化的边界(数据库调用、HTTP 调用、缓存查找、锁获取)反而没有计时。我想要的:分布式追踪回答「时间去哪了」,日志携带 trace ID 让两者对上,日志级别允许只为一个租户或端点提高详细度而不影响所有人。 雷区回答:「我们应该少记点日志。」——直觉对、结论错。问题不是量,是既不可查询又无关联。

Q16. 依赖升级弄挂了生产,所有测试却都过了。缺了什么? 诚实的答案:测试在测我们的代码,而变化发生在我们代码和世界的接缝处。常见形态:测试 mock 了依赖,于是断言的是我们的假设而不是库的真实行为,而变的恰好是假设;或者传递依赖移动了——升的包拉了三层之下某个东西的另一个版本,测试套件里没有任何路径覆盖它;或者变化只在真实条件下可见:连接池、超时默认值、边界值的序列化、TLS 协商、文化敏感的解析。之后要补的:针对我们真正依赖的那几个行为,对真实依赖做契约/集成测试(基础设施用 Testcontainers),加上 canary 或分批上线,让生产流量的首次暴露是 1% 而不是 100%。也要直说:没有测试套件能抓住一切,有用的问题是你多快发现、多快回滚。如果答案都是「几小时」,缺口在可观测性和部署,不在测试。 雷区回答:「我们需要更好的测试覆盖率。」——覆盖率是个数字,在这里根本不会动。缺口是测试的种类,不是数量。 追问:怎么判断哪些依赖值得契约测试、哪些不值得?

Q17. p50 是 40ms,p99 是 6 秒。先看哪? 这个差距本身就是重点:公共路径健康,一小撮请求发生了什么事——所以不是找慢代码,而是找偶尔让本来正常的代码停住的东西。按命中频率大致排序:GC 暂停,击中谁谁倒霉,和 dotnet.gc.pause.time 对延迟尖峰的相关性对得上;线程池排队,请求本身很快但等了一会儿才开始;锁争用,看 dotnet.monitor.lock_contentions连接池耗尽(数据库或 HTTP 客户端),工作快但拿连接慢;数据倾斜——p99 是某个租户的行数是别人一百倍,代码没问题。区分动作:慢请求是随机样本还是成模式?按端点、租户、负载大小聚集,是数据问题;均匀散开,是进程级问题——GC、池、或吵邻居。 雷区回答:「我优化最慢的端点。」——p50 说明端点没问题。优化一个本来 40ms 的代码,动不了一个由 stop-the-world 暂停造成的 p99。 追问:慢请求全属于一个租户,然后呢?

04 性能与测量(4 题)

技术面试里最可靠的高级信号:你分不分得清「你相信更快」和「你测过更快」。

Q18. 你说你的改动让端点快了三倍。证明它。 方法级改动用 BenchmarkDotNet:它处理了秒表循环搞错的事——预热(不把 JIT 编译算进去)、跑足够迭代得到分布而不是单点、报告方差让你区分真实差异和噪声;加 [MemoryDiagnoser] 还报告分配——真正的赢点常常在这。端点级改动,基准是完全错误的仪器:对两个版本跑贴近真实并发度的压测,比较百分位延迟和吞吐——我改的东西可能孤立地更快,却在争用下无关紧要。要大声说出来的比较纪律:同一硬件、同一数据量、同一热状态、只变一个变量。第二次运行的热缓存带来的「快三倍」不是结果,被坑过的面试官会专门听你提不提这个。 雷区回答:「我用 Stopwatch 在循环里掐过表。」——诚实的直觉、多数人的起点,但没有预热和迭代次数,你经常测的是 JIT 和 GC 的时机而不是你的代码。 追问:基准说三倍,生产毫无变化。发生了什么?

Q19. 微基准为什么撒谎? 因为它恰恰移除了让生产变慢的条件。JIT 会高兴地优化掉结果没被使用的工作——基准可以什么都测不到还报个漂亮数字。同一份微小输入跑一百万次,分支预测器和 CPU 缓存被不真实地加热——真实流量有冷缓存和不可预测的分支。没有争用:没有其他线程、没有应用其他部分的 GC 压力、没有锁队列。输入是均匀的,而生产数据有离群值——那通常才是全部问题。具体的咬法:基准里方法快了 40%,上线纹丝不动——因为那个方法只占请求时间的 2%,其余 98% 在等数据库。这是阿姆达尔定律在工作——不是基准错了,是基准被问了错误的问题。所以我的立场:微基准是比较同一件事的两种实现的正确工具,是决定「这件事值不值得优化」的错误工具。Profiling 决定优化什么,benchmarking 决定怎么优化。 雷区回答:「再多跑几次迭代就行。」——更多迭代修噪声,修不了「测了个无关紧要的东西」。

Q20. 慢 API 的优化精力按什么顺序投入? 从外到内,收益通常就是这么排的。先测量,找到时间真正花在哪,这步之前什么都别做。然后是数据库——典型 CRUD 形态 API 的大部分墙钟时间在这:缺索引、查询返回比端点需要的多得多的行、N+1。这通常是最大的单一收益、也最便宜。然后网络形态——端点做了几趟往返、顺序调用能否并发、负载是否比客户端需要的更大。然后缓存——刻意地,在知道什么贵、允许多旧之后。然后代码——分配、序列化、热点路径。最后运行时——GC 配置、池化、其余旋钮,因为它是杠杆最小、最容易拧错的。考的是你不从底层开始:给真实问题是缺索引的服务调 GC 参数,是花大价钱实现零收益。 雷区回答:「我加缓存。」——有时正确,但作为第一步它掩盖问题、增加失效 bug 面,还让底层查询以后更难找到。 追问:什么时候「加个缓存」是错误答案?——见下一题。

Q21. 什么时候「加个缓存」是错的? 底层操作是的而不是慢的时候——缓存一个返回错误数据的查询只是更快地提供错误数据,还额外变旧了。数据不能容忍陈旧的时候——用户刚写下的、期望立刻看到的;任何用于授权判断的;任何金融相关的。缓存权限检查,就是让一个被撤销的用户在 TTL 内继续享受旧权限。命中率会很低的时候——长尾唯一 key 的数据前面放缓存,你付出一次查找、一次序列化、一份内存,换来的全是 miss;说缓存有用之前你得先知道访问分布。它掩盖一个可修的问题的时候——查询因缺索引要 4 秒,缓存它意味着每个 TTL 周期有一个倒霉用户等 4 秒,而且永远没人回去加索引。还有失效比原问题更难的时候——数据有多个写入者时,缓存把性能问题变成正确性问题,而正确性 bug 更贵。 雷区回答:「TTL 设短点,缓存总是好的。」——短 TTL 在降低陈旧的同时摧毁命中率。如果 TTL 必须短到安全,缓存往往根本就没在给你买什么。

05 数据规模(4 题)

查询机制在 EF Core 那页。这里问的是数据长到超出代码编写时的假设后发生的事。

Q22. 读副本落后四秒,用户刚保存完就看到旧数据。修它。 这是 read-your-own-writes(读己之写),是把读指向副本时没决定怎么处理滞后而制造的一致性问题的标准形态。务实的修法:用户写入后的短窗口内把该用户的读路由回主库——通常用会话上的时间戳或日志位置标记,副本追上之前都读主库。副本的好处留给 95% 不读自己刚写的数据的流量。更便宜的变体通常够用:按操作路由——写后读流程全读主库;报表、搜索、列表视图读副本。粗糙但容易推理、很难微妙地出错。第三个选项是停止骗用户:写入真的是异步时,「已保存,稍后更新」比假装立即生效再自相矛盾诚实得多。这既是产品决定也是技术决定,愿意把它提出来本身就是答案的一部分。 雷区回答:「给副本加资源让它跟上。」——负载下的复制滞后是正常运营状态,不是能花钱买掉的 bug。任何假设零滞后的设计都会再次坏掉。 追问:怎么测量滞后、好到能用来告警?

Q23. 夜间批处理锁住订单表,API 开始 500。你改什么? 批处理在做单个巨型事务,锁升级到表级,一锁几分钟,其他一切排队然后超时。第一个改动是分批:几千行一块处理、每块提交、块间短暂停顿——一个长锁变成许多短锁,API 可以穿插其中;任务还可重启,这对它中途第一次失败很重要。第二是收窄锁定范围:在索引键范围上操作,让引擎取行/页锁而不是升级到表锁,并确认任务的 WHERE 真的用了索引而不是全表扫。第三是问它是否需要碰运营表:很多夜间任务是聚合,读副本或读拷贝更合适,根本不和线上流量争。还要给批处理而不是 API 设置锁超时——如果必须有输家,应该是凌晨四点可以重试的任务,而不是客户请求。 雷区回答:「换个更安静的时间跑。」——缓解不是修复:业务在另一个时区有客户时立刻失效,而且对任务随数据增长而变慢毫无帮助。 追问:任务失败一半怎么办?——(参考上文:分批使它可重启。)

Q24. 给一张两亿行的表加 NOT NULL 列,要求零停机。走一遍。 不能一次迁移完成:单个带默认值的非空列 ALTER 会重写整张表并持有等长的锁。模式是 expand and contract(扩展-收缩),分多个独立部署:Expand——加列,可空、无默认值、无约束;现代引擎上这是纯元数据操作,几乎瞬间完成。Backfill——分批回填、每块提交、限速以免吃光线上流量需要的 IO;可以跑几小时,没关系,因为没有任何东西被阻塞。Dual-write——部署同时写新列的代码(每次 insert/update),读仍容忍 null;数据不再在回填后面变旧。Contract——回填完成并验证后加 NOT NULL 约束,之后才部署假设列永远存在的代码。底下的规则:schema 变更和代码变更必须可独立部署、且对至少一个发布周期向后兼容——滚动部署期间新旧代码同时打同一库。任何要求它们同步变化的步骤,都是要求停机的步骤。 雷区回答:「一条迁移加个默认值搞定。」——某些引擎上默认值让事情更糟:它强制走那条元数据路径正在避免的重写。 追问:回填跑了六小时才完成三分之一,让它继续吗?

Q25. 什么时候离开关系型数据库,实际得到什么? 很少,而且诚实的回答以这句话开头——大多数「我们需要 NoSQL」的对话最后发现其实是「我们需要一个索引」。我认为真正值得的场景:访问模式是已知 key 查找、规模上水平分区比 join 更重要;数据真正无 schema、逐条差异大到表只能用一片可空列来表达;时序或只追加数据,专用存储便宜一个数量级;全文与相关性搜索——搜索引擎其实不是数据库替代品,而是旁边的专用索引。得到的是贴合访问模式的数据模型,和不需要解决分布式 join 的水平扩展。要明确说出的代价——后悔通常来自这里:跨实体事务、没预料到的临时查询、关系引擎上存在而你的新选择上没有的海量运维知识。而且你很少真的替换关系库——你是加第二个存储,然后自己拥有它们之间的一致性。 雷区回答:「关系型不能扩展。」——它扩展到几乎任何团队永远用不到的程度;说出这句,说明你吸收了一场会议演讲而不是撞到了极限。

06 架构与判断(6 题)

这些没有正确答案。它们在测你的本能是让东西变好,还是让东西变成你的。

Q26. 接手一个三种模式并存的代码库,第一个月做什么? 刻意地,不做任何结构性动作。第一个月用来理解它为什么长这样——三个模式通常意味着三个时代,每个在当时、在我还看不到的约束下都是合理决定。实际动作:在三个区域都交付小功能——这比阅读更快教会你痛在哪;找待得最久的人,弄清哪些决定是深思熟虑、哪些是意外;观察代码库的哪部分在制造事故和慢评审——那是真正的优先级清单,不是让我觉得碍眼的部分。然后挑一件造成可测量痛苦的事,为新代码定方向而不是发动迁移:新代码跟选定模式,旧代码在被碰到时才转换——改进在复利,而不是一场和功能开发抢资源、会在 60% 处被放弃的大爆炸重写。要明确说出来:三种模式不自动等于值得解决的问题——如果它们边界清晰、各自内部自洽,统一它们的成本可能大于收益。 雷区回答:「全部统一成我最熟的模式。」——烧掉信任和一个季度的最快方式。 追问:这题没有追问——但它会以 Q28 的形式回来。

Q27. 团队想采纳某个新东西,你怎么决策? 技术优点之前,先要三个问题的答案。我们在解决什么问题?——不是工具能干什么,是现在什么在痛。没人指得出具体痛点,诚实的结论是有人读了好博客,那不是理由。演示之后要花多少?——每次采纳都有长尾:团队学习、CI 改动、它凌晨两点出问题时怎么调试、升级跑步机、招聘多一条要求。演示是你和它相处中最便宜的一小时。怎么退出?——一年后发现错了,逆转要花多少?接口后面的库退得便宜;消息平台或数据库不便宜。我更容易对退出成本低的东西说 yes。然后要它在真实但小的东西上证明自己——一个服务、一个功能、约定日期决策——而不是一个刻意避开所有难点的、注定成功的 POC。 雷区回答:「它是行业标准,我们该用。」——对谁、什么规模、什么团队规模的标准?千名工程师那儿的标配工具,在十人团队里多半是负担。

Q28. 讲一个你今天会做不同决定的技术决策。 这不是陷阱,也不真的关于那个决定。面试官在查三件事:你拥有过某个东西足够久、看到过它的后果;你能批评自己的工作,既不防御也不表演式自贬;你根据证据更新,而不是根据时尚。能打的答案有特定形状:说出决定和当时让它合理的语境——一个做的时候就明显错误的决定,比一个结果出错的决定更说明问题。说出揭示问题的具体东西:一次事故、一条成本线、一个花了三倍时间的功能。然后说出你会改做什么、以及它为什么不同——不只是更新。常见失败:选个无关痛痒的(「变量名会起得更好」)——读起来像要么没拥有过真东西,要么不愿在面试里诚实;另一种失败是怪约束——截止日期和遗留代码是真的,但问的是会做什么不同,「没有,都是截止日期的错」是个没人会给分的答案。 雷区回答:「我想不出来。」——这题唯一真正错误的答案。

Q29. 怎么决定哪些技术债该还、哪些该留? 看它在花多少钱,而不是多让我难受。大多数代码库有大量又丑又稳又无关紧要的代码,重写它们是爱好,不是工程。值得还的是带利息的债:每次事故都出现的模块、每个估算都错三倍的区域、挡住业务真正想要的改变的东西、正在被复制进新代码因而在增长的模式——最后这种最紧急,因为它是唯一会复利的那种。值得留的:稳定、很少被碰、无事故的区域,或有明确退役日期的组件——清理六个月后就要删除的代码是纯亏损。要强调:这必须以业务的语言论证才能拿到预算。「写得烂」什么也换不来;「这个模块造成了过去六次事故里的四次、每次改动都要三倍时间」能换一个迭代。同一笔债,第二种说法才是真的那个。 雷区回答:「每个迭代拨 20% 给技术债。」——听起来自律,压力一来第一个被砍。计划里一个有名有姓、论证过的工作项能活下来,一个百分比不能。

Q30. 继承的架构是错的,重写又不在选项里。你怎么打? 接受约束——它几乎总是正确的那个。重写的失败率高到该让人谨慎,「不在选项里」通常意味着已经有人目睹过一次失败。做法:先止血——新代码放进不依赖坏设计的边界后面,问题停止增长(即使不缩小)。然后增量绞杀(strangle):一次一条能力路由到新路径,新旧并存直到旧路径没有流量,然后删除。每一步都在交付,任何一步都可以是最后一步(如果优先级变了)——这正是重写给不了的。前提是接缝:现有设计没有边界的话,第一项真实工作是引入一个——接口、facade、边缘的反腐层——让新实现有地方安放。也要诚实:有时正确答案就是让它继续错下去——系统稳定、团队在交付、错误只是审美而非运营问题时,修复成本可能真的超过容忍成本。能当面说出这句话,本身就是一个高级信号。 雷区回答:「我并行建个 v2,准备好就切过去。」——那是换了个名字的重写,有同样的失败模式:和功能开发竞争、随原系统变化而漂移、在 70% 处被放弃。

Q31. 流量扩大十倍,你怎么扩展? 先礼貌地拒绝在抽象层面回答——「10 倍流量」不是一个问题:10 倍读和 10 倍写几乎没有共同点,持续 10 倍和 10 倍尖峰是完全不同的练习。第一步是问流量是什么形状,然后找真正的约束。通常不是应用层——无状态服务加实例就能水平扩展,那是容易的部分。约束几乎总是数据库、某个没注意就加进去的共享状态、或一个不随你扩展的下游服务。然后按成本排序:缓存占主导且容忍陈旧的读;副本水平扩展读;把不需要在请求路径里的东西移出去——通知、导出、用户不等的东西;最后才看分区写——分片是系统从此永久更难运维的点。还要点名多数人跳过的东西:10 倍时失败模式会变——曾经无害的重试变成放大器、缓存 miss 风暴能打垮以前没事的数据库、曾经慷慨的连接池成为瓶颈。只做容量规划不做失败模式规划,是半个答案。 雷区回答:「加实例放负载均衡后面。」——扩展了从来不是问题的层,10 倍时通常让数据库问题来得更快。

07 迁移与 2026 现实(4 题)

最后一节是时效性检查。这些问题对面试官来说便宜到几乎免费,却极具揭示性——答案每两个版本就变一次。

Q32. 一个 .NET Framework 4.8 单体,任务书是现代化。计划? 先清点,其他什么都别做——计划完全取决于里面有什么:哪些依赖有现代等价物、哪些有非 drop-in 的替代、哪些什么都没有——最后这张清单决定这是移植还是部分重写。然后按此顺序:先把项目重定向到现代 SDK 风格工程文件(仍留在 Framework 上也能做,去掉大量噪音);接着移共享库,理想目标是 netstandard2.0,过渡期两个世界都能消费;然后移叶子项目,入口点最后——它依赖其他一切。交付模式主张绞杀者(strangler fig):前面放反向代理,一次把一条路由或能力移到新应用,两者并行。每一步可交付可回退——这是这种规模的迁移能扛住路线图冲击的唯一方式。一个会暴露年代的工具注记:.NET Upgrade Assistant 已被官方弃用,微软改指向 GitHub Copilot app modernization agent(Visual Studio 2026 及新版 2022 中)。2026 年还推荐 Upgrade Assistant,一个小细节就告诉面试官你上次干这事是什么时候。 雷区回答:「大爆炸重写,更干净。」——更干净,且十八个月不可交付,期间旧系统还在你脚下不断变化。

Q33. .NET Framework 里什么没有 .NET 10 等价物? 短清单,也正是上一题清点步骤要找的:ASP.NET Web Forms——没有可移植的东西,必须重写,通常按交互程度去 ASP.NET Core MVC、Razor Pages 或 Blazor。服务端 WCF——客户端有支持,现代 .NET 里托管 WCF 服务不在框内;CoreWCF 是社区驱动的 .NET Foundation 项目,覆盖大部分表面积且被微软作为迁移路径支持,但它是迁移而不是重编译。Windows Workflow Foundation——无等价物,它编排的东西要重新表达,通常是持久化工作流引擎或显式状态机。AppDomains——不支持;隔离现在意味着独立进程,插件加载用 AssemblyLoadContext——精神相似、实践不同。BinaryFormatter——要精确,这是常被说错的一点:.NET 5 弃用,.NET 7 变编译错误,.NET 8 运行时抛异常,.NET 9 移除了 in-box 实现(API 还在、一律抛异常)。有个不受支持的 System.Runtime.Serialization.Formatters 包能恢复旧行为连同它全部漏洞,用它应该是带日期的、刻意的、临时的决定。 雷区回答:「BinaryFormatter 在 .NET 10 被移除的。」——接近正确,所以很多博客都这么说,但错了:它是 .NET 9 走的。

Q34. 怎么让大型代码库跟上每年的 .NET 发布? 把它当例行维护而不是项目——升级一旦变成「项目」就不再发生,然后你要一次跨四个版本。实操机制:用 Directory.Packages.props 集中版本,一次升级改一个文件而不是四十个;即使没东西强迫,也保持 TFM 现行——一个版本的成本很小,四个版本的成本不是线性;读每个版本的 breaking-changes 清单——它很短,是整个升级里价值最高的一小时。大型慢速系统瞄准 LTS 到 LTS:待在 .NET 10 直到 .NET 12;小服务每个版本都升,让团队保持手感——问题被在修起来便宜的服务里发现。另一半是安全网:升级正是那种「测试过了、生产挂了」的变化,因为变的多是运行时行为而不是 API 表面——GC 默认值就是当下的例子,DATAS 具体是人们在 .NET 9/10 上撞到的那个。所以:canary 部署,盯一整个流量周期的延迟百分位和内存,而不是构建变绿就宣布胜利。 雷区回答:「有安全补丁逼我们才升。」——可以理解、非常常见,它把小的经常性成本变成一次大的计划外成本。

Q35. 团队用 AI 编码工具,怎么防止质量滑坡? 这是 2026 的问题,开始出现在职位描述里,值得有真实立场而不是外交辞令。要点名的失败模式不是生成的代码差——它孤立地看通常没问题——而是代码量的增长快于理解量的增长。评审容量成为瓶颈,悄悄失守的是评审者真正读完一个大 diff 的意愿——缺陷就从这里进去。所以要的实践:作者对代码负责,无论谁写的——评审里能解释每一行,不能解释就不合入;小 diff 保持小——2000 行的生成 PR 会被橡皮图章,人人都知道;测试由人写或至少由人验证——生成测试的习惯是断言代码做了什么而不是该做什么;项目约定放在工具真正读得到的地方——它产出的代码贴合代码库,而不是互联网的平均数。要标记的真正风险是架构漂移:这些工具擅长生产更多已经存在的东西,不擅长注意到现有模式是错的。设计决定仍然需要一个看着整个系统的人。 雷区回答:「我们禁用它们」或「让它们全写、我们只审」——前者在不获得质量收益的情况下丢掉生产力(反正大家会私下用),后者正是事故的来源。

关键结论

怎么准备高级 .NET 面试

如果面试临近,按实际效用排序:

  1. 能端到端讲一次生产事故——什么坏了、怎么找到的、改了什么、下次会怎么做。这是最高频的高级题形态,也是准备最不足的。
  2. 诊断工具能叫上名——dotnet-countersdotnet-stackdotnet-tracedotnet-gcdumpdotnet-dump。场景题里能说对工具就已经领先多数候选人。
  3. 两三个架构辩论有可辩护的立场——微服务、CQRS、仓储模式。不是正确答案,而是带适用条件的立场。
  4. 刷新时效性——本文里 .NET 9/10 的变化正是那种会暴露候选人的细节。
  5. 练习说「我会先测量」——然后说出测什么、什么结果会改变你的想法。这句话说得自然,就是高级回答和自信猜测之间的大部分差距。

总结

35 题的模式是一致的:高级面试不测你比中级多记住多少事实,测的是信息不完整时你能不能运转、能不能说出机制而不是症状、能不能对自己知识的边界诚实。如果要带走一句:养成区分「你测量过的」和「你相信的」的习惯——几乎每个强答案都回到它:用堆 diff 确认的泄漏而不是假设的,在计数器里看到的饥饿而不是猜的,用基准证明的优化而不是感觉的。这个习惯就是高级信号,大约三十秒的对话里就能看出来。


如果你也在准备 .NET 技术面试、搭建可观测性体系,欢迎关注 Aide Hub。我们会继续分享 .NET 运行时、性能调优和软件工程实践的一手内容。

参考


Tags


Previous

Agentic Code Quality:质量藏在约束里

Next

NLog 性能调优:AsyncWrapper 与缓冲日志