在上一篇实现 HTTP 长连接 中,研究了整体实现的方案
初步实现并上线后,相对于原先的短链接用法,整个链路的耗时下降了50%,还是挺有成就感的
在这一篇中着重讨论其中选择连接算法,单独写这一篇是因为在揣摩golang的连接池实现中发现了不符合预期的问题
golang版本如下:
1 2 ~ go version go version go1.21.0 linux/amd64
问题一:go实现不完全符合LRU淘汰规则
在上一篇 讨论配置项的时候,得出了空闲连接数量字段 是刚需的结论,golang和nginx都进行了实现
回顾一下上篇博文内容,go发起请求使用的Client会使用同一个默认Transport,idleConn管理了所有域名的连接池
下面代码片段的注释来自go源码,每个请求总会使用MRU 的方式选取池中连接(越久没有使用的空闲连接越不可信赖 )
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 type Transport struct { ... idleConn map [connectMethodKey][]*persistConn ... } func (t *Transport) queueForIdleConn(w *wantConn) (delivered bool ) { ... if list, ok := t.idleConn[w.key]; ok { } ... }
对应的,池满了以后在淘汰连接的时候,就应该使用LRU 算法淘汰
golang有两个相关配置:
但是对这两个配置的处理却不完全一致
这两个逻辑都在transport.go的func (t *Transport) tryPutIdleConn(pconn *persistConn)中
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 func (t *Transport) maxIdleConnsPerHost() int { if v := t.MaxIdleConnsPerHost; v != 0 { return v } return DefaultMaxIdleConnsPerHost } func (t *Transport) tryPutIdleConn(pconn *persistConn) { ... idles := t.idleConn[key] if len (idles) >= t.maxIdleConnsPerHost() { return errTooManyIdleHost } ... t.idleConn[key] = append (idles, pconn) t.idleLRU.add(pconn) if t.MaxIdleConns != 0 && t.idleLRU.len () > t.MaxIdleConns { oldest := t.idleLRU.removeOldest() oldest.close (errTooManyIdle) t.removeIdleConnLocked(oldest) } ... } func (t *Transport) putOrCloseIdleConn(pconn *persistConn) { if err := t.tryPutIdleConn(pconn); err != nil { pconn.close (err) } }
nginx实现
空闲连接数量字段 在 nginx 中为 upstream 的 keepalive 配置,默认不开启
1 2 3 Syntax: keepalive connections; Default: — Context: upstream
例如
1 2 3 4 upstream test_backend { server x.x.x.x:12345; keepalive 32; }
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 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 typedef struct { ngx_uint_t max_cached; ngx_uint_t requests; ngx_msec_t time; ngx_msec_t timeout; ngx_queue_t cache; ngx_queue_t free ; ngx_http_upstream_init_pt original_init_upstream; ngx_http_upstream_init_peer_pt original_init_peer; } ngx_http_upstream_keepalive_srv_conf_t ; typedef struct { ... ngx_http_upstream_keepalive_srv_conf_t *conf; ngx_http_upstream_t *upstream; } ngx_http_upstream_keepalive_peer_data_t ; typedef struct { ... ngx_queue_t queue ; ngx_connection_t *connection; socklen_t socklen; ngx_sockaddr_t sockaddr; } ngx_http_upstream_keepalive_cache_t ;
初始化逻辑,可以发现放回队列中的元素是由free预先初始化好的,初始情况free.size = max_cached
1 2 3 4 5 6 7 8 9 10 11 12 static ngx_int_t ngx_http_upstream_init_keepalive (ngx_conf_t *cf, ngx_http_upstream_srv_conf_t *us) { ... cached = ngx_pcalloc(cf->pool, sizeof (ngx_http_upstream_keepalive_cache_t ) * kcf->max_cached); ngx_queue_init(&kcf->cache); ngx_queue_init(&kcf->free ); for (i = 0 ; i < kcf->max_cached; i++) { ngx_queue_insert_head(&kcf->free , &cached[i].queue ); cached[i].conf = kcf; } }
选取连接的逻辑如下,选出后,free.size就会增加1
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 static ngx_int_t ngx_http_upstream_get_keepalive_peer (ngx_peer_connection_t *pc, void *data) { ngx_http_upstream_keepalive_peer_data_t *kp = data; ngx_queue_t *q; ngx_http_upstream_keepalive_cache_t *item; cache = &kp->conf->cache; for (q = ngx_queue_head(cache); q != ngx_queue_sentinel(cache); q = ngx_queue_next(q)) { item = ngx_queue_data(q, ngx_http_upstream_keepalive_cache_t , queue ); c = item->connection; if (ngx_memn2cmp((u_char *) &item->sockaddr, (u_char *) pc->sockaddr, item->socklen, pc->socklen) == 0 ) { ngx_queue_remove(q); ngx_queue_insert_head(&kp->conf->free , q); goto found; } } return NGX_OK; found: pc->connection = c; return NGX_DONE; }
用过的连接放回连接池逻辑,free.size为0就说明已经不能放回连接池了
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 static void ngx_http_upstream_free_keepalive_peer (ngx_peer_connection_t *pc, void *data, ngx_uint_t state) ngx_http_upstream_keepalive_peer_data_t *kp = data; ngx_queue_t *q; ngx_http_upstream_keepalive_cache_t *item; c = pc->connection; ... if (ngx_queue_empty(&kp->conf->free )) { q = ngx_queue_last(&kp->conf->cache); ngx_queue_remove(q); item = ngx_queue_data(q, ngx_http_upstream_keepalive_cache_t , queue ); ngx_http_upstream_keepalive_close(item->connection); } else { q = ngx_queue_head(&kp->conf->free ); ngx_queue_remove(q); item = ngx_queue_data(q, ngx_http_upstream_keepalive_cache_t , queue ); } ngx_queue_insert_head(&kp->conf->cache, q); item->connection = c; }
小结
nginx对于每个连接池,取连接总是取队列的第一个元素,放队列也是放到队列头部,符合MRU
当连接池keepalive最大空闲连接满了以后,会使用LRU淘汰掉队列的尾部元素
这个算法是符合我认知的,而golang不符合,我倾向于golang实现的时候存在疏漏
问题二:go的删除连接算法时间复杂度过高
根据上一节中给出的nginx源码分析,nginx的队列是链表实现的,所以取元素,删除,放回(满了淘汰)的三个操作都是\(O(1)\) 时间复杂度的
再看下golang的实现:
1 2 3 type Transport struct { idleConn map [connectMethodKey][]*persistConn }
每个连接池的数据结构实际上是个数组
取元素:\(O(1)\)
1 2 3 4 5 6 7 8 9 10 11 12 13 func (t *Transport) queueForIdleConn(w *wantConn) (delivered bool ) { ... if list, ok := t.idleConn[w.key]; ok { stop := false for len (list) > 0 && !stop { ... t.idleLRU.remove(pconn) list = list[:len (list)-1 ] } }
删除:\(O(n)\)
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 func (t *Transport) removeIdleConnLocked(pconn *persistConn) bool { ... switch len (pconns) { case 0 : case 1 : if pconns[0 ] == pconn { delete (t.idleConn, key) removed = true } default : for i, v := range pconns { if v != pconn { continue } copy (pconns[i:], pconns[i+1 :]) t.idleConn[key] = pconns[:len (pconns)-1 ] removed = true break } } }
放回(满了淘汰):不满\(O(1)\) ,满了调用删除接口\(O(n)\)
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 func (t *Transport) tryPutIdleConn(pconn *persistConn) { ... t.idleConn[key] = append (idles, pconn) t.idleLRU.add(pconn) if t.MaxIdleConns != 0 && t.idleLRU.len () > t.MaxIdleConns { oldest := t.idleLRU.removeOldest() oldest.close (errTooManyIdle) t.removeIdleConnLocked(oldest) } ... }
这个删除连接池的算法,让我看的很不解
因为队列头部的元素是最久没有使用的,因此全局连接池达到上限,淘汰时一定先删除某个子连接池的第一个元素,这意味着每次删除都需要把所有元素移动一遍
但是考虑到数据存在cpu的局部性原理,因此对性能下结论为时过早,我写一段cpp代码验证一下算法
算法实现
nginx的算法性能显然是最好的,但是c代码封装的不太好,可维护性比较差,考虑封装一个RemovableLRU的数据结构
提供取元素,放回,删除的三个操作popBack,pushBack,remove
go的算法实现很简单,为了完整的模仿go行为,淘汰的popHead复用remove操作
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 template <typename K, bool isPopHeadUseRemove = true > class SimpleRemovableLRU { vector<K> vs_; uint limit_ = 0 ; K popHead() { K k = vs_.front(); if (isPopHeadUseRemove) { remove(k); return k; } vs_.erase(vs_.begin()); return k; } public: void setLimit(uint limit) { limit_ = limit; } template <typename T> pair<bool , K> pushBack(T && k) { vs_.emplace_back(move(k)); if (limit_ != 0 && vs_.size() > limit_) { return {true , popHead()}; } return {false , {}}; } void remove(const K &k) { auto iter = find(vs_.begin(), vs_.end(), k); vs_.erase(iter); } K popBack() { auto v = move(vs_.back()); vs_.pop_back(); return v; } };
而我的实现要做到三个操作都是\(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 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 template <typename K, bool isPopHeadUseRemove = true >class RemovableLRU { struct Data { template <typename T> Data (T && k) : k (std::forward<T>(k)) {} K k; typename unordered_map<K, typename list<Data>::iterator>::iterator mIter; }; list<Data> datas_; unordered_map<K, typename list<Data>::iterator> m_; uint limit_ = 0 ; K popHead () { Data data = move (datas_.front ()); if (isPopHeadUseRemove) { remove (data.k); return data.k; } datas_.pop_front (); m_.erase (data.mIter); return data.k; } public : void setLimit (uint limit) { limit_ = limit; } template <typename T> pair<bool , K> pushBack (T && k) { datas_.emplace_back (std::forward<T>(k)); typename list<Data>::iterator lIter = datas_.end (); lIter--; auto ret = m_.insert ({k, lIter}); if (ret.second) { auto mIter = ret.first; mIter->second->mIter = mIter; } if (limit_ != 0 && datas_.size () > limit_) { return {true , popHead ()}; } return {false , {}}; } template <typename T> void remove (T && k) { auto iter = m_.find (k); if (iter != m_.end ()) { auto &lIter = iter->second; datas_.erase (lIter); m_.erase (iter); } } K popBack () { Data data = move (datas_.back ()); datas_.pop_back (); m_.erase (data.mIter); return data.k; } };
算法性能测试
完整代码见RemovableLRU.cpp
使用2000个元素进行测试,通过生成随机元素进行操作:
pushWithoutLimit:pushBack没到上限
pushWithLimit:pushBack到上限
remove
popBack
1 2 3 4 5 6 7 8 9 10 ~/algorithm_test# g++ -std=c++17 -g -Wall -O2 RemovableLRU.cpp ~/algorithm_test# ./a.out RemovableLRU<int, true> pushWithoutLimit: Cost 0.332 ms RemovableLRU<int, true> pushWithLimit: Cost 0.326 ms SimpleRemovableLRU<int, true> pushWithoutLimit: Cost 0.044 ms SimpleRemovableLRU<int, true> pushWithLimit: Cost 0.346 ms RemovableLRU<int, true> remove: Cost 0.172 ms SimpleRemovableLRU<int, true> remove: Cost 0.425 ms RemovableLRU<int, true> popBack: Cost 0.118 ms SimpleRemovableLRU<int, true> popBack: Cost 0.001 ms
通过改变代码中performanceElementSize = 2000;可以对不同数量的元素进行测试,制表如下
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出来这么多年也相当稳定了,也许存在我分析错误的可能性,和朋友们讨论一下看看
参考资料
从HTTP.TRANSPORT看连接池的设计
nginx upstream中长连接池的维护
net/http: Why not close the conn directly instead of put the conn into a new list?