u客服是一款非常优秀的开源客服系统,使用了很多先进的开源技术。缓存使用了hazelcast内存数据网格。网络方面使用了netty-socketio。无论是新手学习springboot的使用,还是老手学习websocket等技术,u客服的源码都值得一看。
但是u客服主要服务于中小客服,整体是一个单体系统。
客服的技术架构
- ImEventHandle为访客端的事件处理类,接受访客端的请求事件。
- AgentEventHandle 为座席端的事件处理类,接受座席端的请求事件。
- Dispatch-Agent为u客服系统的调度服务,用于访客第一次接入的时候,查找坐席。
- Hazelcast-Cache为u客服使用的缓存队列,主要用于坐席队列和访客队列。
从整体来看,u客服系统属于单体系统,访客端和座席端都是通过sockio与u客服直连进行websocket通讯。
单体客服架构的弊端
- 访客端和座席端都是直连u客服。访客端的接入方式不止是web,也有可能是微信公众平台等。客服系统本质上就是调度服务加上消息中转服务器。没有统一的消息接入接口,适应性太差,耦合性比较强。
- 单体客服系统,就算单机处理能力很强,但是依然是单点的,没有高可用。如果机器一旦宕机,访客就完全无法使用了。
- 缓存和应用没有进行分离。这样,如果应用一旦重启,缓存非常容易丢失。
- 安全性比较差,在公网部署,很少有应用直接对外提供服务。
大规模集群改造方案
- 访客端与座席端服务进行解耦
- 提供客服系统统一接入API。2种方式,http接口和RocketMq消息接入。
- Hazelcast缓存单独部署,使用客户端方式连接。
- 消息转发改造,主要使用Nginx的iphash和Hazelcast里的topic订阅模型。
- job改造为xxl-job。
大规模集群改造后的技术架构
集群具体改造措施
访客端与座席端服务进行解耦
- 访客端接入独立为单独的IM通道,方便多端消息同步和消息推送。可以更换为环信,七鱼等第三方通道。
- 访客端的IM通道需要发送MQ给客服系统。
- 按照同一个会员ID发送到同一条MQ队列的方式发送,保证每一个会员的消息只进入同一个队列。
- 客服消息接收模块,采用集群顺序消费模式消费,保证同一个消费线程消费同一个队列的消息,保证每条消息只被一个调度节点消费。
- 消费采用推送方式接收消息DefaultMQPushConsumer,顺序消费MessageListenerOrderly,线程数配置采用默认配置20-64;消息读取位置采用CONSUME_FROM_LAST_OFFSET(第一次启动从队列最后位置消费,后续再启动接着上次消费的进度开始消费 );消费分配策略采用平均分配默认配置;
生产者关键发送代码
private static int getHashKey(String message) {
JSONObject json = JSONObject.parseObject(message);
String hashKey = json.getString("fromUser");
return Math.abs(hashKey.hashCode());
}
public static SendResult send(String arg, String message, DefaultMQProducer producer)
throws MQClientException, RemotingException, InterruptedException, MQBrokerException {
String key = getUUID();
Message msg = new Message(topic, tag, key, message.getBytes());
int hashKey = getHashKey(message);
msg.setDelayTimeLevel(0);
SendResult sendResult = producer.send(msg, new MessageQueueSelector() {
@Override
public MessageQueue select(List<MessageQueue> mqs, Message msg, Object arg) {
Integer id = (Integer) arg;
int index = id % mqs.size();
return mqs.get(index);
}
}, hashKey);
return sendResult;
}
消费者关键接收代码
public MessageListenerOrderly buildListener() {
return new MessageListenerOrderly() {
@Override
public ConsumeOrderlyStatus consumeMessage(List<MessageExt> paramList, ConsumeOrderlyContext paramConsumeOrderlyContext) {
if (paramList==null||paramList.size()==0) {
return ConsumeOrderlyStatus.SUCCESS;
}
paramConsumeOrderlyContext.setAutoCommit(true);
for (Iterator<MessageExt> i$ = paramList.iterator(); i$.hasNext();) {
MessageExt msg = i$.next();
String body = new String(msg.getBody());
System.out.println(msg.getMsgId()+",body="+body);
}
return ConsumeOrderlyStatus.SUCCESS;
}
};
}
Nginx 设置
upstream xxx.com.cn {
server 10.115.88.2:3230 max_fails=3 fail_timeout=10s;
server 10.115.88.4:3230 max_fails=3 fail_timeout=10s;
}
upstream xxx.com.cn.ws {
server 10.115.88.2:9081 max_fails=3 fail_timeout=10s;
server 10.115.88.4:9081 max_fails=3 fail_timeout=10s backup;
}
server {
listen 80;
server_name xxx.com.cn;
ssi on;
ssi_silent_errors on;
location / {
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_redirect off;
proxy_pass http://xxx.com.cn;
}
}
server {
listen 9081;
server_name xxx.com.cn;
ssi on;
ssi_silent_errors on;
location / {
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
proxy_redirect off;
proxy_pass http://xxx.com.cn.ws;
}
}
消息转发改造
- 节点识别
这里可以试用环境变量,或者从diamond获取节点字符串,然后和本机ip进行匹配获得当前节点。或者zookeeper进行动态的注册,删除。
2.主要逻辑
访客发送来消息,调度消息消费模块消费消息,检查访客是否已经接入座席,没有接入进行访客接入调度,已经接入座席进行消息转发,根据座席连接调度节点,判断由自己转发还是由其它节点转发。
3.辅助逻辑
- 座席接入某个调度节点逻辑,某个座席接入调度节点后需要保存该座席与调度节点的对应关系。
- 调度节点自我发现逻辑,调度节点可以根据配置实现自我发现,并根据座席与节点对应关系实现一系列消息转发路由的操作。
4.业务场景
- 聊天信息点对点转发
a.在访客第一次接入,调度找到坐席后,系统需要判断坐席当前连接的是哪个节点。如果是自己的话,自己就直接发送弹屏消息通知坐席。如果不是,需要把弹屏消息转发到坐席所连接的节点上。
b.在访客已经正常接入后,系统也需要判断坐席当前连接的是哪个节点。如果是自己的话,自己就直接发送访客聊天消息通知坐席。如果不是,需要把访客聊天消息转发到坐席所连接的节点上。
- 接入访客统计广播转发
a. 在访客第一次接入后,调度成功,需要更新所有坐席,访客的状态统计
b. 坐席会话结束,需要更新所有坐席,访客的状态统计
c. 坐席邀请,需要更新所有坐席,访客的状态统计
d. 坐席就绪,不就绪,示忙,示闲,需要更新所有坐席,访客的状态统计
5.消息处理流程图
6.客服系统之间通讯
客服系统之间通信采用消息订阅模式,具体使用Hazelcast的轻量级的topic的订阅发布模式。
7.代码示例
注册节点消息监听器
private void registerServerNotifyMsgListener() {
HazelcastInstance hazelcastInstance=UKDataContext.getContext().getBean(HazelcastInstance.class);
ITopic<AgentServerNodeMsg> topic = hazelcastInstance.getTopic(UKDataContext.thisServerNode);
topic.addMessageListener( new NodeMsgListener() );
}
节点监听器
import org.slf4j.LoggerFactory;
import com.alibaba.fastjson.JSON;
import com.corundumstudio.socketio.SocketIOClient;
import com.corundumstudio.socketio.SocketIONamespace;
import com.hazelcast.core.Hazelcast;
import com.hazelcast.core.HazelcastInstance;
import com.hazelcast.core.ITopic;
import com.hazelcast.core.Message;
import com.hazelcast.core.MessageListener;
import com.ukefu.core.UKDataContext;
import com.ukefu.util.client.NettyClients;
import com.ukefu.webim.service.acd.ServiceQuene;
import com.ukefu.webim.util.server.handler.AgentEventHandler;
/**
* 节点消息监听器
*
* @author liuyonghong
*
*/
public class NodeMsgListener implements MessageListener<AgentServerNodeMsg> {
private static final org.slf4j.Logger log = LoggerFactory.getLogger(NodeMsgListener.class);
public void onMessage(Message<AgentServerNodeMsg> message) {
final AgentServerNodeMsg myEvent = message.getMessageObject();
log.info("收到兄弟服务器推送过来的消息:topic=" + UKDataContext.thisServerNode + ",data=" + JSON.toJSONString(myEvent));
if (myEvent.getType() == 1) {
NettyClients.getInstance().sendAgentEventMessage(myEvent.getAgentno(), myEvent.getEvent(),
myEvent.getData());
} else {
//广播消息
UKDataContext.getContext().getBean("agentNamespace", SocketIONamespace.class).getBroadcastOperations()
.sendEvent(myEvent.getEvent(), ServiceQuene.getAgentReport(myEvent.getOrgi()));
}
}
}
点到点消息发送
public void sendAgentEventMessage(String id , String event , Object data){
//判断id是否在当前节点
Object o=CacheHelper.getAgentServerCacheBean().getCacheObject(id, UKDataContext.SYSTEM_ORGI);
HazelcastInstance hazelcastInstance=UKDataContext.getContext().getBean(HazelcastInstance.class);
String agentNode=null;
if(o!=null)
{
agentNode=(String)o;
if(agentNode.equals(UKDataContext.thisServerNode))
{
List<SocketIOClient> agents = agentClients.getClients(id) ;
for(SocketIOClient agentClient : agents){
agentClient.sendEvent(event, data);
log.info("send msg="+data+",event="+event+",type=0,topic="+agentNode);
}
}else
{
ITopic<AgentServerNodeMsg> topic = hazelcastInstance.getTopic(agentNode);
topic.publish( new AgentServerNodeMsg(data,1,id, event,UKDataContext.SYSTEM_ORGI) );
log.info("pubulish msg="+data+",event="+event+",type=1,topic="+agentNode);
}
}
}
广播消息发送
public static void publishMessage(String orgi) {
HazelcastInstance hazelcastInstance=UKDataContext.getContext().getBean(HazelcastInstance.class);
List<String> nodeList = new ArrayList<String>(UKDataContext.serverNodeMap.values());
for(String node:nodeList)
{
if(node.equals(UKDataContext.thisServerNode))
{
AgentReport data=ServiceQuene.getAgentReport(orgi);
/**
* 坐席状态改变,通知监测服务
*/
UKDataContext.getContext().getBean("agentNamespace", SocketIONamespace.class).getBroadcastOperations()
.sendEvent("status", data);
log.info("myself broadcast msg="+data+",event=status,type=2,topic="+node);
}else
{
ITopic<AgentServerNodeMsg> topic = hazelcastInstance.getTopic(node);
AgentReport data=ServiceQuene.getAgentReport(orgi);
topic.publish( new AgentServerNodeMsg(data,2,null, "status",orgi) );
log.info("pubulish broadcast msg="+data+",event=status,type=2,topic="+node);
}
}
}
job改造
修改为使用xxl-job