俗话说:不会服务端的研究员不是好的前端工程师(☺)。先说说背景吧,在15年底,我们组唯一的一个服务端大牛跑路了,然后就是研究员当家。。。正好又赶上我这个项目全量上线,日活从500w增加到6kw。实话实说大牛的服务端写的还是挺好的,不过确实坑也挺多的。。。毕竟日请求25亿+次,后来就是被各个组无尽的追杀(QA、OP、流量组等等),当时有些问题我也一下子解决不了,比如晚间高峰服务性能会下降,比如莫名其妙几个服务的结果整合会超时&重连超时,比如连接池的socket会莫名断开,比如服务不能返回正常结果而报警,比如服务在一些请求下大面积core掉。。。不一而足,不过一切都过去了,在经过近一年的痛苦折磨后,在16年Q4的自我总结中我写道:XX服务优化,Q4的报警为0次,可用性达到99.9999%。
写这篇文章的目的是想梳理一下服务端开发的整体流程,包括一些关键点(连接池、epoll、aio、线程池、等待队列、semaphore、管道、socket、重连器等),当然这只是对于我的这个项目的理解(高并发、低延迟),深知服务端博大精深,需要走的路还有很长,最后还会提及一些我对于服务端易跳坑处的个人浅见。
大部分公司服务端底层都会有架构部大致写好的框架,我下面所列的点主要是由阅读我司底层源代码得来,也查阅了一些资料,这些点组合起来足以构成一个高可用性的服务端,只是对于不同的业务可能要进行按需调整,并且我会对这些点的原理稍加梳理,这样需要哪些点不需要哪些点也能了然于胸了,各位如果想深入的话还是请参见《UNP》和《APUE》。服务端是一个项目好不好的根本,一个稳定的服务也是我们这些搞算法的工作的前提。
-
socket:
socket大家还是比较熟悉了,简单的说是为了实现网络间进程通信而设计,Unix/Linux端内核已经封装了socket,当申请了一个socket后,会返回该socket的句柄,我们可以像操作文件一样操作socket进行I/O,具体可以参见socket的MAN,下面说一下客户端和服务端使用socket连接的方式:
客户端:
char buf[1000]; //要发送的数据
int len = 1000; //发送数据长度
hostent *svr_host = gethostbyname("www.baidu.com"); //得到IP
struct sockaddr_in serv_addr;
serv_addr.sin_family = AF_INET;
serv_addr.sin_addr = *((in_addr *) (svr_host->h_addr));
serv_addr.sin_port = htons(80); //绑定端口
int sock_fd = socket(AF_INET, SOCK_STREAM, 0); //创建一个socket
if (connect(sockfd, (sockaddr *) (&serv_addr), sizeof(serv_addr)) == -1) //连接socket
{
printf("Connect failed: %s\n", strerror(errno)); //连接socket失败,输出错误码
close(sockfd);
return -1;
}
write(sockfd, buf, len); //发送数据
read(sockfd, buf, len); //读取服务端返回的数据
close(sockfd); //关闭socket
服务端:
#define SERVPORT 3333 //服务监听端口号
#define max_conn 1024 //最大连接数
char buf[1000]; //要发送的数据
int len = 1000; //发送数据长度
struct sockaddr_in serv_addr;
serv_addr.sin_family = AF_INET;
serv_addr.sin_addr.s_addr = INADDR_ANY;
serv_addr.sin_port=htons(SERVPORT); //绑定端口
int sock_fd = socket(AF_INET, SOCK_STREAM, 0); //创建一个socket
bind(socket_fd, (const sockaddr*)&serv_addr, sizeof serv_addr); //绑定socket
listen(socket_fd, max_conn); //监听该socket上的事件
client_fd = accept(socket_fd, NULL, NULL); //得到当前连接的有数据的socket
read(client_fd, buf, len); //读取新socket的数据
dosomething(buff); //使用新socket上读取的数据做一些服务端业务逻辑
write(client_fd, buf, len); //发送数据给客户端
close(client_fd); //关闭socket
-
select、poll、epoll:
这三个就放在一块写了,实现的都是对socket的管理,在我所负责的项目中使用的是epoll,据说在我们组才成立的时候,使用的是select,后来由于性能的问题,全部都上epoll了。的确epoll是非常强大的,epoll是一个伪AIO模型(异步IO模型),与select/poll比较起来,实现了就绪socket的callback函数,所以节省了select/poll轮询每一个socket句柄来判断其是否有数据到来的资源和时间(而且服务连接数越大节省的越多,或者说select/poll的性能随连接数变大下降很快),并且epoll无最大连接句柄的限制(理论上可以达到Linux服务器的最大限制,所以解决了高并发的问题)。epoll使用了mmap内存映射,即当callback函数被调用时,socket上的数据就已经从Linux内核态copy到用户态了,效率很高。epoll可以说是目前Linux下比较完美的socket事件监听解决方案了,下面讲一下epoll的使用:
#define max_conn 1024 //最大连接数
int fd = socket(AF_INET, SOCK_STREAM, 0); //创建一个需要监听的socket
int efd = epoll_create(max_conn); //创建epoll
epoll_event* es;
epoll_event event; //创建epoll事件
event.events = EPOLLIN | EPOLLET; //该事件可读可写
event.data.fd = fd; //把需要监听的socket赋值给epoll事件
epoll_ctl(efd, EPOLL_CTL_ADD, fd, &event); //将该socket注册到epoll,EPOLL_CTL_ADD是注册增加,还有EPOLL_CTL_DEL是注册删除
int event_num = epoll_wait(efd, es, max_conn, -1); //等待epoll事件发生,如无事件则阻塞
for (int i = 0; i < event_num; i++) //遍历处理epoll事件
{
int sockfd = es[i].data.fd; //得到有事件发生的socket,按标准socket处理方式进行处理
dosomething(); //做一些服务端业务逻辑
close(sockfd); //关闭socket,也可以使用epoll_ctl来进行增加和删除
}
-
pthread:
pthread实现的是对线程的管理,包括创建多少个线程进行业务处理,以及在需要时关闭线程,其中我们需要实现业务处理函数,并将其托管给pthread进行多线程处理。pthread使用方式如下:
pthread_t tid; //创建一个pthread
pthread_create(&tid, NULL,dosomething,NULL); //dosomething为需要实现的业务处理函数,这时就有一个线程执行该函数了
pthread_join(tid,NULL); //将该线程挂起(相当于结束),主线程会等待所有线程都挂起后再退出。线程结束的方式有很多也比较复杂,这里只介绍这一种
-
管道:
管道用于本地进程间通信,我的项目在线程的停止函数中对管道的写端写入了数据,并且将管道的读端注册增加了epoll的监听,故当线程的停止函数触发则epoll会监听到该管道的信号并进行相应处理(比如退出epoll的遍历,从而整个进程可以退出)。管道的创建和使用如下:
int pipe_fd[2]; //申请管道的空间
char msg[]; //用来存储消息
pipe(pipe_fd); //得到管道的句柄,其中0是读端,1是写端
write(pipe_fd[1], "signal", 6); //向管道写数据
read(pipe_fd[0], msg, 6); //从管道读数据
-
semaphore:
多线程编程中信号量也是需要注意的问题,尤其是在需要竞争读写和修改资源值的时候,比较常用的信号量有pthread_mutex_t(互斥锁),其用来对线程资源互斥访问,使用方法有init、destroy、lock、unlock;还有pthread_spinlock_t(自旋锁,即得不到资源时会cpu空转阻塞等待);还有pthread_cond_t(条件锁,满足了某种条件才触发资源,即信号量变得可控)。这其中的细节也比较多,在不同的情况下知道用哪种信号量就行,感兴趣的可以深究一下,我就不贴代码了。
-
线程池:
线程池在我的项目中并没有用到(☺),线程池会自动创建/结束一些线程,即有一个请求过来时会取出一个thread来处理业务,当业务处理完毕会归还这个thread,相当于动态控制服务的负载,尤其是在服务器资源紧张的情况下。不过我负责的这个服务是创建了N个线程然后使用epoll_wait()来实现阻塞,不担心服务器资源的情况下,效果还是不错滴(☺),所以这一小节也没有代码可贴。。。贴一个教程吧基于C++11的线程池。
-
等待队列:
其实就是一个泛型队列,用于存储业务需要的数据,也可以是待处理的socket,并使用了信号量使其线程安全,实现的方法有put、get、flush等。曾经听老人们说,我司C++的等待队列代码是从Linux源代码里copy出来的,所以这一小节也没有代码。
-
连接池:
适用于频繁使用socket的情况,如果每次业务都需要创建&&关闭socket,这样会浪费很多的资源(亲测有效,我的项目里曾有一个服务尖峰非常严重,尤其是晚间高峰的时候,甚至搞得依赖该服务的其他服务也出现了尖峰,后来为其增加了连接池,然后该服务的请求线、平均处理时间线都平稳了下来,代码上线那一区间的线更是蔚为壮观,有一种高峡出平湖的赶脚☺,叼得一笔)。
连接池实现的是端对端的大量socket维护,比如你有一个服务给用户处理业务,然后这个服务会访问后端的服务,那么你就可以对该服务和后端的服务建立连接池,每次处理业务逻辑时,从连接池取出一个socket,然后再在这个socket上传输数据,用完socket之后不断开而是把socket收回以备下次使用。要实现的主要方法是互斥的get_socket和return_socket,并用一个数组来记录该socket是否在使用,以及判断某socket是否可用的函数(有些会涉及到重连)。
-
重连器:
重连器的作用是对断开的socket进行重新连接,实现为开辟一个线程,并用一个等待队列存储需要重连的socket地址(所以是异步重连),且实现了加入队列和重连的方法,加入队列就是使用等待队列的put方法,而重连方法需要具体说一下:这里的重连不是对本身的socket进行重连,而是重新创建一个socket再按地址进行重连,这也是等待队列存储的是重连socket地址的原因。这样,开辟的线程轮询等待队列,就实现了自动排队重连。
-
其他:
socket分为阻塞和非阻塞,如果是非阻塞的话,socket的一次read和write不一定能够传完数据,所以最好是计算一下需要传输数据的大小,并循环read/write直到数据传输完。并且有时候socket connect后会出现仍在连接的情况,即errno = EINPROGRESS,需要继续select或者poll来检测该socket是否可用。
再说一下socket传输的数据格式,如果是用HTTP协议的话,就需要对HTTP BODY进行加密处理(其中涉及很多商业机密的东西,加密算法双方商量好),并且使用HTTP头中的Content-Length来判断此次read是否完成。
-
服务端崩溃、性能、QPS等相关:
以下都是一些血淋淋的教训,从几次服务端崩溃的惨痛经历中而来,首先一个服务端的请求一般是经由proxy-nginx-server,在proxy端一般都会有超时重发逻辑,需要注意的是重发次数不宜过多,不然服务一旦挂掉大量重发的请求会加重服务负担。在nginx端也需要进行一些配置,在我的项目里曾有一次服务端崩溃,然后nginx会向服务端发出GET 50x.html的HTTP请求,这是一个nginx判断请求超时后需要给proxy返回的页面,而在nginx服务配置中并没有这个页面,故nginx会向服务端请求,这样又加重了服务负担。。。所以需要在nginx中配置该50x页面(可能有些情况下也需要配置40x页面)。
再说一下一个服务估算处理能力的问题,很多新服务全量上线当天都是渡劫的一天,经常会暴露这样那样的问题,其实最重要的问题是性能和QPS,这两项保证了,服务起码不会大面积挂掉。QPS比较好理解,即一个服务所有用户的请求量,需要注意的是QPS需要使用每天/周的峰值来估算,如果希望服务比较健壮的话,需要保证崩溃重发后的翻倍QPS也能抵御住(真实例子,我的项目有一次挂掉,服务端问题处理好后依然不足以消化翻倍的QPS)。
性能也非常重要,并且容易被忽视,大家通常都只谈QPS,殊不知性能也会拖垮服务。先打个比方,服务端部署在服务器上,那他的处理能力其实是跟服务器配置相关,就像一个有进水龙头和出水龙头的水池,水池的蓄水量最大只能达到服务器的最大配置,而进水龙头就是用户带来的请求,出水龙头就是服务处理请求的过程,进水龙头的宽窄(流量)可以看做QPS,出水龙头的宽窄就是你的性能,如果性能不够就会出现水池积满的情况(新来的请求排队阻塞超时,服务瘫痪)。换成数学来说就是[服务处理一个请求的时间(秒)*QPS峰值/线程数] < 1(秒),各位可以自行理解一下,简单说就是一秒的出水量要大于进水量,其中服务处理一个请求的时间就是你所写服务的性能。
参考:
-
1:《UNP》
-
2:《APUE》