个人主页:二郎腿 (erlangtui.top)
一、背景
- 在服务端开启长连接的情况下,四层负载均衡转发请求时,会出现服务端收到的请求qps不均匀的情况,或是服务重启后会长时间无法接受到请求,导致不同服务端机器的负载不一致,qps高的机器过载的问题;
- 该问题的原因是只有在新建连接时才会触发负载四层负载均衡器的再均衡策略,客户端随机与不同的服务器新建 TCP 连接,否则现有的 TCP 连接够用时,会一致被复用,在现有的 TCP 连接上传输请求,出现 qps 不均匀的情况;
- 因此需要服务端定期主动断开一些长连接 ,触发四层转发连接的再均衡策略,实现类似于七层负载均衡 Nginx 中的 keepalive_requests 字段的功能,即同一个 TCP 长连接上的请求数达到一定数量时,服务端主动断开 TCP 长连接。
二、基本介绍
1,TCP 的 Keepalive:
- 即 TCP 保活机制,是由 TCP 层(内核态) 实现的,位于传输层,相关配置参数在
/proc/sys/net/ipv4
目录下:tcp_keepalive_intvl
:保活探测报文发送时间间隔,75s;tcp_keepalive_probes
:保活探测报文发送次数,9次,9次之后直接关闭;tcp_keepalive_time
:保活超时时间,7200s,即该 TCP 连接空闲两小时后开始发送保活探测报文;
- TCP 连接传输完数据后,不会立马主动关闭,会先存活一段时间,超过存活时间后,会触发保活机制发送探测报文,多次探测确认没有数据继续传输后,再进行 TCP 四次挥手,关闭 TCP 连接;
- 在 go 语言中,建立 TCP 连接时,默认设置的 keep-alive 为 15s,详见
go1.21 src/net/tcp/tcpsocket.go
2,HTTP 的 Keep-Alive
-
即 HTTP 长连接,是由应用层(用户态) 实现的,位于应用层;
-
需要在 HTTP 报文的头部设置以下信息:
http* Connection: keep-alive * Keep-Alive: timeout=7200
-
上面信息表示,http 采用长连接,且超时时间为7200s;
-
http 协议 1.0 默认采用短连接,即每次发送完数据后会设置
Connection: close
表示需要主动关闭当前 TCP 连接,进行四次挥手后关闭;下次再发送数据前,又需要先进行三次握手建立 TCP 连接,才能发送数据;循环往复,每次建立的 TCP 连接都只能发送一次数据,每次发送数据都需要进行三次握手与四次挥手,每次建立连接与断开连接会导致网络耗时变长,如下图;
- http 协议 1.1 开始默认采用长连接;即每次发送完数据后会设置
Connection: keep-alive
表示需要复用当前 TCP 连接,建立一次 TCP 连接后,可以发送多次的 HTTP 报文,即多次发送数据也只需要一遍三次握手与四次挥手,省去了每次建立连接与断开连接的时间,如下图:
3,四层负载均衡
- 四层负载均衡是一种在网络层(第四层)上进行负载均衡的技术,通过传输层协议 TCP 或 UDP,将传入的请求分发到多个服务器上,以实现请求的负载均衡和高可用性;
- 四层负载均衡主要基于目标IP地址和端口号对请求进行分发,不深入分析请求的内容和应用层协议,通常使用负载均衡器作为中间设备,接收客户端请求,并将请求转发到后端服务器;
- 负载均衡器可以根据预定义的算法(例如轮询、最小连接数、哈希、随机、加权随机等)选择后端服务器来处理请求;
- 四层负载均衡只需要解析到传输层协议即可进行请求转发,且是直接和真实服务器建立 TCP 连接,所以整体耗时比较小;
4,七层负载均衡
- Nginx 可以用于七层负载均衡器,客户端与 Nginx 所在的服务器建立起 TCP 连接,通过解析应用层中的内容,选择对应的后端服务器,Nginx 所在的机器再与后端服务器建立起 TCP 连接,将应用层数据转发后端服务器上,这就是所谓的七层负载均衡,即根据应用层的信息进行转发;
- 在 Nginx 中,
keepalive_requests
指令用于设置在长连接上可以处理的最大请求数量,一旦达到这个数量,Nginx 将关闭当前连接并等待客户端建立新的连接以继续处理请求; - 通过限制每个持久连接上处理的请求数量,
keepalive_requests
可以帮助控制服务器资源的使用,并防止连接过度占用服务器资源,也可以帮助避免潜在的连接泄漏和提高服务器的性能; - 该值设置得过小,会导致经常需要 TCP 三次握手和四次挥手,无法有效发挥长连接的性能;该值设置得过大,会无法发挥该值的作用,导致长连接上的请求过多;具体的大小,要根据实际请求的 QPS 和响应耗时来设置;
- 七层负载均衡能够根据应用层的请求内容实现更惊喜的请求分发和处理,但是需要建立两次 TCP 连接,以及每次将报文逐步解析到应用层再又逐步封装链路层,会导致耗时和失败率上涨;
- 四层负载均衡与七层负载均衡的比较如下:
三、具体实现
1,代码示例
go
package main
import (
"sync"
"time"
"github.com/labstack/echo"
)
type QpsBalance struct {
mu sync.Mutex
data map[string]int // key: ip:port
num int // 通过配置文件来配置
}
// Update 返回 true 表示当前的 tcp 连接上的请求数超过限制,需要断开连接
// 如果是某个 tcp 连接长时间没有后续请求了,默认 15s 之后会发送保活报文,
func (q *QpsBalance) Update(k string) bool {
q.mu.Lock()
defer q.mu.Unlock()
num := q.data[k] + 1
if num >= q.num {
q.data[k] = 0
return true
}
q.data[k] = num
return false
}
// Reset 通过定时任务每天3点重置,避免上游多次不同的扩容ip形成脏数据
func (q *QpsBalance) Reset() {
q.mu.Lock()
defer q.mu.Unlock()
q.data = make(map[string]int)
}
func (q *QpsBalance) Init(n int) {
q.mu.Lock()
defer q.mu.Unlock()
q.data = make(map[string]int)
q.num = n
}
func main() {
balancer := &QpsBalance{}
balancer.Init(200)
e := echo.New()
e.PUT("/handle", func(c echo.Context) error {
if balancer.Update(c.Request().RemoteAddr) {
c.Response().Header().Set("Connection", "close")
}
// do other
return nil
})
go func(b *QpsBalance) {
ticker := time.NewTicker(time.Hour)
for {
t := <-ticker.C
if t.Hour() == 3 {
b.Reset()
}
}
}(balancer)
}
2,基本原理
- 当同一个 TCP 连接上的请求数达到一定限制时,设置返回头部为
Connection: close
,主动关闭 TCP 连接,并重置计数器; - 需要注意的是,客户端如果也是服务器,并且存在自动扩容,那么需要定期清理计数的 map,避免多次不同的扩容ip形成脏数据;
- 以及某些 TCP 连接可能没有达到计数的阈值,便不再被复用了,经过一段时间后会主动断开,这些 TCP 的计数依然存在 map 中,形成了脏数据;
- 在 TCP 连接中,使用四元组来标记的一个唯一的 TCP 连接,即源ip、源端口、目的ip、目的端口,现在是在服务端进行计数的,所以目的ip和目的端口都是一样的,仅仅通过源ip和源端口便可以分别出 TCP 连接;
四、数据验证
- 通过服务记录的监控,查看上游请求过来的平均耗时、P99 耗时、失败率 是否有明显的变化,以及不同服务器收到的请求 QPS 是否均匀;
- 通过
tcpdump src port 28080 -A | grep Connection
命令查看服务端响应的HTTP报文头部 是否有Connection: close
字段,即是否会主动关闭 TCP 连接; - 通过
netstat -antp | grep main | grep :28080
命令查看不同服务器上的服务建立的长连接数是否均匀; - 选取某个长连接,多次查看其存在的时间,是否符合预期;
- 预期时间:假如
keepalive_requests
设置为 100,客户端记录的响应耗时 20ms(包括网络耗时和服务端耗时),那么平均一个长连接一秒能够发送 5 个请求,约 20s 后能够处理 100 个,那么该长连接能够存活 20s; - 注意不能查看某个长连接对应的 socket 创建的时间,因为同一个 socket 会被不同的长连接复用,一般不会被关闭;
- 预期时间:假如