Skip to content
Go back

HTTP/3 在 .NET 10:用 HttpClient 和 Kestrel 启用 QUIC

HTTP 协议在过去几年里迭代速度很快。HTTP/2 带来了多路复用,HTTP/3 则直接换掉了底层的传输协议——从 TCP 切换到基于 UDP 的 QUIC。到 .NET 10,HTTP/3 已经是生产就绪的一等公民,不需要任何 feature flag 就能在客户端和服务端同时启用。

这篇文章覆盖从 QUIC 的基本原理到 HttpClient 和 Kestrel 的完整配置,最后给出一个能直接跑通的端到端示例。

QUIC 解决了什么

HTTP/3 与 HTTP/2 最大的区别不在应用层,而在传输层:HTTP/2 跑在 TCP 上,HTTP/3 跑在 QUIC(基于 UDP)上。这一个架构层面的改动,解决了三个 TCP 层面无解的问题。

队头阻塞

HTTP/2 的多路复用让多个请求共享一条 TCP 连接。问题是 TCP 自身——如果有一个包丢了,整条连接都会停等重传。所有流,包括完全不相关的请求,全被堵住。这是 TCP 层面的队头阻塞,是协议的根本局限。

QUIC 在传输层解决这个问题:每个 QUIC 流都是独立的。A 流丢包不会影响 B 流。多路复用的好处保住了,队头阻塞的代价去掉了。

0-RTT 连接建立

标准 TLS 1.3 over TCP 在建连阶段至少需要一个 round-trip 才能开始传数据。QUIC 把传输握手和加密握手合并到一起,首次连接只需 1-RTT。如果客户端之前连过同一个服务器,还能用 0-RTT 恢复——在第一个包里就开始发数据。

对移动端、跨地域服务这类延迟敏感的场景,减少一个 round-trip 是实打实的收益。

连接迁移

TCP 连接由四元组标识:源 IP、源端口、目标 IP、目标端口。任何一个变了,连接就断了。手机在 Wi-Fi 和蜂窝网络之间切换时这个情况非常常见。

QUIC 用 Connection ID 来标识连接,不绑定网络地址。IP 在中途变了,QUIC 可以无缝迁移,不断开、不重建连接。HTTP/3 直接继承了这个能力。

.NET 中 HTTP/3 的支持时间线

往回倒一下各版本的进展:

平台层面要注意:HTTP/3 底层依赖 MsQuic。从 .NET 8 起,Windows x64 和 Linux x64 的运行时已经内置了 MsQuic,大多数部署场景不需要额外安装。在 Windows 11 / Windows Server 2022 及以上版本里 MsQuic 是系统组件。Linux 上如果没有内置版本,需要单独装 libmsquic 包。

客户端:用 HttpVersionPolicy 启用 HTTP/3

HttpClient 上控制 HTTP/3 的核心属性是两个:DefaultRequestVersionDefaultVersionPolicy

using System.Net;
using System.Net.Http;

var client = new HttpClient
{
    DefaultRequestVersion = HttpVersion.Version30,
    DefaultVersionPolicy = HttpVersionPolicy.RequestVersionOrLower
};

var response = await client.GetAsync("https://example.com/api/data");
Console.WriteLine($"Response version: {response.Version}");

如果用的是 IHttpClientFactory(ASP.NET Core 的推荐方式),在注册时配置:

using Microsoft.Extensions.DependencyInjection;
using System.Net;
using System.Net.Http;

var builder = WebApplication.CreateBuilder(args);

builder.Services.AddHttpClient("http3-client", client =>
{
    client.DefaultRequestVersion = HttpVersion.Version30;
    client.DefaultVersionPolicy = HttpVersionPolicy.RequestVersionOrLower;
});

var app = builder.Build();
app.Run();

三种 VersionPolicy 的区别

HttpVersionPolicy 有三个值,理解它们的差异对生产环境的正确性很重要。

RequestVersionOrLower:最安全的选项。请求指定版本,服务器不支持时自动降级到下一个更低版本(HTTP/2 -> HTTP/1.1)。适合”能用 HTTP/3 最好,不行也没关系”的场景。

var request = new HttpRequestMessage(HttpMethod.Get, "https://api.example.com/data")
{
    Version = HttpVersion.Version30,
    VersionPolicy = HttpVersionPolicy.RequestVersionOrLower
};

RequestVersionOrHigher:请求至少某个版本。服务器支持更高版本时可以协商升级。适合有最低版本要求、同时想享受更高版本好处的场景。注意这个选项没有降级逻辑——如果服务器连最低版本都不支持,会抛 HttpRequestException

var request = new HttpRequestMessage(HttpMethod.Get, "https://api.example.com/data")
{
    Version = HttpVersion.Version20,
    VersionPolicy = HttpVersionPolicy.RequestVersionOrHigher
};

RequestVersionExact:最严格。必须精确匹配指定版本。不匹配就抛 HttpRequestException。适合测试场景(验证某个端点是否真的在提供 HTTP/3),不适合面向公用 API 的生产客户端。

var request = new HttpRequestMessage(HttpMethod.Get, "https://api.example.com/data")
{
    Version = HttpVersion.Version30,
    VersionPolicy = HttpVersionPolicy.RequestVersionExact
};

try
{
    var response = await client.SendAsync(request);
}
catch (HttpRequestException ex)
{
    Console.WriteLine($"HTTP/3 not available: {ex.Message}");
}

TLS 要求

HTTP/3 必须走 HTTPS,更准确地说需要 TLS 1.3 及以上。不存在明文 HTTP/3。这是设计使然——QUIC 在设计之初就把安全作为核心要求而非附加层。这意味着:

服务端:Kestrel 启用 HTTP/3

Kestrel 通过 Protocols 配置按端点启用 HTTP/3。

using Microsoft.AspNetCore.Server.Kestrel.Core;

var builder = WebApplication.CreateBuilder(args);

builder.WebHost.ConfigureKestrel(options =>
{
    options.ListenAnyIP(443, listenOptions =>
    {
        listenOptions.UseHttps();
        listenOptions.Protocols = HttpProtocols.Http1AndHttp2AndHttp3;
    });
});

var app = builder.Build();

app.MapGet("/", () => "Hello from HTTP/3!");

app.Run();

appsettings.json 方式更适合按环境切换配置:

{
  "Kestrel": {
    "Endpoints": {
      "Https": {
        "Url": "https://*:443",
        "Protocols": "Http1AndHttp2AndHttp3"
      }
    }
  }
}

两种方式等价。容器化部署时还可以用环境变量 Kestrel__Endpoints__Https__Protocols 覆盖。

Alt-Svc:版本协商是怎样发生的

HTTP/3 用的是升级发现机制,不是直接协商。客户端第一次请求服务器时走的是 HTTP/1.1 或 HTTP/2(通过 TCP)。服务器在响应头里加一个 Alt-Svc,相当于通知客户端:“我也在端口 443 上支持 HTTP/3”:

Alt-Svc: h3=":443"; ma=86400

这告诉客户端:QUIC(HTTP/3)在端口 443 可用,这个声明的有效期是 86400 秒。客户端缓存这个信息,后续到同一个源的请求就走 HTTP/3 了。

Kestrel 从 .NET 7 起在配置了包含 HTTP/3 的协议组合后会自动添加这个响应头,不需要手动处理。

这也是为什么你有时会看到第一次请求走 HTTP/2、后续请求走 HTTP/3——这不是 bug,是协议按设计工作的表现。

连接迁移的实际意义

连接迁移是 QUIC 对现实场景最有用的特性之一。当一个移动设备从 Wi-Fi 切到蜂窝网络,IP 地址变了。在 TCP 下这直接断连——操作系统得新建 TCP 连接、重新做 TLS 握手、重新发所有在途请求。用户感受到的是延迟、请求失败或报错。

QUIC 下,客户端在新网络路径上给服务器发 PATH_CHALLENGE。服务器验证新路径后更新路由。原有 Connection ID 被保留。在途的流不受影响地继续。

在 .NET 10 里这由 MsQuic 层透明处理,应用代码不需要任何配置。

实际限制是:很多企业防火墙和负载均衡器是按源 IP 做会话亲和的。如果你的负载均衡器用源 IP 绑定连接,连接迁移会导致路由错乱。这是部署层面的问题而非 .NET 层面的问题。

本地测试 HTTP/3

先信任 .NET 开发证书:

dotnet dev-certs https --trust

服务端最小示例:

using Microsoft.AspNetCore.Server.Kestrel.Core;

var builder = WebApplication.CreateBuilder(args);

builder.WebHost.ConfigureKestrel(options =>
{
    options.ListenLocalhost(5001, listenOptions =>
    {
        listenOptions.UseHttps();
        listenOptions.Protocols = HttpProtocols.Http1AndHttp2AndHttp3;
    });
});

var app = builder.Build();

app.MapGet("/version-check", (HttpContext ctx) =>
    $"Protocol: {ctx.Request.Protocol}");

app.Run();

客户端测试代码(仅开发环境使用,生产环境不要跳过证书验证):

using System.Net;
using System.Net.Http;

var handler = new SocketsHttpHandler
{
    SslOptions = new System.Net.Security.SslClientAuthenticationOptions
    {
        RemoteCertificateValidationCallback = (_, _, _, _) => true
    }
};

var client = new HttpClient(handler)
{
    DefaultRequestVersion = HttpVersion.Version30,
    DefaultVersionPolicy = HttpVersionPolicy.RequestVersionOrLower
};

var response = await client.GetStringAsync(
    "https://localhost:5001/version-check");
Console.WriteLine(response); // 应该输出 "Protocol: HTTP/3"

同时启动服务端和客户端。第一次请求可能协商到 HTTP/1.1 或 HTTP/2(Alt-Svc 发现周期),后续请求应该走 HTTP/3。检查服务端 ctx.Request.Protocol 或客户端 response.Version 进行确认。

Linux 用户注意:确保安装了 libmsquic。Ubuntu/Debian 上运行 sudo apt-get install -y libmsquic。没有它的话 HTTP/3 会静默降级到 HTTP/2。

容器与反向代理中的 HTTP/3

UDP 防火墙问题

QUIC 跑在 UDP 上,而很多云环境和公司网络默认屏蔽或限制 UDP。如果容器或虚拟机的 UDP 443 端口在网络上被禁,Kestrel 怎么配置都没用。

Docker Compose 里要同时映射 TCP 和 UDP:

services:
  api:
    image: myapp:latest
    ports:
      - "443:443/tcp"
      - "443:443/udp" # HTTP/3 / QUIC 必需

Azure 上要确保网络安全组的规则同时允许 UDP 443 和 TCP 443。

nginx

nginx 1.25+ 支持 HTTP/3,但需要编译时带 QUIC 实现(quiche 或 BoringSSL)。标准包管理器的 nginx 构建通常不包括。如果你用 nginx 做反向代理,先确认具体构建的能力。

Azure Front Door 和 Cloudflare

这两类服务在边缘终止 HTTP/3,然后通过 HTTP/1.1 或 HTTP/2 代理请求到你的源站。这意味着:

什么时候用 HTTP/3

场景推荐版本
现代浏览器客户端、公用 APIHTTP/3 优先(RequestVersionOrLower
gRPC 服务HTTP/2(gRPC 基于 HTTP/2 帧)
数据中心内部服务间调用HTTP/2(低延迟、稳定网络下 TCP 完全够用)
兼容旧版服务器HTTP/1.1
移动端为主的场景HTTP/3(连接迁移收益明显)
高丢包网络环境HTTP/3(QUIC 按流处理丢包)
反向代理不支持 HTTP/3HTTP/2 或 HTTP/1.1

实用的建议:对大多数新的 .NET 10 API,服务端用 HttpProtocols.Http1AndHttp2AndHttp3 同时启用三个版本,让 Alt-Svc 协商自己决定。能走 HTTP/3 的客户端自然会走,不能走的平滑降级。客户端用 RequestVersionOrLower + Version30 作为安全默认值。

完整端到端示例

服务端(Program.cs):

using Microsoft.AspNetCore.Server.Kestrel.Core;

var builder = WebApplication.CreateBuilder(args);

builder.WebHost.ConfigureKestrel(options =>
{
    options.ListenLocalhost(7443, listenOptions =>
    {
        listenOptions.UseHttps();
        listenOptions.Protocols = HttpProtocols.Http1AndHttp2AndHttp3;
    });
});

var app = builder.Build();

app.MapGet("/ping", (HttpContext ctx) => new
{
    Protocol = ctx.Request.Protocol,
    Timestamp = DateTimeOffset.UtcNow
});

app.Run();

客户端:

using System.Net;
using System.Net.Http;
using System.Net.Http.Json;
using System.Text.Json.Serialization;

var handler = new SocketsHttpHandler
{
    SslOptions = new System.Net.Security.SslClientAuthenticationOptions
    {
        RemoteCertificateValidationCallback = (_, _, _, _) => true
    }
};

var client = new HttpClient(handler)
{
    BaseAddress = new Uri("https://localhost:7443"),
    DefaultRequestVersion = HttpVersion.Version30,
    DefaultVersionPolicy = HttpVersionPolicy.RequestVersionOrLower
};

// 第一次请求可能走 HTTP/1.1 或 HTTP/2(Alt-Svc 发现中)
var first = await client.GetFromJsonAsync<PingResponse>("/ping");
Console.WriteLine($"First request -- Protocol: {first?.Protocol}");

// 稍等让 Alt-Svc 缓存生效
await Task.Delay(100);

// 后续请求应该协商到 HTTP/3
for (var i = 0; i < 3; i++)
{
    var result = await client.GetFromJsonAsync<PingResponse>("/ping");
    Console.WriteLine($"Request {i + 2} -- Protocol: {result?.Protocol}");
}

record PingResponse(
    [property: JsonPropertyName("protocol")] string Protocol,
    [property: JsonPropertyName("timestamp")] DateTimeOffset Timestamp
);

两端一起跑起来后你会看到协议版本从 HTTP/1.1 或 HTTP/2 过渡到 HTTP/3——这正是 Alt-Svc 协商按设计工作的表现。

常见问题

HTTP/3 在 .NET 10 里需要额外安装 NuGet 包吗?

不需要。HTTP/3 支持内置于 .NET 10 自带的 System.Net.HttpMicrosoft.AspNetCore.Server.Kestrel 包里。Linux 可能需要安装系统级的 libmsquic,但那不是 .NET 包。

为什么设了 Version30 之后 HttpClient 还显示 HTTP/2?

最常见的原因是 Alt-Svc 发现。第一次请求服务器时用的是基于 TCP 的 HTTP,因为客户端还不知道服务器支持 QUIC。收到 Alt-Svc 响应头后,后续到同一个源的请求才会走 HTTP/3。试着发第二个请求再看 response.Version

能和 gRPC 一起用吗?

gRPC 基于 HTTP/2 帧(h2ch2)。截至 .NET 10,标准的 Grpc.AspNetCoreGrpc.Net.Client 包用的是 HTTP/2。如果需要 gRPC,在 Kestrel 端点上用 HttpProtocols.Http1AndHttp2。可以给 HTTP/3 REST 和 HTTP/2 gRPC 分别配不同端口。

QUIC 握手在系统层面失败了会怎样?

只要 VersionPolicy 是 RequestVersionOrLower,HttpClient 会优雅降级。运行时内部记录一条警告但不会抛异常。如果用 RequestVersionExact 且 QUIC 失败,会收到 HttpRequestException。排查之前先检查 VersionPolicy 的配置——答案往往是”它按配置正确工作了,只是选择降级了”。

HTTP/3 对所有请求类型都能提升性能吗?

不是一刀切。HTTP/3 在高延迟、高丢包的网络条件下收益最大(移动端、跨洲调用)。对数据中心内部低延迟、低丢包的通信,HTTP/2 和 HTTP/3 之间的差异很小——有时候 HTTP/2 反而因为更低的单连接开销更快。先用自己的实际负载做基准测试再下结论。

怎么验证生产环境实际用的是哪个 HTTP 版本?

服务端在中间件里记录 HttpContext.Request.Protocol。客户端每次调用后检查 HttpResponseMessage.Version。基础设施层面,APM 工具(Azure Monitor、Datadog、OpenTelemetry)会把 HTTP 版本作为一个维度呈现在请求指标上——查 OpenTelemetry 语义约定里的 http.version

收尾

HTTP/3 在 .NET 10 里不是实验性功能——它是完整集成、生产就绪的特性,可以渐进式启用。HttpVersionPolicy 枚举给了版本协商的精细控制,Kestrel 的 HttpProtocols 一行配置就能在单个端口上同时启用三个版本。Alt-Svc 升级机制保证了与尚不支持 QUIC 的客户端的向后兼容。

大多数应用的前进路径很清楚:在 Kestrel 协议列表里加上 Http3,客户端设好 DefaultVersionPolicyRequestVersionOrLower,剩下的交给协议自己去处理。网络和基础设施支持 HTTP/3 的地方享受性能红利,不支持的场景自动平滑降级。

如果你关注 .NET 开发、性能优化和工程实践,可以关注 Aide Hub。这里会继续分享能落地的技术教程和开发经验。

参考


Tags


Next

NuGet 包创建完全指南:从 .csproj 到 CI 自动化发布