服务一多,很多问题就会从“能用”变成“怎么稳定地用”。这几年分布式系统几乎成了后端架构的主战场,我自己维护的服务也从单体拆到了十几个微服务,安全通信和性能调优成了每天绕不开的事。在 .NET 11 的预览阶段,我就开始把部分内部服务迁到 ASP.NET Core 10 上跑,踩了不少坑,也沉淀下一套能落地的做法。这篇文章想聊的就是这套做法——从 TLS/mTLS 到 JWT 认证,从 Kestrel 参数到 HttpClient 连接池,再到一个真实订单系统的压测与排障过程。如果你正在做服务拆分、网关改造,或者只是想把现有服务的吞吐往上拉一拉,这个内容应该能给你一些直接能用的参考。
1. 内容整体设计与思路拆解
1.1 安全通信和性能调优为什么必须放到一起想
很多团队把安全和性能当成两个独立的优化方向,实际动手时才发现它们是互相拉扯的。比如你给服务之间加了 mTLS 双向认证,每次握手都要做证书校验和密钥协商,握手开销比普通 HTTPS 高出不少;又比如你在 API 网关统一做 JWT 校验,验证签名、校验过期时间、加载用户权限,这些操作都会在每次请求里消耗 CPU 和内存。如果一开始不把这些开销算进容量规划里,线上一定会出问题。
我的思路是:安全设计决定性能优化空间,性能参数反过来制约安全策略的选择。分布式系统里,每个服务都是一条链路上的一环,网关、订单服务、支付服务、库存服务任何一个节点慢下来,整个调用链就会被拖死。我见过太多团队把安全策略堆得很满,结果压测时发现 P99 延迟从 50ms 飙到 500ms,最后又是加机器又是砍功能,反而把本来应该守住的安全底线给丢了。
所以这篇文章不会只讲“安全怎么做”或者“性能怎么调”,而是把两者放在同一个架构视角下看:传输层怎么加固,应用层怎么鉴权,中间件怎么编排,连接池怎么设置,缓存怎么分级,最终落到一个完整的实践方案里。
1.2 .NET 11 和 ASP.NET Core 10 在分布式方向上的变化
从 .NET 6 到 .NET 9,微软在云原生上的投入越来越明显。到了 .NET 11 这代,ASP.NET Core 10 里我对三个能力印象最深:一是 Kestrel 的 HTTP/3 支持更成熟,长连接和流式传输的稳定性比之前的版本好不少;二是内置的分布式追踪和 OpenTelemetry 集成更顺手,服务间调用链在日志里能直接串起来;三是安全中间件在配置方式上做了不少简化,特别是证书管理和认证方案注册,少写了很多样板代码。
这些能力听着抽象,落到我自己的项目里其实都是实打实的收益。之前我在服务间做 mTLS,得自己写证书轮换的定时任务,稍不注意证书过期就直接线上事故。ASP.NET Core 10 里统一通过配置和认证选项管理客户端证书校验,配合新的证书监听机制,轮换这事变得自动化了很多。
你如果还在用 .NET Core 3.1 或者 .NET 6,迁到 .NET 11 的感受大概会和我一样:API 更规整,性能默认值更好,但配置项也更复杂了。换句话说,框架给的能力越多,你越得想清楚自己的场景该用哪一层,不然每个配置项都开一点,最后反而容易劣化。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 安全通信的核心实现
2.1 传输层防护:TLS 1.3、mTLS 与证书管理
分布式系统的安全通信,第一层应该是传输层,这层守不住,后面应用层做再多都白搭。ASP.NET Core 里启用 HTTPS 很容易,但生产环境真正难的是把 TLS 版本、证书信任链、密钥交换算法这些细节调对。
我在自己的 Service A 和 Service B 之间做的是双证书模式,也就是客户端和服务端各自持有证书,同时互相验证。Kestrel 配置证书是在 appsettings.json 里声明,也可以用代码动态加载:
csharp复制builder.WebHost.ConfigureKestrel(options =>
{
options.ConfigureHttpsDefaults(https =>
{
https.SslProtocols = SslProtocols.Tls13 | SslProtocols.Tls12;
https.ClientCertificateMode = ClientCertificateMode.RequireCertificate;
https.CheckCertificateRevocation = true;
});
});
这里我把 TLS 1.3 和 TLS 1.2 同时打开,是因为有些内部老服务用的还是旧版 OpenSSL,客户端库不支持 TLS 1.3,必须留一个兼容口子。但要明确一点:TLS 1.1 和 TLS 1.0 在我的环境里是直接禁掉的,这两个版本早就被安全社区判了死刑,留着就是给攻击者送分。
mTLS 真正麻烦的地方在证书管理。证书分发、信任列表维护、轮换、吊销,任何一个环节出问题,服务之间就随时可能握不上手。我的做法是让 mTLS 只用于服务间通信,不暴露给浏览器客户端,这样证书的生命周期控制在一个集中平台手里,不需要和外网用户打交道。证书快到期时,在测试环境先全量替换,然后灰度发布新证书,最后把旧证书的信任关系移除。这个流程走顺之后,证书轮换基本不产生感知。
2.2 应用层认证授权:JWT 与策略式授权
传输层只是第一道门,应用层还要解决“这个调用方是谁,能干什么”的问题。我在网关层用 JWT Bearer 认证做统一入口校验,服务内再用策略授权做细粒度控制。
JWT 的配置在 ASP.NET Core 里已经很成熟了,核心是验证 token 的签名、颁发者、受众和过期时间:
csharp复制builder.Services
.AddAuthentication(JwtBearerDefaults.AuthenticationScheme)
.AddJwtBearer(options =>
{
options.Authority = "https://auth.example.com";
options.Audience = "order-api";
options.TokenValidationParameters = new TokenValidationParameters
{
ValidateIssuer = true,
ValidateAudience = true,
ValidateLifetime = true,
ClockSkew = TimeSpan.FromSeconds(30)
};
});
builder.Services.AddAuthorization(options =>
{
options.AddPolicy("order:write", policy =>
policy.RequireAuthenticatedUser()
.RequireClaim("scope", "order:write"));
});
这里有一个很多新手容易忽略的点:Audience 和 Scope 是两个完全不同的概念。Audience 是“这个 token 是给哪个服务用的”,Scope 是“这个 token 允许调用方做什么”。一个登录用户可能同时访问订单服务和支付服务,两个服务也会返回不同的 scope 权限,如果只是简单校验了签名和过期时间,不去校验 Audience 和 Scope,任何一个拿到合法 token 的服务都能任意调用别的服务,这是典型的越权漏洞。
在分布式环境下,token 的传递我建议走“直接传递模式”,也就是网关负责把认证信息透传给下游服务。具体做法是在 HttpClient 中间件里把 Authorization 头原样转发,这样下游服务能看到原始 token,再做自己的 audience 和 scope 校验,不需要每个服务都去拉一次用户信息。
2.3 中间件排序与安全头配置
ASP.NET Core 的请求管道是线性执行的,中间件顺序错了,安全策略很可能直接失效。我在 StartUp 或者最小 API 里配置中间件的顺序是固定的:
csharp复制app.UseForwardedHeaders();
app.UseHttpsRedirection();
app.UseRouting();
app.UseAuthentication();
app.UseAuthorization();
app.UseResponseCaching();
app.UseOutputCache();
app.MapControllers();
这里有一个常见错误:很多人把 UseAuthentication 放在 UseRouting 之前,或者把 UseAuthorization 放到了 MapControllers 后面。前者会导致路由数据还没准备好就去解析用户身份,拿不到正确的 endpoint 元数据;后者则会让授权过滤器根本不会执行,等于白配了策略。
除了认证授权,安全头也很重要。我之前给网关层统一加过一组安全头,防止点击劫持、MIME 嗅探等常见攻击:
csharp复制app.Use(async (context, next) =>
{
context.Response.Headers["X-Content-Type-Options"] = "nosniff";
context.Response.Headers["X-Frame-Options"] = "DENY";
context.Response.Headers["Referrer-Policy"] = "no-referrer";
context.Response.Headers["Permissions-Policy"] = "camera=(), microphone=(), geolocation=()";
await next();
});
这些头在后端 API 之间其实并不太重要,但只要是面向浏览器的边缘节点,都建议开起来。有一次我在渗透测试报告里看到自己服务的 header 缺失被单独列了一条,虽然危害等级不高,但暴露出的问题是团队对安全头没形成统一规范。
注意:中间件顺序一定要保持“先路由、后认证、再授权、最后响应”的链路,任何跳过认证直接进授权或者先授权后认证的写法,都等于在安全策略上开了一扇后门。
3. 性能调优的实操参数
3.1 Kestrel 与线程池
ASP.NET Core 内置的 Kestrel 服务器性能默认值其实已经很不错了,但分布式系统里的服务经常要处理高并发、大流量、长时间连接,这些场景还是需要手工调参数。下面是我在订单服务里实际用过的配置:
csharp复制builder.WebHost.ConfigureKestrel(options =>
{
options.Limits.MaxConcurrentConnections = 20000;
options.Limits.MaxConcurrentUpgradedConnections = 20000;
options.Limits.MaxRequestBodySize = 10 * 1024 * 1024;
options.Limits.MinRequestBodyDataRate =
new MinimumDataRate(100, TimeSpan.FromSeconds(30));
options.Limits.MinResponseDataRate =
new MinimumDataRate(100, TimeSpan.FromSeconds(30));
options.Limits.Http2.MaxStreamsPerConnection = 100;
options.Limits.Http2.KeepAlivePingDelay = TimeSpan.FromSeconds(30);
options.Limits.Http2.KeepAlivePingTimeout = TimeSpan.FromSeconds(60);
});
MaxConcurrentConnections 默认值是 null,也就是不限制,但如果不限制,高并发下线程池和内存不一定跟得上。我这里限制到 20000,是因为后面的数据库连接池和依赖服务的并发能力也就是这个量级,再高只会让请求堆在队列里。MinRequestBodyDataRate 和 MinResponseDataRate 的作用是防止慢速攻击,客户端如果传数据太慢,直接断掉连接,这个对安全也有帮助。
线程池这块,很多人误解成“线程数越大越好”。ASP.NET Core 用异步 I/O 配合线程池,大多数情况下线程数不是瓶颈,用默认值就行。真正需要改的是 IoC 池的最小线程数,防止在突发流量下线程创建跟不上导致延迟飙升:
csharp复制ThreadPool.SetMinThreads(200, 200);
3.2 HttpClient 与连接池
分布式系统里最常见的一个坑就是 HttpClient 使用不当。用 new HttpClient() 每请求一次就创建一次,最终会耗尽 Socket 连接,线上出现 “SocketException: Only one usage of each socket address”。这个问题在 .NET Core 时代就有了,ASP.NET Core 10 里 IHttpClientFactory 仍然是官方推荐方案。
IHttpClientFactory 的核心价值是管理 HttpClient 的生命周期和底层连接池。之前我在支付服务里这样配置过:
csharp复制builder.Services.AddHttpClient("payment", client =>
{
client.BaseAddress = new Uri("https://payment-gateway.internal:5001");
client.Timeout = TimeSpan.FromSeconds(10);
client.DefaultRequestHeaders.Add("X-Client-Name", "order-service");
})
.ConfigurePrimaryHttpMessageHandler(() => new SocketsHttpHandler
{
PooledConnectionLifetime = TimeSpan.FromMinutes(10),
MaxConnectionsPerServer = 50,
AutomaticDecompression = DecompressionMethods.GZip | DecompressionMethods.Deflate
});
PooledConnectionLifetime 设成 10 分钟,是为了定期刷新连接,让 DNS 变更和负载均衡器调整能尽快生效。MaxConnectionsPerServer 限制单个目标服务器的并发连接数,防止某个下游服务雪崩时把自己的连接池全部占满。
如果你用的是 .NET 8+,还可以按命名客户端注册时直接指定 SocketsHttpHandler 这部分行为,它比 HttpClientHandler 的性能更好,原生支持 HTTP/2 和 HTTP/3。HTTP/3 在 ASP.NET Core 10 里的服务端支持已经稳定了,但客户端要启用还得看目标服务端是不是也有对应能力,这里面的兼容性需要自己在压测环境多验证。
3.3 缓存与序列化
分布式系统里,性能调优的两大法宝就是缓存和序列化。缓存能把耗时的下游调用直接省掉,序列化则决定了每个请求要花多少 CPU 在数据转换上。
ASP.NET Core 的输出缓存和响应缓存是两层东西:响应缓存是客户端/网关侧缓存,输出缓存是服务端缓存。我一般在读多写少的查询接口上开输出缓存:
csharp复制builder.Services.AddOutputCache(options =>
{
options.AddPolicy("ProductCache", policy =>
{
policy.Expire(TimeSpan.FromSeconds(60));
policy.SetVaryByQuery("categoryId");
policy.Tag("product");
});
});
这样同一类商品数据在 60 秒内不会重复打到下游服务,压力直接降了一个量级。分布式缓存方面,我用的是 Redis,注册方式依然是用 StackExchange.Redis 包:
csharp复制builder.Services.AddStackExchangeRedisCache(options =>
{
options.Configuration = builder.Configuration.GetConnectionString("Redis");
options.InstanceName = "order-api:";
});
Redis 缓存要注意缓存失效风暴,热点 Key 同时过期时,大量请求会同时打到数据库。我的做法是在业务上做“逻辑过期”,也就是在缓存里额外存一个过期时间,后台任务主动刷新,而不是等到缓存过期后被动回源。这个技巧在订单服务的商品信息上效果非常明显。
序列化方面,System.Text.Json 在 .NET 11 里生成代码路径做了不少优化,但默认反射模式在处理复杂对象图时还是会耗内存。我的建议是生产环境尽量用源生成器:
csharp复制[JsonSourceGenerationOptions(PropertyNamingPolicy = JsonKnownNamingPolicy.CamelCase)]
[JsonSerializable(typeof(OrderDto))]
[JsonSerializable(typeof(PagedResult<OrderDto>))]
internal partial class AppJsonSerializerContext : JsonSerializerContext;
然后用 JsonSerializer.SerializeAsync(Utf8JsonWriter, value, AppJsonSerializerContext.Default.OrderDto) 这种方式,能明显减少序列化和反序列化时的分配。如果你的负载主要是内部服务间通信,可以更进一步,直接用 gRPC + Protobuf,性能比 JSON 高出不少。
4. 端到端场景:一个分布式订单系统的安全通信与性能调优实例
4.1 架构与目标
我拿一个自己搭过的订单系统当例子吧。这套系统包含四个核心服务:API 网关(Gateway)、订单服务(Order)、支付服务(Payment)、库存服务(Inventory)。网关负责接收客户端的 HTTPS 请求,校验 JWT,然后通过服务间通信调用下游;订单、支付、库存之间有网络依赖,订单创建后要扣库存、发起支付。
系统要满足三个目标:
- 外部客户端和网关之间采用 HTTPS + JWT,用户身份由网关统一校验
- 服务间采用 mTLS,确保任何非授权服务无法伪装成内部服务发起调用
- P99 延迟在 1000 并发下不超过 200ms,吞吐量不低于每秒 3000 请求
这三个目标摆出来,安全策略和性能参数之间就得同时设计,不能先堆安全再看性能。服务间 mTLS 和网关 JWT 校验已经消耗了一部分握手成本,所以后面在 Kestrel、HttpClient、缓存上做的每一分调优,都是为了把这些成本补偿回来。
4.2 安全配置步骤
网关层的配置分三步。第一步是启用 HTTPS 并配置 mTLS 客户端证书校验,这部分代码前面已经给过,关键是 ClientCertificateMode 一定要设成 RequireCertificate。第二步是配置 JWT Bearer 认证,我上面给的 AddJwtBearer 配置也可以直接用,唯一要改的是 Authority 地址换成实际可用的 IDS 服务。第三步是把网关到下游服务的 HttpClient 加上客户端证书,确保调用外部服务时也能通过对方的 mTLS 校验。
服务间的 HttpClient 加证书,可以这样:
csharp复制builder.Services.AddHttpClient("inventory", client =>
{
client.BaseAddress = new Uri("https://inventory.internal:5002");
})
.ConfigureHttpClient(client =>
{
client.DefaultRequestHeaders.Add("X-Internal-Token", internalToken);
})
.ConfigurePrimaryHttpMessageHandler(() =>
{
var handler = new SocketsHttpHandler();
handler.SslOptions.ClientCertificates = new X509CertificateCollection
{
clientCertificate // 从证书中心加载的服务端证书
};
handler.SslOptions.EnabledSslProtocols = SslProtocols.Tls13 | SslProtocols.Tls12;
return handler;
});
这里我在服务间调用时额外塞了一个 X-Internal-Token,这个 token 是服务注册中心签发的短时令牌,解决“服务 A 如何证明自己有权限调用服务 B”的信任问题。mTLS 只解决了传输层双向认证,但服务身份到权限的映射,还是需要这样一层带权限范围的内部令牌,防止某个服务被入侵后可以随便横向调用别的服务。在 ASP.NET Core 10 里,这类服务间的安全令牌配置可以直接内置到认证配置中,但我们在生产的做法仍然是网关层统一签发、下游服务各自校验。
4.3 性能调优与压测
我压测时用的是 k6,脚本模拟真实用户创建订单的链路:先请求网关登录拿 JWT,再带 token 创建订单。压测工具向网关发起 1000 并发,持续 10 分钟。
第一轮压测结果很差,P99 达到了 450ms,主要瓶颈在三个地方:第一个是网关到支付服务的 HttpClient 连接池刚初始化,每次请求都在新建连接;第二个是商品信息的 Redis 缓存大量同时过期,导致数据库瞬时压力巨大;第三个是 Kestrel 的默认并发限制和线程池参数在 1000 并发下拖后腿。
第一轮后我做调整。缓存过期时间从 5 分钟改成 60 秒内随机抖动,每个 Key 的 TTL 在 45-75 秒之间随机,避免同时失效。网关和下游服务的 HttpClient 连接池按前面的配置加上:PooledConnectionLifetime 设为 10 分钟,MaxConnectionsPerServer 根据下游服务实例数估算,支付服务和库存服务各开了 3 个副本,所以 MaxConnectionsPerServer 可以设 50。Kestrel 的 MaxConcurrentConnections 设成 20000,线程池最小线程调到 200。
第二轮压测 P99 降到了 180ms 左右,吞吐量到了 2900 请求每秒,基本达标。当时我还试过把输出缓存打开,但仔细分析发现订单创建是写接口,根本不应该缓存,真正值得缓存的是商品信息这类读接口,所以最后只对商品查询和库存查询开了输出缓存。写接口加缓存是很多团队的经典误用场景,别踩。
到这里你可能会问,为什么不在第一轮就直接把参数调好?其实我看过太多人直接照抄“最佳实践”配置,结果压测环境和线上环境差异巨大,抄过来并不适用。合理的做法是先跑一轮基础压测,通过日志和指标定位瓶颈,再针对性地调参数,这样每一步都有数据支撑。
5. 常见问题与排查技巧实录
5.1 高并发下证书握手失败
压测时我第一次碰到大量 AuthenticationException: RemoteCertificateNameMismatch,排查过程花了不少时间。原因是证书的 SAN(Subject Alternative Name)里只配置了服务域名,没有包含压测机访问时用的 IP 地址。客户端用 IP 直连的时候,证书校验找不到对应的 SAN,于是握手失败。
这个问题的解决方法是给证书 SAN 同时加上服务域名和内网 IP。很多团队只注意域名,忽略 IP,结果服务间通信靠 IP 直连时全挂了。压测脚本也一样,尽量用域名去访问服务,不要用 IP,能少掉很多证书相关的坑。
5.2 连接池耗尽导致 SocketException
一旦出现 Unable to connect because the maximum number of active connections is reached,基本就是 HttpClient 使用方式有问题。我当时排查发现,有个通知服务每次发微信通知都用 new HttpClient(),没有走 IHttpClientFactory,底层连接没有被池化管理,大量连接占满系统句柄。
解决方式很直接:所有 HttpClient 统一走 IHttpClientFactory 命名客户端,并发调用时还要注意 HttpClient 实例是线程安全的,可以复用,但不会自动做连接复用,所以还是得设置好 MaxConnectionsPerServer 和 PooledConnectionLifetime。遇到这个问题,先检查代码里有没有直接 new HttpClient,再检查注册客户端的连接池参数。
5.3 高并发下线程池饥饿
ASP.NET Core 应用出现请求排队、CPU 使用率却不高的情况,十有八九是线程池饥饿。出现原因很典型:某个请求在异步方法里用了 .Result 或者 .Wait() 同步阻塞,占用了一个线程池线程去等待一个永远不会完成的异步任务,线程池线程被吃光,后面的请求排队等线程。
我遇到的实际场景是某个老代码在服务间调用支付接口时,为了拿响应结果用了 .GetAwaiter().GetResult(),在低并发下没问题,一到高并发就线程池饥饿。排查思路是看 Application Insights 或者 OpenTelemetry 里是否出现大量 ThreadPool starvation 日志。修法是把所有同步阻塞改成真正的 async/await:
csharp复制var response = await httpClient.GetAsync(url);
不要跳过 await 去拿 .Result。这属于老生常谈,但每次线上高并发问题十有八九都跟它有关,值得反复提醒。
5.4 JWT 过期时间不同步问题
分布式系统里不同服务的时钟不一定是完全同步的,如果 JWT 的过期时间判断太严,服务 A 认为 token 还有效,服务 B 认为已经过期,请求就会被拒绝。这个比较少见但很影响体验。
我的做法是在 JWT 校验时留一个小的 ClockSkew,默认 5 分钟,但这个长度在安全性和体验之间要平衡,我在网关层统一设为 30 秒,既不会因为时钟微小偏差导致大量 401,也不会给 token 太多额外寿命。同时在服务间通信中,内部令牌的 TTL 设得短一些,比如 5 分钟,即使泄露影响范围也可控,而且每次调用都会走签发服务刷新令牌。
下表是我整理的几个高频问题速查:
| 现象 | 可能原因 | 排查优先级与解决 |
|---|---|---|
| 握手失败 / 证书报错 | 证书 SAN 缺失、CRL 访问失败 | 先查服务器证书 SAN,再查吊销列表网络可达性 |
| SocketException | HttpClient 未走连接池 | 全局搜索 new HttpClient,统一走 IHttpClientFactory |
| 高并发延迟飙升、CPU 不高 | 线程池饥饿,同步阻塞异步 | 搜索 .Result / .Wait(),改为 await |
| 401 偶发 | JWT 时钟偏差 / 签名密钥轮换 | 检查 ClockSkew、签名密钥版本 |
| 响应缓慢、数据库压力大 | 缓存穿透/热点 Key | 检查 Redis Key 过期策略,加逻辑过期和随机抖动 |
6. 写在最后的实践心得
在 .NET 11 和 ASP.NET Core 10 这代版本上做分布式系统,我最大的体会是:安全通信和性能调优不是两个独立课题,它们本质上是同一件事——你如何在一个由多个服务组成的系统里,既保证数据不泄露、权限不越界,又保证请求能快速稳定地流转。安全策略做得太薄,服务间完全信任,性能再高也没意义;安全策略做得太厚,每次调用都做大量加密和鉴权,性能又会拖垮体验。
如果你暂时用不到 .NET 11 的新特性,这篇文章里的很多思路也依然适用。把传输层合理解密、应用层鉴权清晰、中间件顺序严谨、连接池有界、缓存有策略、序列化高效,这六件事做扎实,分布式系统无论拆多少个服务,底座都不会晃。
最后再分享一个小技巧:每次调完参数,我习惯把压测前后的对比数据直接贴到日志查询系统里,让团队其他人能随时看到调整收益。这个习惯看着不起眼,但对后续维护非常有用,毕竟性能调优不是一次性的,服务流量一变,之前设的参数很可能就要跟着调整。希望你的分布式系统也能稳定落地,少踩我踩过的那些坑。
