vlambda博客
学习文章列表

(七):详解|阻塞式连接池和多路复用连接池的区别

----- 学习笔记 -----

云世推荐搜索
长链接和短链接
HTTP和HTTP2
TCP和HTTP
什么是短链接
连接池怎么实现
什么是长链接

今天要和你分享的内容是阻塞式连接池和多路复用连接池的差别,简单来说你也可以认为是讲 HTTP 连接池和 HTTP/2 连接池的差别。因为这两个协议正好符合这里的两个特性:HTTP 连接是阻塞的、无法复用的,HTTP/2 的连接是非阻塞的、多路复用的。

连接 Connection 

在讲连接池前,我们先来看一下什么叫作连接。不知道你是否有这样的困惑:为什么 TCP 会建立连接,而 UDP 不需要建立连接,这里的连接指的是什么?要想理解 TCP 连接是什么,首先我们看一下连接这个词英文和中文分别是什么意思。

 

在中文里,连接这个词很好理解,它指的是两个事物相互连接,比如网线,这个词的解释很具象,它指的是具体的事物,我们在日常中也是这么理解的。但在 TCP 连接这个场景,你本能地想去寻找一个具体的、起到连接作用的事物去解释,但往往无功而返。

 

我们再来看一下英文辞典中的解释, TCP 连接的英语是 Transmission Control Protocol Connection。connection 在剑桥英英词典中具体解释为:the state of being related to someone or something else,翻译成中文指的是某些人或者事物相互关联的一种状态。注意状态这个说法,在计算机中,经常会提到有状态和无状态;在微服务中,我们也经常会提到有状态服务和无状态服务。

 

⚠️在微服务中我们经常要求服务是无状态的,其实是指服务内存或者本地不保存任何节点相关的信息,这样就可以做到服务扩缩容、迁移的机器无关性;


相反有状态服务,就是服务所在的机器上存有节点相关的信息,比如和某个客户端建立了 session 连接。在负载均衡模块我们提到了一种会话保持(Sticky Sessions)的技术,这种就属于有状态的服务。

 

另外我们也常说 HTTP 服务是无状态的,因为大多数情况 HTTP 是短连接的(并非十分准确,后面会详细解释)。相对而言在一些需要长连接的场景,因为连接本身的有状态的特性,经常会在连接上存储一些连接相关的信息,这样服务也成了有状态服务。

 

回到连接这个词本身,根据 connection 的英文解释,我们基本可以理解 TCP 连接,实际上是维护了两台机器的某种状态,进一步结合有状态和无状态的解释,也就是说两台机器上分别存储了对方机器的某些信息。

 

当然,这只是我们根据英文字面上的理解,为了更进一步理解 TCP 连接的准确意思,我们看一下 RFC 文档中关于 TCP 连接的定义。在 RFC793 文档中对 TCP 连接定义如下:

The reliability and flow control mechanisms described above require that TCPs initialize and maintain certain status information for each data stream. The combination of this information, including sockets, sequence numbers, and window sizes, is called a connection.  


这个定义中提到了两个非常重要的关键词 status 和 information,最重要的一句话,“TCP 初始化并维护某些信息用于每个数据流”,结合以上信息,connection 这个词汇翻译成“联接”更加合适。

 

其实:很多中文翻译并不准确,这可能是由于最早的翻译者并没有完全理解英文词汇的意思,也可能是因为中英文思维差异的问题找不到合适的词汇,这要求我们在学习计算机知识的过程中,多多查看英文本身意思,而不是盯着中文翻译苦苦思考。更重要的是,弄懂“联接”的意思有利于我们接下来的学习。

 

相信到这里,你已经完全理解了 connection 的意思,接下来我们看一看 HTTP 和 HTTP/2 两个七层协议在连接层面的相关知识。

 

HTTP

HTTP 协议相信你已经非常了解了,实际上只要你从事过 Web 相关的开发工作,在互联网时代,无论是前端还是后端,都“逃不过”HTTP 协议。这里我们不过多讲解 HTTP 相关的内容了,因为实在是过于庞大,大家如果感兴趣,可以像上面翻看 TCP 的 RFC 文档一样,去看一看HTTP 的 RFC 文档。这里我们只把重点聚焦在 HTTP 的连接上。

 

短连接和长连接

实际上连接并没有长短之分,只是取决于传输完数据后是否断开,毕竟 HTTP 协议也是基于 TCP 协议实现的。那么为什么会有长短连接的叫法呢?这是因为 HTTP 协议多用于 Web 中,Web 的交互方式多是一来一回的模式,这样的应用场景下,不需要服务端推数据,所以建立连接后立即释放也是完全可以的。

 

实际上随着互联网的发展,请求量越来越大,以往的短连接模式频繁的建立—释放连接,造成服务器资源产生不必要的消耗,但现在 HTTP 协议往往会利用 Connection: Keep-alive 让客户端和服务器保持长连接,使连接可以重用;当需要主动断开连接的时候,发送 Connection: close 就可以了。

 

下面我们来看看今天的“重头戏”——连接池。

 

连接池的实现 

那什么叫连接池呢?简单来说,连接池就是将应用所需的连接对象放在池中,每次访问时从池中获取,使用完毕再放回池中,以达到连接复用的目的,减少创建、销毁连接过程中不必要消耗的资源。

 

HTTP 是一种阻塞式协议,简单来说就是 ping-pong 这种一来一回的模式,假设一个 ping 请求从客户端发到服务器端,那么这条连接一定要等到服务端返回了 pong 信息,才能继续发送下一条 ping 信息。

 

基于这样的设定,你应该可以理解为什么 HTTP 需要连接池,因为HTTP 无法复用连接,这个时候如果请求比较多,会造成请求的阻塞,所以HTTP 需要连接池这样的数据结构做并行请求


那么如何实现一个简单的连接池呢?

 

简单来说,我们只要建立多条连接,用一个数组维护多条连接就行了;如果使用一条连接,那么从数组里拿出这条连接,使用完再放入数组即可。当数组为空时,只要建立新的连接就可以了。

 

(七):详解|阻塞式连接池和多路复用连接池的区别

 

连接池实现线路图

 

具体的实现你可以参考 go-micro 的通用连接池设计:

 

package poolimport ( "sync" "time" "github.com/google/uuid" "github.com/micro/go-micro/v2/transport")type pool struct {    size int //连接池大小,很多类库也叫作 maxIdleConns,最大空闲连接数    //多久后连接会断开,很多连接池会在一定时间后断开连接,然后将新鲜的连接    // 放入连接池 // 比如 HTTP 中因为服务器端因为 keepalive 过期,会主动断开连接, // 这种情况我们设置一个比 keepalive 时间短的 TTL // 确保连接先被移出,就不会拿到已断开的连接了 ttl time.Duration // go-micro 连接层抽象类,当作连接对象类即可 tr transport.Transport sync.Mutex // 互斥锁 conns map[string][]*poolConn // 存储连接对象的数组}type poolConn struct { transport.Client // go-micro 抽象的客户端连接层对象 id string // 唯一 ID created time.Time // 连接创建时间}// 创建连接池对象func newPool(options Options) *pool { return &pool{ size: options.Size, tr: options.Transport, ttl: options.TTL, conns: make(map[string][]*poolConn), }}// 关闭连接池,释放所有连接func (p *pool) Close() error { p.Lock() for k, c := range p.conns { for _, conn := range c { conn.Client.Close() } delete(p.conns, k) } p.Unlock() return nil}// 获取一个连接对象func (p *pool) Get(addr string, opts ...transport.DialOption) (Conn, error) { p.Lock() conns := p.conns[addr] //根据地址获取连接池数组 // 循环直到拿到一个可用的连接 // 否则创建一个新连接返回 for len(conns) > 0 { // 获取一个连接 conn := conns[len(conns)-1] conns = conns[:len(conns)-1] p.conns[addr] = conns // 如果超时则主动关闭连接 if d := time.Since(conn.Created()); d > p.ttl { conn.Client.Close() continue } // 获得了可用的连接,返回新连接 p.Unlock() return conn, nil } p.Unlock() // 创建新连接 c, err := p.tr.Dial(addr, opts...) if err != nil { return nil, err } return &poolConn{ Client: c, id: uuid.New().String(), created: time.Now(), }, nil}// 使用完将连接放回连接池func (p *pool) Release(conn Conn, err error) error { // 如果上层发生错误,则不放回连接池 if err != nil { return conn.(*poolConn).Client.Close() } // 放入没有错误的连接 p.Lock() conns := p.conns[conn.Remote()] if len(conns) >= p.size { p.Unlock() return conn.(*poolConn).Client.Close() } p.conns[conn.Remote()] = append(conns, conn.(*poolConn)) p.Unlock() return nil}


虽然我们讲解的是 HTTP 的连接池,但实际上像 DB 层的协议,比如 Redis、MySQL 也类似 HTTP 的一来一回的协议,所以这套连接池的设计也可以应用在 Redis 和 MySQL 上面,可以说应用非常广泛。

 

最后再解释一个在连接池中不好理解,或者经常被错误理解的参数设置:MaxIdleConns 最大空闲连接数。这个参数应该如何理解呢?

 

这个值经常会和 MaxConns 搞混,但你要注意这个值不是最大连接数,而是最大空闲连接数,结合我们上面讲的连接池实现原理,这个值可以理解为连接池的大小。

 

这个值的设置很有讲究,需要结合后端服务的连接承载能力设置。如果设置得太大,假如设置 1000,始发集群有 100 台机器,那么就会建立 10w 的持久连接,这对后端服务的压力可想而知。但也不能设置得太小,举个极端的例子,假如设置 1,当并发数量上去时,会出现两种情况:

 

如果连接池满了,就会建立新的连接,不断建立的新连接会耗光后端服务的资源;

 

新建立的连接在用完之后,有两种选择——连接池有余量的情况会放入连接池,反之会直接丢弃,这种情况在瞬间很容易出现,连接池持续瞬间被空闲连接占满(最大空闲连接数的叫法也由此得来),导致新连接无法放回连接池,进而丢弃,这样就会形成建立连接—用完丢弃的恶性循环,连接池的作用也就消失了,退化成了短连接。

 

这里可以给出一个经验值,一般情况下连接池容量设置在 10-100 的区间内是比较合理的。

 

HTTP/2

我们理解了 HTTP 的连接池设计,带着问题再来看 HTTP/2 的连接设计,就比较容易理解了。HTTP/2 设计的最大目的就是解决 HTTP 连接阻塞的问题。当然也有很多其他内容的优化,比如 header 头部压缩、基于二进制的传输协议等。但我们还是把关注点集中到连接层面。

 

首先我们看一下什么叫多路复用(Multiplexing)。诚然讲,这个词翻译得还是比较准确的,但中文词汇依然不是很好理解,特别是多路指的是什么。按照之前的方式,查看一下剑桥英语词典的解释:

an electronic process that allows more than one electrical signal to be sent using only one connection


意思是在一个连接上发送多条电信号。这里的连接,指的是物理连接,也就是在一条电缆上传输多个信号。

 

实际在物理上,还是多个信号轮流占用物理信道的,单核心 CPU 的处理,也是类似的原理。如果你想更详细地了解可以看一下时分多路复用(Time-Division Multiplexing,TDM),这个词汇最早源于电缆物理信号的传输,而计算机科学也只是借来使用。

 

多路复用这个技术/叫法,在计算机领域特别是网络传输方面应用非常广泛,比如 Linux 的 IO 多路复用中的 select 和 epoll,可以做到一个进程或线程处理多个 TCP 连接。那么 HTTP/2 中的多路复用指的是什么呢?

 

HTTP/2 中的多路复用,指的是在一条连接中可以同时处理多个请求,这正是解决了 HTTP 中一个连接同时只能处理一个请求的问题。这和 Linux IO 多路复用是不是也有些类似?之所以都叫多路复用,就是因为它们基本上都复用了某个通道,比如HTTP/2 复用了连接,Linux IO 复用了进程。

 

那么 HTTP/2 中的多路复用是怎么实现的呢?我们先抛开 frame 的概念,因为这个概念不是多路复用的关键,但会增加我们理解的复杂度。

 

HTTP/2 抽象出了一种流(stream)的概念,客户端在已经建立的连接上发送流,而不是像 HTTP 1.0 一样直接发送数据包,每个流都有一个唯一的标识号,这样客户端就可以不管服务端是否已经返回数据而不断往服务端发送新的流。因为每个流有独立的 ID,在服务端返回处理流的数据包时,只要根据这个流的 ID 找到发送的信息,就可以一一对应

 

这种方式是不是很巧妙?其实这个流我们可以理解成一个虚拟连接,在代码实现时,也可以看到 stream 其实就是用虚拟连接的方式实现的。我们在一条 TCP 连接上,建立了很多虚拟连接,这些虚拟连接(也就是流)的创建几乎没有成本,在每次发送新的请求前,直接 new stream() 返回新的数据流(虚拟连接),然后发送数据即可。

 

HTTP/2 多路复用连接流程图

 

至此,你应该已经理解了 HTTP/2 的多路复用连接到底是什么。那么 HTTP/2 的连接池如何实现呢?说到这个问题,想先问问你,在你看来 HTTP/2 到底需不需要连接池?

 

答案是:需要,但和传统的阻塞式连接池也就是 HTTP 1.0 的连接池有一些区别。实际上HTTP/2 已经复用了连接,从某种程度上讲实现连接池已经没有太多意义了,多路复用做到一条连接就可以了,但因为 HTTP 2 .0 还是基于 TCP 连接的,而 TCP 存在队头阻塞的问题,比如某条连接突然遇到了网络重传,此时会阻塞连接。另外在一些高并发场景,一条连接难以跑满带宽,也需要创建多条连接。

 

所以 HTTP/2 的连接池实现有所不同,新连接的创建条件往往是依据 maxConcurrentStreams 的参数,当同时请求中的 stream 的数量超过上限时,才创建新的连接。所以在 gRPC 和 HTTP/2 中这个参数的设置非常重要,它的重要性可以等同于上面讲的 MaxIdleConns 参数。这样的设计也很好理解,因为 HTTP/2 已经可以复用同一条连接发送多个请求了,自然没有必要一个请求创建一个连接,只需等这个连接无法满足设置的最大 stream 的条件时才建立新的连接。

 

具体 HTTP/2 的连接池,代码实现也可以通过 go-micro 的 gRPC 连接池来学习

 

结语

 

这一讲主要还是讲解连接池的实现。实际在微服务中,连接池是非常重要的组件,因为服务间需要建立连接通信,通过连接池可以极大地提高服务间的通信性能。另外也详细讲解了连接池的前置知识点:连接以及 HTTP 和 HTTP/2 两个协议的连接层实现。

 

本节内容到这里就结束了,下一讲我们一同来学习网关在微服务架构中的作用。

 

经过这节内容的讲解,你知道了 HTTP/2 是通过 stream 来确定到底是哪条流发送的信息的,那么 HTTP/2 中是如何区分数据流是客户端收到请求返回流还是服务端发出推送流的呢?


经典问题+专家解答:
说下envoy中,基于连接池的断路器的实现?用httpbin做测试,一直没搞明白,envoy中的这个断路器设置的算法。
专家回复:envoy的断路器和传统的hystrix不太一样,他不是根据一些错误码的统计进行熔断的,envoy是根据一些指标判断是否开启熔断,他的理念是这些指标一旦达到设置值,系统会处于不可用的状态,比如最大连接数达到1024,同时请求数达到1024等,具体参数如下 { "priority": "...", "max_connections": "{...}", "max_pending_requests": "{...}", "max_requests": "{...}", "max_retries": "{...}", "retry_budget": "{...}", "track_remaining": "...", "max_connection_pools": "{...}" }


 

云世
【云世】专注微服务和云原生前沿技术和落地实践的布道。分享Spring Cloud微服务架构,及容器、Kubernetes、DevOps、Service Mesh等云原生技术干货、案例、学习实录、经验。持续发布读书笔记,好书推荐等。
128篇原创内容
Official Account

 

上一篇:微服务的核心模块--服务治理:限流和熔断如何实现

下一篇:详解|网关在微服务架构中的作用