Core 核心能力落地:从场景痛点到价值验证的实战指南

ASP.NET Core 高并发性能优化:给小白的实战指南

本文讨论的是 ASP.NET Core Web API 的性能与稳定性治理。示例以 ASP.NET Core 10 / .NET 10 为主线,也尽量适用于 .NET 8+。文中的“盲盒小店”是虚构案例,压测表里的数字是记录模板,不代表任何真实生产系统的成绩。

先看结论:高并发不是“把服务器加大”

想象一下,一家小店把 100 个限量盲盒放到网上。

平时每分钟只有几十个人浏览商品,店里的一个收银员、一个数据库和一台应用服务器都够用。突然到了开售时间,几千个人同时点击“查看详情”,几百个人同时点击“立即下单”。结果可能是这样的:

  • 商品图片和介绍反复从数据库读取,数据库 CPU 先升高。
  • 请求拿不到数据库连接,只能排队等待。
  • 页面平均只慢了一点,但最后 1% 的请求已经超过了 5 秒。
  • 应用为了“不要让用户失败”不断重试,反而把已经变慢的支付服务压得更慢。
  • 有人连续点击下单,系统没有幂等控制,最后出现两笔订单。

这时,真正的问题通常不是“ASP.NET Core 不够快”,而是请求进入系统后,所有环节都在排队,而且系统没有在入口处控制流量,也没有保护数据库和下游服务。

本文会沿着这家小店的请求链路走一遍:先理解指标,再建立基线,然后从查询、缓存、限流、写入正确性、下游保护和可观测性几个方向逐步处理。每一步都要回答两个问题:

  1. 它解决了哪一个瓶颈?
  2. 怎样用数据证明它真的解决了瓶颈?

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 能力;业务里的ShopDbProduct等类型只是为了让例子容易读懂,仍然需要按你的项目定义。

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-10

LTS 和 STS 的主要区别是支持周期,不是“代码质量一高一低”。实际项目还必须安装对应版本的最新补丁,并确认 ASP.NET Core、EF Core 和第三方 NuGet 包是否兼容。

本文优先使用当前的 ASP.NET Core 10 文档和 API 名称。比如限流使用AddRateLimiter,输出缓存使用AddOutputCache,HTTP 下游保护使用AddStandardResilienceHandler。如果你的项目还在 .NET 8,先在测试环境编译和压测,不要因为方法名相似就直接替换生产配置。

3. 一次请求到底经历了什么

把请求想成顾客到店买盲盒:

  1. 顾客走到门口,相当于请求到达网关或负载均衡器。
  2. 店员检查是否超过当前接待能力,相当于限流。
  3. 店员先看柜台上有没有已经准备好的商品信息,相当于缓存。
  4. 没有缓存时,店员去仓库查库存,相当于访问数据库。
  5. 下单时,店员必须确认库存并登记订单,相当于事务、原子扣减和幂等。
  6. 如果需要联系配送员或支付平台,店员不能无限期等待,相当于超时和下游服务保护。
  7. 店长需要知道今天哪里堵了,相当于日志、指标、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:一半请求比这个时间快,代表普通体验。
  • P95:95% 的请求比这个时间快,能看到一部分慢请求。
  • P99:99% 的请求比这个时间快,最能暴露尾部排队和偶发故障。

如果平均响应时间是 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}",asyncTask<IResult>(intid,ShopDbdb,CancellationTokenct)=>{varproduct=awaitdb.Products.AsNoTracking().Where(p=>p.Id==id).Select(p=>newProductDto(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 N+1 查询

代码先查 1 个商品,再为每个商品查一次分类、标签和评论,假设返回 100 个商品,就可能产生 301 次查询。应使用合适的投影、批量查询或明确的联表查询,并用 SQL 日志确认实际发出了多少条 SQL。

6.2 无上限分页

pageSize=100000不是“灵活”,而是让一个请求有机会把数据库和应用内存一起拖垮。对分页设置上限,深分页场景可以研究基于游标或主键的分页。

6.3 索引不能凭感觉添加

如果接口经常按照ProductIdStatus查询,应结合执行计划和真实查询条件评估索引。索引能加速读取,但也会增加写入、存储和维护成本。最好的索引不是“列越多越好”,而是能匹配主要查询且经过实际验证的索引。

6.4 连接池不是越大越好

假设每个应用实例最多打开 200 个数据库连接,10 个实例就可能同时申请 2000 个连接。数据库未必承受得住,应用也可能因连接争抢而更慢。连接池大小必须和数据库最大连接数、实例数、每个请求的数据库占用时间一起评估。

7. 第二个改动:缓存重复读取

商品名称、分类和活动说明通常被大量用户重复读取。每次都查询数据库,就像每来一个顾客,店员都跑去仓库重新看一遍同一张说明卡。

ASP.NET Core 提供输出缓存能力。下面是一个简化示例:

builder.Services.AddOutputCache(options=>{options.AddPolicy("ProductCatalog",policy=>policy.Expire(TimeSpan.FromSeconds(5)).Tag("catalog"));});varapp=builder.Build();app.UseRouting();app.UseOutputCache();app.MapGet("/api/products/{id:int}",async(intid,ShopDbdb,CancellationTokenct)=>{varproduct=awaitdb.Products.AsNoTracking().Where(p=>p.Id==id).Select(p=>newProductDto(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.RejectionStatusCode=StatusCodes.Status429TooManyRequests;options.AddFixedWindowLimiter("catalog",limiterOptions=>{limiterOptions.PermitLimit=100;limiterOptions.Window=TimeSpan.FromSeconds(1);limiterOptions.QueueLimit=0;limiterOptions.QueueProcessingOrder=QueueProcessingOrder.OldestFirst;});});varapp=builder.Build();app.UseRouting();app.UseRateLimiter();app.MapGet("/api/products/{id:int}",(intid)=>Results.Ok(new{id})).RequireRateLimiting("catalog");

官方文档说明:如果按端点使用限流策略,UseRateLimiter应在UseRouting之后调用。上面的PermitLimit=100仍然只是示例,真正的值要根据应用实例数、下游容量和业务优先级压测出来。

8.1 怎样选择限流方式

  • 固定窗口:一个时间窗口允许固定数量,请求容易理解,但窗口边界可能出现短时突刺。
  • 滑动窗口:让流量更平滑,适合需要减少边界突发的场景。
  • 令牌桶:平时积累令牌,允许一定程度的突发,适合有明确平均速率和峰值的接口。
  • 并发限流:限制同时处理中的请求数,适合一个请求会长期占用数据库或外部连接的场景。

可以给商品浏览、登录、下单分别设置策略。登录要防刷,下单要保护库存,商品详情则可以在缓存后承受更高的读取量。不要把所有接口都设成同一个数。

8.2 限流和排队的区别

队列不是免费的。QueueLimit=1000意味着系统可能先收下 1000 个请求,再让它们等待。如果每个请求平均要等 3 秒,用户拿到的不是成功,而是 3 秒后才知道失败。

对不能及时完成的请求,快速返回 429 或 503 往往比无限排队更诚实。对真正适合异步处理的下单任务,则可以把任务放入有容量上限的消息队列,并立即返回“处理中”的订单状态。关键是给用户一个可查询的状态,而不是让 HTTP 请求一直挂着。

还要注意:应用内限流通常是每个实例各自计数。多实例部署时,如果业务要求“整个系统每个用户每秒只能请求 10 次”,应在网关、共享限流存储或专门的流量治理层实现全局规则。

9. 写操作的底线:不能为了快而牺牲正确性

读接口可以在一定范围内容忍短暂旧数据;下单、扣库存和支付则不同。

9.1 用幂等键防止重复下单

用户点击一次后没有立即看到结果,很可能会再次点击。网络重试、浏览器重发和消息重复投递也会产生类似问题。

可以让客户端为一次业务动作生成幂等键,例如Idempotency-Key,服务端保存这个键对应的处理结果,并在数据库建立唯一约束:

-- 以下为 SQL Server 风格示例,具体语法按数据库调整。CREATEUNIQUEINDEXUX_Orders_IdempotencyKeyONOrders(IdempotencyKey);

第二次收到相同幂等键时,不是再创建一笔订单,而是返回第一次处理结果,或者返回“处理中”。幂等记录要有生命周期、状态和异常补偿策略,不能只在内存字典里放一个键。

9.2 用原子条件更新扣库存

“先查询库存,再在代码里减 1,再保存”在并发下可能让两个请求同时看到库存为 1,然后都认为自己可以买到。

一个更接近正确方向的数据库操作是:

UPDATEProductsSETStock=Stock-1WHEREId=@ProductIdANDStock>0;

检查更新影响的行数:

  • 影响 1 行:说明本次扣减成功,可以继续创建订单或推进订单状态。
  • 影响 0 行:可能是商品不存在,也可能已经售罄,应根据查询结果返回明确业务状态。

真实系统还要把库存扣减、订单写入、幂等记录放在合适的事务边界内;如果跨数据库或跨服务,还要设计消息、补偿和对账,不要只在文章里写一句“使用分布式事务”就算完成。

9.3 队列不是无限大的仓库

异步队列适合把“现在收到请求”和“稍后执行任务”分开。例如下单后异步发送通知、生成发票或刷新搜索索引。

但消息通常至少一次投递,消费者可能收到重复消息,所以消费者仍然需要幂等。队列还必须有:

  • 最大长度和生产者背压。
  • 消费失败后的重试次数。
  • 死信队列和人工处理入口。
  • 可以被用户查询的业务状态。
  • 监控队列长度、最老消息年龄和消费速度。

如果消息越来越多,不能只把队列容量改得更大。那只是把故障从 HTTP 请求延迟,移动到了“用户等订单”的时间里。

10. 保护下游:超时、重试、熔断和隔离

小店的下单接口可能还要调用推荐、支付、优惠券或物流服务。每一个下游都可能变慢,不能让主请求无限等待。

微软提供的Microsoft.Extensions.Http.Resilience包可以为HttpClient配置标准 resilience handler:

dotnetaddpackage Microsoft.Extensions.Http.Resilience

示例:推荐服务属于非核心读取依赖,可以为它使用标准处理器,并关闭不安全 HTTP 方法的自动重试:

builder.Services.AddHttpClient<RecommendationClient>(client=>{client.BaseAddress=newUri("https://recommendation.example");}).AddStandardResilienceHandler(options=>{options.Retry.DisableForUnsafeHttpMethods();});

官方文档说明,标准处理器会组合超时、重试和断路器等策略,并提供可调整的默认选项。默认配置不是业务答案:推荐服务和支付服务的超时、重试和降级方式必须分开评估。

10.1 四个概念的区别

  • 超时:到时间就停止等待,防止一个请求占满线程、连接或队列。
  • 重试:对短暂网络抖动或明确的瞬时错误再试有限次数,最好使用指数退避和抖动。
  • 熔断:下游持续失败时暂时停止调用,让它恢复,也保护本地资源。
  • 隔离:给不同依赖分开连接、并发额度或线程资源,推荐服务堵塞时不影响支付和下单。

10.2 为什么不能“所有请求都重试”

GET 商品详情通常可以在确定安全时重试。POST 创建订单如果已经到达服务端,客户端没有收到响应,并不代表服务端没有创建成功;再次重试可能产生重复订单。支付、扣款、发券等操作要么关闭自动重试,要么使用可靠的幂等键和可查询结果。

降级也要尊重业务含义:推荐服务失败可以返回空推荐;支付服务失败不能伪造“支付成功”;库存服务不可用时不能返回“库存充足”。稳定的系统不是永远返回 200,而是在失败时返回真实、可处理、不会误导用户的结果。

11. 可观测性:让系统告诉你它在哪里慢

只看一条“接口耗时 2 秒”的日志还不够。你需要知道这 2 秒是:

  • 应用排队了 300ms?
  • 数据库连接等了 500ms?
  • SQL 执行用了 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(resource=>resource.AddService("shop-api")).WithTracing(tracing=>tracing.AddAspNetCoreInstrumentation().AddHttpClientInstrumentation()).WithMetrics(metrics=>metrics.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”

盲盒小店至少可以分成三种场景:

  1. 商品详情:读多写少,允许几秒内的数据短暂过期。
  2. 下单:写操作,需要幂等、库存正确和明确的失败状态。
  3. 依赖故障:推荐或优惠券服务变慢,观察主链路是否仍能工作。

每种场景要记录请求比例、用户分布、商品热点、缓存状态和持续时间。只压一个最简单的Hello World接口,不能推导真实业务的承载能力。

12.2 压测脚本示意

下面是一个 k6 的简化读接口脚本。vus表示虚拟用户数,不等于 QPS;实际请求速率还取决于响应时间和脚本中的等待逻辑。

importhttpfrom'k6/http';import{check}from'k6';exportconstoptions={vus:20,duration:'1m'};exportdefaultfunction(){constresponse=http.get('http://localhost:5000/api/products/1');check(response,{'status is 200':(res)=>res.status===200});}

压测要先预热,再执行稳定阶段;不能把应用刚启动、JIT 尚未稳定、缓存完全为空的第一分钟和稳定结果混成一张表。生产压测还要确认测试数据、支付沙箱和第三方服务不会误伤真实用户。

12.3 记录对照结果

不要直接写“优化后快了 60%”,先把条件记录完整:

版本改动请求量P95P995xx429数据库 CPU缓存命中率
A原始基线待填待填待填待填待填待填不适用
B只增加索引待填待填待填待填待填待填不适用
C索引 + 商品缓存待填待填待填待填待填待填待填
D再增加限流和下游保护待填待填待填待填待填待填待填

结果要写成完整句子,例如:

在相同代码版本、相同数据量、相同 20 分钟请求模型和相同两台应用实例下,B 版本的商品详情 P99 从待填降到待填,同时数据库锁等待从待填降到待填。下单接口没有使用该缓存策略,因此订单正确性指标没有被缓存影响。

这种写法看起来没有“一眼惊人的数字”,却比脱离条件的成功故事更可信、更容易复现。

13. 从一个端点开始上线

架构升级不需要一夜之间完成。可以按下面的顺序推进:

第一步:盘点和选点

选一个低风险读接口,记录它的调用量、P95/P99、数据库查询和错误率。不要把支付、库存和登录同时当成第一个试点。

第二步:只改一个问题

如果执行计划显示索引缺失,先加索引并压测;如果数据库主要被重复读击穿,再评估缓存。每次变更都要有开关和回滚方式。

第三步:增加保护

在确认正常流量下的基线后,再添加限流、下游超时、健康探针和告警。保护策略上线后,重点观察 429、超时和业务失败是否符合预期。

第四步:灰度和回滚

让一小部分实例或一小部分流量先使用新策略,比较新旧版本的尾延迟、错误率、数据库负载和业务结果。达到预设回滚条件就关闭开关,不要等到所有实例都被拖慢才处理。

第五步:沉淀规范

每个新端点至少写清楚:缓存是否允许、限流策略、超时时间、重试是否安全、健康检查依赖、指标名称和回滚办法。这样新成员不需要猜每个数字的来历。

14. 小白最容易踩的 8 个坑

  1. 先加机器再定位。如果瓶颈在数据库锁或第三方服务,机器越多可能只是同时发起更多请求。
  2. 只看平均响应时间。用户常常被 P99 和超时影响。
  3. 把缓存 TTL 当成正确性方案。缓存只能减少读取,不能保证库存和订单一致。
  4. 对所有异常都重试。写操作重试可能重复扣款或重复创建订单。
  5. 用一个大队列接住全部流量。队列会把立刻失败变成长时间等待。
  6. 把 429 当作优化失败。在过载时及时拒绝可能正是系统保护成功的表现,但 429 比例必须符合容量设计。
  7. 把健康检查写成“只要进程没死就返回 200”。负载均衡器需要知道实例是否真的准备好接流量。
  8. 把示例数字当成配置答案。1005 秒200 个连接都只是上下文里的值,不能脱离测试环境复制。

15. 最终检查清单

上线前逐项回答:

  • 我知道最慢的端点和最慢的下游在哪里吗?
  • 我有同环境、同流量模型的基线和对照结果吗?
  • 查询是否只取需要的列,是否存在 N+1 和无上限分页?
  • 索引是否看过执行计划,连接池是否和数据库容量一起评估?
  • 缓存的数据是否允许短暂过期,缓存键是否隔离了用户和租户?
  • 过载时系统会快速返回、有限排队还是转为异步任务?用户能看懂返回状态吗?
  • 重试会不会造成重复下单、重复扣款或重复发消息?
  • 库存扣减是否是原子条件更新,订单是否有幂等约束?
  • P95、P99、5xx、429、队列长度和数据库等待是否有监控?
  • 存活检查与就绪检查是否分开,灰度和回滚是否真的演练过?

如果这些问题还答不上来,先不要急着引入更多中间件。把一次请求画出来、把基线测出来,通常比再加一个听起来高级的组件更有价值。

官方资料与版本依据

  • .NET official support policy
  • Rate limiting middleware in ASP.NET Core
  • Output caching middleware in ASP.NET Core
  • HTTP resilience in .NET
  • Health checks in ASP.NET Core
  • Observability with OpenTelemetry