如何开发一个移动端信令网络库

873 阅读8分钟

如何开发一个移动端信令网络库

1、背景介绍

最近几年一直在做IM相关的开发,对于IM通道侧接收消息的方式一般有三种:

  • 长连接推
  • 短连HTTP轮询拉
  • 推拉结合

IM通道有两个基本的要求:”不丢不重“,纯推不丢不重机制复杂,纯拉性能和实时性又不是特别好,所以我们采用的是推拉结合的方案。当有新消息时长连接负责推送指令告知客户端有新消息,客户端再通过HTTP去请求最新消息。

这个通道和机制一直稳定运行了好多年,直到今年ChatGPT的到来,带来了很多业务场景,对长连接的通道的要求有高了起来。

2、建设长连接通道会有哪些问题

2.1 问题一 连接超时问题

面试时经常被问到的TCP三次握手过程,我们都能脱口而出了。我们也知道三次握手分别对应服务端的accept和客户端的connect,下面摘录了一个客户端服务端实现socket通信的demo:

#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;
}

#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。

再具体点,什么时候回出现这种情况呢?我们列举一下常见场景:

  1. 网络彻底断了:直接返回失败;
  2. 服务端的accet函数所在线程阻塞(如上面例子,已经有一个client连接,并且在线程里被阻塞):客户端还是可以成长connect成功,建立连接时系统侧干的;
  3. 服务端负载达到极限或者中间路由问题:会连接很长时间失败
  4. 网络差,丢包严重:会连接很长时间失败

这会给我们的长连接信令网络带来哪些问题呢?当我们connect时,如果没有网络那很快返回错误,我们能马上感知,但是如果是服务端负载达到极限,要等到75秒后才返回失败,那对我们产品的影响就很大了,这个时候我们需要快速感知到错误,尝试连接其他服务器;如果是网络差,丢包严重,那我们又希望等多等待来增加连接的成功率,而不是频繁的重连。

为了能快速连上,我们最直观的办法就是并行连接,连接时同时请求多个服务地址,哪个最快返回用哪个。但是这样服务端同学就有意见了,一下多了数倍的连接。按上面的场景,最好是客户端网络好的时候并行连,网络不好使加大超时。微信的开源mars库提供了一个方案:串行加并行混合连接。思路就是先尝试连一个,如果一定时间(比如4秒)没有返回那么再尝试连第二个。这样大部分网络号的场景只需要连一台服务即可。

2.2 断开感知

socket断开靠读写失败来感知。要监听socket连接是否被断开,一般使用以下方法:

  1. 使用心跳包(Keep-Alive):你在一定时间间隔内,服务端和客户端相互发送小型的数据包以确认连接是否仍然有效。如果一方停止收到心跳包,就可以认为连接已经断开。
  2. 使用超时机制:在进行读取或写入操作时,你可以设置一个超时时间。如果在超时时间内没有收到数据,则可以认为连接已经断开。
  3. 通过错误码检测:在进行读取或写入操作后,检查返回的错误码。如果错误码指示连接已经断开,你可以进行相应的处理。
  4. 使用select()或poll()函数:你可以使用select()或poll()函数来检测套接字上的读取事件。如果套接字上有可读事件,你可以尝试读取数据。如果读取失败并且errno被设置为ECONNRESET或者recv()返回0,这表示连接已经断开。

KeepAlive 并不适用于检测双方存活的场景,这种场景还得依赖于应用层的心跳。应用层心跳更灵活,可以控制检测时机,间隔和处理流程,甚至可以在心跳包上附带额外信息(比如时间戳等)。链路断开, 不进行写操作的TCP连接是感知不到的, 只有造成写超时才能感知到。而如果是服务端心态,服务端写超时关闭socket,FIN问题。

3、如何建立可靠信令网络

之前长连接应用与IM的“推”动作,当IM产生新消息时,通过长连接告知客户端,客户端收到指令后再通过HTTP拉取消息,当时长连接和IM可以算是耦合在一起的。

image.png

image.png 随着业务的发展,很多不需要依赖IM的业务需要用到长连接(比如音视频通话的呼叫过程),而且一些新的聊天场景,比如比如ChatGPT带来的各种助手类场景,与传统聊天场景相比,这种场景的消息动态性很强,一条消息有很多种状态,与之前的设计有冲突,如果消息队列保存这些临时态的消息,也会带来成本的增加,如果单独依靠长连接,我们从图中可以看到,长连接不具有状态,只负责推送,至于你在线不在线推到到达没有完全不关心。这个时候就需要优化架构,建立可靠信令。

image.png

新的设计为长连接增加了服务端信箱缓存,当有新信令到达时先通过长连接服务推送数据到客户端,客户端判断本地最新信息seq和推送的内容的seq是否连续,如果连续则直接处理,如果不连续,则需要通过http调用sync服务拉取完整信令。客户端收到信令后向服务端发送ACK,服务端记录客户端消费情况。

服务端推送信箱的缓存要比之前IM的短很多,比如五分钟。完善的方案还应该包括:

  1. 不同的业务信令支持不同的有效期;
  2. 服务端支持按消息数量和有效期定期清除;
  3. 支持按设备未读标记或清除已消费信令;
  4. 支持按设备、用户ID等全量推送信令。

有点类似于透传Push的服务,通过信箱增加了消息送达的可靠性。

此外客户端要健壮,则需要防止大量发送消息被丢失问题。客户端需要建立消息队列缓存消息,做防雪崩处理等。

4、总结

本文介绍了可靠信令网络移动端设计的背景,以及长连接建立要考虑的两个问题:连接速度和感知断连。后面介绍了基于信箱机制的可靠保障实现方案。