1、背景介绍
最近几年一直在做IM相关的开发,对于IM通道侧接收消息的方式一般有三种:
- 长连接推
- 短连HTTP轮询拉
- 推拉结合
IM通道有两个基本的要求:"不丢不重",纯推不丢不重机制复杂,纯拉性能和实时性又不是特别好,所以我们采用的是推拉结合的方案。当有新消息时长连接负责推送指令告知客户端有新消息,客户端再通过HTTP去请求最新消息。
这个通道和机制一直稳定运行了好多年,直到今年ChatGPT的到来,带来了很多业务场景,对长连接的通道的要求有高了起来。
2、建设长连接通道会有哪些问题
2.1 问题一 连接超时问题
面试时经常被问到的TCP三次握手过程,我们都能脱口而出了。我们也知道三次握手分别对应服务端的accept和客户端的connect,下面摘录了一个客户端服务端实现socket通信的demo:
server.c
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <unistd.h>
#include <sys/types.h>
#include <sys/socket.h>
#include <netinet/in.h>
int main() {
int sockfd, newsockfd, portno;
socklen_t clilen;
char buffer[256];
struct sockaddr_in serv_addr, cli_addr;
int n;
sockfd = socket(AF_INET, SOCK_STREAM, 0);
if (sockfd < 0) {
perror("ERROR opening socket");
exit(1);
}
bzero((char *) &serv_addr, sizeof(serv_addr));
portno = 5001;
serv_addr.sin_family = AF_INET;
serv_addr.sin_addr.s_addr = INADDR_ANY;
serv_addr.sin_port = htons(portno);
if (bind(sockfd, (struct sockaddr *) &serv_addr, sizeof(serv_addr)) < 0) {
perror("ERROR on binding");
exit(1);
}
listen(sockfd, 5);
clilen = sizeof(cli_addr);
printf("new client\n");
newsockfd = accept(sockfd, (struct sockaddr *) &cli_addr, &clilen);
printf("newsocketfd:%d\n", newsockfd);
if (newsockfd < 0) {
perror("ERROR on accept");
exit(1);
}
bzero(buffer, 256);
n = read(newsockfd, buffer, 255);
if (n < 0) {
perror("ERROR reading from socket");
exit(1);
}
printf("Here is the message: %s\n", buffer);
n = write(newsockfd, "I got your message", 18);
if (n < 0) {
perror("ERROR writing to socket");
exit(1);
}
close(newsockfd);
close(sockfd);
return 0;
}
client.c
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <unistd.h>
#include <sys/types.h>
#include <sys/socket.h>
#include <netinet/in.h>
#include <netdb.h>
int main() {
int sockfd, portno, n;
struct sockaddr_in serv_addr;
struct hostent *server;
char buffer[256];
portno = 5001;
sockfd = socket(AF_INET, SOCK_STREAM, 0);
if (sockfd < 0) {
perror("ERROR opening socket");
exit(1);
}
server = gethostbyname("localhost");
if (server == NULL) {
fprintf(stderr, "ERROR, no such host\n");
exit(0);
}
bzero((char *) &serv_addr, sizeof(serv_addr));
serv_addr.sin_family = AF_INET;
bcopy((char *)server->h_addr, (char *)&serv_addr.sin_addr.s_addr, server->h_length);
serv_addr.sin_port = htons(portno);
if (connect(sockfd, (struct sockaddr *) &serv_addr, sizeof(serv_addr)) < 0) {
perror("ERROR connecting");
exit(1);
}
printf("Please enter the message: ");
bzero(buffer, 256);
fgets(buffer, 255, stdin);
n = write(sockfd, buffer, strlen(buffer));
if (n < 0) {
perror("ERROR writing to socket");
exit(1);
}
bzero(buffer, 256);
n = read(sockfd, buffer, 255);
if (n < 0) {
perror("ERROR reading from socket");
exit(1);
}
printf("%s\n", buffer);
close(sockfd);
return 0;
}
运行demo时没有任何问题,因为demo一般都运行在我们相对稳定的环境。到了现实中更复杂的场景,可能客户端去connect时会超时或者失败,但是它并不是立即超时失败,在Linux 实现中,如果主动 connect 方没有收到 SYN 的回应,会在第6秒发送第2个 SYN 进行重试,第3个 SYN 则是与第2个间隔24秒。直到第75秒还没有收到回应,则 connect 调用返回 ETIMEOUT。
再具体点,什么时候回出现这种情况呢?我们列举一下常见场景:
- 网络彻底断了:直接返回失败;
- 服务端的accet函数所在线程阻塞(如上面例子,已经有一个client连接,并且在线程里被阻塞):客户端还是可以成长connect成功,建立连接时系统侧干的;
- 服务端负载达到极限或者中间路由问题:会连接很长时间失败
- 网络差,丢包严重:会连接很长时间失败
2.2 断开感知
socket断开靠读写失败来感知。要监听socket连接是否被断开,一般使用以下方法:
- 使用心跳包(Keep-Alive):你在一定时间间隔内,服务端和客户端相互发送小型的数据包以确认连接是否仍然有效。如果一方停止收到心跳包,就可以认为连接已经断开。
- 使用超时机制:在进行读取或写入操作时,你可以设置一个超时时间。如果在超时时间内没有收到数据,则可以认为连接已经断开。
- 通过错误码检测:在进行读取或写入操作后,检查返回的错误码。如果错误码指示连接已经断开,你可以进行相应的处理。
- 使用select()或poll()函数:你可以使用select()或poll()函数来检测套接字上的读取事件。如果套接字上有可读事件,你可以尝试读取数据。如果读取失败并且errno被设置为ECONNRESET或者recv()返回0,这表示连接已经断开。
3、如何建立可靠信令网络
3.1 客户端
客户端要健壮,则需要防止大量发送消息被丢失问题。客户端需要建立消息队列缓存消息,做防雪崩处理等。
3.2 服务端
通道建立了,但是服务端推送的事件想不丢失,则需要服务端有缓存队列,服务端发送失败则缓存下来,如果客户端收到并发送了ACK则从队列清除,如何清除还需要一定的机制配合。