用C語言擼了個DBProxy
前言
筆者在閱讀了一大堆源碼后,就會情不自禁產(chǎn)生造輪子的想法。于是花了數(shù)個周末的時間用C語言擼了一個DBProxy(MySQL協(xié)議)。在筆者的github中給這個DBProxy起名為Hero。
為什么采用C語言
筆者一直有C情節(jié),求學(xué)時候一直玩C。工作之后,一直使用Java,就把C漸漸放下了。在筆者最近一年閱讀了一堆關(guān)于linux Kernel(C)和MySQL(C++)的源碼后,就萌生了重拾C的想法。同時用純C的話,勢必要從基礎(chǔ)開始造一大堆輪子,這也符合筆者當(dāng)時造輪子的心境。
造了哪些輪子
習(xí)慣了Java的各種好用的類庫框架之后,用純C無疑就是找虐。不過,既然做了這個決定,跪著也得搞完。在寫Hero的過程中。很大一部分時間就是在搭建基礎(chǔ)工具,例如:
- Reactor模型
- 內(nèi)存池
- packet_buffer
- 協(xié)議分包處理
- 連接池
- ......
下面在這篇博客里面一一道來
DBProxy的整體原理
Hero(DBProxy)其實(shí)就是自己偽裝成MySQL,接收到應(yīng)用發(fā)過來的SQL命令后,再轉(zhuǎn)發(fā)到后端。如下圖所示:
由于Hero在解析SQL的時候,可以獲取各種信息,例如事務(wù)這個信息就可以通過set auto_commit和begin等命令存在連接狀態(tài)里面,再根據(jù)解析出來的SQL判斷其是否需要走主庫。這樣就可以對應(yīng)用透明的進(jìn)行主從分離以至于分庫分表等操作。
當(dāng)然了,筆者現(xiàn)在的Hero剛把基礎(chǔ)的功能搭建好(協(xié)議、連接池等),對連接狀態(tài)還沒有做進(jìn)一步的處理。
Reactor模式
Hero的網(wǎng)絡(luò)模型采用了Reactor模式,而且是多線程模型,同時采用epoll的水平觸發(fā)。
采用多線程模型
為什么采用多線程,純粹是為了編寫代碼簡單。多進(jìn)程的話,還得考慮worker進(jìn)程間負(fù)載均衡問題,例如nginx就在某個worker進(jìn)程達(dá)到7/8最大連接數(shù)的時候拒絕獲取連接從而轉(zhuǎn)給其它worker。多線程的話,在accept線程里面通過取模選擇一個worker線程就可以輕松的達(dá)到簡單的負(fù)載均衡結(jié)果。
采用epoll水平觸發(fā)
為什么采用epoll的水平觸發(fā),純粹也是為了編寫代碼簡單。如果采用邊緣觸發(fā)的話,需要循環(huán)讀取直到read返回字節(jié)數(shù)為0為止。然而如果某個連接特別活躍,socket的數(shù)據(jù)一直讀不完,會造成其它連接饑餓,所以必須還得自己寫個均衡算法,在讀到一定程度后,去選擇其它連接。
Reactor
整體Reactor模型如下圖所示:
其實(shí)代碼是很簡單的,如下面代碼所示的就是reactor中的accept處理:
- // 中間省略了大量的錯誤處理
- int init_reactor(int listen_fd,int worker_count){
- // 注意,這邊需要是unsigned 防止出現(xiàn)負(fù)數(shù)
- unsigned int current_worker = 0;
- for(;;){
- int numevents = 0;
- int retval = epoll_wait(reactor->master_fd,reactor->events,EPOLL_MAX_EVENTS,500);
- ......
- for(j=0; j < numevents; j++){
- client_fd = accept(listen_fd, (struct sockaddr *) &client_addr, &client_len))
- poll_add_event(reactor->worker_fd_arrays[current_worker++%reactor->worker_count],conn->sockfd,EPOLLOUT,conn)
- ......
- }
- }
上面代碼中,每來一個新的連接current_worker都自增,這樣取模后便可在連接層面上對worker線程進(jìn)行負(fù)載均衡。
而worker線程則通過pthread去實(shí)現(xiàn),如下面代碼所示:
- // 這里的worker_count根據(jù)調(diào)用get_nprocs得到的對應(yīng)機(jī)器的CPU數(shù)量
- // 注意,由于docker返回的是宿主機(jī)CPU數(shù)量,所以需要自行調(diào)整
- for(int i=0;i<worker_count;i++){
- reactor->worker_fd_arrays[i] = epoll_create(EPOLL_MAX_EVENTS);
- if(reactor->worker_fd_arrays[i] == -1){
- goto error_process;
- }
- // 通過pthread去創(chuàng)建worker線程
- if(FALSE == create_and_start_rw_thread(reactor->worker_fd_arrays[i],pool)){
- goto error_process;
- }
- }
而worker線程的處理也是按照標(biāo)準(zhǔn)的epoll水平觸發(fā)去處理的:
- static void* rw_thread_func(void* arg){
- ......
- for(;;){
- int numevents = 0;
- int retval = epoll_wait(epfd,events,EPOLL_MAX_EVENTS,500);
- ......
- for(j=0; j < numevents; j++){
- if(event & EPOLLHUP){
- // 處理斷開事件
- ......
- }else if(event & EPOLLERR){
- // 處理錯誤事件
- ......
- }
- }else {
- if(event & EPOLLIN){
- handle_ready_read_connection(conn);
- continue;
- }
- if(event & EPOLLOUT){
- hanlde_ready_write_connection(conn);
- continue;
- }
- }
- }
- ......
- }
內(nèi)存池
為什么需要內(nèi)存池
事實(shí)上,筆者在最開始編寫的時候,是直接調(diào)用標(biāo)準(zhǔn)庫里面的malloc的。但寫著寫著,就發(fā)現(xiàn)一些非常坑的問題。例如我要在一個請求里面malloc數(shù)十個結(jié)構(gòu),同時每次malloc都有失敗的可能,那么我的代碼可能寫這樣。
- void do_something(){
- void * a1 = (void*)malloc(sizeof(struct A));
- if(a1 == NULL){
- goto error_process;
- }
- ......
- void * a10 = (void*)malloc(sizeof(struct A));
- if(a10 == NULL{
- goto error_process;
- }
- error_process:
- if(NULL != a1){
- free(a1);
- }
- ......
- if(NULL != a10){
- free(a10);
- }
- }
寫著寫著,筆者就感覺完全不可控制了。尤其是在各種條件分支加進(jìn)去之后,可能本身aN這個變量都還沒有被分配,那么就還不能用(NULL != aN),還得又一堆復(fù)雜的判斷。
有了內(nèi)存池之后,雖然依舊需要仔細(xì)判斷內(nèi)存池不夠的情況,但至少free的時候,只需要把內(nèi)存池整體給free掉即可,如下面代碼所示:
- void do_something(mem_pool* pool){
- void * a1 = (void*)mem_pool_alloc(sizeof(struct A),pool);
- if(a1 == NULL){
- goto error_process;
- }
- ......
- void * a10 = (void*)mem_pool_alloc(sizeof(struct A),pool);
- if(a10 == NULL{
- goto error_process;
- }
- error_process:
- // 直接一把全部釋放
- mem_pool_free(pool);
- }
內(nèi)存池的設(shè)計(jì)
為了編寫代碼簡單,筆者采用了比較簡單的設(shè)計(jì),如下圖所示:
這種內(nèi)存池的好處就在于分配很多小對象的時候,不必一一去清理,直接連整個內(nèi)存池都重置即可。而且由于每次free的基本都是mem_block大小,所以產(chǎn)生的內(nèi)存碎片也少。不足之處就在于,一些可以被立即銷毀的對象只能在最后重置內(nèi)存池的時候才銷毀。但如果都是小對象的話,影響不大。
內(nèi)存池的分配優(yōu)化
考慮到內(nèi)存對齊,每次申請內(nèi)存的時候都按照sizeof(union hero_aligin)進(jìn)行最小內(nèi)存分配。這是那本采用\<\>的推薦的對齊大小。
- 采用<<c interface and implemention>>的實(shí)現(xiàn)
- union hero_align{
- int i;
- long l;
- long *lp;
- void *p;
- void (*fp)(void);
- float f;
- double d;
- long double ld;
- };
真正的對齊則是參照nginx的寫法:
- size = (size + sizeof(union hero_align) - 1) & (~(sizeof(union hero_align) - 1));
其實(shí)筆者一開始還打算做一下類似linux kernel SLAB緩存中的隨機(jī)著色機(jī)制,這樣能夠緩解false sharing問題,想想為了代碼簡單,還是算了。
為什么需要packet_buffer
packet_buffer是用來存儲從socket fd中讀取或?qū)懭氲臄?shù)據(jù)。
設(shè)計(jì)packet_buffer的初衷就是要重用內(nèi)存,因?yàn)橐粋€連接反復(fù)的去獲取寫入數(shù)據(jù)總歸是需要內(nèi)存的,只在連接初始化的時候去分配一次內(nèi)存顯然是比反復(fù)分配再銷毀效率高。
為什么不直接用內(nèi)存池
上文中說到,銷毀內(nèi)存必須將池里面的整個數(shù)據(jù)重置。如果packet_buffer和其它的數(shù)據(jù)結(jié)構(gòu)在同一內(nèi)存池中分配,要重用它,那么在packet_buffer之前分配的數(shù)據(jù)就不能被清理。如下圖所示:
這樣顯然違背了筆者設(shè)計(jì)內(nèi)存池是為了釋放內(nèi)存方便的初衷。
另外,如果使用內(nèi)存池,那么從sockfd中讀取/寫入的數(shù)據(jù)就可能從連續(xù)的變成一個一個mem_block分離的數(shù)據(jù),這種不連續(xù)性對數(shù)據(jù)包的處理會特別麻煩。如下圖所示:
當(dāng)然了,這種非連續(xù)的分配方式,筆者曾經(jīng)在閱讀lwip協(xié)議時見過(幫某實(shí)時操作系統(tǒng)處理一個詭異的bug),lwip在嵌入式這種內(nèi)存稀缺的環(huán)境中使用這種方式從而盡量避免大內(nèi)存的分配。
所以筆者采取了在連接建立時刻一次性分配一個比較大的內(nèi)存來處理單個請求的數(shù)據(jù),如果這個內(nèi)存也滿足不了,則realloc(當(dāng)然了realloc也有坑,需要仔細(xì)編寫)。
packet_buffer結(jié)構(gòu)
packet_buffer這種動態(tài)改變大小而且地址上連續(xù)的結(jié)構(gòu)為處理包結(jié)構(gòu)提供了便利。
由于realloc的時候,packet_buffer->buffer本身指向的地址可能會變,所以盡量避免直接操作內(nèi)部buffer,非得使用內(nèi)部buffer的時候,不能將其賦予一個局部變量,否則buffer變化,局部變量可能指向了之前廢棄的buffer。
MySQL協(xié)議分包處理
MySQL協(xié)議基于tcp(當(dāng)然也有unix域協(xié)議,這里只考慮tcp)。同時Hero采用的是非阻塞IO模式,讀取包時,recv系統(tǒng)調(diào)用可能在包的任意比特位置上返回。這時候,就需要仔細(xì)的處理分包。
MySQL協(xié)議外層格式
MySQL協(xié)議是通過在幀頭部加上length field的設(shè)計(jì)來處理分包問題。如下圖所示:
Hero的處理
Hero對于length field采用狀態(tài)機(jī)進(jìn)行處理,這也是通用的手法。首先讀取3byte+1byte的packet_length和sequenceId,然后再通過packet_length讀取剩下的body長度。如下圖所示:
連接池
Hero的連接池造的還比較粗糙,事實(shí)上就是一個數(shù)組,通過mutex鎖來控制并發(fā)的put/get
連接管理部分尚未開發(fā)。
MySQL協(xié)議格式處理
相較于前面的各種輪子,MySQL協(xié)議本身反倒顯得輕松許多,唯一復(fù)雜的地方在于握手階段的加解密過程,但是MySQL是開源的,筆者直接將MySQL本身對于握手加解密的代碼copy過來就行了。以下代碼copy自MySQL-5.1.59(密碼學(xué)太高深,這個輪子不造也罷):
- // 摘自MySQL-5.1.59,用作password的加解密
- void scramble(char *to, const char *message, const char *password) {
- SHA1_CONTEXT sha1_context;
- uint8 hash_stage1[SHA1_HASH_SIZE];
- uint8 hash_stage2[SHA1_HASH_SIZE];
- mysql_sha1_reset(&sha1_context);
- /* stage 1: hash password */
- mysql_sha1_input(&sha1_context, (uint8 *) password, (uint) strlen(password));
- mysql_sha1_result(&sha1_context, hash_stage1);
- /* stage 2: hash stage 1; note that hash_stage2 is stored in the database */
- mysql_sha1_reset(&sha1_context);
- mysql_sha1_input(&sha1_context, hash_stage1, SHA1_HASH_SIZE);
- mysql_sha1_result(&sha1_context, hash_stage2);
- /* create crypt string as sha1(message, hash_stage2) */;
- mysql_sha1_reset(&sha1_context);
- mysql_sha1_input(&sha1_context, (const uint8 *) message, SCRAMBLE_LENGTH);
- mysql_sha1_input(&sha1_context, hash_stage2, SHA1_HASH_SIZE);
- /* xor allows 'from' and 'to' overlap: lets take advantage of it */
- mysql_sha1_result(&sha1_context, (uint8 *) to);
- my_crypt(to, (const uchar *) to, hash_stage1, SCRAMBLE_LENGTH);
- }
剩下的無非就是按格式解釋包中的各個字段,然后再進(jìn)行處理而已。典型代碼段如下:
- int handle_com_query(front_conn* front){
- char* sql = read_string(front->conn->read_buffer,front->conn->request_pool);
- int rs = server_parse_sql(sql);
- switch(rs & 0xff){
- case SHOW:
- return handle_show(front,sql,rs >> 8);
- case SELECT:
- return handle_select(front,sql,rs >> 8);
- case KILL_QUERY:
- ......
- default:
- return default_execute(front,sql,FALSE);
- }
- return TRUE;
- }
需要注意的是,hero在后端連接backend返回result_set結(jié)果集并拷貝到前端連接的write_buffer的時候,前端連接可能正在寫入,也會操縱write_buffer。所以在這種情況下要通過Mutex去保護(hù)write_buffer(packet_buffer)的內(nèi)部數(shù)據(jù)結(jié)構(gòu)。
性能對比
下面到了令人激動的性能對比環(huán)節(jié),筆者在一個4核8G的機(jī)器上用hero和另一個用java nio寫的成熟DBProxy做對比。兩者都是用show databases,這條sql并不會路由到后端的數(shù)據(jù)庫,而是純內(nèi)存返回。這樣筆者就能知道筆者自己造的reactor框架的性能如何,以下是對比情況:
- 用作對比的兩個server的代碼IO模型
- hero(c):epoll 水平觸發(fā)
- proxy(java):java nio(內(nèi)部也是epoll 水平觸發(fā))
- benchMark:
- 服務(wù)器機(jī)器
- 4核8G
- CPU主屏2399.996MHZ
- cache size:4096KB
- 壓測機(jī)器:
- 16核64G,jmeter
- 同樣配置下,壓測同一個簡單的sql
- hero(c):3.6Wtps/s
- proxy(java):3.6Wtps/s
- tps基本沒有差別,因?yàn)槠款i是在網(wǎng)絡(luò)上
- CPU消耗:
- hero(c):10% cpu
- proxy(java):15% cpu
- 內(nèi)存消耗:
- hero(c):0.2% * 8G
- proxy(java):48.3% * 8G
- 結(jié)論:
- 對于IO瓶頸的情況,用java和C分別處理簡單的組幀/解幀邏輯,C語言帶來的微小收益并不能讓tps有顯著改善。
Hero雖然在CPU和內(nèi)存消耗上有優(yōu)勢,但是限于網(wǎng)絡(luò)瓶頸,tps并沒有明顯提升-_-!
相比于造輪子時候付出的各種努力,投入產(chǎn)出比(至少在表面上)是遠(yuǎn)遠(yuǎn)不如用Netty這種非常成熟的框架的。如果是工作,那我會毫不猶豫的用后者。
總結(jié)
造輪子是個非常有意思的過程,在這個過程中能夠強(qiáng)迫筆者去思考平時根本無需思考的地方。
造輪子的過程也是非常艱辛的,造出來的輪子也不一定比現(xiàn)有的輪子靠譜。但造輪子的成就感還是滿滿的^_^
github鏈接
https://github.com/alchemystar/hero
碼云鏈接
https://gitee.com/alchemystar/hero
本文轉(zhuǎn)載自微信公眾號「解Bug之路」,可以通過以下二維碼關(guān)注。轉(zhuǎn)載本文請聯(lián)系解Bug之路公眾號。