iOS消息批量推送优化:减少无效token,解决推送延迟问题
发布时间:2026-03-06 01:40:36

最让撰写推送程序感到头疼的是应对那些无效的设备令牌,不进行清理的话每次都停滞于途中,想要清理却又不清楚哪些是无效的。去年八月所撰写的那个版本运用了阻塞式IO,效率不仅无法提升,还常常卡顿在某次读写上无法动弹,用户体验实在难以让人接受。趁着近期有空余时间,我将其整体进行了重构,转而采用非阻塞SSL读写,总算把这点棘手的问题解决了。

为什么改用非阻塞模式

一个最大的问题是阻塞式IO会一直持续下去,要是碰到网络震动情况,或是苹果服务器回应速度迟缓,那整个进程就只能在那里干巴巴地等待着。此次转变为非阻塞读写,其核心的想法是仅仅在数据发送接收的环节运用非阻塞的模式,连接搭建以及SSL握手依旧保持阻塞的方式。如此这般,握手时期的繁杂逻辑不用重新去进行设计,还能够把读写出现卡顿的痛点得以解决。按照实际测试的结果来看,进程阻塞的时间降低了超出百分之八十。

连接和握手保持阻塞的考量

SSL握手过程原本就繁杂,需要往来返回调换证书,商量研讨加密套件,倘若采用非阻塞类型,一回握手就得中断停顿展开多次重试。代码当中得维持维护状态机,处置处理各类多种重入情景情况,复杂度径直直接成倍增加。所以呢我还是使得让连接以及和SSL握手遵循依照阻塞流程程序,等连接稳定平稳之后再将把套接字切换转变到非阻塞模式类型。这样既简化简略化了代码逻辑条理,又可以够享受到非阻塞IO的益处好处。

非阻塞SSL读写的关键点

 int do_ssl_connect_blocking(SSL *ssl, int fd)
 {
     int flags, ret;
     SSL_set_mode(ssl, SSL_MODE_AUTO_RETRY);
     flags = fcntl(fd, F_GETFL, 0);
     flags &= ~O_NONBLOCK;
     fcntl(fd, F_SETFL, flags);
     ret = SSL_set_fd(ssl, fd);
     if (ret != 1) {
         return -1;
     }
     ret = SSL_connect(ssl);     
     if (ret != 1) {
         return -1;
     }
     return 0;    
 }

有着非阻塞特性的SSL读写,其中最让人觉得棘手的是那个标注为SSL_ERROR_WANT_READ以及SSL_ERROR_WANT_WRITE的错误码,单单从表面上去看,似乎是出现了错误状况,然而实际上它是在向应用层传达底层尚未准备妥当的信息。在这样的时刻,需要借助select或者epoll来对套接字事件展开监控,一直等到可读或者可写的条件得以满足之后,才再次去尝试执行上次遭遇失败的SSL读写操作。我在代码里面特意增设了一个标志位,用于记录上次进入阻塞状态的原因所在,以此来保障在进行重试的时候能够准确无误地进入正确的分支。

苹果服务器的应答处理陷阱

 int my_ssl_set_non_blocking(SSL *ssl)
 {
     int fd = SSL_get_fd(ssl);
     if (fd < 0) {
         return -1;
     }
     // remove auto-retry from ssl
     long mode;
     mode = SSL_get_mode(ssl);
     mode &= ~SSL_MODE_AUTO_RETRY;
     SSL_set_mode(ssl, mode);
     // set no-blocking for socket fd
     int flags;
     flags = fcntl(fd, F_GETFL, 0);
     flags |= O_NONBLOCK;
     fcntl(fd, F_SETFL, flags);
     return 0;
 }

苹果所拥有的APNS服务,其设计呈现出颇为粗暴的态势,在发送完应答包过后,便即刻将连接予以关闭。通过抓包能够发现,它在最后一个包当中,同时携带了FIN、PSH、ACK这几种标志,当应用层回复ACK之后,苹果那边直接返回了一个RST,根本不给能够进行优雅关闭的机会。更为关键的是,要是在一批推送里面存在无效令牌,苹果给出的应答仅仅会告知你第几个存在问题,却不会说明你之前已经发送出去的那些里头,究竟哪些需要进行重传。

应答丢失与重传难题

 int check_availability(int sockfd, unsigned int *can_read, unsigned int *can_write)
 {
     *can_read = 0;
     *can_write = 0;
     fd_set rset;
     fd_set wset;
     struct timeval timeout = {60, 0};
     int n;
     FD_ZERO(&rset);
     FD_ZERO(&wset);
     FD_SET(sockfd, &rset);
     FD_SET(sockfd, &wset);
     n = select(sockfd+1, &rset, &wset, NULL, &timeout);
     if (n == -1) {
         return -1;
     }
     else if (n) {
         if (FD_ISSET(sockfd, &rset))
             *can_read = 1;
         if (FD_ISSET(sockfd, &wset))
             *can_write = 1;
         return 1;
     }
     else {
         // timeout
         return 0;
     }
 }

假如有一次发出了100个推送,当中第50个令牌是无效的,苹果会于第50个之后返回错误应答。然而问题存在,应用层有可能已经发到第80个了,此时收到应答表明第50个无效,那么前面50到80之间的那些令牌究竟应不应该重发呢?要是丢失了应答,那就会更加麻烦,根本没法知道从哪一个位置开始重传。我曾经试过记录每个令牌的状态,可是SSL层频繁返回WANT错误时,很容易陷于死循环,最终不得不放弃百分百可靠这个目标。

性能优化的教训与方向

 int data_transfer(SSL *ssl, int send_cnt)
 {
     // set non-blocking for socket and ssl
     my_ssl_set_non_blocking(ssl);
     int sockfd = SSL_get_fd(ssl);
     // ssl_read
     unsigned int can_read = 0;
     // ssl read retry flag
     unsigned int read_waiton_read = 0;
     unsigned int read_waiton_write = 0;
     // ssl_write
     unsigned int can_write = 0;
     // ssl write retry flag
     unsigned int write_waiton_read = 0;
     unsigned int write_waiton_write = 0;
     // read buffer
     int len_rd = 0;
     char buf_rd[MAX_BUFF_SIZE];
     // write buffer
     int len_wr = 0;
     char buf_wr[MAX_BUFF_SIZE];
     int ret_val = -1;
     int ret, sslerrno;
     int timeout_cnt = 0;
     while (send_cnt >= 0) {
         // get socket I/O event flag: can_read or can_write or both
         ret = check_availability(sockfd, &can_read, &can_write); 
         if (ret < 0) {
             return -1;
         }
         else if (ret == 0) {
             // bad network condition
             timeout_cnt ++;
             if (timeout_cnt >= 3) {
                 goto end;
             }
         }
         else {
             timeout_cnt = 0;
         }
         // ssl read
         // try ssl read first if can both read and write
         //if (!(write_waiton_read || write_waiton_write) 
         //        && (can_read || (can_write && read_waiton_read))
         //        && len_rd < MAX_BUFF_SIZE) 
         if (can_read || (can_write && read_waiton_read))
         {
             // clear ssl_read retry flag
             read_waiton_read = 0;
             read_waiton_write = 0;
             ret = SSL_read(ssl, buf_rd + len_rd, MAX_BUFF_SIZE - len_rd);
             sslerrno = SSL_get_error(ssl, ret);
             switch (sslerrno) {
                 case SSL_ERROR_NONE:
                     len_rd += ret;
                     if (len_rd >= RSP_MSG_LEN) {
                         // parse and consume RSP_MSG_LEN
                         // ... ...    
                         // get rsp id to reset token queue
                         // ... ...
                         len_rd -= RSP_MSG_LEN;
                     }
                     goto end;
                 case SSL_ERROR_WANT_WRITE:
                     read_waiton_write = 1;
                     goto end;
                 case SSL_ERROR_WANT_READ:
                     read_waiton_read = 1;
                     goto end;
                 case SSL_ERROR_ZERO_RETURN:
                     // connection closed
                     goto end;
                 default:
                     goto end;
             }
         } // ssl read
         // ssl write
         if (!(read_waiton_read || read_waiton_write) 
                 && (can_write || (can_read && write_waiton_write))) 
         {
             // clear ssl_write retry flag
             write_waiton_read = 0;
             write_waiton_write = 0;
             if (len_wr == 0) {
                 // get next token from token queue
                 // create push msg, set to buf_wr
                 // ... ... 
             }
             ret = SSL_write(ssl, buf_wr, len_wr);
             sslerrno = SSL_get_error(ssl, ret);
             switch (sslerrno) {
                 /* We wrote something*/
                 case SSL_ERROR_NONE:
                     len_wr -= ret;
                     if (len_wr == 0) {
                         send_cnt --;
                     }
                     else {
                         memmove(buf_wr, buf_wr + ret, len_wr);
                     }
                     break;
                 case SSL_ERROR_WANT_WRITE:
                     write_waiton_write = 1;
                     break;
                 case SSL_ERROR_WANT_READ:
                     write_waiton_read = 1;
                     break;
                 case SSL_ERROR_ZERO_RETURN:
                     //rollback token, resend token
                     // ... ...
                     goto end;
                 default:          
                     //rollback token, resend token
                     // ... ...
                     goto end;
             }
         } // ssl write
     } // while
 end:
     SSL_shutdown(ssl);
     close(sockfd);
     return ret_val;
 }

在当下,单连接每秒钟能够处理的推送数量,七八千条左右,与苹果文档所提及的九千条相比,存在着差距。文档之中指出,连接复用以及错误重试逻辑方面,具备很大的可优化空间。接下来,打算将令牌队列转变为批量确认机制,以此来削减应答等待所产生的开销。另外,存在一个槽点,那就是APNS的错误处理极为不透明,一旦出现错误,只能依靠猜测,社区里吐槽这一情况的文章,随便一抓就有很多。要是你正在编写类似的工具,是否遇到过更加奇葩的坑呢?欢迎在评论区分享你的踩坑经历,点个赞,使得更多人能够看到这些实战经验。