You need to enable JavaScript to run this app.
优惠活动
大模型
产品
解决方案
定价
更多

如何在.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; }
}

关键注意事项

  1. HttpClient单例复用:务必保证HttpClient是单例模式,否则每次新建实例会重复建立TCP连接和TLS握手,这也是你测得时间远高于浏览器的核心原因之一(浏览器会复用连接池)。
  2. 缓存差异:浏览器会缓存DNS和连接,需对齐HttpClient的缓存策略来缩小与浏览器的耗时差距。
  3. 服务器日志差异:服务器日志的响应时间仅包含内部处理时间,不包含网络传输、TLS握手等客户端侧环节,因此必然与客户端统计存在差距。

内容的提问来源于stack exchange,提问作者Hagay Goshen

相关产品推荐
方舟 Agent Plan

超全模态模型 × Harness 升级,最新支持 Deepseek-V4.1-Flash、GLM-5.3 系列、Doubao-Seedream-5.0-pro、Kimi-K3 (部分), 限时 9.9 元起

最近更新时间:2026.08.08 00:45:31