Core 核心能力落地:从场景痛点到价值验证的实战指南 📅 2026/7/13 22:22:44 ASP.NET Core 高并发性能优化给小白的实战指南本文讨论的是 ASP.NET Core Web API 的性能与稳定性治理。示例以 ASP.NET Core 10 / .NET 10 为主线也尽量适用于 .NET 8。文中的“盲盒小店”是虚构案例压测表里的数字是记录模板不代表任何真实生产系统的成绩。先看结论高并发不是“把服务器加大”想象一下一家小店把 100 个限量盲盒放到网上。平时每分钟只有几十个人浏览商品店里的一个收银员、一个数据库和一台应用服务器都够用。突然到了开售时间几千个人同时点击“查看详情”几百个人同时点击“立即下单”。结果可能是这样的商品图片和介绍反复从数据库读取数据库 CPU 先升高。请求拿不到数据库连接只能排队等待。页面平均只慢了一点但最后 1% 的请求已经超过了 5 秒。应用为了“不要让用户失败”不断重试反而把已经变慢的支付服务压得更慢。有人连续点击下单系统没有幂等控制最后出现两笔订单。这时真正的问题通常不是“ASP.NET Core 不够快”而是请求进入系统后所有环节都在排队而且系统没有在入口处控制流量也没有保护数据库和下游服务。本文会沿着这家小店的请求链路走一遍先理解指标再建立基线然后从查询、缓存、限流、写入正确性、下游保护和可观测性几个方向逐步处理。每一步都要回答两个问题它解决了哪一个瓶颈怎样用数据证明它真的解决了瓶颈1. 先把 “Core” 说清楚原来的标题叫“Core 核心能力落地”这个说法太宽泛。这里的 Core 具体指ASP.NET Core也就是用 .NET 构建 Web 应用和 API 的框架。几个名称可以这样记名称小白理解在本文中的位置.NET运行 C# 程序的运行时、基础类库和 SDK决定程序怎样运行、怎样发布ASP.NET Core构建 HTTP 网站和 API 的框架接收请求、执行中间件和端点EF Core让 C# 代码访问数据库的数据访问技术查询商品、创建订单Redis 等缓存把常用结果临时放在更快的地方减少重复查询消息队列暂存需要稍后处理的任务把峰值流量变成可控的工作量所以“Core 架构”不是一个安装后自动变快的产品也不是一个可以随意编造 API 的抽象词。文章中的代码会使用真实的 ASP.NET Core 能力业务里的ShopDb、Product等类型只是为了让例子容易读懂仍然需要按你的项目定义。2. 截至 2026 年版本事实是什么截至 2026-07-13微软官方支持策略列出的版本如下版本发布日期支持类型官方支持结束日期.NET 102025-11-11LTS长期支持2028-11-14.NET 92024-11-12STS标准支持2026-11-10.NET 82023-11-14LTS长期支持2026-11-10LTS 和 STS 的主要区别是支持周期不是“代码质量一高一低”。实际项目还必须安装对应版本的最新补丁并确认 ASP.NET Core、EF Core 和第三方 NuGet 包是否兼容。本文优先使用当前的 ASP.NET Core 10 文档和 API 名称。比如限流使用AddRateLimiter输出缓存使用AddOutputCacheHTTP 下游保护使用AddStandardResilienceHandler。如果你的项目还在 .NET 8先在测试环境编译和压测不要因为方法名相似就直接替换生产配置。3. 一次请求到底经历了什么把请求想成顾客到店买盲盒顾客走到门口相当于请求到达网关或负载均衡器。店员检查是否超过当前接待能力相当于限流。店员先看柜台上有没有已经准备好的商品信息相当于缓存。没有缓存时店员去仓库查库存相当于访问数据库。下单时店员必须确认库存并登记订单相当于事务、原子扣减和幂等。如果需要联系配送员或支付平台店员不能无限期等待相当于超时和下游服务保护。店长需要知道今天哪里堵了相当于日志、指标、Trace 和健康检查。对应到程序里大致是浏览器 - 网关 / 负载均衡 - ASP.NET Core 中间件 - 路由端点 - 缓存、数据库、外部 HTTP 服务 - 返回 HTTP 响应高并发时任何一个环节都可能成为瓶颈。性能优化的第一步不是猜而是把这条路线拆开测量。4. 先认识 6 个关键指标4.1 QPS一秒来了多少请求QPS 是每秒请求数。例如 1 秒收到 500 个请求QPS 就是 500。它只说明“进来了多少人”不说明“有多少人顺利完成”。如果系统 1 秒收到了 500 个请求但有 300 个返回 500 错误不能说系统稳定承载了 500 QPS。4.2 延迟一个请求花了多久延迟是从请求开始到响应返回的时间。常见的百分位数这样理解P50一半请求比这个时间快代表普通体验。P9595% 的请求比这个时间快能看到一部分慢请求。P9999% 的请求比这个时间快最能暴露尾部排队和偶发故障。如果平均响应时间是 100ms但 P99 是 4 秒仍然会有一批用户觉得系统“卡死”。因此不要只报平均值。4.3 错误率有多少请求没有完成要分别记录 HTTP 4xx、HTTP 5xx、超时、被限流的 429以及业务失败。例如“库存售罄”可能是正常业务结果不能和数据库崩溃混在一个错误率里。4.4 饱和度哪个资源快满了常见饱和点包括CPU 使用率和 GC 暂停。内存和容器限制。.NET 线程池排队。数据库连接池等待。数据库 CPU、锁等待、磁盘 I/O。缓存命中率和缓存容量。消息队列长度和消费者处理速度。“CPU 只有 40%”并不表示系统还有很多余量。它可能正在等待数据库连接或者某一个依赖已经先满了。4.5 并发数此时有多少请求正在处理中QPS 是“每秒进来多少人”并发数是“店里此刻有多少人正在排队或办理”。在平均延迟相近时可以用一个很粗略的关系帮助理解同时处理中的请求数 ≈ 每秒请求数 × 平均响应秒数例如每秒 100 个请求、平均每个请求耗时 0.2 秒大约有 20 个请求处于处理中。这不是容量公式但能帮助小白理解请求只要变慢系统里的“在场人数”就会增加排队会进一步变长。4.6 业务结果技术快不等于业务对订单接口即使 P99 只有 100ms如果一个用户能创建两笔重复订单系统仍然失败。性能指标必须和业务正确性一起看重复订单数、超卖数、支付重复扣款数、消息重复消费数都应该是零或有明确的补偿方案。5. 建立基线没有对照就没有“优化成功”在改代码前先把环境写下来。下面这张表可以直接复制到项目的测试记录中项目基线记录Git 提交或发布版本待填写.NET SDK / ASP.NET Core 版本待填写应用实例数和 CPU/内存待填写数据库版本、数据量、索引待填写缓存状态冷缓存 / 热缓存请求比例商品详情 __%下单 __%其他 __%压测工具和脚本版本待填写并发模型与持续时间待填写P50 / P95 / P99待填写4xx / 5xx / 429 / 超时待填写CPU、内存、GC、连接池、数据库锁等待待填写压测时要固定几个条件数据量不能一会儿 1 万行、一会儿 1 亿行缓存不能一组是空的、一组是热的代码不能在测试中途偷偷改了版本。每次只改一个主要变量例如先只加索引再只加缓存这样才能知道收益来自哪里。本地可以使用dotnet-counters观察运行时指标使用dotnet-trace或 OpenTelemetry 找慢链路压测工具可以选择 k6、wrk、Bombardier 等。工具不是重点重点是脚本、环境和结果可以被别人复现。6. 第一个改动让查询少做一点工作很多慢接口不是因为 ASP.NET Core 处理 HTTP 慢而是接口查了太多数据、做了太多无用工作。下面是一个商品详情查询的示意代码app.MapGet(/api/products/{id:int},asyncTaskIResult(intid,ShopDbdb,CancellationTokenct){varproductawaitdb.Products.AsNoTracking().Where(pp.Idid).Select(pnewProductDto(p.Id,p.Name,p.Price,p.Stock)).SingleOrDefaultAsync(ct);returnproductisnull?Results.NotFound():Results.Ok(product);});这段代码里有几个值得学习的点AsNoTracking()适合只读查询告诉 EF Core 不需要跟踪实体变化。Select()只取接口需要的列避免把图片、描述、内部字段全部从数据库搬回来。CancellationToken让客户端已经断开时数据库查询有机会尽早停止。SingleOrDefaultAsync()是真正的异步数据库调用不要用Task.Run把同步 I/O 包装一下就叫异步。还要检查以下常见问题6.1 N1 查询代码先查 1 个商品再为每个商品查一次分类、标签和评论假设返回 100 个商品就可能产生 301 次查询。应使用合适的投影、批量查询或明确的联表查询并用 SQL 日志确认实际发出了多少条 SQL。6.2 无上限分页pageSize100000不是“灵活”而是让一个请求有机会把数据库和应用内存一起拖垮。对分页设置上限深分页场景可以研究基于游标或主键的分页。6.3 索引不能凭感觉添加如果接口经常按照ProductId和Status查询应结合执行计划和真实查询条件评估索引。索引能加速读取但也会增加写入、存储和维护成本。最好的索引不是“列越多越好”而是能匹配主要查询且经过实际验证的索引。6.4 连接池不是越大越好假设每个应用实例最多打开 200 个数据库连接10 个实例就可能同时申请 2000 个连接。数据库未必承受得住应用也可能因连接争抢而更慢。连接池大小必须和数据库最大连接数、实例数、每个请求的数据库占用时间一起评估。7. 第二个改动缓存重复读取商品名称、分类和活动说明通常被大量用户重复读取。每次都查询数据库就像每来一个顾客店员都跑去仓库重新看一遍同一张说明卡。ASP.NET Core 提供输出缓存能力。下面是一个简化示例builder.Services.AddOutputCache(options{options.AddPolicy(ProductCatalog,policypolicy.Expire(TimeSpan.FromSeconds(5)).Tag(catalog));});varappbuilder.Build();app.UseRouting();app.UseOutputCache();app.MapGet(/api/products/{id:int},async(intid,ShopDbdb,CancellationTokenct){varproductawaitdb.Products.AsNoTracking().Where(pp.Idid).Select(pnewProductDto(p.Id,p.Name,p.Price,p.Stock)).SingleOrDefaultAsync(ct);returnproductisnull?Results.NotFound():Results.Ok(product);}).CacheOutput(ProductCatalog);这里的 5 秒只是演示策略不是推荐值。缓存前要先回答这个数据允许旧几秒吗缓存键有没有包含必要的查询参数、语言或租户信息这个响应是否包含用户身份、权限或个人信息商品修改后怎样失效等 TTL 过期是否足够应用有多个实例时进程内缓存是否会出现每台机器内容不同商品描述可以接受短暂过期但库存和订单状态通常不能直接套用公共输出缓存。尤其不能为了让页面看起来顺滑在库存不足时返回“库存充足”。正确做法是明确返回“售罄”“库存同步中”或可解释的降级状态。ASP.NET Core 输出缓存默认使用进程内存储多实例时每个实例都有自己的缓存。要使用共享存储需要按官方文档配置对应的 Redis 或自定义IOutputCacheStore同时解决失效、容量和故障问题。缓存不是把数据搬走就结束了最难的是“什么时候这份数据不再可信”。7.1 三种常见缓存问题缓存穿透大量请求查询根本不存在的商品缓存也没有结果最后全部打到数据库。可以缓存短时间的空结果并对参数做校验。缓存击穿热门商品的缓存恰好同时过期大量请求一起查询数据库。可以使用资源锁、预热或错峰过期但仍要观察数据库峰值。缓存雪崩大批缓存同时失效整个流量突然回源。TTL 可以加入少量随机时间发布时也要避免一次性清空所有缓存。8. 第三个改动在入口处限流和背压当店里只有两个柜台时让 10 万人全部挤进店里并不会提高成交量只会让大家互相挡路。限流的作用是保护系统让超出当前处理能力的请求尽早得到明确结果。ASP.NET Core 的限流中间件可以这样配置一个简单的固定窗口策略usingSystem.Threading.RateLimiting;builder.Services.AddRateLimiter(options{options.RejectionStatusCodeStatusCodes.Status429TooManyRequests;options.AddFixedWindowLimiter(catalog,limiterOptions{limiterOptions.PermitLimit100;limiterOptions.WindowTimeSpan.FromSeconds(1);limiterOptions.QueueLimit0;limiterOptions.QueueProcessingOrderQueueProcessingOrder.OldestFirst;});});varappbuilder.Build();app.UseRouting();app.UseRateLimiter();app.MapGet(/api/products/{id:int},(intid)Results.Ok(new{id})).RequireRateLimiting(catalog);官方文档说明如果按端点使用限流策略UseRateLimiter应在UseRouting之后调用。上面的PermitLimit100仍然只是示例真正的值要根据应用实例数、下游容量和业务优先级压测出来。8.1 怎样选择限流方式固定窗口一个时间窗口允许固定数量请求容易理解但窗口边界可能出现短时突刺。滑动窗口让流量更平滑适合需要减少边界突发的场景。令牌桶平时积累令牌允许一定程度的突发适合有明确平均速率和峰值的接口。并发限流限制同时处理中的请求数适合一个请求会长期占用数据库或外部连接的场景。可以给商品浏览、登录、下单分别设置策略。登录要防刷下单要保护库存商品详情则可以在缓存后承受更高的读取量。不要把所有接口都设成同一个数。8.2 限流和排队的区别队列不是免费的。QueueLimit1000意味着系统可能先收下 1000 个请求再让它们等待。如果每个请求平均要等 3 秒用户拿到的不是成功而是 3 秒后才知道失败。对不能及时完成的请求快速返回 429 或 503 往往比无限排队更诚实。对真正适合异步处理的下单任务则可以把任务放入有容量上限的消息队列并立即返回“处理中”的订单状态。关键是给用户一个可查询的状态而不是让 HTTP 请求一直挂着。还要注意应用内限流通常是每个实例各自计数。多实例部署时如果业务要求“整个系统每个用户每秒只能请求 10 次”应在网关、共享限流存储或专门的流量治理层实现全局规则。9. 写操作的底线不能为了快而牺牲正确性读接口可以在一定范围内容忍短暂旧数据下单、扣库存和支付则不同。9.1 用幂等键防止重复下单用户点击一次后没有立即看到结果很可能会再次点击。网络重试、浏览器重发和消息重复投递也会产生类似问题。可以让客户端为一次业务动作生成幂等键例如Idempotency-Key服务端保存这个键对应的处理结果并在数据库建立唯一约束-- 以下为 SQL Server 风格示例具体语法按数据库调整。CREATEUNIQUEINDEXUX_Orders_IdempotencyKeyONOrders(IdempotencyKey);第二次收到相同幂等键时不是再创建一笔订单而是返回第一次处理结果或者返回“处理中”。幂等记录要有生命周期、状态和异常补偿策略不能只在内存字典里放一个键。9.2 用原子条件更新扣库存“先查询库存再在代码里减 1再保存”在并发下可能让两个请求同时看到库存为 1然后都认为自己可以买到。一个更接近正确方向的数据库操作是UPDATEProductsSETStockStock-1WHEREIdProductIdANDStock0;检查更新影响的行数影响 1 行说明本次扣减成功可以继续创建订单或推进订单状态。影响 0 行可能是商品不存在也可能已经售罄应根据查询结果返回明确业务状态。真实系统还要把库存扣减、订单写入、幂等记录放在合适的事务边界内如果跨数据库或跨服务还要设计消息、补偿和对账不要只在文章里写一句“使用分布式事务”就算完成。9.3 队列不是无限大的仓库异步队列适合把“现在收到请求”和“稍后执行任务”分开。例如下单后异步发送通知、生成发票或刷新搜索索引。但消息通常至少一次投递消费者可能收到重复消息所以消费者仍然需要幂等。队列还必须有最大长度和生产者背压。消费失败后的重试次数。死信队列和人工处理入口。可以被用户查询的业务状态。监控队列长度、最老消息年龄和消费速度。如果消息越来越多不能只把队列容量改得更大。那只是把故障从 HTTP 请求延迟移动到了“用户等订单”的时间里。10. 保护下游超时、重试、熔断和隔离小店的下单接口可能还要调用推荐、支付、优惠券或物流服务。每一个下游都可能变慢不能让主请求无限等待。微软提供的Microsoft.Extensions.Http.Resilience包可以为HttpClient配置标准 resilience handlerdotnetaddpackage Microsoft.Extensions.Http.Resilience示例推荐服务属于非核心读取依赖可以为它使用标准处理器并关闭不安全 HTTP 方法的自动重试builder.Services.AddHttpClientRecommendationClient(client{client.BaseAddressnewUri(https://recommendation.example);}).AddStandardResilienceHandler(options{options.Retry.DisableForUnsafeHttpMethods();});官方文档说明标准处理器会组合超时、重试和断路器等策略并提供可调整的默认选项。默认配置不是业务答案推荐服务和支付服务的超时、重试和降级方式必须分开评估。10.1 四个概念的区别超时到时间就停止等待防止一个请求占满线程、连接或队列。重试对短暂网络抖动或明确的瞬时错误再试有限次数最好使用指数退避和抖动。熔断下游持续失败时暂时停止调用让它恢复也保护本地资源。隔离给不同依赖分开连接、并发额度或线程资源推荐服务堵塞时不影响支付和下单。10.2 为什么不能“所有请求都重试”GET 商品详情通常可以在确定安全时重试。POST 创建订单如果已经到达服务端客户端没有收到响应并不代表服务端没有创建成功再次重试可能产生重复订单。支付、扣款、发券等操作要么关闭自动重试要么使用可靠的幂等键和可查询结果。降级也要尊重业务含义推荐服务失败可以返回空推荐支付服务失败不能伪造“支付成功”库存服务不可用时不能返回“库存充足”。稳定的系统不是永远返回 200而是在失败时返回真实、可处理、不会误导用户的结果。11. 可观测性让系统告诉你它在哪里慢只看一条“接口耗时 2 秒”的日志还不够。你需要知道这 2 秒是应用排队了 300ms数据库连接等了 500msSQL 执行用了 1 秒调用支付服务重试了两次序列化大响应占用了剩余时间11.1 日志要能关联结构化日志可以这样记录logger.LogInformation(Order state changed. OrderId{OrderId}, UserId{UserId}, State{State},order.Id,userId,order.State);不要把密码、完整支付卡号、访问令牌和不必要的个人信息写入日志。请求的 Trace ID、订单号和业务幂等键应该能串起一次操作但敏感字段必须脱敏或不记录。11.2 指标和 Trace 各自回答什么指标适合看趋势和告警例如 P99、5xx 率、数据库连接池等待、队列长度。Trace适合跟踪单次请求经过了哪些服务和数据库操作。日志适合查看某个业务状态和错误上下文。如果只有日志没有指标通常等到用户投诉才知道系统变慢如果只有指标没有 Trace知道慢了也可能找不到哪一跳慢。ASP.NET Core 可以通过 OpenTelemetry 接入标准化的指标和链路。下面是概念示例实际还需要按项目安装对应的 instrumentation 和 exporter 包builder.Services.AddOpenTelemetry().ConfigureResource(resourceresource.AddService(shop-api)).WithTracing(tracingtracing.AddAspNetCoreInstrumentation().AddHttpClientInstrumentation()).WithMetrics(metricsmetrics.AddAspNetCoreInstrumentation().AddRuntimeInstrumentation());不要一开始采集所有请求体和全部日志。观测系统本身也会消耗 CPU、内存、网络和存储生产环境要配置采样、保留周期和敏感字段过滤。11.3 存活和就绪不是一回事健康检查常被分成两类Liveness存活进程是否还能响应。进程活着但数据库暂时不可用不一定要立刻重启进程。Readiness就绪这个实例现在是否适合接收流量。数据库连接、关键配置或必要依赖不可用时可以让负载均衡器暂时摘掉它。基础配置可以这样写builder.Services.AddHealthChecks();app.MapHealthChecks(/health/live,newHealthCheckOptions{Predicate_false});app.MapHealthChecks(/health/ready);这只是最小示例。生产环境通常会给数据库等检查加上ready标签再让/health/ready只执行这些检查健康端点还要考虑访问控制、响应缓存和超时。微软官方文档也把健康检查定位为容器编排器和负载均衡器判断实例状态的入口。12. 一次可复现的压测应该怎样做12.1 先定义场景不要只写“压 10 万 QPS”盲盒小店至少可以分成三种场景商品详情读多写少允许几秒内的数据短暂过期。下单写操作需要幂等、库存正确和明确的失败状态。依赖故障推荐或优惠券服务变慢观察主链路是否仍能工作。每种场景要记录请求比例、用户分布、商品热点、缓存状态和持续时间。只压一个最简单的Hello World接口不能推导真实业务的承载能力。12.2 压测脚本示意下面是一个 k6 的简化读接口脚本。vus表示虚拟用户数不等于 QPS实际请求速率还取决于响应时间和脚本中的等待逻辑。importhttpfromk6/http;import{check}fromk6;exportconstoptions{vus:20,duration:1m};exportdefaultfunction(){constresponsehttp.get(http://localhost:5000/api/products/1);check(response,{status is 200:(res)res.status200});}压测要先预热再执行稳定阶段不能把应用刚启动、JIT 尚未稳定、缓存完全为空的第一分钟和稳定结果混成一张表。生产压测还要确认测试数据、支付沙箱和第三方服务不会误伤真实用户。12.3 记录对照结果不要直接写“优化后快了 60%”先把条件记录完整版本改动请求量P95P995xx429数据库 CPU缓存命中率A原始基线待填待填待填待填待填待填不适用B只增加索引待填待填待填待填待填待填不适用C索引 商品缓存待填待填待填待填待填待填待填D再增加限流和下游保护待填待填待填待填待填待填待填结果要写成完整句子例如在相同代码版本、相同数据量、相同 20 分钟请求模型和相同两台应用实例下B 版本的商品详情 P99 从待填降到待填同时数据库锁等待从待填降到待填。下单接口没有使用该缓存策略因此订单正确性指标没有被缓存影响。这种写法看起来没有“一眼惊人的数字”却比脱离条件的成功故事更可信、更容易复现。13. 从一个端点开始上线架构升级不需要一夜之间完成。可以按下面的顺序推进第一步盘点和选点选一个低风险读接口记录它的调用量、P95/P99、数据库查询和错误率。不要把支付、库存和登录同时当成第一个试点。第二步只改一个问题如果执行计划显示索引缺失先加索引并压测如果数据库主要被重复读击穿再评估缓存。每次变更都要有开关和回滚方式。第三步增加保护在确认正常流量下的基线后再添加限流、下游超时、健康探针和告警。保护策略上线后重点观察 429、超时和业务失败是否符合预期。第四步灰度和回滚让一小部分实例或一小部分流量先使用新策略比较新旧版本的尾延迟、错误率、数据库负载和业务结果。达到预设回滚条件就关闭开关不要等到所有实例都被拖慢才处理。第五步沉淀规范每个新端点至少写清楚缓存是否允许、限流策略、超时时间、重试是否安全、健康检查依赖、指标名称和回滚办法。这样新成员不需要猜每个数字的来历。14. 小白最容易踩的 8 个坑先加机器再定位。如果瓶颈在数据库锁或第三方服务机器越多可能只是同时发起更多请求。只看平均响应时间。用户常常被 P99 和超时影响。把缓存 TTL 当成正确性方案。缓存只能减少读取不能保证库存和订单一致。对所有异常都重试。写操作重试可能重复扣款或重复创建订单。用一个大队列接住全部流量。队列会把立刻失败变成长时间等待。把 429 当作优化失败。在过载时及时拒绝可能正是系统保护成功的表现但 429 比例必须符合容量设计。把健康检查写成“只要进程没死就返回 200”。负载均衡器需要知道实例是否真的准备好接流量。把示例数字当成配置答案。100、5 秒、200 个连接都只是上下文里的值不能脱离测试环境复制。15. 最终检查清单上线前逐项回答我知道最慢的端点和最慢的下游在哪里吗我有同环境、同流量模型的基线和对照结果吗查询是否只取需要的列是否存在 N1 和无上限分页索引是否看过执行计划连接池是否和数据库容量一起评估缓存的数据是否允许短暂过期缓存键是否隔离了用户和租户过载时系统会快速返回、有限排队还是转为异步任务用户能看懂返回状态吗重试会不会造成重复下单、重复扣款或重复发消息库存扣减是否是原子条件更新订单是否有幂等约束P95、P99、5xx、429、队列长度和数据库等待是否有监控存活检查与就绪检查是否分开灰度和回滚是否真的演练过如果这些问题还答不上来先不要急着引入更多中间件。把一次请求画出来、把基线测出来通常比再加一个听起来高级的组件更有价值。官方资料与版本依据.NET official support policyRate limiting middleware in ASP.NET CoreOutput caching middleware in ASP.NET CoreHTTP resilience in .NETHealth checks in ASP.NET CoreObservability with OpenTelemetry