环信http请求失败排查

分析问题

在我们的语音直播项目中,消息系统是采购的环信IM,服务端和环信采用openapi的方式进行交互,但是在业务服务中偶发的出现连接错误:

bash 复制代码
net/http: request canceled while waiting for connection

咨询环信回复说,未收到错误的请求,应该是我们得请求未到达,建议我们这边进行排查。

从错误信息描述,应该是在tcp建连环节出现了超时错误,由于没有更加具体的日志,无法确认是建连的哪个环节存在超时,需要增加hook来定位具体出问题的步骤。

在日常中,一般使用域名来访问网络,比如我们要打开百度搜索主页,在浏览器输入www.zhihu.com即可打开主页。访问知乎的主页,需要经历如下几个步骤:

bash 复制代码
dns解析 -> tcp建连 -> tls握手 -> 数据传输 -> 页面渲染

在本次问题排查中,我们需要定位到是dns解析、tcp建连、tls握手,这三个步骤是哪个环节的超时。

增加http trace排查日志

golang提供http trace的方式,方便我们注入start、done方法,用于执行每个环节的自定义代码。 这里我们注入DNS、TCPConnect、TLSHandshake的start、done共计6个方法,在start中记录开始时间,在end中打印耗时,来收集每个环节的耗时。代码如下:

Go 复制代码
func newHttpTrace(ctx context.Context) *httptrace.ClientTrace {
    var dnsStart, connectStart, tlsStart time.Time
    trace := &httptrace.ClientTrace{
        DNSStart: func(info httptrace.DNSStartInfo) {
            dnsStart = time.Now()
            fmt.Printf("DNS start, host:%v\n", info.Host)
        },
        DNSDone: func(info httptrace.DNSDoneInfo) {
            notification := fmt.Sprintf("DNS done cost: %v. [addrs:%v, err:%v, Coalesced:%v]", time.Since(dnsStart), info.Addrs, info.Err, info.Coalesced)
            fmt.Printf("%v\n", notification)
        },
        ConnectStart: func(network, addr string) {
            connectStart = time.Now()
            fmt.Printf("TCP connect start, network:%v, addr:%v\n", network, addr)
       },
        ConnectDone: func(network, addr string, err error) {
            notification := fmt.Sprintf("TCP connect done, cost: %v. [network:%v, addr:%v, err:%v]", time.Since(connectStart), network, addr, err)
            fmt.Printf("%v\n", notification)
        },
        TLSHandshakeStart: func() { tlsStart = time.Now() },
        TLSHandshakeDone: func(_ tls.ConnectionState, _ error) {
            notification := fmt.Sprintf("TLS handshake done cost: %v", time.Since(tlsStart))
            fmt.Printf("%v\n", notification)
        },
    }
    return trace
}

使用方式,在发起http请求时,把http trace注入到request中:

Go 复制代码
func do(ctx context.Context, req *http.Request) ([]byte, int, error) {
    req = req.WithContext(httptrace.WithClientTrace(req.Context(), newHttpTrace(ctx)))
    resp, err := c.client.Do(req)
}

dns耗时

修改之后,通过线上观察,发现dns解析耗时不稳定,偶尔会有2s的高耗时,而我们配置的请求总耗时只有2s。

简单说明一下DNS解析是什么:域名IP地址 的转换过程。 核心流程:

  1. 输入域名 :浏览器访问 zhihu.com
  2. DNS查询 :计算机询问"zhihu.com的IP是多少?"
  3. 返回IP:DNS服务器回答"123.123.123.123"
  4. 建立连接:用这个IP访问真实服务器

咨询过基础架构的老师,公司的coreDNS会做dns结果缓存,ttl是60s,那服务端的dns解析会先查询本地DNS缓存,向coreDNS查询,最后在向公网DNS查询。基础架构老师做了测试,公网DNS偶发的出现超时问题,概率不确定,但是问题确实存在。

建议我们做dns本地缓存,但是这并不解决问题,因为如果缓存都失效了,只要向公网DNS查询结果,就有概率发生超时。

DNS解析的路径:

-> k8s集群的coreDNS,查询缓存(ttl是60s)

-> 缓存失效,回源公网dns服务器(该步骤是超时问题的原因)

ps:如果想了解coreDNS,可以deepseek

优化方案

分析日志发现,dns解析的高耗时不会连续的出现,即使是同时发起的连接,也不是都出现dns解析超时(有点怀疑是DNS公网劫持的问题,不确定)。所以只要指定一个dns超时时间,在发生超时立即返回然后重试,大概率可以规避掉这个问题。

优化一版http client配置,上线观察观察。具体的配置如下:

Go 复制代码
// setDefaults 设置默认值
func (config *ClientConfig) setDefaults() {
    timeout := config.Timeout
    if timeout == 0 {
        timeout = 2 * time.Second 
    }

    // 设置默认值
    if config.TCPTimeout == 0 {
        config.TCPTimeout = 500 * time.Millisecond
    }
    if config.KeepAlive == 0 {
        config.KeepAlive = 10 * time.Second
    }
    if config.MaxIdleConns == 0 {
        config.MaxIdleConns = 6
    }
    if config.MaxIdleConnsPerHost == 0 {
        config.MaxIdleConnsPerHost = 3
    }
    if config.MaxConnsPerHost == 0 {
        config.MaxConnsPerHost = 6
    }
    if config.IdleConnTimeout == 0 {
        config.IdleConnTimeout = 90 * time.Second
    }
    if config.TLSHandshakeTimeout == 0 {
        config.TLSHandshakeTimeout = 500 * time.Millisecond
    }
    if config.ResponseHeaderTimeout == 0 {
        config.ResponseHeaderTimeout = timeout / 2
    }
}
  • TCPTimeout:连接超时时间,500ms
  • TLSHandshakeTimeout:tls握手超时时间,500ms

建连阶段(包括DNS解析、TCP握手)超过500ms,会立即返回,进行下一次重试。

Go 复制代码
// doWithRetry 执行请求并处理重试,支持重新构造请求体
func (c SimpleClient) doWithRetry(ctx context.Context, req *http.Request, originalBody []byte) ([]byte, int, error) {
    // 第一次请求
    body, statusCode, err := c.do(ctx, req)
    // 检查是否需要重试
    if shouldRetry(ctx, req, err) {
        // 重试请求
        return c.do(ctx, req)
    }
    return body, statusCode, err
}

func (c SimpleClient) do(ctx context.Context, req *http.Request) ([]byte, int, error) {
    resp, err := c.client.Do(req)
    if err != nil {
        // 包含 timeout 和 请求异常
        return []byte{}, 0, err
    }
    defer resp.Body.Close()
    body, err := io.ReadAll(resp.Body)
    if err != nil {
        // 协议读取 err 理论不会出现
        return []byte{}, resp.StatusCode, err
    }
    return body, resp.StatusCode, nil
}

// shouldRetry 检查是否应该重试请求
func shouldRetry(ctx context.Context, req *http.Request, err error) bool {
    if err == nil {
        return false
    }
    // TCP reset 错误需要重试
    if isTcpResetError(err) {
        return true
    }
    // DNS 或建连超时错误需要重试
    if isDNSOrDialTimeoutError(err) {
        fmt.Printf("请求失败重试: %s %s, err: %v\n", req.Method, req.URL.Host, err)
        return true
    }
    return false
}

func isTcpResetError(err error) bool {
    if err == nil {
        return false
    }
    // read tcp 10.42.xx.xx:55214->xx.xx.53.xx:443: read: connection reset by peer
    return strings.Contains(err.Error(), "connection reset by peer")
}

// isDNSOrDialTimeoutError 检查是否是 DNS 或建连超时错误
func isDNSOrDialTimeoutError(err error) bool {
    if err == nil {
        return false
    }
    errStr := err.Error()
    // DNS 超时错误
    dnsTimeoutErrors := []string{
        "dial tcp: lookup zhihu.com: i/o timeout",
        "dial tcp 1.1.1.1:443: i/o timeout",
        "net/http: TLS handshake timeout",
    }
    // 检查 DNS 超时
    for _, dnsErr := range dnsTimeoutErrors {
        if strings.Contains(errStr, dnsErr) {
            return true
        }
    }
    return false
}

超时错误:

  • DNS超时:dial tcp: lookup zhihu.com: i/o timeout
  • TCP握手超时:dial tcp xx.xx.xx.197:443: i/o timeout
  • TLS握手超时:net/http: TLS handshake timeout

上线之后,观察两天,dns超时的问题解决了,但是有三个新的问题。

首先从日志看,第一次超时打印【请求失败重试】日志,通过trace id 追踪第二次,并没有在出现dns超时错误,至此dns超时问题已初步解决。下面详细分析一下出现的三个新问题:

  1. 一个新的错误,http2: timeout awaiting response headers
  2. 观察日志发现,每隔20s左右,连接会被主动关闭,再次发起请求需要建立新的连接
  3. 发现error有打印的error日志【请求失败重试】,第二次重试会返回400错误,说明请求已经发送到环信侧,该错误是环信的报错。错误代码:
html 复制代码
<html>
    <head><title>400 Bad Request</title></head>
    <body bgcolor="white">
        <center><h1>400 Bad Request</h1></center>
        <hr><center>alb</center>
    </body>
</html>

问题1

错误信息应该是等待响应头超时,会看之前的配置,有一个ResponseHeaderTimeout,它设置为了Timeout/2=1s的时间,如果环信侧服务抖动导致接口响应超过1s,该错误就会发生。

后续把ResponseHeaderTimeout的时间配置去掉,默认永久等待,恢复正常。

问题2

原因是tcp连接被断开,新的业务请求到来需要重新建连。如果是客户端的原因,可能是tcp keepalive时间过短,一开始配置的是90s,缩短为15s还是会被断开。怀疑是被环信侧主动关闭。

咨询环信,回复说:阿里云slb的心跳间隔是15s,tcp keepalive的心跳包不算,只有业务包才算,所以只要连接空闲超过15s未发送过业务包,就会被阿里云的slb主动断开。

所以,tcp keepalive配不配置的没啥用处。

问题3

询问环信侧,回复说该错误是请求的body为空,通过服务日志发现,凡是该类错误的出现,必然是重试导致的,错误的上下文日志是:

json 复制代码
[I 2025-10-10 11:08:30.054 1adefeafff873f83ec7f23deff2e81ec xhttp.go:83] [{"logger":"default"}] DNS start, host:xxxxx.com
[I 2025-10-10 11:08:30.087 1adefeafff873f83ec7f23deff2e81ec xhttp.go:87] [{"logger":"default"}] DNS done cost: 32.925355ms. [addrs:[{xxxx}], err:<nil>, Coalesced:false]
[I 2025-10-10 11:08:30.088 1adefeafff873f83ec7f23deff2e81ec xhttp.go:91] [{"logger":"default"}] TCP connect start, network:tcp, addr:xxxx:443
[E 2025-10-10 11:08:30.555 1adefeafff873f83ec7f23deff2e81ec http_segment.go:180] [{"elapsed":"500.71935ms","error.class":"Timeout","http.method":"POST","http.url":"https://xxxxx","logger":"default"}] dial tcp xxxxx:443: i/o timeout
[E 2025-10-10 11:08:30.556 1adefeafff873f83ec7f23deff2e81ec xhttp.go:146] [{"logger":"default"}] 请求失败重试: POST xxxx.com, err: Post "https://xxxx": dial tcp xxx:443: i/o timeout
[I 2025-10-10 11:08:30.556 1adefeafff873f83ec7f23deff2e81ec xhttp.go:83] [{"logger":"default"}] DNS start, host:xxxx.com
[I 2025-10-10 11:08:30.558 1adefeafff873f83ec7f23deff2e81ec xhttp.go:87] [{"logger":"default"}] DNS done cost: 2.068912ms. [addrs:[{xxxxx } {yyyyy }], err:<nil>, Coalesced:false]
[I 2025-10-10 11:08:30.558 1adefeafff873f83ec7f23deff2e81ec xhttp.go:91] [{"logger":"default"}] TCP connect start, network:tcp, addr:xxxxx:443
[I 2025-10-10 11:08:30.570 1adefeafff873f83ec7f23deff2e81ec xhttp.go:95] [{"logger":"default"}] TCP connect done, cost: 11.351552ms. [network:tcp, addr:xxxxx:443, err:<nil>]
[I 2025-10-10 11:08:30.587 1adefeafff873f83ec7f23deff2e81ec xhttp.go:100] [{"logger":"default"}] TLS handshake done cost: 16.616402ms
[E 2025-10-10 11:08:30.599 1adefeafff873f83ec7f23deff2e81ec http_segment.go:180] [{"elapsed":"42.660246ms","error.class":"Bad Request","http.method":"POST","http.status_code":400,"http.url":"https://xxxx","logger":"default"}] Bad Request

所有该类型的错误,无一例外都是重试导致的,由此判断是第一次请求导致request body被读取消耗了,第二次重试读取的body为空。 现在需要排查到在哪个环节,request body被消耗了。复现问题的场景时间线:

shell 复制代码
T0: client.Do(req)
T1: transport.RoundTrip(req)
T2: getConn() 获取连接
    - T3: DNS 开始解析 (你的代码中有 600ms 延迟)
    - T500ms: DNS 超时 (配置的 500ms 超时)
T500ms: getConn() 返回 DNS 超时错误  # 🚨 在这里失败
T500ms: transport.go:594 调用 req.closeBody()
T500ms: 重试时 Body 为空

从net/http官方包,分析net/http/transport.go:591行代码,如果建连失败body会被关闭,但是body是io.NopCloser 类型,调用 closeBody 方法并不会产生影响。

公司的http耗时监控打点是注入到http.DefaultTransport中,该方法在处理请求时会被自动执行。除了公司的打点,我们还有一块是记录错误请求的中间件,也是通过注入到http.DefaultTransport的方式,这个打点在返回错误时打印了req body和resp body,伪代码如下:

Go 复制代码
func WrapRoundTripper(original http.RoundTripper) http.RoundTripper {
    return roundTripperFunc(func(r *http.Request) (*http.Response, error) {
        ctx := r.Context()
        _ = ctx
        r = r.Clone(ctx)
        depend := entity.NewDepend(entity.DEPEND_HTTP)

        // 实际执行 http 请求
        resp, err := original.RoundTrip(r)

        // 统计
        depend.ResponseTime = time.Now()
        depend.Duration = time.Since(depend.RequestTime).Microseconds()

        // 把 http 的非 200 返回也当做错误
        var tmpErr = err
        if err == nil && resp.StatusCode >= 400 {
            tmpErr = errors.New(strconv.Itoa(resp.StatusCode))
        }
        // 记录异常事件
        if tmpErr != nil {
            var requestBody = ""
            contentType := r.Header.Get("Content-Type")
            if r.Body != nil && !strings.Contains(contentType, "multipart/form-data") {
                reqBodyBytes, _ := io.ReadAll(r.Body)
                _ = r.Body.Close()
                r.Body = io.NopCloser(bytes.NewBuffer(reqBodyBytes))
                requestBody = string(reqBodyBytes)
            }
            depend.Domain = r.Host
            depend.Path = r.URL.Path + "?" + r.URL.RawQuery
            depend.Method = r.Method
            depend.Request = requestBody

            // 获取 返回 body
            if resp != nil {
                var responseBody = "非 json 结构,不可展示"
                cType := resp.Header.Get("Content-Type")
                if resp.Body != nil && strings.Contains(cType, "application/json") {
                    respBodyBytes, _ := io.ReadAll(resp.Body)
                    _ = resp.Body.Close()
                    resp.Body = io.NopCloser(bytes.NewBuffer(respBodyBytes))
                    responseBody = string(respBodyBytes)
                }
                depend.Response = responseBody
            }
            saveDepend(depend)
        }
        return resp, err
    })
}

type roundTripperFunc func(*http.Request) (*http.Response, error)

func (f roundTripperFunc) RoundTrip(r *http.Request) (*http.Response, error) { return f(r) }

首先这块代码有个问题,request被clone了,虽然读取之后重置了body,但是原始的request的body还是被读取了,重置的只是clone的request。把r = r.Clone(ctx)一行注释掉,测试还是不好使。所以猜测传入此处的request不是最原始的那个,在RoundTrip 函数中的request可能就是个clone的。

果然,在net/http/client.go:232行代码,发现在调用 RoundTrip 之前,request被clone了一份,所以在RoundTrip 函数中,无论怎么重置request body都无济于事。

最终的解决方案,在 doWithRetry 方法中,在重试之前先把request body手动重置掉,以此来解决问题。

Go 复制代码
// doWithRetry 执行请求并处理重试,支持重新构造请求体
func (c SimpleClient) doWithRetry(ctx context.Context, req *http.Request, originalBody []byte) ([]byte, int, error) {
    // 第一次请求
    body, statusCode, err := c.do(ctx, req)
    // 检查是否需要重试
    if shouldRetry(ctx, req, err) {
        // 重新构造请求体,因为第一次失败时 req.Body 已经被 closeBody() 关闭
        if originalBody != nil {
            req.Body = io.NopCloser(bytes.NewReader(originalBody))
            req.ContentLength = int64(len(originalBody))
        }
        // 重试请求
        return c.do(ctx, req)
    }
    return body, statusCode, err
}

总结

相关推荐
_码力全开_1 天前
P1005 [NOIP 2007 提高组] 矩阵取数游戏
java·c语言·c++·python·算法·矩阵·go
程序员爱钓鱼1 天前
Python编程实战 · 基础入门篇 | Python程序的运行方式
后端·go
光头闪亮亮2 天前
gozxing库-对图片中多个二维码进行识别的完整示例教程
go
召摇2 天前
在浏览器中无缝运行Go工具:WebAssembly实战指南
后端·面试·go
王中阳Go3 天前
我发现不管是Java还是Golang,懂AI之后,是真吃香!
后端·go·ai编程
半枫荷3 天前
二、Go语法基础(基本语法)
go
struggle20254 天前
AxonHub 开源程序是一个现代 AI 网关系统,提供统一的 OpenAI、Anthropic 和 AI SDK 兼容 API
css·人工智能·typescript·go·shell·powershell
Mgx4 天前
高性能 Go 语言带 TTL 的内存缓存实现:精确过期、自动刷新、并发安全
go
考虑考虑4 天前
go格式化时间
后端·go