客服技术揭秘-大规模集群改造

u客服是一款非常优秀的开源客服系统,使用了很多先进的开源技术。缓存使用了hazelcast内存数据网格。网络方面使用了netty-socketio。无论是新手学习springboot的使用,还是老手学习websocket等技术,u客服的源码都值得一看。
但是u客服主要服务于中小客服,整体是一个单体系统。

客服的技术架构

  1. ImEventHandle为访客端的事件处理类,接受访客端的请求事件。
  2. AgentEventHandle 为座席端的事件处理类,接受座席端的请求事件。
  3. Dispatch-Agent为u客服系统的调度服务,用于访客第一次接入的时候,查找坐席。
  4. Hazelcast-Cache为u客服使用的缓存队列,主要用于坐席队列和访客队列。

从整体来看,u客服系统属于单体系统,访客端和座席端都是通过sockio与u客服直连进行websocket通讯。

单体客服架构的弊端

  1. 访客端和座席端都是直连u客服。访客端的接入方式不止是web,也有可能是微信公众平台等。客服系统本质上就是调度服务加上消息中转服务器。没有统一的消息接入接口,适应性太差,耦合性比较强。
  2. 单体客服系统,就算单机处理能力很强,但是依然是单点的,没有高可用。如果机器一旦宕机,访客就完全无法使用了。
  3. 缓存和应用没有进行分离。这样,如果应用一旦重启,缓存非常容易丢失。
  4. 安全性比较差,在公网部署,很少有应用直接对外提供服务。

大规模集群改造方案

  1. 访客端与座席端服务进行解耦
  2. 提供客服系统统一接入API。2种方式,http接口和RocketMq消息接入。
  3. Hazelcast缓存单独部署,使用客户端方式连接。
  4. 消息转发改造,主要使用Nginx的iphash和Hazelcast里的topic订阅模型。
  5. job改造为xxl-job。

大规模集群改造后的技术架构

集群具体改造措施

访客端与座席端服务进行解耦

  1. 访客端接入独立为单独的IM通道,方便多端消息同步和消息推送。可以更换为环信,七鱼等第三方通道。
  2. 访客端的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;
       }
}

消息转发改造

  1. 节点识别

         这里可以试用环境变量,或者从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

 

  • 1
    点赞
  • 5
    收藏
    觉得还不错? 一键收藏
  • 2
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论 2
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值