1.WebSocket简介
2.项目背景、硬件环境及客户端支持
本项目通过WebSocket实现同时在线用户量几千的推送服务器(可内网运行)。且可实时查看用户在线状态。
- 服务器:centos 6.5、tomcat 7
- 客户端:移动端(安卓、IOS)、网页端。
- 服务端第三方库 :javax.websocket
3.本文研究内容
应用的线上环境后各种异常情况处理:
- 使用WebSocket时,依赖TCP keepalive还是做业务层心跳
- 服务器如何感知客户端断开(用以查看实时用户在线状态)
- 客户端如何感知服务端异常(用以决定客户端何时重连)
4.基于javax.websocket服务端代码(源码后续补充git连接)
WebSocketServer.java
package cn.milo.wsdemo; import org.apache.log4j.Logger; import javax.websocket.*; import javax.websocket.server.PathParam; import javax.websocket.server.ServerEndpoint; /* * create : 17-07-21 * auth : milo */ @ServerEndpoint("/connect/{userId}") public class WebSocketServer { private static Logger log = Logger.getLogger(WebSocketServer.class); /* New Connected */ @OnOpen public void onOpen(@PathParam("userId") String userId , Session session){ log.info("[WebSocketServer] Connected : userId = "+ userId); WebSocketUtils.add(userId , session); } /* Send Message */ @OnMessage public String onMessage(@PathParam("userId") String userId, String message) { log.info("[WebSocketServer] Received Message : userId = "+ userId + " , message = " + message); if (message.equals("&")){ return "&"; }else{ WebSocketUtils.receive(userId , message); return "Got your message ("+ message +")."; } } /* Errot */ @OnError public void onError(@PathParam("userId") String userId, Throwable throwable, Session session) { log.info("[WebSocketServer] Connection Exception : userId = "+ userId + " , throwable = " + throwable.getMessage()); WebSocketUtils.remove(userId); } /* Close Connection */ @OnClose public void onClose(@PathParam("userId") String userId, Session session) { log.info("[WebSocketServer] Close Connection : userId = " + userId); WebSocketUtils.remove(userId); } }
Utils.java
- package cn.milo.wsdemo;
import cn.milo.FileUtils.CreateFile;
import org.apache.log4j.Logger;
import javax.websocket.Session;
import java.util.Map;
import java.util.concurrent.ConcurrentHashMap;
public class WebSocketUtils {
private static Logger log = Logger.getLogger(WebSocketUtils.class);
public static Map<String, Session> clients = new ConcurrentHashMap<String, Session>();
/*
Add Session
*/
public static void add(String userId, Session session) {
clients.put(userId,session);
log.info("当前连接数 = " + clients.size());
}
/*
Receive Message
*/
public static void receive(String userId, String message) {
log.info("收到消息 : UserId = " + userId + " , Message = " + message);
log.info("当前连接数 = " + clients.size());
}
/*
Remove Session
*/
public static void remove(String userId) {
clients.remove(userId);
log.info("当前连接数 = " + clients.size());
}
/*
Get Session
*/
public static boolean sendMessage(String userId , String message) {
log.info("当前连接数 = " + clients.size());
if(clients.get(userId) == null){
return false;
}else{
clients.get(userId).getAsyncRemote().sendText(message);
return true;
}
}
}
5.客户端代码
<body>
server地址 : <input id ="serveraddress" type="text" /><br/>
您的用户id : <input id ="userId" type="text" /><br/>
<button οnclick="initSocket()">连接</button><br/>
=====================================================<br/>
消息 : <input id ="message" type="text" /><br/>
<button οnclick="send()">发送</button><br/>
=====================================================<br/>
连接状态 : <button οnclick="clearConnectStatu()">清空</button><br/>
<div id="connectStatu"></div><br/>
=====================================================<br/>
收到消息 :<br/>
<div id="receivedMessage"></div><br/>
=====================================================<br/>
心跳 :<br/>
<div id="heartdiv"></div><br/>
</body>
<script src="<%=basePath%>/resources/jquery-1.7.2.min.js"></script>
<script type="text/javascript">
var heartflag = false;
var webSocket = null;
var tryTime = 0;
$(function () {
// initSocket();
window.onbeforeunload = function () {
};
});
/**
* 初始化websocket,建立连接
*/
function initSocket() {
var serveraddress = $("#serveraddress").val();
var userId = $("#userId").val();
if (!window.WebSocket) {
$("#connectStatu").append(getNowFormatDate()+" 您的浏览器不支持ws<br/>");
return false;
}
webSocket = new WebSocket(serveraddress+"/"+userId);
// 收到服务端消息
webSocket.onmessage = function (msg) {
if(msg.data == "&"){
}else{
$("#receivedMessage").append(getNowFormatDate()+" 收到消息 : "+msg.data+"<br/>");
}
};
// 异常
webSocket.onerror = function (event) {
heartflag = false;
$("#connectStatu").append(getNowFormatDate()+" 异常<br/>");
};
// 建立连接
webSocket.onopen = function (event) {
heartflag = true;
heart();
$("#connectStatu").append(getNowFormatDate()+" 建立连接成功<br/>");
tryTime = 0;
};
// 断线重连
webSocket.onclose = function () {
heartflag = false;
// 重试10次,每次之间间隔10秒
if (tryTime < 10) {
setTimeout(function () {
webSocket = null;
tryTime++;
initSocket();
$("#connectStatu").append( getNowFormatDate()+" 第"+tryTime+"次重连<br/>");
}, 3*1000);
} else {
alert("重连失败.");
}
};
}
function send(){
var message = $("#message").val();
webSocket.send(message);
}
function clearConnectStatu(){
$("#connectStatu").empty();
}
function getNowFormatDate() {
var date = new Date();
var seperator1 = "-";
var seperator2 = ":";
var month = date.getMonth() + 1;
var strDate = date.getDate();
if (month >= 1 && month <= 9) {
month = "0" + month;
}
if (strDate >= 0 && strDate <= 9) {
strDate = "0" + strDate;
}
var currentdate = date.getFullYear() + seperator1 + month + seperator1 + strDate
+ " " + date.getHours() + seperator2 + date.getMinutes()
+ seperator2 + date.getSeconds();
return currentdate;
}
function heart() {
if (heartflag){
webSocket.send("&");
$("#heartdiv").append(getNowFormatDate()+" 心跳 <br/>");
}
setTimeout("heart()", 10*60*1000);
}
</script>
6.问题探索
首先ws是基于TCP的应用层技术,那么很多同学这里会有疑问TCP本身是有keepalive机制的为什么还要做应用层心跳。原因有以下几个:1.client异常挂死,此时keepalive机制无法反馈真实的client状态; 2.client 异常断电断网出现TCP假死keepalive并不能根本性解决问题,实际上互联网环境很不稳定;3.ws在应用层,基于传输层,在ws中操作TCP也很不方便。封装就意味着易用性提高灵活性降低。
所以我们在应用层开启心跳。1次/10mins
接下来我们聊一聊客户端正常断开异常断开如何处理:
客户端:
client | server处理方法 | client处理方法 | 处理思路 |
---|---|---|---|
关闭浏览器 | 触发onClose回调 | / | 应用层ws主动关掉连接(优雅关闭) |
杀掉浏览器 | 触发onClose和onError回调 | / | 在操作系统中,应用程序对应的进程被干掉的时候会关闭其端口,也就是触发了TCP四次挥手。对于ws来讲直接在外部断开TCP会触发ws异常,对于ws来讲这样的关闭方式为非优雅关闭会触发异常. |
断电断网 | 检测client最后心跳上报时间 | 触发onClose(断网) | server角度:如果client最后上报时间已经超过正常周期*3,server认为其离线 client角度:断电就不说了。断网的情况client之所以触发了onClose我认为可能是当断网时操作系统关闭了所有对外的网络端口或者操作系统通知了浏览器断网(由此看出操作系统的知识真的是太重要了);所以此时三个心跳周期过后当我们认为此session已经断开时不要忘记通知ws close掉这个session,不然有可能出现大量服务端TCP假死.接下来说重连,大家要注意重连对于server是来讲是一个新的连接,大家可以通过断网重连后server产生的session判断出断网重连实际上是产生了一个新的连接。对于server的原session如何处理我做了这样一个测试,当客户端断网后server依然通过原session发送数据给client当发送的数据超过一定时间一定数量没有回复后server会触发onError和onClose方法,对于原session server在client断开后从来不给这个client发消息的情况也就是重连的情况,我们要在新的session产生时及时清掉旧的session.同TCP假死处理一致. |
服务端:
server | server处理方法 | client处理方法 | 处理思路 |
---|---|---|---|
重启tomcat | / | 触发onClose | 应用层ws主动关掉连接(优雅关闭) |
杀掉tomcat(kill -9 pid | / | 触发onClose和onError回调 | (同client被杀死) |
断电断网 | 检测client最后心跳上报时间 | 心跳异常 | (见下表:server断电断网时client如何感知),也就是说对于client来讲,只要正常发送心跳给server就可以了。如果server断开网络超过20分钟(心跳:次/10mins)所有client均会掉线 |
server断电断网时client如何感知
心跳周期 | client现象 |
---|---|
次/1s | 断网/断电后167s(中间经历了167次心跳)触发client onClose方法 |
次/1min | 断网/断电后6mins 40s(中间经历了6次心跳)触发client onClose方法 |
次/10mins | 第一次测试:断网/断电后11mins 27s(中间经历了1次心跳)触发client onClose方法 第二次测试:断网/断电后14mins 28s(中间经历了1次心跳)触发client onClose方法 第三次测试:断网/断电后15mins 54s(中间经历了1次心跳)触发client onClose方法 |
8月3日补充 中间线路断网情况
补充一下中间线路断网情况:
如:中间nat设备断网(互联网环境中间nat设备是非常多的)或者server网络断开.这里大家注意client断网不算是中间线路断网,因为client端断网应用程序马上可以感知.但是client所在局域网的出口nat断开的就算是中间网络断开.
其实上边已经提到了server网络断开的情况,分别说明了server和client各自的检测办法.但是很多网络不稳定的情况,如:断开18分钟后网络又恢复了,这里涉及到一个重连机制,首先大家要明白当中间网络断开时实际上是两段各自维护本端tcp的.最终会触发tcp强制拆链(不发送四次挥手).分为两种情况讨论:
(1)网络恢复时,client已经将自己连接断开了,但是server认为网络还在连接中,和tcp假死很像.这种情况在服务端检测心跳超时之前,服务端推送消息是没有办法到达客户端的.但是这时服务端的试图发消息动作会触发服务端发现这个连接已经断开了. 从现象看ws重连时间为: 网络恢复时间——>server发现连接断开(server发消息)+超时/server心跳检测超时 (前提:网络断开后到网络恢复中间这段时间server没法过消息给client,如果发送过可能网络连接上立即触发服务端发现连接断开.)
(2)网络恢复时,client没有将自己连接断开,但是server已经断开.这种情况在client下一次心跳发送后会触发tcp重发,重发一定时间没有回复client也会进行强制拆链.ws重连时间为:网络恢复时间——>client下一次心跳时间+超时. (前提:网络断开后到网络恢复中间这段时间client没发过心跳给server,如果发送过可能网络连接上立即触发客户端发现连接断开.)
上边两个前提有点难懂,意思是当网络断开到网络恢复中间这段时间发送过消息,那么这个消息第一次发送肯定是到不了对端,但是这时就已经开始tcp重传机制了,可能网络恢复时恰好有一次重传,你的消息可以发到对端了,但是对端tcp端口已经关闭,tcp发生异常也就立即触发了本端tcp的关闭.
综上:tcp重连是需要时间的,这个时间肯定是越短越好,但是又不能太短,这个时间的确定大家可以参考本篇最后的测试.
7.如何做到支持几千个client同时在线人数
首先tomcat最大线程数默认肯定到不了几千,所以我们需要调tomcat最大线程数及运行内存。具体参数大家百度一下吧。我这边最大运行内存3个g,最大线程调到5k的情况下,3k个client同时在线是没问题的。再者大家注意下linux操作系统本身有些涉及到tcp连接的配置也可能需要修改。
8.后续
之前本来是想通过udp打洞方式实现内网推送的,但是上周花了一个周末的时间测试结果都不是很理想。有时间我会针对udp打洞原理专门写篇博客。
9.8月3日补充(相关测试)
这几天做了主流浏览器的测试工作,测试结果如下:
浏览器 | 心跳间隔:次/10mins | 心跳间隔:(无心跳) |
---|---|---|
360浏览器 | (不支持ws) | (不支持ws) |
ie10/ie11 | 48h稳定(只测了48h) | 32mins断开(错误号:1005) |
google浏览器 | 18h稳定(只测了18h) | 30mins断开 |
火狐浏览器 | 5mins断开 | 5mins断开 |
傲游浏览器 | 18h稳定(只测了18h) | 30mins断开 |
UC浏览器 | 18h稳定(只测了18h) | 30mins断开 |
橘子浏览器 | 3h5mins 异常(且没有错误号e.code) | 3h断开 |
搜狗浏览器 | 18h稳定(只测了18h) | 30mins断开 |
QQ浏览器 | 18h稳定(只测了18h) | 18h稳定(只测了18h) |
猎豹浏览器 | 18h稳定(只测了18h) | 18h稳定(只测了18h) |
这里有几个点说明一下:
1.除特殊说明的橘子浏览器,其他浏览器断开时错误号均为1006
2.橘子浏览器:心跳间隔次/10mins情况下,发生异常且没有错误号,我初步判断为浏览器内部发生异常,可见橘子浏览器很不稳定呀.
3.火狐浏览器很特殊,心跳次/10min情况下也会断开,所以我这边把心跳时间调整为4分半,目前1小时连接正常.
4.ie浏览器无心跳情况下32mins断开,错误号1005,1005意思为超时.
结论:由此也证明了[问题探索]中的开启应用层心跳是非常有必要的.不然连接超过一定时间后自动断开,且心跳推荐时间为4分半,用以适配所有浏览器.