在线客服IM系统设计

1. 概要

公司的客服分为电话客服和在线客服。提供电话客服服务的YTalk平台2020年已经上线,为多个业务提供服务并取得了很好的效果,而在线客服还在使用第三方公司的服务,既增加成本也无法根据我们的自身业务提供各种定制化服务。因此开发自己的在线客服系统对我们公司客服业务的重要性不言而喻,2021年年初,在线客服的需求便提上了日程。经过客服项目团队同学的共同努力,在线客服系统已于2021年6月20日上线,并在之后持续丰富功能,接入多个业务。

在线客服系统的核心便是即时通讯。即时通讯(Instant Messaging,简称IM)是一个实时通信系统,允许两人或多人使用网络实时的传递文字消息、文件、语音与视频交流。本文主要介绍在开发IM系统过程中对于系统设计的思考以及服务搭建过程中使用的技术和踩过的坑。一方面希望本文能够加深读者对相关技术的理解,另一方面也希望能够为读者在工作中开发类似系统时提供借鉴和思路。

2. 系统设计

2.1 需求分析

在线客服项目对于IM系统的需求如下:

(1)IM系统的用户群体分为两类:客服人员和客户,客户又来自于不同的渠道,包括web端、app端、微信h5等

(2)聊天过程是由客户主动发起,等待分配客服人员后便可以进行聊天

(3)客服可以查询用户的所有聊天记录

(4)不需要语音和视频聊天

很明显这与我们日常的聊天工具有所区别,一次聊天的对象双方始终是客户客服,客户与客户之间不能自行发起,也不存在群聊。由于客服的接待能力有限,因此我们系统中某一时刻客户端之间的网络通讯最大数量其实就是该时刻客服接待能力的上限。其他没有客服接待的用户只能发消息,但是没有客户端接收。

2.2 网络模式

Client-Server:客户端之间的通讯依赖服务端的转发。客户端之间无网络连接,而是服务端与所有客户端建立连接。其特点是服务端掌握了客户端的所有通讯信息,便于监控。其局限性在于server的最大连接数成为了系统的性能瓶颈。

Peer to Peer:去中心化的网络,在客户端之间直接建立网络连接进行通讯。其优势在于可以确保客户端通讯的私密性,而且网络规模可以无限扩大,不存在Server的性能瓶颈,无需server转发对于聊天视频等功能的实现比较友好。但是应用中经常需要解决网络穿透问题。

对于客服系统而言,客户端之间通讯的量级有限,聊天记录需要保存,不存在语音视频。而且一个客户发起聊天时,不一定会立即有空闲的一个客服与其建立连接,Peer to Peer模式显然无法满足,其优势也不是客服系统所必须的。因此我们选用Client-Server网络模型。

2.3 应用层协议

我们常用的应用层协议HTTP和WebSocket都可以实现客户端和服务端之间的发消息和转发消息。

2.3.1 HTTP

http轮询

http长轮询

通过客户端轮询服务端,拉取新消息。其缺陷在于每次请求服务端都无论有没有新消息都需要马上返回响应,存在大量无用请求。优化为长轮询模式后,有新消息才返回,否则等待直到超时返回,这样可以有效减少请求数,但是由于每次请求都建立了网络连接,增加了服务端的网络I/O开销,对性能不友好。

2.3.2 WebSocket

websocket连接

WebSocket是一种在单个TCP连接上进行全双工通信的协议,允许服务端主动向客户端推送数据。在WebSocket API中,浏览器和服务器只需要完成一次握手,两者之间就直接可以创建持久性的连接,并进行双向数据传输。创建连接时,客户端向服务端发送一次HTTP请求进行握手,收到服务端握手成功的响应之后,便可以维持一条长连接,进行客户端与服务端之间的通讯。

显然WebSocket协议建立长连接且全双工通信的特点,更适合IM系统。

2.4 分布式架构

在Server-Client网络模式下,单体架构无法抵御单机故障,而且随着业务规模增长可能会需要扩容,因此在系统设计时,我们考虑了分布式系统的实现。

连接不同server的client该如何通讯?

分布式架构下的问题主要在于如何解决连接不同server的client的通讯问题。

2.4.1 取模规则

最直接的办法就是在连接时根据某一规则,确定client应该与哪个server建立连接。这样在发送消息时便可以根据规则找到对应的Server。

最简单的取模规则

这个方法的优势在于:首先我们避免了维护全局路由表方案中同步连接数据的种种问题;其次实现非常简单,不依赖任何中间件,转发消息时可以直接找到对应的server性能开销小。

但是,它的不足之处在于client选择server的规则会受server数量的影响,实际生产环境中对服务扩容时或者服务宕机又重启时,都会造成系统中服务器数量的变化,我们没法把受影响的客户端连接从一台server移动到另一台server,只能断开重连。而且,由于客户端选择服务器连接时不是随机的也不是根据当前负载量选择,很有可能时间段内的导致服务器负载不均衡。

可见连接规则方案在一台服务宕机时导致全体用户断开重连,会严重影响用户体验,这种通过规则选择server的方式显然不适合客服系统。

2.4.2 redis共享内存

如果连接时client随机选择Server,如何确保在发送消息时找到对应client连接的server呢?

我们首先会想到用中心化存储,保存一份完整的client-server路由表,转发时,查询路由获取对应的server。最常见的便是用redis保存路由表,

redis共享内存

每次client与server建立连接时,server在redis中储存一对clientId:ServerId,断开连接时根据clientId删除key。但是网络不稳定时,可能会发生频繁的断开重连,如果新连接建立时保存key时,之前连接断开的删除key操作还未完成,会导致删除操作发生时,新的key被删除。

断开重连时重连事务T3的存储先于删除事务T2的存储被执行

比图示更极限的情况,还可能会出现T1的存储操作覆盖了T3的存储操作。由于redis事务不具有原子性,我们只能使用lua脚本或者加锁来保证T2的删除操作,不会覆盖T3的存储操作。在保存连接信息时用唯一标识和连接建立时间来进行版本控制。

1
2
3
4
5
6
7
8
delete:
if(delete.id == old.id) {
del(delete.id);//确保T2的delete操作不会覆盖T3store的信息
}
store:
if(newConnection.time >= old.time) {
store(newConnection.id, newConnection);//确保T1的store操作不会覆盖T3store的信息
}

这种方式其缺陷在于增加了路由表更新的时延,某一时刻查询到的结果,可能还是更新前的结果,导致发送消息失败,这可能会造成用户建立连接后无法及时收到消息。

是否可以解决这个现象呢?

最显而易见的方法就是引入重试机制,发送失败的方法延迟重试几次,但是延迟的时间难以确定,可能造成消息的延迟发送。

2.4.3 server-master同步策略

换一个思路,如果将发送失败的消息缓存一定时间,并且去监听路由表的改变,在缓存过期的时间内,发现对应的连接就转发。这样做可以在路由表更新完成时,及时发送消息,缓存时间内都没有发现连接的便可以视为连接不存在。但是对于使用redis存储路由表而言,监听redis中路由表的所有变动,网络开销无疑是比较大的。因此我们考虑在server本地存储一个全局路由表,这样性能开销便会小很多。

可以让每台server在与client创建连接成功后,除了在本地保存连接外,还要向其他server同步,这样每台server中便有了路由表的全部信息。实现了转发消息和路由表存储位于一个服务。但是,如果server之间没有主从关系,同步路由表的操作很难确保一致性,而且当server数量隔很多时同步连接信息的开销会很大。因此使用这种方案时,可以抽出一台server作为master,只与其他server建立连接负责同步数据,不与client建立连接。由master维护路由表并监听其改动,转发时,server统一将消息发给master,由master来选择发送给哪台server;没有连接或发送失败时,缓存一段时间,监听到对应的连接建立再发送。

当master只有一台时,那么数据一致性问题便不需再考虑。然而实际情况下,由于master承担了复杂的功能,如果只有一台,一旦出现故障,整个系统便无法运行,显然需要两台及以上master,当master存在两台时,我们需要确保master之间的数据一致性。我们可以建立主备集群,由一台主master向另一台备用master同步,备用master只在主master宕机后提供服务。或者在同时提供服务的master之间实现分布式一致性。


server-master同步策略

显然,master的加入使得整个系统变得复杂,增加了服务器数量,并且每次发送消息,都需先转发到master,存在额外的性能开销。但是对于量级很大的分布式服务而言,master的加入使得服务器之间分工明确。可以让server只处理与用户连接和收发消息两件事,其余所有的业务逻辑和计算逻辑都在master中处理,这样一台server就可以处理更多的用户连接。另外,由于server主要处理的是I/O操作,master更多的则是CPU操作,也有利于服务器配置的选择。

server-master同步策略实现复杂,需要增加版本控制、路由表监听、发送失败消息缓存来解决复杂的网络状态下可能出现的问题,并且占用的服务器数量较多,从性价比的角度上不太适合在线客服系统使用。

如此复杂的系统都是为了保证全局路由表的准确性和实时性,那么可否在不维护全局路由表的情况下,实现消息的成功转发呢?

2.4.4 广播策略

前面无论连接规则还是全局路由表方案中,我们都毫无例外地在转发时,选择了一个准确的server来进行转发,因此我们要么需要规则选择server,要么通过记录和查询选择server。如果无选择性地让每个server都收到所有需要转发的消息,那么我们便无需关心“该选择哪个server”这个问题了。当然,向所有server都发送一遍消息显然不太合适,因此我们选择借助消息队列实现向所有server广播发消息。


广播策略

每台server只需要在本地map中保存属于自己的连接信息,收到消息时通过广播方式发送到消息队列中,所有server都收到消息并且判断是否拥有该连接,存在则发送。

由于只在本地存储自身的连接信息,不需要处理不同server之间数据覆盖以及数据同步的问题。在频繁断开重连时,可能由于删除不及时,出现新连接和旧连接同时保存在两台server上的情况,但是这并不会影响新连接成功发送消息,无需采取过多复杂的设计。不过要注意在断开重连发生在同一个server的情况下,需要保证删除的连接与map中的连接相同,防止在同一server中出现的“覆盖”问题。

1
2
delete:
map.remove(key, connection); //保证删除的连接与map中的连接相同,确保不会删除新连接

可以借助消息队列或者其他已经实现了广播功能的消息中间件来实现。

这与之前的解决方案相比,简单很多,并且在网络状况差的情况下依然具有不错的可靠性。其主要问题在于引入了额外的中间件,当吞吐量很大时,可能消息队列会成为性能瓶颈,但我们的在线客服系统显然吞吐量没有那么大。因此最终选择此方案。

广播方案下,server转发消息的伪代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
//server接收消息:
public void receive(Message message){
mqTopic.send(message);
}

//server监听消息队列
mqTopic.addListener(message->sendToClient(message));

public void sendToClient(Message message) {
Channel channel = map.get(message.getUserId());
if(channel != null){
channel.write(message);
}
}

总结

几种解决方案互有优劣,总结如下:

方案 优点 缺点
使用连接规则 1.实现简单,不需要考虑不同server共享或同步信息的问题 2.消息时延小 3.性能开销小 1.一台服务宕机会造成全局用户断开重连,体验很差,尤其服务规模庞大时影响更大 2.不能随时进行服务扩容 3.无法实现单位时间内的负载均衡
redis共享路由表 1.实现较为简单,开发量小 2.只存在一次server间的转发,消息处理效率高 1.需要使用版本控制的方法,来防止网络环境不好时产生的bug 2.无法解决某些情况下路由表不实时导致发送失败的问题
server-master同步 1.master能更灵活地处理复杂情况,能够解决路由表不实时等问题 2.服务之间分工明确,利于发挥服务器的性能 3.适用于规模庞大的系统 1.master路由信息一致性的实现复杂,开发量大 2.需要使用版本控制的方法,来防止网络环境不好时产生的bug,并且需要复杂的设计来解决路由表不实时的问题 3.需要的服务器数量较多
消息中间件广播 1.实现简单,不需要考虑不同server共享或同步信息的问题 2.server之间的连接信息互不影响,server建立连接成功后就可以及时提供服务 1.1.消息吞吐量很大时,消息队列的性能可能会成为系统性能的瓶颈 2.依赖中间件,需要考虑中间件宕机带来的影响

以上几种都是在分布式的IM系统中可选择的解决方案,只是综合利弊,考虑性价比,我们选择了更适合在线客服系统的消息中间件广播方案。

3. 开发框架

开发网络通讯服务器可以使用的网络I/O模式有同步阻塞I/O,同步非阻塞I/O和异步I/O,在Java技术栈中,提供了与之对应的BIO,NIO和AIO等API。BIO对服务器性能利用率低,单机可提供的最大连接数少,AIO在Linux中的底层实现还不完善,因此我们选择使用NIO模式。

但是由于Java原生的NIO的API繁杂,断连重连、网络闪断、半包读写、失败缓存、网络拥塞和异常码流的处理等问题都需要开发者处理,而市面上的Netty框架已经对java原生的NIO进行了封装,简化了繁琐的API,并且对网络通讯编程中可能出现很多问题进行了处理。

Netty框架用java语言实现,与公司技术栈契合,易于推广和进行二次开发。而且netty比较底层,可以为各种应用层协议提供支持,在线客服系统使用的WebSocket协议,在Netty中便有对应的实现,这极大简化了我们的开发工作。

介于Netty的优势以及对其的熟悉程度,最终选择Netty作为在线客服IM系统的开发框架。虽然,现在市面上的通讯框架很多已经使用协程,具有不可否认的性能优势,但是Netty内部高效的多线程Reactor线程模型,无锁化的串行设计,高效的序列化,零拷贝,内存池等特性也保证了Netty不会存在性能问题。关于Netty的特性,不在此展开,可能会在后续文章中结合在线客服服务端的实现来介绍。

4. 总结

在工作中,面对系统设计问题,常常会有很多不同的方案可供选择,在选择的过程中,我们需要结合业务特点出发,充分挖掘所开发系统的特点,衡量利弊。本文主要介绍了在线客服系统设计过程中对技术选型的思考,希望读者可以对在线客服系统的全貌有一定了解,也希望可以为读者在工作中遇到相关的系统设计问题时提供一定的可借鉴经验。