如何在.NET 6的HttpClient中实现请求耗时的细分统计?
实现.NET 6 HttpClient的请求耗时细分统计
完全可以实现类似curl的多阶段耗时统计,下面提供两种可行方案,覆盖DNS解析、TCP连接、TLS握手、请求传输及响应接收等关键环节:
方案一:自定义DelegatingHandler手动拆分阶段
这种方式适合需要直接控制测量点的场景,实现简单直观,部分阶段需结合HttpClient配置保证准确性:
核心思路
- DNS解析时间:提前调用
Dns.GetHostEntryAsync测量,需禁用HttpClient的DNS缓存才能每次触发新解析。 - 请求发送到首字节响应(对应curl的
time_starttransfer):在DelegatingHandler的SendAsync方法中,记录调用base.SendAsync前后的时间差(该方法完成时已收到响应头,即首字节到达)。 - 响应内容读取时间:记录读取响应体的耗时。
- 总耗时:从请求发起至响应完全接收的完整时间。
- TCP连接+TLS握手:可通过首次请求与复用连接请求的时间差间接计算。
代码实现
public class RequestTimingHandler : DelegatingHandler { protected override async Task<HttpResponseMessage> SendAsync(HttpRequestMessage request, CancellationToken cancellationToken) { var stats = new RequestTimingStats { RequestStartTime = DateTimeOffset.UtcNow }; // 单独测量DNS解析(需禁用HttpClient的DNS缓存才会每次触发) var dnsStart = DateTimeOffset.UtcNow; await Dns.GetHostEntryAsync(request.RequestUri.Host, cancellationToken); stats.DnsLookupTime = DateTimeOffset.UtcNow - dnsStart; // 测量请求发送到响应首字节的时间 var sendStart = DateTimeOffset.UtcNow; var response = await base.SendAsync(request, cancellationToken); stats.SendToFirstByteTime = DateTimeOffset.UtcNow - sendStart; // 测量响应内容读取时间 var contentStart = DateTimeOffset.UtcNow; // 根据实际业务场景选择读取方式,此处以读取字符串为例 await response.Content.ReadAsStringAsync(cancellationToken); stats.ContentReadTime = DateTimeOffset.UtcNow - contentStart; // 总耗时 stats.TotalTime = DateTimeOffset.UtcNow - stats.RequestStartTime; // 输出统计结果(可替换为日志或存储逻辑) Console.WriteLine($"DNS解析: {stats.DnsLookupTime.Value.TotalMilliseconds:F2}ms"); Console.WriteLine($"请求到首字节: {stats.SendToFirstByteTime.TotalMilliseconds:F2}ms"); Console.WriteLine($"响应内容读取: {stats.ContentReadTime.TotalMilliseconds:F2}ms"); Console.WriteLine($"总耗时: {stats.TotalTime.TotalMilliseconds:F2}ms"); return response; } } public class RequestTimingStats { public DateTimeOffset RequestStartTime { get; set; } public TimeSpan DnsLookupTime { get; set; } public TimeSpan SendToFirstByteTime { get; set; } public TimeSpan ContentReadTime { get; set; } public TimeSpan TotalTime { get; set; } }
禁用DNS缓存配置
要让DNS测量准确,需配置SocketsHttpHandler禁用DNS缓存:
var socketHandler = new SocketsHttpHandler { DnsRefreshTimeout = TimeSpan.FromSeconds(0), // 禁用DNS缓存 PooledConnectionLifetime = TimeSpan.FromMinutes(1), PooledConnectionIdleTimeout = TimeSpan.FromSeconds(30) }; // 注册自定义Handler var httpClient = new HttpClient(new RequestTimingHandler { InnerHandler = socketHandler });
方案二:利用DiagnosticSource追踪全生命周期
.NET的HttpClient内部会发出一系列诊断事件,通过订阅这些事件可精准获取TCP连接、TLS握手等底层阶段的耗时,无需手动干预连接逻辑:
代码实现
using System.Diagnostics; // 订阅HttpClient的诊断事件 var diagnosticListener = new DiagnosticListener("System.Net.Http"); diagnosticListener.Subscribe(new HttpRequestTimingObserver()); public class HttpRequestTimingObserver : IObserver<KeyValuePair<string, object>> { private readonly Dictionary<string, RequestTiming> _activeRequests = new(StringComparer.Ordinal); public void OnCompleted() { } public void OnError(Exception error) { } public void OnNext(KeyValuePair<string, object> value) { var activity = Activity.Current; if (activity == null) return; switch (value.Key) { case "System.Net.Http.Request.Start": _activeRequests[activity.Id] = new RequestTiming { RequestUri = activity.GetTagValue("http.request.uri").Value?.ToString(), RequestStart = DateTimeOffset.UtcNow }; break; case "System.Net.Http.Connection.ConnectStart": _activeRequests[activity.Id].ConnectStart = DateTimeOffset.UtcNow; break; case "System.Net.Http.Connection.ConnectEnd": if (_activeRequests.TryGetValue(activity.Id, out var timing)) { timing.ConnectDuration = DateTimeOffset.UtcNow - timing.ConnectStart; } break; case "System.Net.Http.Connection.TlsHandshakeStart": _activeRequests[activity.Id].TlsHandshakeStart = DateTimeOffset.UtcNow; break; case "System.Net.Http.Connection.TlsHandshakeEnd": if (_activeRequests.TryGetValue(activity.Id, out var timing)) { timing.TlsHandshakeDuration = DateTimeOffset.UtcNow - timing.TlsHandshakeStart; } break; case "System.Net.Http.Response.AfterReceive": if (_activeRequests.TryRemove(activity.Id, out var timing)) { timing.TotalDuration = DateTimeOffset.UtcNow - timing.RequestStart; // 输出统计 Console.WriteLine($"请求地址: {timing.RequestUri}"); Console.WriteLine($"TCP连接耗时: {timing.ConnectDuration?.TotalMilliseconds:F2}ms"); Console.WriteLine($"TLS握手耗时: {timing.TlsHandshakeDuration?.TotalMilliseconds:F2}ms"); Console.WriteLine($"总耗时: {timing.TotalDuration.TotalMilliseconds:F2}ms"); } break; } } } public class RequestTiming { public string RequestUri { get; set; } public DateTimeOffset RequestStart { get; set; } public DateTimeOffset ConnectStart { get; set; } public TimeSpan? ConnectDuration { get; set; } public DateTimeOffset TlsHandshakeStart { get; set; } public TimeSpan? TlsHandshakeDuration { get; set; } public TimeSpan TotalDuration { get; set; } }
关键注意事项
- HttpClient单例复用:务必保证HttpClient是单例模式,否则每次新建实例会重复建立TCP连接和TLS握手,这也是你测得时间远高于浏览器的核心原因之一(浏览器会复用连接池)。
- 缓存差异:浏览器会缓存DNS和连接,需对齐HttpClient的缓存策略来缩小与浏览器的耗时差距。
- 服务器日志差异:服务器日志的响应时间仅包含内部处理时间,不包含网络传输、TLS握手等客户端侧环节,因此必然与客户端统计存在差距。
内容的提问来源于stack exchange,提问作者Hagay Goshen
相关产品推荐
相关产品推荐

