http连接池的选择连接算法
在上一篇实现 HTTP 长连接中,研究了整体实现的方案
初步实现并上线后,相对于原先的短链接用法,整个链路的耗时下降了50%,还是挺有成就感的
在这一篇中着重讨论其中选择连接算法,单独写这一篇是因为在揣摩golang的连接池实现中发现了不符合预期的问题
golang版本如下:
1 | ~ go version |
问题一:go实现不完全符合LRU淘汰规则
在上一篇讨论配置项的时候,得出了空闲连接数量字段是刚需的结论,golang和nginx都进行了实现
回顾一下上篇博文内容,go发起请求使用的Client会使用同一个默认Transport,idleConn管理了所有域名的连接池
下面代码片段的注释来自go源码,每个请求总会使用MRU的方式选取池中连接(越久没有使用的空闲连接越不可信赖)
1 | type Transport struct { |
对应的,池满了以后在淘汰连接的时候,就应该使用LRU算法淘汰
golang有两个相关配置:
每个连接池的上限MaxIdleConnsPerHost(限制idleConn的某个key),默认值为2
总的上限MaxIdleConns(限制整个idleConn),默认值为100
但是对这两个配置的处理却不完全一致
这两个逻辑都在transport.go
的func (t *Transport) tryPutIdleConn(pconn *persistConn)
中
MaxIdleConnsPerHost的限制超出以后,直接返回errTooManyIdleHost,这会在
putOrCloseIdleConn
直接关闭连接而不是复用显然,这里没有进行LRU淘汰,反而进行了MRU的淘汰
MaxIdleConns则包含了完整的LRU逻辑
1 | //下面会用到这个函数,在存在配置项的时候,使用配置项,否则使用默认值 |
nginx实现
空闲连接数量字段在 nginx 中为 upstream 的 keepalive 配置,默认不开启
1 | Syntax: keepalive connections; |
例如
1 | upstream test_backend { |
The connections parameter sets the maximum number of idle keepalive connections to upstream servers that are preserved in the cache of each worker process. When this number is exceeded, the least recently used connections are closed.
connections参数设置了每个工作进程缓存中保留到上游服务器的空闲keepalive连接的最大数量。当超过这个数量时,使用LRU淘汰连接。
It should be particularly noted that the keepalive directive does not limit the total number of connections to upstream servers that an nginx worker process can open. The connections parameter should be set to a number small enough to let upstream servers process new incoming connections as well.
特别需要注意的是,keepalive指令不限制nginx工作进程可以打开到上游服务器的总连接数。connections参数应设置为一个足够小的数值,以便让上游服务器处理新的进入连接。
好在我对nginx还算熟悉,我来看下nginx的具体实现
相关逻辑在src/http/modules/ngx_http_upstream_keepalive_module.c中
先看下数据结构:
max_cached,cache和free需重点关注
1 | typedef struct { |
初始化逻辑,可以发现放回队列中的元素是由free预先初始化好的,初始情况free.size = max_cached
1 | static ngx_int_t ngx_http_upstream_init_keepalive(ngx_conf_t *cf, ngx_http_upstream_srv_conf_t *us) { |
选取连接的逻辑如下,选出后,free.size就会增加1
1 | static ngx_int_t ngx_http_upstream_get_keepalive_peer(ngx_peer_connection_t *pc, void *data) { |
用过的连接放回连接池逻辑,free.size为0就说明已经不能放回连接池了
1 |
|
小结
nginx对于每个连接池,取连接总是取队列的第一个元素,放队列也是放到队列头部,符合MRU
当连接池keepalive最大空闲连接满了以后,会使用LRU淘汰掉队列的尾部元素
这个算法是符合我认知的,而golang不符合,我倾向于golang实现的时候存在疏漏
问题二:go的删除连接算法时间复杂度过高
根据上一节中给出的nginx源码分析,nginx的队列是链表实现的,所以取元素,删除,放回(满了淘汰)的三个操作都是\(O(1)\)时间复杂度的
再看下golang的实现:
1 | type Transport struct { |
每个连接池的数据结构实际上是个数组
取元素:\(O(1)\)
1 | func (t *Transport) queueForIdleConn(w *wantConn) (delivered bool) { |
删除:\(O(n)\)
1 | func (t *Transport) removeIdleConnLocked(pconn *persistConn) bool { |
放回(满了淘汰):不满\(O(1)\),满了调用删除接口\(O(n)\)
1 | func (t *Transport) tryPutIdleConn(pconn *persistConn) { |
这个删除连接池的算法,让我看的很不解
因为队列头部的元素是最久没有使用的,因此全局连接池达到上限,淘汰时一定先删除某个子连接池的第一个元素,这意味着每次删除都需要把所有元素移动一遍
但是考虑到数据存在cpu的局部性原理,因此对性能下结论为时过早,我写一段cpp代码验证一下算法
算法实现
nginx的算法性能显然是最好的,但是c代码封装的不太好,可维护性比较差,考虑封装一个RemovableLRU的数据结构
提供取元素,放回,删除的三个操作popBack
,pushBack
,remove
go的算法实现很简单,为了完整的模仿go行为,淘汰的popHead复用remove操作
1 | template <typename K, bool isPopHeadUseRemove = true> |
而我的实现要做到三个操作都是\(O(1)\)复杂度
pushBack和popBack显然必须要用链表实现,而remove需要使用哈希表来实现
因此链表的元素和哈希表的元素必须相互引用各自的元素指针
graph LR;
A["pushBack() / popBack()"] -- Use --> B[list]
C["remove()"] -- Use --> D[unordered_map]
E[Data element in list] -->|Reference| F[Data element in unordered_map]
F -->|Reference| E
那么这个算法就非常简单了
1 | template <typename K, bool isPopHeadUseRemove = true> |
算法性能测试
完整代码见RemovableLRU.cpp
使用2000个元素进行测试,通过生成随机元素进行操作:
- pushWithoutLimit:pushBack没到上限
- pushWithLimit:pushBack到上限
- remove
- popBack
1 | g++ -std=c++17 -g -Wall -O2 RemovableLRU.cpp |
通过改变代码中performanceElementSize = 2000;
可以对不同数量的元素进行测试,制表如下
100元素 | 2000元素 | 10000元素(经典c10k) | 65535元素(最差case) | |
---|---|---|---|---|
RemovableLRU pushBackWithoutLimit $ O(1)$ | 0.019 ms | 0.332 ms | 1.825 ms | 14.232 ms |
RemovableLRU pushBackWithLimit $ O(1)$ | -(同上) | -(同上) | -(同上) | -(同上) |
RemovableLRU remove $ O(1)$ | 0.012 ms | 0.172 ms | 1.158 ms | 8.703 ms |
RemovableLRU popBack $ O(1)$ | 0.006 ms | 0.118 ms | 0.662 ms | 4.915 ms |
SimpleRemovableLRU pushBackWithoutLimit $ O(1)$ | 0.002 ms | 0.044 ms | 0.277 ms | 2.283 ms |
SimpleRemovableLRU pushBackWithLimit $ O(n)$ | 0.002 ms | 0.346 ms | 18.08 ms | 873.601 ms |
SimpleRemovableLRU remove $ O(n)$ | 0.005 ms | 0.425 ms | 10.45 ms | 506.061 ms |
SimpleRemovableLRU popBack $ O(1)$ | 0ms | 0.001 ms | 0.006 ms | 0.04 ms |
和我猜测的差不多,在小数量级中局部性原理的性能非常优秀
但是随着数量级的增长,2000是生产中有可能遇到的一种情况
虽然pushBackWithLimit
的时间差值更明显,由于调用了remove
,因此直接比较remove
即可:
go的算法比起我的实现性能下降了60%左右(0.172ms -> 0.425ms)
在经典c10k的大并发场合下,性能下降了90%(1.158ms -> 10.45ms),该量级下就已经超过内网的rtt耗时了
在65536端口都被占满的最差case下,性能下降了99%(8.703ms -> 506.061ms)
总结
问题一的不完全符合LRU也许影响不大
问题二的删除操作时间复杂度过高就很致命了,删除在这个场景下有三种使用情况:
- 淘汰(对应表中
pushBackWithLimit
):因为队列头部的元素是最久没有使用的,因此全局连接池达到上限,淘汰时一定先删除某个子连接池的第一个元素,这意味着每次删除都需要把所有元素移动一遍 - 请求超时导致的需要删除连接(对应表中
remove
):在这种场合下,时间复杂度更高的算法会更耗费cpu,使超时更严重,恶性循环导致雪崩 - 对端关闭的正常删除:这种情况相对前两种反而是小问题了
go出来这么多年也相当稳定了,也许存在我分析错误的可能性,和朋友们讨论一下看看
参考资料
net/http: Why not close the conn directly instead of put the conn into a new list?
-
2024-06-23
业务有个需求是为taf的HTTP客户端实现长连接
我5年前在前公司就写过cpp的HTTP连接池版本sheep/HTTP_client实现广告项目的RTB
当初的那个实现很粗糙,凑活用就行,但是现在的这个实现是给全公司人用的,首先摆在面前的问题就是实现HTTP长连接的哪个版本?
个人倾向于1.1版本,对服务端要求低比较通用,最后确实选择了1.1版本,选定了版本随之而来又有这些问题:
是否需要实现Pipelining
是否需要解析协议上的任何控制字段
需要哪些配置字段?默认值是什么?
例如连接池的默认的空闲连接数量,这种默认设置最怕大佬问到底,为什么设为5?为什么设为10?
有现有方案就可以直接转移仇恨:为什么golang设为x?为什么nginx设为x?
以域名为粒度实现每个域名一个连接池,还是以ip为粒度
当初是实现了一个基于ip的Client用来实现rpc框架(每个ip单连接多路复用),基于Client又封装了ClientPool给redis, HTTP, mysql用(每个ip连接池)
现在看着不太对劲,HTTP的长连接应该以域名为粒度吧?
-
这个放到taf框架的博文里面去了,因为初版不打算为HTTP客户端实现太多功能