背景

最新有同事反馈,服务间有调用超时的现象,在业务高峰期发生的概率和次数比较高。从日志中调用关系来看,有2个调用链经常发生超时问题。

问题1: A服务使用 http1.1 发送请求到 B 服务超时。

问题2: A服务使用一个轻量级http-sdk(内部http2.0) 发送请求到 C 服务超时。

Golang给出的报错信息时:

  1. Post http://host/v1/xxxx: net/http: request canceled while waiting for connection (Client.Timeout exceeded while awaiting headers)

通知日志追踪ID来排查,发现有的请求还没到服务方就已经超时。

有些已经到服务方了,但也超时。

这里先排查的是问题2,下面是过程。

排查

推测

调用方设置的http请求超时时间是1s。

请求已经到服务端了还超时的原因,可能是:

  1. 服务方响应慢。 通过日志排查确实有部分存在。

  2. 客户端调用花了990ms,到服务端只剩10ms,这个肯定会超时。

请求没到服务端超时的原因,可能是:

  1. golang CPU调度不过来。通过cpu监控排除这个可能性

  2. golang 网络库原因。重点排查

排查方法:

本地写个测试程序,1000并发调用测试环境的C服务:

  1. n := 1000
  2. var waitGroutp = sync.WaitGroup{}
  3. waitGroutp.Add(n)
  4. for i := 0; i < n; i++ {
  5. go func(x int) {
  6. httpSDK.Request()
  7. }
  8. }
  9. waitGroutp.Wait()

报错:

  1. too many open files // 这个错误是笔者本机ulimit太小的原因,可忽略
  2. net/http: request canceled (Client.Timeout exceeded while awaiting headers)

并发数量调整到500继续测试,还是报同样的错误。

连接超时

本地如果能重现的问题,一般来说比较好查些。

开始跟golang的源码,下面是创建httpClient的代码,这个httpClient是全局复用的。

  1. func createHttpClient(host string, tlsArg *TLSConfig) (*http.Client, error) {
  2. httpClient := &http.Client{
  3. Timeout: time.Second,
  4. }
  5. tlsConfig := &tls.Config{InsecureSkipVerify: true}
  6. transport := &http.Transport{
  7. TLSClientConfig: tlsConfig,
  8. MaxIdleConnsPerHost: 20,
  9. }
  10. http2.ConfigureTransport(transport)
  11. return httpClient, nil
  12. }
  13. // 使用httpClient
  14. httpClient.Do(req)

跳到net/http/client.go 的do方法

  1. func (c *Client) do(req *Request) (retres *Response, reterr error) {
  2. if resp, didTimeout, err = c.send(req, deadline); err != nil {
  3. }
  4. }

继续进 send 方法,实际发送请求是通过 RoundTrip 函数。

  1. func send(ireq *Request, rt RoundTripper, deadline time.Time) (resp *Response, didTimeout func() bool, err error) {
  2. rt.RoundTrip(req)
  3. }

send 函数接收的 rt 参数是个 inteface,所以要从 http.Transport 进到 RoundTrip 函数。

其中log.Println("getConn time", time.Now().Sub(start), x) 是笔者添加的日志,为了验证创建连接耗时。

  1. var n int
  2. // roundTrip implements a RoundTripper over HTTP.
  3. func (t *Transport) roundTrip(req *Request) (*Response, error) {
  4. // 检查是否有注册http2,有的话直接使用http2的RoundTrip
  5. if t.useRegisteredProtocol(req) {
  6. altProto, _ := t.altProto.Load().(map[string]RoundTripper)
  7. if altRT := altProto[scheme]; altRT != nil {
  8. resp, err := altRT.RoundTrip(req)
  9. if err != ErrSkipAltProtocol {
  10. return resp, err
  11. }
  12. }
  13. }
  14. for {
  15. //n++
  16. // start := time.Now()
  17. pconn, err := t.getConn(treq, cm)
  18. // log.Println("getConn time", time.Now().Sub(start), x)
  19. if err != nil {
  20. t.setReqCanceler(req, nil)
  21. req.closeBody()
  22. return nil, err
  23. }
  24. }
  25. }

结论:加了日志跑下来,确实有大量的getConn time超时。

疑问

这里有2个疑问:

  1. 为什么Http2没复用连接,反而会创建大量连接?

  2. 创建连接为什么会越来越慢?

继续跟 getConn 源码, getConn第一步会先获取空闲连接,因为这里用的是http2,可以不用管它。

追加耗时日志,确认是dialConn耗时的。

  1. func (t *Transport) getConn(treq *transportRequest, cm connectMethod) (*persistConn, error) {
  2. if pc, idleSince := t.getIdleConn(cm); pc != nil {
  3. }
  4. //n++
  5. go func(x int) {
  6. // start := time.Now()
  7. // defer func(x int) {
  8. // log.Println("getConn dialConn time", time.Now().Sub(start), x)
  9. // }(n)
  10. pc, err := t.dialConn(ctx, cm)
  11. dialc <- dialRes{pc, err}
  12. }(n)
  13. }

继续跟dialConn函数,里面有2个比较耗时的地方:

  1. 连接建立,三次握手。

  2. tls握手的耗时,见下面http2章节的dialConn源码。

分别在dialConn函数中 t.dial 和 addTLS 的位置追加日志。

可以看到,三次握手的连接还是比较稳定的,后面连接的在tls握手耗时上面,耗费将近1s。

  1. 2019/10/23 14:51:41 DialTime 39.511194ms https.Handshake 1.059698795s
  2. 2019/10/23 14:51:41 DialTime 23.270069ms https.Handshake 1.064738698s
  3. 2019/10/23 14:51:41 DialTime 24.854861ms https.Handshake 1.0405369s
  4. 2019/10/23 14:51:41 DialTime 31.345886ms https.Handshake 1.076014428s
  5. 2019/10/23 14:51:41 DialTime 26.767644ms https.Handshake 1.084155891s
  6. 2019/10/23 14:51:41 DialTime 22.176858ms https.Handshake 1.064704515s
  7. 2019/10/23 14:51:41 DialTime 26.871087ms https.Handshake 1.084666172s
  8. 2019/10/23 14:51:41 DialTime 33.718771ms https.Handshake 1.084348815s
  9. 2019/10/23 14:51:41 DialTime 20.648895ms https.Handshake 1.094335678s
  10. 2019/10/23 14:51:41 DialTime 24.388066ms https.Handshake 1.084797011s
  11. 2019/10/23 14:51:41 DialTime 34.142535ms https.Handshake 1.092597021s
  12. 2019/10/23 14:51:41 DialTime 24.737611ms https.Handshake 1.187676462s
  13. 2019/10/23 14:51:41 DialTime 24.753335ms https.Handshake 1.161623397s
  14. 2019/10/23 14:51:41 DialTime 26.290747ms https.Handshake 1.173780655s
  15. 2019/10/23 14:51:41 DialTime 28.865961ms https.Handshake 1.178235202s

结论:第二个疑问的答案就是tls握手耗时

http2

为什么Http2没复用连接,反而会创建大量连接?

前面创建http.Client 时,是通过http2.ConfigureTransport(transport) 方法,其内部调用了configureTransport:

  1. func configureTransport(t1 *http.Transport) (*Transport, error) {
  2. // 声明一个连接池
  3. // noDialClientConnPool 这里很关键,指明连接不需要dial出来的,而是由http1连接升级而来的
  4. connPool := new(clientConnPool)
  5. t2 := &Transport{
  6. ConnPool: noDialClientConnPool{connPool},
  7. t1: t1,
  8. }
  9. connPool.t = t2
  10. // 把http2的RoundTripp的方法注册到,http1上transport的altProto变量上。
  11. // 当请求使用http1的roundTrip方法时,检查altProto是否有注册的http2,有的话,则使用
  12. // 前面代码的useRegisteredProtocol就是检测方法
  13. if err := registerHTTPSProtocol(t1, noDialH2RoundTripper{t2}); err != nil {
  14. return nil, err
  15. }
  16. // http1.1 升级到http2的后的回调函数,会把连接通过 addConnIfNeeded 函数把连接添加到http2的连接池中
  17. upgradeFn := func(authority string, c *tls.Conn) http.RoundTripper {
  18. addr := authorityAddr("https", authority)
  19. if used, err := connPool.addConnIfNeeded(addr, t2, c); err != nil {
  20. go c.Close()
  21. return erringRoundTripper{err}
  22. } else if !used {
  23. go c.Close()
  24. }
  25. return t2
  26. }
  27. if m := t1.TLSNextProto; len(m) == 0 {
  28. t1.TLSNextProto = map[string]func(string, *tls.Conn) http.RoundTripper{
  29. "h2": upgradeFn,
  30. }
  31. } else {
  32. m["h2"] = upgradeFn
  33. }
  34. return t2, nil
  35. }

TLSNextProto 在 http.Transport-> dialConn 中使用。调用upgradeFn函数,返回http2的RoundTripper,赋值给alt。

alt会在http.Transport 中 RoundTripper 内部检查调用。

  1. func (t *Transport) dialConn(ctx context.Context, cm connectMethod) (*persistConn, error) {
  2. pconn := &persistConn{
  3. t: t,
  4. }
  5. if cm.scheme() == "https" && t.DialTLS != nil {
  6. // 没有自定义DialTLS方法,不会走到这一步
  7. } else {
  8. conn, err := t.dial(ctx, "tcp", cm.addr())
  9. if err != nil {
  10. return nil, wrapErr(err)
  11. }
  12. pconn.conn = conn
  13. if cm.scheme() == "https" {
  14. // addTLS 里进行 tls 握手,也是建立新连接最耗时的地方。
  15. if err = pconn.addTLS(firstTLSHost, trace); err != nil {
  16. return nil, wrapErr(err)
  17. }
  18. }
  19. }
  20. if s := pconn.tlsState; s != nil && s.NegotiatedProtocolIsMutual && s.NegotiatedProtocol != "" {
  21. if next, ok := t.TLSNextProto[s.NegotiatedProtocol]; ok {
  22. // next 调用注册的升级函数
  23. return &persistConn{t: t, cacheKey: pconn.cacheKey, alt: next(cm.targetAddr, pconn.conn.(*tls.Conn))}, nil
  24. }
  25. }
  26. return pconn, nil
  27. }

结论:

当没有连接时,如果此时来一大波请求,会创建n多http1.1的连接,进行升级和握手,而tls握手随着连接增加而变的非常慢。

解决超时

上面的结论并不能完整解释,复用连接的问题。因为服务正常运行的时候,一直都有请求的,连接是不会断开的,所以除了第一次连接或网络原因断开,正常情况下都应该复用http2连接。

通过下面测试,可以复现有http2的连接时,还是会创建N多新连接:

  1. sdk.Request() // 先请求一次,建立好连接,测试是否一直复用连接。
  2. time.Sleep(time.Second)
  3. n := 1000
  4. var waitGroutp = sync.WaitGroup{}
  5. waitGroutp.Add(n)
  6. for i := 0; i < n; i++ {
  7. go func(x int) {
  8. sdk.Request()
  9. }
  10. }
  11. waitGroutp.Wait()

所以还是怀疑http1.1升级导致,这次直接改成使用 http2.Transport

  1. httpClient.Transport = &http2.Transport{
  2. TLSClientConfig: tlsConfig,
  3. }

改了后,测试发现没有报错了。

为了验证升级模式和直接http2模式的区别。 这里先回到升级模式中的 addConnIfNeeded 函数中,其会调用addConnCall 的 run 函数:

  1. func (c *addConnCall) run(t *Transport, key string, tc *tls.Conn) {
  2. cc, err := t.NewClientConn(tc)
  3. }

run参数中传入的是http2的transport。

整个解释是http1.1创建连接后,会把传输层连接,通过addConnIfNeeded->run->Transport.NewClientConn构成一个http2连接。 因为http2和http1.1本质都是应用层协议,传输层的连接都是一样的。

然后在newClientConn连接中加日志。

  1. func (t *Transport) newClientConn(c net.Conn, singleUse bool) (*ClientConn, error) {
  2. // log.Println("http2.newClientConn")
  3. }

结论:

升级模式下,会打印很多http2.newClientConn,根据前面的排查这是讲的通的。而单纯http2模式下,也会创建新连接,虽然很少。

并发连接数

那http2模式下什么情况下会创建新连接呢?

这里看什么情况下http2会调用 newClientConn。回到clientConnPool中,dialOnMiss在http2模式下为true,getStartDialLocked 里会调用dial->dialClientConn->newClientConn。

  1. func (p *clientConnPool) getClientConn(req *http.Request, addr string, dialOnMiss bool) (*ClientConn, error) {
  2. p.mu.Lock()
  3. for _, cc := range p.conns[addr] {
  4. if st := cc.idleState(); st.canTakeNewRequest {
  5. if p.shouldTraceGetConn(st) {
  6. traceGetConn(req, addr)
  7. }
  8. p.mu.Unlock()
  9. return cc, nil
  10. }
  11. }
  12. if !dialOnMiss {
  13. p.mu.Unlock()
  14. return nil, ErrNoCachedConn
  15. }
  16. traceGetConn(req, addr)
  17. call := p.getStartDialLocked(addr)
  18. p.mu.Unlock()
  19. }

有连接的情况下,canTakeNewRequest 为false,也会创建新连接。看看这个变量是这么得来的:

  1. func (cc *ClientConn) idleStateLocked() (st clientConnIdleState) {
  2. if cc.singleUse && cc.nextStreamID > 1 {
  3. return
  4. }
  5. var maxConcurrentOkay bool
  6. if cc.t.StrictMaxConcurrentStreams {
  7. maxConcurrentOkay = true
  8. } else {
  9. maxConcurrentOkay = int64(len(cc.streams)+1) < int64(cc.maxConcurrentStreams)
  10. }
  11. st.canTakeNewRequest = cc.goAway == nil && !cc.closed && !cc.closing && maxConcurrentOkay &&
  12. int64(cc.nextStreamID)+2*int64(cc.pendingRequests) < math.MaxInt32
  13. // if st.canTakeNewRequest == false {
  14. // log.Println("clientConnPool", cc.maxConcurrentStreams, cc.goAway == nil, !cc.closed, !cc.closing, maxConcurrentOkay, int64(cc.nextStreamID)+2*int64(cc.pendingRequests) < math.MaxInt32)
  15. // }
  16. st.freshConn = cc.nextStreamID == 1 && st.canTakeNewRequest
  17. return
  18. }

为了查问题,这里加了详细日志。测试下来,发现是maxConcurrentStreams 超了,canTakeNewRequest才为false。

在http2中newClientConn的初始化配置中, maxConcurrentStreams 默认为1000:

  1. maxConcurrentStreams: 1000, // "infinite", per spec. 1000 seems good enough.

但实际测下来,发现500并发也会创建新连接。继续追查有设置这个变量的地方:

  1. func (rl *clientConnReadLoop) processSettings(f *SettingsFrame) error {
  2. case SettingMaxConcurrentStreams:
  3. cc.maxConcurrentStreams = s.Val
  4. //log.Println("maxConcurrentStreams", s.Val)
  5. }

运行测试,发现是服务传过来的配置,值是250。

结论: 服务端限制了单连接并发连接数,超了后就会创建新连接。

服务端限制

在服务端框架中,找到ListenAndServeTLS函数,跟下去->ServeTLS->Serve->setupHTTP2_Serve->onceSetNextProtoDefaults_Serve->onceSetNextProtoDefaults->http2ConfigureServer。

查到new(http2Server)的声明,因为web框架即支持http1.1 也支持http2,所以没有指定任何http2的相关配置,都使用的是默认的。

  1. // Server is an HTTP/2 server.
  2. type http2Server struct {
  3. // MaxConcurrentStreams optionally specifies the number of
  4. // concurrent streams that each client may have open at a
  5. // time. This is unrelated to the number of http.Handler goroutines
  6. // which may be active globally, which is MaxHandlers.
  7. // If zero, MaxConcurrentStreams defaults to at least 100, per
  8. // the HTTP/2 spec's recommendations.
  9. MaxConcurrentStreams uint32
  10. }

从该字段的注释中看出,http2标准推荐至少为100,golang中使用默认变量 http2defaultMaxStreams, 它的值为250。

真相

上面的步骤,更多的是为了记录排查过程和源码中的关键点,方便以后类似问题有个参考。

简化来说:

  1. 调用方和服务方使用http1.1升级到http2的模式进行通讯
  2. 服务方http2Server限制单连接并发数是250
  3. 当并发超过250,比如1000时,调用方就会并发创建750个连接。这些连接的tls握手时间会越来越长。而调用超时只有1s,所以导致大量超时。
  4. 这些连接有些没到服务方就超时,有些到了但服务方还没来得及处理,调用方就取消连接了,也是超时。

并发量高的情况下,如果有网络断开,也会导致这种情况发送。

重试

A服务使用的轻量级http-sdk有一个重试机制,当检测到是一个临时错误时,会重试2次。

  1. Temporary() bool // Is the error temporary?

而这个超时错误,就属于临时错误,从而放大了这种情况发生。

解决办法

不是升级模式的http2即可。

  1. httpClient.Transport = &http2.Transport{
  2. TLSClientConfig: tlsConfig,
  3. }

为什么http2不会大量创建连接呢?

这是因为http2创建新连接时会加锁,后面的请求解锁后,发现有连接没超过并发数时,直接复用连接即可。所以没有这种情况,这个锁在 clientConnPool.getStartDialLocked 源码中。

问题1

问题1: A服务使用 http1.1 发送请求到 B 服务超时。

问题1和问题2的原因一样,就是高并发来的情况下,会创建大量连接,连接的创建会越来越慢,从而超时。

这种情况没有很好的办法解决,推荐使用http2。

如果不能使用http2,调大MaxIdleConnsPerHost参数,可以缓解这种情况。默认http1.1给每个host只保留2个空闲连接,来个1000并发,就要创建998新连接。

该调整多少,可以视系统情况调整,比如50,100。

Go中http超时问题的排查的更多相关文章

  1. 如何解决python中urlopen超时问题

    看代码: 利用urlopen中的超时参数设立一个循环 while True: try: page = urllib.request.urlopen(url, timeout=3) break exce ...

  2. 从mina中学习超时程序编写

    从mina中学习超时程序编写 在很多情况下,程序需要使用计时器定,在指定的时间内检查连接过期.例如,要实现一个mqtt服务,为了保证QOS,在服务端发送消息后,需要等待客户端的ack,确保客户端接收到 ...

  3. PHP socket 编程中的超时设置

    PHP socket 编程中的超时设置.网上找了半天也没找到.贴出来分享之:设置$socket 发送超时1秒,接收超时3秒: $socket = socket_create(AF_INET,SOCK_ ...

  4. nginx中的超时配置

    nginx.conf配置文件中timeout超时时间设置 client_header_timeout 语法 client_header_timeout time默认值 60s上下文 http serv ...

  5. Linux系统中的硬件问题如何排查?(6)

    Linux系统中的硬件问题如何排查?(6) 2013-03-27 10:32 核子可乐译 51CTO.com 字号:T | T 在Linux系统中,对于硬件故障问题的排查可能是计算机管理领域最棘手的工 ...

  6. Linux系统中的硬件问题如何排查?(5)

    Linux系统中的硬件问题如何排查?(5) 2013-03-27 10:32 核子可乐译 51CTO.com 字号:T | T 在Linux系统中,对于硬件故障问题的排查可能是计算机管理领域最棘手的工 ...

  7. Linux系统中的硬件问题如何排查?(4)

    Linux系统中的硬件问题如何排查?(4) 2013-03-27 10:32 核子可乐译 51CTO.com 字号:T | T 在Linux系统中,对于硬件故障问题的排查可能是计算机管理领域最棘手的工 ...

  8. Linux系统中的硬件问题如何排查?(3)

    Linux系统中的硬件问题如何排查?(3) 2013-03-27 10:32 核子可乐译 51CTO.com 字号:T | T 在Linux系统中,对于硬件故障问题的排查可能是计算机管理领域最棘手的工 ...

  9. Linux系统中的硬件问题如何排查?(2)

    Linux系统中的硬件问题如何排查?(2) 2013-03-27 10:32 核子可乐译 51CTO.com 字号:T | T 在Linux系统中,对于硬件故障问题的排查可能是计算机管理领域最棘手的工 ...

随机推荐

  1. 深入解析 Kubebuilder:让编写 CRD 变得更简单

    作者 | 刘洋(炎寻) 阿里云高级开发工程师 导读:自定义资源 CRD(Custom Resource Definition)可以扩展 Kubernetes API,掌握 CRD 是成为 Kubern ...

  2. [LeetCode]sum合集

    LeetCode很喜欢sum,里面sum题一堆. 1.Two Sum Given an array of integers, return indices of the two numbers suc ...

  3. Ubuntu+docker+jenkins安装详细指南

    最近项目上开始实行自动化测试,避免不了与jenkins等持续集成工具打交道,今天就给大家分享一下有关jenkins的简单安装和使用 1,准备环境 (1)ubuntu系统 (2)docker (3)je ...

  4. SpringBoot系列——ElasticSearch

    前言 本文记录安装配置ES环境,在SpringBoot项目中使用SpringData-ElasticSearch对ES进行增删改查通用操作 ElasticSearch官网:https://www.el ...

  5. 2018年蓝桥杯java b组第四题

    标题:测试次数 x星球的居民脾气不太好,但好在他们生气的时候唯一的异常举动是:摔手机.各大厂商也就纷纷推出各种耐摔型手机.x星球的质监局规定了手机必须经过耐摔测试,并且评定出一个耐摔指数来,之后才允许 ...

  6. 品Spring:负责bean定义注册的两个“排头兵”

    别看Spring现在玩的这么花,其实它的“筹码”就两个,“容器”和“bean定义”. 只有先把bean定义注册到容器里,后续的一切可能才有可能成为可能. 所以在进阶的路上如果要想走的顺畅些,彻底搞清楚 ...

  7. 基于SpringBoot + Mybatis实现 MVC 项目

    1.预览: (1)完整项目结构 (2) 创建数据库.数据表: [user.sql] SET FOREIGN_KEY_CHECKS=0; -- ---------------------------- ...

  8. Ubuntu 查看操作系统的位数

    查看Ubuntu操作系统的位数是32位还是64位,可以通过以下命令来查看: getconf LONG_BIT 返回32或64 :如图

  9. 【ADO.NET基础】加密方法公共类

    各种加密方法集锦: using System; using System.Security.Cryptography; using System.Text; using System.IO; usin ...

  10. Win10下80端口被System占用导致Apache无法启动

    Windows10下80端口被PID为4的System占用导致Apache无法启动的分析与解决方案 方法/步骤     最近更新了Windows10,总体上来说效果还是蛮不错的,然而今天在开启Apac ...