当前位置: 首页 > 科技观察

我用C语言做了一个DBProxy

时间:2023-03-18 21:20:59 科技观察

前言笔者看了一大堆源码,不由得有了造轮子的想法。于是花了好几个周末的时间,用C语言做了一个DBProxy(MySQL协议)。在作者的github中将此DBProxyHero命名为。为什么要用C语言作者一直有C情节,学习的时候一直在玩C。工作之后,一直在用Java,逐渐放下了C。笔者在过去的一年里看了一堆关于linuxKernel(C)和MySQL(C++)的源码后,重拾C的念头来了向上。同时,如果使用纯C,势必要从基础造很多轮子,这也符合笔者当时造轮子的心情。在造了哪些轮子,习惯了Java各种好用的类库框架之后,使用纯C无疑是自寻弊端。不过,既然做出了这个决定,就不得不跪下了。在写英雄的过程中。很大一部分时间都花在了基础工具的搭建上,比如:Reactor模型内存池packet_buffer协议分包处理连接池……本篇博客讲一下DBProxy的整体原理。Hero(DBProxy)其实就是自己伪装成MySQL,接收到应用发送的SQL命令后,转发给后台。如下图:由于Hero在解析SQL的时候可以获取到各种信息,比如事务信息可以通过setauto_commit和begin等命令保存在连接状态,然后判断是否需要去主库根据解析的SQL。这样就可以透明的对应用进行主从分离、分库分表等操作。当然,笔者现在的Hero只是搭建了基本的功能(协议、连接池等),并没有对连接状态做进一步的处理。Reactor模式Hero的网络模型采用Reactor模式,是多线程模型,使用epoll级别触发。多线程模型之所以采用多线程,纯粹是为了代码编写简单。如果有多个进程,还必须考虑worker进程之间的负载均衡问题。例如nginx在一个worker进程达到最大7/8连接数时拒绝获取连接,将其转移给其他worker。对于多线程,通过在accept线程中选择一个worker线程,可以很容易的达到简单的负载均衡效果。使用epoll级别触发为什么使用epoll级别触发纯粹是为了写代码简单。如果使用边沿触发,需要循环读取,直到read返回的字节数为0。但是如果某个连接特别活跃,socket的数据不能一直读到,会导致其他连接饿死,所以就得自己写一个均衡算法,读到一定程度后再选择其他连接.Reactor的整体Reactor模型如下图所示:其实代码很简单,如下代码所示就是reactor中的accept处理//中间省略了很多错误处理intinit_reactor(intlisten_fd,intworker_count){//注意,这边需要无符号,防止出现负数unsignedintcurrent_worker=0;for(;;){intnumevents=0;intretval=epoll_wait(reactor->master_fd,reactor->events,EPOLL_MAX_EVENTS,500);...for(j=0;jworker_fd_arrays[current_worker++%reactor->worker_count],conn->sockfd,EPOLLOUT,conn)......}}在上面的代码中,current_worker会为每个新连接自动递增,这样工作线程取模后就可以在连接级别进行负载均衡。worker线程是通过pthread实现的,如下代码所示//这里的worker_count是根据调用get_nprocs得到的对应机器的CPU数//注意,由于docker返回的是hostCPU数,所以需要adjustfor(inti=0;iworker_fd_arrays[i]=epoll_create(EPOLL_MAX_EVENTS);if(reactor->worker_fd_arrays[i]==-1){gotoerror_process;}//创建workerthreadthroughpthreadif(FALSE==create_and_start_rw_thread(reactor->worker_fd_arrays[i],pool)){gotoerror_process;}}并且按照标准的epoll级别也触发worker线程的处理:staticvoid*rw_thread_func(void*arg){......for(;;){intnumevents=0;intretval=epoll_wait(epfd,events,EPOLL_MAX_EVENTS,500);......for(j=0;j的书籍的推荐对齐大小。<>unionhero_align{inti;longl;long*lp;void*p;void(*fp)(void);floatf;doubled;longdoubleld;}的实现;真正的对齐是指nginx的写法:size=(size+sizeof(unionhero_align)-1)&(~(sizeof(unionhero_align)-1));其实作者一开始就打算做一个类似linux内核SLAB缓存的随机着色机制,可以缓解falsesharing问题。想想还是为了代码的简洁,还是算了吧。为什么需要packet_bufferPacket_buffer用于存储从socketfd读取或写入的数据。设计packet_buffer的初衷是为了复用内存,因为一个连接需要内存来反复获取和写入数据。在连接初始化时只分配一次内存显然比重复分配和销毁它更有效。为什么不直接使用内存池呢?如上所述,销毁内存必须重置池中的整个数据。如果packet_buffer和其他数据结构分配在同一个内存池中,并且被重用,那么在packet_buffer之前分配的数据是无法清理的。如下图所示:这显然违背了作者为了方便释放内存而设计内存池的初衷。另外,如果使用内存池,从sockfd读取/写入的数据可能会从连续数据变为mem_block分隔数据。这种不连续性对于数据包的处理会特别麻烦。如下图所示:当然,笔者在阅读lwip协议时看到了这种不连续的分配方式(帮助一个实时操作系统处理了一个怪异的bug),而lwip是在嵌入式内存匮乏的环境中使用的。这样就尽可能的避免了大内存的分配。所以作者采用在连接建立时一次性分配一块比较大的内存来处理单个请求的数据。如果这个内存不够用,那就realloc(当然realloc也有坑,需要慎重写)。packet_buffer结构packet_buffer结构,大小动态变化,地址连续,为处理packet结构提供了方便。由于packet_buffer->buffer本身指向的地址在realloc时可能会发生变化,所以尽量避免直接操作内部buffer。当不得不使用内部缓冲区时,不能将其赋值给局部变量,否则缓冲区发生变化,局部变量可能指向之前的Obsolete缓冲区。MySQL协议分包处理MySQL协议是基于tcp的(当然也有unix域协议,这里只考虑tcp)。同时,Hero采用了非阻塞IO模式。读取数据包时,recv系统调用可能会在数据包的任何位位置返回。这时候分包需要慎重处理。MySQL协议的外层格式MySQL协议通过在帧头中增加一个长度字段来处理分包。如下图所示:Hero的处理Hero使用状态机来处理长度字段,这也是一种常用的方法。先读取3byte+1byte的packet_length和sequenceId,再通过packet_length读取剩余的bodylength。如下图所示:连接池Hero的连接池比较粗糙,其实就是一个数组,互斥锁控制的并发put/get连接管理部分还没有开发。MySQL协议格式处理与前面的轮子相比,MySQL协议本身就简单多了。唯一比较复杂的是握手阶段的加解密过程,但是MySQL是开源的。握手加解密代码作者直接使用了MySQL本身。只需将其复制到此处即可。以下代码是从MySQL-5.1.59复制过来的(密码学太高深了,这个轮子无所谓)//摘自MySQL-5.1.59,用于密码的加解密voidscramble(char*to,constchar*message,constchar*密码){SHA1_CONTEXTsha1_context;uint8hash_stage1[SHA1_HASH_SIZE];uint8hash_stage2[SHA1_HASH_SIZE];mysql_sha1_reset(&sha1_context);/*stage1:hashpassword*/mysql_sha1_input(&sha1_context,(uint8*)password,(uint)strlen(sha1_result);mysql&sha1_context,hash_stage1);/*stage2:hashstage1;notethathash_stage2isstoredinthedatabase*/mysql_sha1_reset(&sha1_context);mysql_sha1_input(&sha1_context,hash_stage1,SHA1_HASH_SIZE);mysql_sha1_result(&sha1_context,hash_stage2);/*createcryptstringassha1(&sha1_stage)*/sha1;context(message2),sha1;context;mysql_sha1_input(&sha1_context,(constuint8*)message,SCRAMBLE_LENGTH);mysql_sha1_input(&sha1_context,hash_stage2,SHA1_HASH_SIZE);/*xorallows'from'and'to'重叠:letstakeadvantageofit*/mysql_sha1_result(&sha1_context,(uint8*)to);my,_crypt(to(结构r*)to,hash_stage1,SCRAMBLE_LENGTH);}剩下的无非就是根据格式解释包中的各个字段,然后进行处理。一个典型的代码段如下:inthandle_com_query(front_conn*front){char*sql=read_string(front->conn->read_buffer,front->conn->request_pool);intrs=server_parse_sql(sql);switch(rs&0xff){caseSHOW:returnhandle_show(front,sql,rs>>8);caseSELECT:returnhandle_select(front,sql,rs>>8);caseKILL_QUERY:......默认:returndefault_execute(front,sql,FALSE);}returnTRUE;}需要注意的是,英雄在后端连接backend返回result_set结果集,复制到前端连接的write_buffer中,当前端连接可能在写的时候,write_buffer也会被操作。所以在这种情况下,write_buffer(packet_buffer)的内部数据结构应该通过Mutex来保护。性能比较现在是激动人心的性能比较环节。作者在4核8G机器上将hero与另外一个用javanio写的成熟的DBProxy进行了比较。两者都使用showdatabases,这个sql不会路由到后端数据库,而是纯粹在内存中返回。这样笔者就可以知道自己制作的reactor框架的性能了。下面是对比:用于对比的两台服务器的代码IO模型英雄(c):epoll级触发器代理(java):javanio(内部也是epoll级触发器)benchMark:服务器机器4核8GCPU主屏2399.996MHZcachesize:4096KB压测机:16核64G,jmeter同配置下,同simplesqlhero(c):3.6Wtps/sproxy(java):3.6Wtps/stps基本没有区别,因为瓶颈是CPU消耗onthenetwork:hero(c):10%cpuproxy(java):15%cpumemoryconsumption:hero(c):0.2%*8Gproxy(java):48.3%*8G结论:对于IO瓶颈的情况,Java和C分别用来处理简单的framing/unframing逻辑,C语言带来的小小收获并不能显着提升tps。Hero虽然在CPU和内存消耗上有优势,但是限于网络瓶颈,tps并没有明显提升-_-!相对于造轮子时的各种努力,投入产出比(至少表面上)远不如使用Netty这样非常成熟的框架。如果是工作,那么我会毫不犹豫地使用后者。综上所述,造轮子是一个非常有趣的过程。在这个过程中,我可以强制去思考一些根本不需要思考的地方。造轮子的过程也很辛苦,造出来的轮子不一定比现有的轮子可靠。不过造轮子的成就感还是满满的^_^github链接https://github.com/alchemystar/hero码云链接https://gitee.com/alchemystar/hero道”,大家可以通过关注二维码,转载请联系BugSolution公众号。