关于后端:TCP长连接实践与挑战

5次阅读

共计 4296 个字符,预计需要花费 11 分钟才能阅读完成。

本文介绍了 tcp 长连贯在理论工程中的实际过程,并总结了 tcp 连贯保活遇到的挑战以及对应的解决方案。

作者:字节跳动终端技术 ——— 陈圣坤

概述

家喻户晓,作为传输层通信协议,TCP 是面向连贯设计的,所有申请之前须要先通过三次握手建设一个连贯,申请完结后通过四次挥手敞开连贯。通常咱们应用 TCP 连贯或者基于 TCP 连贯之上的应用层协定例如 HTTP 1.0 等,都会为每次申请建设一次连贯,申请完结即敞开连贯。这样的益处是实现简略,不必保护连贯状态。但对于大量申请的场景下,频繁创立、敞开连贯可能会带来大量的开销。因而这种场景通常的做法是放弃长连贯,一次申请后连贯不敞开,下次再对该端点发动的申请间接复用该连贯,例如 HTTP 1.1 及 HTTP 2.0 都是这么做的。然而在工程实际中会发现,实现 TCP 长连贯并不像设想的那么简略,本文总结了实现 TCP 长连贯时遇到的挑战和解决方案。

事实上 TCP 协定自身并没有规定申请实现时要敞开连贯,也就是说 TCP 自身就是长连贯的,直到有一方被动敞开连贯为止。实现 TCP 连贯遇到的挑战次要有两个:连接池和连贯保活。

连接池

长连贯意味着连贯是复用的,每次申请完连贯不敞开,下次申请持续应用该连贯。如果申请是串行的,那齐全没有问题。但在并发场景下,所有申请都须要应用该连贯,为了保障连贯的状态正确,加锁不可避免,如果连贯只有一个,就意味着所有申请都须要排队期待。因而长连贯通常意味着连接池的存在:连接池中将保留肯定数量的连贯不敞开,有申请时从池中取出可用的连贯,申请完结将连贯返回池中。

用 go 实现一个简略的连接池(参考《Go 语言实战》):

import (
    "errors"
    "io"
    "sync"
)

type Pool struct {
    m         sync.Mutex
    resources chan io.Closer
    closed    bool
}

func (p *Pool) Acquire() (io.Closer, error) {
    r, ok := <-p.resources
    if !ok {return nil, errors.New("pool has been closed")
    }
    return r, nil
}

func (p *Pool) Release(r io.Closer) {p.m.Lock()
    defer p.m.Unlock()

    if p.closed {r.Close()
        return
    }
    
    select {
    case p.resources <- r:
    default:
        // pool is full , just close
        r.Close()}
}

func (p *Pool) Close() error {p.m.Lock()
    defer p.m.Unlock()
    if p.closed {return nil}
    
    p.closed = true
    close(p.resources)
    for r := range p.resources {if err := r.Close(); err != nil {return err}
    }

    return nil
}

func New(fn func() (io.Closer, error), size uint) (*Pool, error) {
    if size <= 0 {return nil, errors.New("size too small")
    }

    res := make(chan io.Closer, size)
    for i := 0; i < int(size); i++ {c, err := fn()
        if err != nil {return nil, err}

        res <- c
    }

    return &Pool{resources: res,}, nil
}

池的对象只需实现 io.Closer 接口即可,利用 go 缓冲通道的个性能够轻松地实现连接池:获取连贯时从通道中接管一个对象,开释连贯时将该对象发送到连接池中。因为 go 的通道自身就是 goroutine 平安的,因而不须要额定加锁。Pool 应用的锁是为了保障 Release 操作和 Close 操作的并发平安,避免连接池在敞开的同时再开释连贯,造成预期外的谬误。

连接池常常遇到的一个问题就是池大小的管制:过大的连接池会带来资源的节约,同时对服务端也会带来连贯压力;过小的连接池在高并发场景下会限度并发性能。通常的解决办法是提早创立和设置闲暇工夫,提早创立是指连贯只在申请到来时才创立,闲暇工夫是指连贯在肯定工夫内未被应用则将被被动敞开。这样日常状况下连接池管制在较小的尺度,当并发申请量较大时会为新的申请创立新的连贯,这些连贯在申请结束后返还连接池,其中的大部分会在闲置肯定工夫后被被动敞开,这样就做到了并发性能和 IO 资源之间较好的均衡。

连贯保活

长连贯的第二个问题就是连贯保活的问题。尽管 TCP 协定并没有限度一个连贯能够放弃多久,实践上只有不敞开连贯,连贯就始终存在。但事实上因为 NAT 等网络设备的存在,一个连贯即便没有被动敞开,它也不会始终存活。

NAT

NAT(Network Address Translation)是一种被广泛应用的网络设备,直观地解释就是进行网络地址转换,通过肯定策略对 tcp 包的源 ip、源端口、目标 ip 和目标端口进行替换。能够说,NAT 无效缓解了 ipv4 地址紧缺的问题,尽管实践上 ipv4 早已耗尽,但正因为 NAT 设施的存在,ipv4 的寿命超出了所预计的工夫。公司外部的网络也是通过 NAT 构建起来的。

尽管 NAT 有如此的长处,但它也带来了一些新的问题,对 TCP 长连贯的影响就是其中之一。咱们将一个通过 NAT 连贯的网络简化成上面的模型:


A 如果想放弃对 B 的长连贯,它理论并不与 B 间接建设连贯,而是与 NAT A 建设长连贯,而 NAT A 又与 NAT B、NAT B 与 B 建设长连贯。如果 NAT 设施任由上面的机器放弃连贯不敞开,那它很容易就耗尽所能反对的连接数,因而 NAT 设施会定时敞开肯定工夫内没有数据包的连贯,并且它不会告诉网络的单方。这就是为什么咱们有时候会遇到这种谬误:

error: read tcp4 1.1.1.1:8888->2.2.2.2:9999: i/o timeout

依照 TCP 的设计,连贯有一方要敞开连贯时会有“四次挥手”的过程,通过一个敞开的连贯发送数据时会抛出 Broken pipe 的谬误。但 NAT 敞开连贯时并不告诉连贯单方,发送方不晓得连贯已敞开,会持续通过该连贯发送数据,并且不会抛出 Broken pipe 的谬误,而接管方也不晓得连贯已敞开,还会继续监听该连贯。这样发送方申请能胜利发送,但接管方无奈接管到该申请,因而发送方天然也等不到接管方的响应,就会阻塞至接口超时。通过实际发现公司的 NAT 超时是一个小时,也就是放弃连贯不敞开并闲置一个小时后,再通过该连贯发送申请时,就会呈现上述 timeout 的谬误。

咱们下面提到连接池大小的管制问题,其实看起来有点相似 NAT 的超时管制,那既然咱们容许连接池敞开超时的闲置连贯,为什么不能承受 NAT 设施敞开呢?答案就是下面提到的,NAT 设施敞开连贯时并未告诉连贯单方,因而客户端应用连贯申请时并不知道该连贯实际上是否可用,而如果是由连接池被动敞开连贯,那它天然晓得连贯是否是可用的。

Keepalive

通过下面的形容咱们就晓得怎么解决了,既然 NAT 会敞开肯定工夫内没有数据包的连贯,那咱们只须要让这个连贯定时主动发送一个小数据包,就能保障连贯不会被 NAT 主动敞开。

实际上 TCP 协定中就蕴含了一个 keepalive 机制:如果 keepalive 开关被关上,在一段时间(保活工夫:tcp_keepalive_time)内此连贯不沉闷,开启保活性能的一端会向对端发送一个保活探测报文。只有咱们保障这个 tcp_keepalive_time 小于 NAT 的超时工夫,这个探测报文的存在就能保障 NAT 设施不会敞开咱们的连贯。

unix 零碎为 TCP 开发封装的 socket 接口通常都有 keepalive 的相干设置,以 go 语言为例:

conn, _ := net.DialTCP("tcp4", nil, tcpAddr)

_ = conn.SetKeepAlive(true)

_ = conn.SetKeepAlivePeriod(5 * time.Minute)

另一个常见的保活机制是 HTTP 协定的 keep-alive,不同于 TCP 协定,HTTP 1.0 设计上默认是不反对长连贯的,服务器响应完立刻断开连接,通过申请头中的设置“connection: keep-alive”放弃 TCP 连接不断开(HTTP 1.1 当前默认开启)。

流水线管制

只管应用连接池肯定水平上能均衡好并发性能和 io 资源,但在高并发下性能还是不够现实,这是因为可能有上百个申请都在等同一个连贯,每个申请都须要期待上一个申请返回后能力收回:

这样无疑是低效的,咱们无妨参考 HTTP 协定的流水线设计,也就是申请不用期待上一个申请返回能力收回,一个 TCP 长连贯会按程序间断收回一系列申请,等到申请发送胜利后再对立按程序接管所有的返回后果:

这样无疑能大大减少网络的等待时间,进步并发性能。随之而来的一个不言而喻的问题是如何保障响应和申请的正确对应关系?通常有两种策略:

  1. 如果服务端是单线程 / 过程地解决每个连贯,那服务端人造就是以申请的程序顺次响应的,客户端接管到的响应程序和申请程序是统一的,不须要非凡解决;
  2. 如果服务端是并发地解决每个连贯上的所有申请(例如将申请入队列,而后并发地生产队列,经典的如 redis),那就无奈保障响应的程序与申请程序统一,这时就须要批改客户端与服务端的通信协议,在申请与响应的数据结构中带上举世无双的序号,通过匹配这个序号来确定响应和申请之间的映射关系;

HTTP 2.0 实现了一个多路复用的机制,其实能够看成是这种流水线的优化,它的响应与申请的映射关系就是通过流 ID 来保障的。

总结

以上就是对 TCP 长连贯实际中遇到的挑战和解决思路的总结,联合笔者在公司外部的实践经验别离探讨了连接池、连贯保活和流水线管制等问题,梳理了实现 TCP 长连贯常常遇到的问题,并提出了解决思路,在升高频繁创立连贯的开销的同时尽可能地保障高并发下的性能。

参考

  • TCP keepalive 的探索
  • HTTP keep-alive 和 TCP keepalive 的区别,你理解吗?
  • HTTP/1.x 的连贯治理 – HTTP | MDN

🔥 火山引擎 APMPlus 利用性能监控是火山引擎利用开发套件 MARS 下的性能监控产品。咱们通过先进的数据采集与监控技术,为企业提供全链路的利用性能监控服务,助力企业晋升异样问题排查与解决的效率。目前咱们面向中小企业特地推出 「APMPlus 利用性能监控企业助力口头」,为中小企业提供利用性能监控免费资源包。当初申请,有机会取得60 天 收费性能监控服务,最高可享 6000 万 条事件量。

👉 点击这里,立刻申请

正文完
 0