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
2
3
4
5
6
7
8
9
10
11
#include <stdio.h>

// 导出符号,即使全局隐藏也要让此符号可见
extern "C" void __attribute__((visibility("default"))) exported_function() {
printf("This function is exported.\n");
}

// 不导出(默认被隐藏)
extern "C" void hidden_function() {
printf("This function is hidden.\n");
}

编译并查看符号:

1
2
3
4
5
6
7
8
9
10
~ g++ -shared -fPIC -fvisibility=hidden -o libexample.so example.cpp
~ readelf -Ws libexample.so
readelf -Ws libexample.so|grep "function\|Symbol table\|Num:"
Symbol table '.dynsym' contains 10 entries:
Num: Value Size Type Bind Vis Ndx Name
9: 0000000000001150 19 FUNC GLOBAL DEFAULT 12 exported_function
Symbol table '.symtab' contains 30 entries:
Num: Value Size Type Bind Vis Ndx Name
20: 0000000000001163 19 FUNC LOCAL DEFAULT 12 hidden_function
24: 0000000000001150 19 FUNC GLOBAL DEFAULT 12 exported_function

hidden_function符号在动态库被加载的时候不会被引入到全局符号表中,甚至使用dlsym拿都无法提取

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
#include <dlfcn.h>
#include <stdio.h>
#include <stdlib.h>

int main() {
void* handle = dlopen("./libexample.so", RTLD_LAZY);
if (!handle) {
fprintf(stderr, "Error: %s\n", dlerror());
return 1;
}

// 使用 dlsym 获取符号指针
void (*function_ptr)() = (void (*)())dlsym(handle, "hidden_function");
if (!function_ptr) {
fprintf(stderr, "Error: %s\n", dlerror());
dlclose(handle);
return 1;
}

dlclose(handle);

return 0;
}

编译并运行:

1
2
3
~ gcc dlsym.c -ldl -lexample -L.
~ ./a.out
Error: ./libexample.so: undefined symbol: hidden_function

但是这种方式会让业务的二进制强制新增一个动态库依赖,在一些特殊场景下,业务的二进制无法额外依赖某些动态库(该场景也不需要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
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
// http3_interface.h

#pragma once

#include <string>
#include <memory>

struct Http3Config {
// 配置参数
};

//用于主程序传入给动态库,依赖反转来反向调用主程序的日志库
struct TC_Http3LoggerInterface {
virtual ~TC_Http3LoggerInterface() = default;
virtual void log_debug(const char *file, int line, const std::string &msg) = 0;
virtual void log_error(const char *file, int line, const std::string &msg) = 0;
};

//用于主程序传入给动态库,依赖反转来反向调用主程序的Http序列化库
struct Http3RequestInterface {
virtual ~Http3RequestInterface() = default;
// 请求数据结构
virtual std::string getReqStr() = 0;
};

//用于动态库传出给主程序,使得主程序可以使用创建的Http3连接
struct Http3Connection {
virtual ~Http3Connection() = default;
virtual void push_request(const std::shared_ptr<Http3RequestInterface>& req) = 0;
using Ptr = std::shared_ptr<Http3Connection>;
};

// 定义导出函数的类型
using InitHttp3Func = void(*)(TC_Http3LoggerInterface*, const Http3Config&);
using CreateHttp3ConnFunc = Http3Connection::Ptr(*)(const std::string& host, uint32_t port);
demo动态库

动态库实现了Http3连接,可以把请求投递到连接中进行处理

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
// http3_library.cpp

#include "http3_interface.h"
#include <iostream>
#include <memory>

class Http3ConnectionImpl : public Http3Connection {
public:
Http3ConnectionImpl(TC_Http3LoggerInterface* logger)
: logger_(logger) {}

void push_request(const std::shared_ptr<Http3RequestInterface>& req) override {
logger_->log_debug(__FILE__, __LINE__, "处理HTTP/3请求:" + req->getReqStr());
// 实现请求处理逻辑
logger_->log_debug(__FILE__, __LINE__, "请求处理完成");
}

private:
TC_Http3LoggerInterface* logger_;
};

static TC_Http3LoggerInterface* global_logger = nullptr;

extern "C" {

__attribute__((visibility("default"))) void initHttp3(TC_Http3LoggerInterface* logger, const Http3Config& config) {
std::cout << "初始化HTTP/3库" << std::endl;
global_logger = logger;
global_logger->log_debug(__FILE__, __LINE__, "HTTP/3库已初始化");
}

__attribute__((visibility("default"))) Http3Connection::Ptr createHttp3Conn(const std::string& host, uint32_t port) {
std::cout << "创建HTTP/3连接到 " << host << ":" << port << std::endl;
if (global_logger) {
global_logger->log_debug(__FILE__, __LINE__, "创建HTTP/3连接实例");
}
return std::make_shared<Http3ConnectionImpl>(global_logger);
}

}
demo主程序
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
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
// http3_client.cpp

#include "http3_interface.h"
#include <dlfcn.h>
#include <iostream>

#define HTTP3LibCallFunc(name, type, ...) \
do { \
using FuncType = type; \
auto _func = (FuncType)dlsym(HTTP3Lib::getHandle(), #name); \
if (!_func) { \
std::cerr << "获取" << #name << "失败: " << dlerror() << std::endl;\
abort(); \
} \
return _func(__VA_ARGS__); \
} while (0)

class TC_Http3Logger : public TC_Http3LoggerInterface {
public:
void log_debug(const char *file, int line, const std::string &msg) override {
std::cout << "[DEBUG] " << file << ":" << line << " " << msg << std::endl;
}
void log_error(const char *file, int line, const std::string &msg) override {
std::cerr << "[ERROR] " << file << ":" << line << " " << msg << std::endl;
}
};

class HTTP3Lib {
public:
static void* getHandle() {
static HTTP3Lib instance;
return instance.handle_;
}
private:
HTTP3Lib() {
handle_ = dlopen("./libhttp3.so", RTLD_NOW | RTLD_LOCAL);
if (!handle_) {
std::cerr << "加载libhttp3.so失败: " << dlerror() << std::endl;
abort();
}
auto initFunc = (InitHttp3Func)dlsym(handle_, "initHttp3");
if (!initFunc) {
std::cerr << "获取initHttp3失败: " << dlerror() << std::endl;
abort();
}
Http3Config config;
static TC_Http3Logger logger;
initFunc(&logger, config);
}
~HTTP3Lib() {
if (handle_) {
dlclose(handle_);
}
}
void* handle_ = nullptr;
};

static Http3Connection::Ptr createHttp3Conn(const std::string& host, uint32_t port) {
HTTP3LibCallFunc(createHttp3Conn, CreateHttp3ConnFunc, host, port);
}

struct Http3Request : public Http3RequestInterface {
std::string getReqStr() override {
return "Hello World";
}
};

int main() {
// 创建HTTP/3连接
auto conn = createHttp3Conn("example.com", 443);
if (conn) {
// 创建请求
auto request = std::make_shared<Http3Request>();
// 发送请求
conn->push_request(request);
}
return 0;
}

编译执行:

1
2
3
4
5
6
7
8
9
~ g++ -std=c++11 -shared -fPIC http3_library.cpp -o libhttp3.so
~ g++ -std=c++11 -shared -fPIC -fvisibility=hidden http3_library.cpp -o libhttp3.so
~ ./main
[DEBUG] http3_library.cpp:27 初始化HTTP/3库
[DEBUG] http3_library.cpp:29 HTTP/3库已初始化
创建HTTP/3连接到 example.com:443
[DEBUG] http3_library.cpp:35 创建HTTP/3连接实例
[DEBUG] http3_library.cpp:13 处理HTTP/3请求:Hello World
[DEBUG] http3_library.cpp:15 请求处理完成
小结

基于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
2
3
4
//以下代码每个Client各能创建一条quic连接
Client c1, c2;
c1.get("www.baidu.com");
c2.get("www.baidu.com");

我的实现方案

作为单例实现,我必须提前于业务同学考虑,对于单个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&lt;TC_HttpConnKey, TC_LRU&lt;unordered_map, uint64_t&gt;&gt; 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
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
// 基础异步请求接口
class HttpAsyncRequstInterface {
public:
virtual ~HttpAsyncRequstInterface() = default;
virtual void onRsp() = 0;
virtual void onException() = 0;
};

// Http3 特有的异步请求接口
class Http3AsyncRequstInterface {
public:
virtual ~Http3AsyncRequstInterface() = default;
int64_t stream_id = -1;
static Http3AsyncRequstInterface* cast(HttpAsyncRequstInterface* req) {
return dynamic_cast<Http3AsyncRequstInterface*>(req);
}
};

// 基础异步请求基类
class HttpAsyncRequstBase : public HttpAsyncRequstInterface {
// 实现 onRsp 和 onException
};

// Http3 异步请求类,继承自 HttpAsyncRequstBase
class Http1AsyncRequst : public HttpAsyncRequstBase {
};

// Http3 异步请求类,继承自 HttpAsyncRequstBase 和 Http3AsyncRequstInterface
class Http3AsyncRequst : public HttpAsyncRequstBase, public Http3AsyncRequstInterface {
};
动态库核心代码
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
// Http3 连接类
class Http3Conn {
public:
void push_requests(std::shared_ptr<HttpAsyncRequstInterface> req) {
// 存储请求
reqPtrs.push_back(req);
}

void processRequests() {
for (auto& req : reqPtrs) {
if (auto http3Req = Http3AsyncRequstInterface::cast(req.get())) {
// 访问 Http3 特有的数据
int64_t sid = http3Req->stream_id;
// 处理 Http3 请求
}
}
}

private:
std::list<std::shared_ptr<HttpAsyncRequstInterface>> reqPtrs;
};