58 集团面向亿级用户 IM 长连接服务设计与实践

长连接服务简介

微聊,是 58 一款聊天工具,目前已经接入 58 的大部分产品。及时准确数据传输,是对一款聊天工具最基本的要求。长连接服务就是在客户端到服务端之间建立一条全双工的数据通路,实现客户端和服务之间逻辑收发数据,在线离线等功能。

角色

  1. 长连接服务在整个微聊系统中,位于客户端与后台逻辑之间;

  2. 整个后台服务最重要的直接对外接口之一 (另一个是短连接请求的 Nginx );

  3. 长连接服务对外和对内的均采用 TCP 连接。

系统瓶颈

  1. 长连接服务主要功能是收发数据,保持在线,使用的系统资源主要包括:CPU,内存,网卡;

  2. 当连接非常活跃的时候,大量数据接收与发送,会用到更多的 CPU 和网卡;当大量用户在线的时候,需要维持这些连接,保持会话,需要用到大量内存;

  3. 考虑到微聊的实际场景,App 端占有很大的比例,由于手机的网络环境相对 PC 来说,不太稳定,需要处理大量连接的新建与断开,所以系统对 CPU 使用率比较高。

设计难点

  1. 设计单台物理机连接数 100W 的处理能力;

  2. CPU 资源充分利用,线程的分配;

  3. 内存合理分配,数据结构选择;

  4. 异步化 ,剥离业务逻辑和网络 IO 之间的相互依赖。

架构设计

架构的设计需要考虑 TCP 连接管理与应用层协议解析与处理相分离,以下是系统主要的功能模块:

  1. TCP 连接层 (黑色虚线框)- 连接保持,session 管理等,实现了 TCP 和 TLS 层;

  2. BlayServer – 逻辑层服务,逻辑层节点管理,协议解析等;

  3. ClientServer – 客户端服务,客户端协议解析;

  4. Protocol – Http, WebSocket ,Protocol 协议封装;

  5. Tools – JSON ,log,config,crypto。

线程管理

为了充分地使用 CPU,需要合理的进行线程规划。整个长连接服务使用事件驱动,包括:定时器事件和 IO 事件(listen fd,socket fd, pipe fd), 所以线程规划就是合理给这些事件分配线程。

线程优化

  1. 大量连接 Socket 需要平均分配到各个线程;

  2. 新的连接请求量比较大,Listen 线程压力较大,需要考虑多线程处理。

这里主要涉及到以下 3 种 fd 的线程分配:

  1. 监听 fd,包括监听逻辑层的连接请求和监听客户端的连接请求,并且支持启动多个监听端口,每个端口多个线程同时工作(可配置),整体会按顺序分配到线程;

  2. 连接 fd,包括逻辑层的连接和客户端的连接,采用 fd 取余线程数,保证平均分配到所有线程;

  3. pipe fd, 负责本线程管道中数据的读取,每个线程一个。

这样使得每个线程的 CPU 使用率相当。

线程间通信

客户端 fd 和 listen fd,采用不同的规则分配到同一组线程中,当客户端的数据需要发给逻辑层,或是逻辑层数据需要发给客户端,就存在客户端连接和逻辑层的连接存在同一线程或不同线程两种情况,不同线程之间传输数据需要用到线程通信。解决不同线程访问资源,传输数据主要考虑了下面几种方式:

  1. 加锁 (比如 session 读写锁,从而让 session 可以在多线程中操作) – 会导致多个线程竞争资源,阻塞线程;

  2. 共享内存,需要加锁保证原子性,需要线程设置定时器做可读性检测,实现起来比较复杂;

  3. 我们采用操作系统提供的管道 -pipe 解决线程间通信的问题。

pipe 通信协议:

  1. close session,一个线程需要关闭另一个线程上的连接,比如:收到逻辑层需要把某个用户踢下线的命令;

  2. send to session, 一个线程给别一个线程上的连接发送数据,比如:给某个用户推送消息;

  3. 应用层事件,应用层事件跨线程转发,一种通用的跨线程调用。

内存管理

在长连接服务工作过程中,会有大量会话不停创建和销毁,在会话过程中,又会有大量长度不等的数据通信,长时间稳定的服务需要合理高效的内存使用。长连接服务中对内存的使用,包括以下几个方面:操作系统 TCP 协议栈内存使用,服务中 session 管理,读写数据缓存 (Buffer) 等。

说明:

  1. 其中最主要的内存使用是 TCP 协议栈,包括 TCP 和读写缓存,其它内存使用体现在 session 存储和 session 读写 buffer;

  2. 由于在 TCP 上层实现了更加逻辑友好的 Buffer(后面会详细讲到),实际部署中可以把 TCP 协议栈中的读写 Buffer 设置成比较小的空间,比如设置为 1k。

session 静态内存模型

当一个客户端在线,服务端会生成一个 session 保存该连接的状态,一些逻辑信息,以及 socket 信息等。 大量的会话保持,需要服务端合理的管理这些 session。

考虑以下几种存储结构:

  1. Hash – 能够实现比较快速存取,需要自己实现 Hash 算法,经常分配释放内存会导致内存碎片;

  2. 固定数组 – 需要一开始分配大块内存,不支持动态扩展,但存取快速,不会有内存碎片;

  3. 动态数据 – 动态扩展,存取快速,无内存碎片,但扩展的时候会有大块内存分配与拷贝。

长连接服务采用数组存储 session:

  1. 预分配 100W session 全量的空间,相当于 100Wsession 的数组(一共 418M),大小可接受,并且有容量使用监控,防止空间满了建立连接失败;

  2. 直接使用 session 对应的 fd 作为下标,实现存取 O(1);

  3. 由于 fd 分配策略是从小到大分配空闲的,所以可以保证数组在当前连接数以下的空间是饱和的,空间利用率比较高。

Buffer

在 Socket 读写过程中,会遇到以下几个问题:

  1. 当向 Socket 中写数据的时候,如果当前不足以写下要写的所有数据,那么会写入部分数据,剩下的数据需要在 Socket 可写的时候继续写入,以保证数据的连贯性,这里需要有一个保存并记录需要写入的数据的数据结构,并且需要保证写入的数据的先后顺序,先入先出;

  2. 当从 Socket 中读数据的时候,TCP 只保证数据流的顺序性,并不知道应用层协议包大小,所以需要从 TCP 流中分离出一个个应用层协议包,在解决拆包和粘包问题的过程中,会遇到数据包不够的情况下,需要等待后续数据的读入,直到读到的数据构成一个完整的应用层协议包,然后把就个协议整体返回给上层;

  3. 在发送和接收的过程中,需要支持大小不一样的应用层数据包(聊天内容为一个字或是 1M 的字),所以这里需要一个可以发送拆解大应用层包的缓存队列。

为了满足以上功能和要求,我们设计了 Buffer:

  1. 分配与释放采用固定存储单元防止产生内存碎片;

  2. 动态扩展的双向循环链表(队列);

  3. 对外提供了连续数据存取接口。

TCP 拆包流程

TCP 是面向字符流的传输,TCP 保证了传输数据的顺序性和可靠性,当接收到字符流的时候,如何从字符流中分离出一段段上层协议,是 TCP 拆包应该考虑的问题。

如上图所示,逻辑层需要实现 getProtocolSize 和 receiveCallback 两个接口,前者通过参数中传入的数据判断出当前应用层协议包大小,后者是返回应用层协议包的回调。 当 Socket 读事件发生时,首先从 Socket 中读取数据,写入 buffer 中,然后,调用 Buffer 的预读接口(只是返回队列头部的只读指针,并不拷贝数据),调用 getProtocolSize 接口,由逻辑层返回应用层协议包大小(只有应用层逻辑才知道自己该识别哪种协议),再根据该大小,从 Buffer 队列中读出协议包,最后通过 receiveCallback 返回给上层处理。

长连接保活由于 TCP 自身的断开确认机制,如果一条 TCP 连接中间网络断开,此时客户端和服务端物理网络的断开导致了客户端和服务端都没有办法通知对方连接的断开,这样,服务端和客户端就会存在死连接,造成假在线,占用的资源得不到有效的回收。长连接保活主要有下面两种方式:

  1. tTCP Keepalive,通过设置 Keepalive 参数,TCP 协议栈会在超过一定时间没有数据交互的时候,发送 Keepalive 探测包,如果连接几次都没有收到回包,则断开连接。 优点是,TCP 协议栈提供的功能,更加稳定,并且占用较少的带宽;

  2. 采用应用层心跳包的方式,客户端定时向服务端发送心跳包,服务端收到心跳包后立即进行回包,客户端如果没有检测到回包,则断开连接;服务端检测超时还没有收到心跳包,则断开连接。优点主要是应用层有感知,可控,并且可以带些业务数据,比如时间戳。

微聊长连接,考虑到多端支持(Android, iOS, Web 等),兼容老版本实现方式,应用层协议主要使用 http,提供了 http-chunk 和 http-long-polling,两种 Http 接入方式,这样就限制了客户端上发数据,所以我们采用了服务端下发心跳,和 Keepalive 结合的方式,实现服务端和客户端的保活, 如下图:

  1. 服务端通过开启 TCP Keepalive 进行保活,当一条连接超过一定时间没有活动, 服务端会发 Keepalive 包,如果连续 3 次都没收到回包,服务端就会认为这是一个死连接,从而关闭它;

  2. 而对于客户端,服务端会定时下发心跳包,客户端通过监测心跳包来判别当前连接是否工作正常,如果不能正常收到心跳包,则会重新建立新的连接。

容量控制

在 TCP 连接建立到通信的流程中,为了防止一些恶意连接与攻击,长连接服务做了容量控制,如果体现在下面几个方面:

  1. 客户端建立 TCP 连接到 TLS 握手再到发出登录数据返回登录结果,这个过程是连贯的,如果客户端停在中间的某一步而不往下进行,就会一直占着服务端资源,针对这种情况,服务端增加了定时控制,在 TCP 连接之后 30s,还没有收到登录请求,服务端会主动断开连接;

  2. 在服务中统计了正在进行 TLS 握手,正在进行登录校验的连接数量,分别设置上限,防止当同时出现大量请求的时候,对后端服务的冲击;

  3. 增加了会话通信过程中 Buffer 内存使用量的上限,防止对端不接收数据,导致服务端数据积压;

  4. 增加 IP 统计服务,当建立连接后,会将新连接的 IP 等信息发送到 IP 统计服务,对整个服务的客户端 IP 情况作统计监控,增加 IP 黑名单功能。

总结

  1. 长连接服务是微聊的基础服务,稳定性尤其重要;

  2. 在稳定性的基础上,通过 TLS 握手优化等,不断提高建立连接的速度,更好的应对断网,弱网等复杂的外网环境;

  3. 通过支持更多的应用层协议,提高多端多设备的接入能力;

  4. 通过监控,不断挖掘潜在在安全威胁,同时预防常见的网络安全问题。

这篇分享主要在长连接服务的整体架构,线程,内存分配等一些普遍问题技术选型方面进行整体性的介绍,随着业务的接入与用户的增长,长连接服务也伴随着新的挑战,在稳定性,高并发,高效率,安全性方面的探索与提高永远没有尽头。

最后,欢迎对分布式长连接服务,Linux 内核网络协议栈感兴趣的同学一起交流。

作者介绍

赵忠生,来自 58 集团 TEG 基础服务部,后端工程师,专注分布式高可用架构设计。本文转载自 58 架构师公众号。