http3客户端实现
继上一篇实现HTTP长连接的HTTP/1.1连接池实现已经过去半年
这一段时间发现了HTTP/1.1连接池无法解决的致命缺点:
在突发高并发场景下,客户端由于需要额外建连很容易退化成短连接
在超高突增并发下和短链接几乎无异,我司由于业务特性就是这种情况
即使突增并发不高,只要并发持续增长超过一定比例后,由于新建连接的tcp握手+ssl握手需要耗费大量的cpu,造成客户端和服务端的cpu不稳定,叠加新建连接的tcp慢启动因素带来了更多的超时。
而HTTP/1.1一旦超时就需要断开连接重新建连,更多的建连带来了更多的超时,又带来了更多的建连,就造成了雪崩效应
HTTP/1.1压测
以下是一次模拟业务的简单压测,请求包使用了业务10K左右的post请求,响应包就只是“hello world”,qps缓慢增长以测试极限处理性能,可以发现在达到某个点以后,qps骤降,耗时突增


并且多次压测的结果均不一致(有时候3000qps就会过载),共同点是一旦发生过载,就立刻雪崩断崖下跌,并且大量耗费服务端(下图的go_server)的CPU

服务端不停地刷日志,提示TLS建连失败 reset by peer,因为我设置了客户端超时不进行四次挥手,而是直接reset以减少timewait

基于ngtcp2和nghttp3的实现
实际上在前文实现HTTP长连接中已经对HTTP的已知版本进行过调研了
当时的结论是,在客户端和服务端需要通过外网的长链接方案,要么简单使用HTTP/1.1,要么一步到位使用HTTP/3
当时对HTTP/1.1的雪崩其实有所预计,但是由于某个业务对接其他公司服务端的一些额外限制(单连接最多使用100次),更早的恶化了雪崩情况
因此HTTP/3的开发也就排入日程中了,根据http3的wiki,由于22年6月份才正式标准化HTTP/3,因此能选择的基础库余地也不多,最终选定了ngtcp2+nghttp3的方案
该方案通用性最好,并且在https://github.com/ngtcp2/ngtcp2/blob/release-1.9/examples/client.cc中有一个功能相当完备的实现,只需要在这个基础上修改一下即可
简化demo
由于这个demo近万行,核心代码文件client.cc也有3000行,我实现了一个简化demo在https://github.com/tedcy/nghttp3_simple_demo
编译步骤在README.md中
在https://github.com/tedcy/nghttp3_simple_demo/tree/master的主分支下
我提供了一个最简单实现,优化了一半的代码,去除了例如连接迁移等功能,并且大幅度的精简了配置项,从50项精简到了10多项核心配置
在https://github.com/tedcy/nghttp3_simple_demo/tree/epoll_dev的分支下
实现去除libev依赖
在https://github.com/tedcy/nghttp3_simple_demo/tree/epoll_with_taf_http_dev的分支下
对接taf_http这个请求序列化和响应反序列化的库
为接入我司项目进行准备
复用连接来投递请求
demo是写死的Client类在连接成功后就发出全部请求,然后退出,这是一次性的,没有复用连接
参考When can the client open new streams?中创建quic流的指导
我在Client类中引入请求队列,来复用连接通过查询请求队列持续的发起请求,详见check_pushed_requests函数
BoringSSL和OpenSSL的冲突问题
ngtcp2是依赖BoringSSL来实现TLS1.3的,而我司C++的taf框架是基于业界标准OpenSSL的
虽然BoringSSL是OpenSSL的超集,对外暴露的符号都是兼容的,在简单的编译场景下可以直接替换,但是私有符号不同还是会造成冲突:
taf框架提供给业务的是静态库例如libtaf_by_openssl.a,里面包含了OpenSSL的私有符号
业务部门会依赖taf框架去编译静态库或者动态库对内或对外使用,比如某个编译产物libxxx.so库,它依赖的私有符号也是基于OpenSSL的,如果和libtaf_by_boringssl.a一起编译,会大量报错undefine reference
graph LR
libtaf_by_openssl.a[原库:libtaf_by_openssl.a] --> OpenSSL[含有OpenSSL私有符号]
libtaf_by_boringssl.a[替换为新库:libtaf_by_boringssl.a]
libtaf_by_boringssl.a --> BoringSSL[含有BoringSSL私有符号]
基于libtaf_by_openssl.a编译的libxxx.so -->|依赖OpenSSL私有符号| 报错[编译错误:Undefined Reference]
基于我所知的编译和链接原理,只能使用动态库隔离解决这个问题
直接依赖的动态库隔离
ngtcp2在链接BoringSSL生成libngtcp2.so动态库的时候,很贴心的使用了-fvisibility=hidden隐藏了全部的符号,并且使用gcc宏导出了所需的符号,例如
1 |
|
编译并查看符号:
1 | ~ g++ -shared -fPIC -fvisibility=hidden -o libexample.so example.cpp |
hidden_function符号在动态库被加载的时候不会被引入到全局符号表中,甚至使用dlsym拿都无法提取
1 |
|
编译并运行:
1 | ~ gcc dlsym.c -ldl -lexample -L. |
但是这种方式会让业务的二进制强制新增一个动态库依赖,在一些特殊场景下,业务的二进制无法额外依赖某些动态库(该场景也不需要http3功能),因此我最终使用了可插拔的方案
基于dlopen的可插拔动态库隔离
核心思想是只导出需要使用的函数:
initHttp3
:用于初始化整个http3库,传入一些配置项createHttp3Conn
:用于创建http连接
基于C++的动态库可以全程使用接口方式来定义需要导出的符号,和需要导入给动态库使用的符号,初版实现在https://github.com/tedcy/nghttp3_simple_demo/tree/epoll_with_taf_http_dev_dynamic_build_hide_boringssl
大致的核心代码如下:
demo接口头文件
在这个接口头文件中,只声名了接口,供动态库和主程序一起使用
1 | // http3_interface.h |
demo动态库
动态库实现了Http3连接,可以把请求投递到连接中进行处理
1 | // http3_library.cpp |
demo主程序
1 | // http3_client.cpp |
编译执行:
1 | ~ g++ -std=c++11 -shared -fPIC http3_library.cpp -o libhttp3.so |
小结
基于dlopen的可插拔动态库隔离,相对于直接依赖的方式,主程序对动态库的耦合更少,在动态库缺失的时候,依然可以降级到不支持HTTP/3的版本
再通过共享接口头文件的技术,可以让C++的主程序和动态库的互相调用相对于C没有那么痛苦,需要一个一个dlsym导出各个符号
perHost连接池实现
每个Host对应一个连接池,这在HTTP/1.1是很容易理解的事情
但是在HTTP/3,由于QUIC已经抽象出Stream来连接复用,并且不存在HTTP/2.0的TCP队头阻塞问题,因此应该只需要一条QUIC连接即可
主流实现方案
大部分的HTTP/3实现也是这样的,以下是各语言的选连接核心逻辑:
java:https://github.com/ptrd/flupke/blob/master/src/main/java/tech/kwik/flupke/impl/Http3ConnectionFactory.java#L38
go:https://github.com/quic-go/quic-go/blob/master/http3/transport.go#L269
python:https://github.com/aiortc/aioquic/blob/main/examples/http3_client.py#L388
那么为什么我还要实现perHost连接池呢?
这是因为我的HTTP客户端遵循taf框架历史包袱的语义,Client是一个单例,使用以下方式发起请求
1 | Http::getInstance()->get("www.baidu.com"); |
而主流的HTTP实现都是可以自己创建Client(go的话是可以指定非默认Transport),在复杂问题上可以自己创建多个Client自己管理
1 | //以下代码每个Client各能创建一条quic连接 |
我的实现方案
作为单例实现,我必须提前于业务同学考虑,对于单个Host,是否有必要创建多条连接?
答案是有必要的,这有两个好处:
假设对端单个Host有非常多IP的时候(实际业务有遇到过DNS解析出100多个IP的情况)
多开连接有助于负载均衡
QUIC的服务端对单条QUIC会有各种各样的限制
在握手阶段,通过TLS扩展的EncryptedExtensions消息发送,完整定义在https://www.iana.org/assignments/quic/quic.xhtml#quic-transport
例如initial_max_streams_bidi指定了双向流的最大并发数,在大部分的服务端实现中默认为100,这显然算不上高并发
由于没有什么作业可以抄了,我的设计思路是遵循HTTP/1.1连接池的核心思想:LRU,最近使用的连接是最活跃的,不会因为空闲断开连接,优先使用
实际实现上可以大量复用HTTP/1.1连接池代码,抽象出连接类,把HTTP1.1的连接看作是并发为1的连接,把HTTP3.0的连接看作是并发为N的连接
每次取连接从队头取
取到连接后当并发耗尽时,将其从连接池中取出不再使用
每个请求处理完毕后,将其重新加入连接池队头
深入理解QUIC
QUIC握手
HANDSHAKE_DONE帧
QUIC断开连接
附录
HTTP3细节实现
类图
classDiagram
class TC_HttpConnKey {
string proxyAddr
int proxyPort
string targetAddr
int targetPort
bool isHttps
}
class HttpConnInterface {
+uint64_t getId()
}
class Timer {
+pure virtual void onTimeout()
}
class HttpAsyncRequstInterface {
+pure virtual void onRsp()
+pure virtual void onException()
}
class Http1AsyncRequst {
+void BindConn(Http1Conn*)
}
class Http1Conn {
-shared_ptr~Http1AsyncRequst~ reqPtr
+void setReqPtr(shared_ptr<Http1AsyncRequst>)
}
class Http1ConnPool {
+void asyncGetConn(TC_HttpConnKey, shared_ptr~Http1AsyncRequst~)
+void idleFunc()
-map<TC_HttpConnKey, TC_LRU<unordered_map, uint64_t>> pool
}
class Http3AsyncRequst {
+void BindConn(Http3Conn*)
}
class Http3AsyncRequstInterface {
+int64_t stream_id
}
class Http3Conn {
-list~shared_ptr~Http1AsyncRequst~~ reqPtrs
+Http3Conn(Epoller, TimeoutQueue)
+void push_requests(shared_ptr~Http3AsyncRequst~)
+void check_pushed_request()
}
class Http3ConnPool {
+void asyncGetConn(TC_HttpConnKey, shared_ptr~Http3AsyncRequst~)
+void idleFunc()
-map~TC_HttpConnKey, uint64_t~ pool
}
class TimeoutQueue {
+void push_timer(shared_ptr~HttpTimer~ timer)
}
class EventLoop {
-Epoller poll;
-TimeoutQueue q;
+void run()
}
class HttpAsync {
+void doAsyncHttp1Request()
+void doAsyncHttp3Requst()
}
Http3ConnPool ..> Http3Conn
Http3Conn ..> Http3AsyncRequst
Http3Conn ..|> HttpConnInterface
Http3AsyncRequst --|> HttpAsyncRequstBase
Http3AsyncRequst --|> Http3AsyncRequstInterface
Http1ConnPool ..> Http1Conn
Http1Conn ..> Http1AsyncRequst
Http1Conn ..|> HttpConnInterface
Http1AsyncRequst --|> HttpAsyncRequstBase
HttpAsyncRequstBase ..|> Timer
HttpAsyncRequstBase ..|> HttpAsyncRequstInterface
Http3Conn ..|> Timer
EventLoop ..> Timer
EventLoop ..> TimeoutQueue
Http3Conn ..> TimeoutQueue
HttpAsync ..> EventLoop
HttpAsync ..> Http1ConnPool
HttpAsync ..> Http3ConnPool
隔离HTTP1Req类和HTTP3Req类的独有成员
由于HTTP1和HTTP3的Conn都继承自HttpConnectionInterface这个Interface,HttpConnectionInterface的push_requests函数只能接受最基础的HttpAsyncRequstInterface
因此传入的Http3AsyncRequst类在变成HttpAsyncRequstInterface接口类后,需要通过dynamic_cast强制转换成Http3AsyncRequstInterface类,以获取到stream_id这个对HTTP1的Req类不透明的成员
细化HTTPReq和HTTPConn类图
classDiagram
class HttpConnectionInterface {
<<interface>>
+push_requests(shared_ptr~HttpAsyncRequestInterface~ req)
}
class HttpAsyncRequestInterface {
<<interface>>
+onRsp()
+onException()
}
class HttpAsyncRequestBase {
+onRsp()
+onException()
}
class Http1AsyncRequest {
+BindConn(Http1Conn*)
}
class Http3AsyncRequestInterface {
<<interface>>
+int64_t stream_id
+static Http3AsyncRequestInterface* cast(req)
}
class Http3AsyncRequest {
+BindConn(Http3Conn*)
}
class Http1Conn {
+push_requests(shared_ptr~HttpAsyncRequestInterface~ req)
}
class Http3Conn {
+push_requests(shared_ptr~HttpAsyncRequestInterface~ req)
+processRequests()
}
HttpConnectionInterface <|-- Http1Conn
HttpConnectionInterface <|-- Http3Conn
HttpAsyncRequestInterface <|.. HttpAsyncRequestBase
HttpAsyncRequestBase <|-- Http1AsyncRequest
HttpAsyncRequestBase <|-- Http3AsyncRequest
Http3AsyncRequestInterface <|.. Http3AsyncRequest
Http3Conn ..> Http3AsyncRequestInterface
note for HttpConnectionInterface "push_requests函数只能接受HttpAsyncRequestInterface"
note for Http3Conn "push_requests函数内通过dynamic_cast<br>获取Http3AsyncRequestInterface,以访问Http3独有成员"
note for Http3AsyncRequestInterface "包含Http3独有成员,如stream_id"
接口头文件核心代码
1 | // 基础异步请求接口 |
动态库核心代码
1 | // Http3 连接类 |