1.起因
前几天下午,测试服务平台接口的接口时发现:接口可以调用,但是服务器下没有任何调用接口的日志;然后重启服务器,可以正常重启,并且有启动日志。再次调测试接口,还是没有调用日志,这种诡异的现象还是第一次碰到。
后来查询所有服务器进程的时候,发现了一个遗留的进程,解开了心头的疑惑。事情是这样的:
这个服务器(这里叫A_1)刚刚改过一次名字比如叫A_2;
伴随名字的变化,服务器所在的文件夹的名字也从A_1变成A_2;
然后运维重启了A_2这个服务器,但是这个时候启动脚本并没有杀死A_1服务器的进程;
所以,当我调用测试接口的时候,所有的请求都发到了A_1进程,但是它又没有输出的日志文件,所以没有记录接口的调用情况。而由于我启动的是A_2服务器,所以A_2正常启动并打印启动日志。
接着,杀掉遗留进程,重启A_2,一切正常。
2.困惑
虽然问题解决了,但是心里却多了一个疑问:启动了2个进程,这两个进程都会bind同一个端口号,为什么不报端口被占用的异常?
于是当晚回去手写了一个简单的ServerSocket绑定端口号的demo,当第二次启动时,肯定会报异常。
由于公司服务器代码是使用的Netty框架,于是马上又写了一个Nio的Demo,第二次启动时,还是报异常。
不死心的我,用Netty框架写了一个服务器Demo,启动两个进程,问题复现了,两个进程都打印了监听的端口。
Netty启动部分代码如下:
try {
int port = config.getPort();
bootstrap.bind(port);
System.out.println(name + " bind on port: "+ port);
} catch (Exception e) {
System.out.println(e);
bossGroup.shutdownGracefully();
workerGroup.shutdownGracefully();
}
3.猜想和结果
看到上面的这种情况,心里产生了第一个猜想:是否是Netty框架中特殊的设置,导致同一个端口被多个进程同时监听呢?
第一时间想到的是Socket中的设置项SO_REUSEADDR,也就是端口重用,然后在SocketDemo中加上这个选项然后绑定,还是会报端口占用异常。仔细阅读这个设置项SO_REUSEADDR的作用,文档是这么解释的:当TCP连接断开时,也就是四次挥手之后,双方都有一个Time_wait阶段,这时端口还不会被释放。在这个阶段如果程序重新绑定这个端口,就会报端口被占用异常。如果SO_REUSEADDR设置为true时,则可以立即绑定到该端口。
否定上面的想法后,百度搜索到一种可能:当ServerSocket指定绑定到不同的本地地址的时候,可以实现多个进程绑定到同一个端口。
当我们使用serverSocket.bind(new InetSocketAddress(8080))绑定一个端口的时候,其实是绑定到0.0.0.0:8080;假如使用serverSocket.bind(new InetSocketAddress("127.0.0.1", 8080))时,是绑定到127.0.0.1:8080。绑定到不同的地址的同一个端口时,Socket是可以接受的。
然后写了一个ServerSocket的Demo测试,这个方案是可以实现的。demo代码如下:
public class SocketTest {
public static void main(String[] args) throws IOException {
int port = 8000;
ServerSocket ss = new ServerSocket();
// ss.setReuseAddress(true);
ss.bind(new InetSocketAddress("127.0.0.2", port));
Socket s = null;
System.out.println("listen port " + port);
while((s = ss.accept()) != null) {
OutputStream out = s.getOutputStream();
out.write("Hello".getBytes(Charset.forName("UTF-8")));
System.out.println(s.getRemoteSocketAddress());
out.flush();
out.close();
s.close();
}
}
}
public class Client {
public static void main(String[] args) throws Exception {
Socket s = new Socket("127.0.0.2", 8000);
InputStream in = s.getInputStream();
int n = -1;
byte[] buf = new byte[1024];
while((n = in.read(buf)) != -1) {
System.out.println(new String(buf, 0, n));
}
}
}
不过,Netty会是这样做的吗?似乎不可能。
Netty为什么会出现这种多次启动占用同一个端口的情况?答案比我想的更简单:捕获端口占用异常,但是不抛出。
在绑定端口中有这样一帧:
AbstractNioMessageChannel$NioMessageUnsafe(AbstractChannel$AbstractUnsafe).bind(SocketAddress, ChannelPromise) line: 533
这是上层调用绑定端口,具体执行由AbstractChannel$AbstractUnsafe的bind方法执行,其中有一段代码:
其中doBind()方法由具体实现类NioServerSocketChanne实现:
NioServerSocketChanne调用java NIO中的ServerSocketChannel去绑定端口。当绑定异常时,AbstractChannel$AbstractUnsafe捕获了异常,并直接返回。也就是说,
重复启动的Netty进程,除了第一个启动的进程,其他的都没有监听端口,而是异常退出,但是又没有抛出异常,并且打印了监听到端口日志,所以误以为端口被多次监听。
由于服务器在netty绑定端口号后,任然有其他任务执行,所以不可能在bind后直接阻塞线程。这也是导致绑定失败又没有抛出异常的一个原因。
例如,使用下面的启动方法,在bind后直接阻塞线程,绑定失败后将退出程序。
public void start() {
try {
int port = config.getPort();
ChannelFuture f = bootstrap.bind(port).sync();
System.out.println(name + " bind on port: "+ port);
f.channel().closeFuture().sync();
} catch (Exception e) {
System.out.println(e);
bossGroup.shutdownGracefully();
workerGroup.shutdownGracefully();
}
}
4.防范方法
关于Netty服务器重复多次启动的过程和现象就比较清楚了,那么我们怎么防止以后再出现这种情况呢?
Netty服务器可以多次启动的原因,就在于没有对监听端口的结果进行处理。
这里提供两种方案,检测端口绑定的结果。
1)绑定前探测端口是否可用
通过检测本地所有网卡地址上的端口是否可用来判断是否可以绑定到指定端口。
public static void checkPortAvailible(int port) throws IOException {
Enumeration<NetworkInterface> networkInterfaces = NetworkInterface.getNetworkInterfaces();
while (networkInterfaces.hasMoreElements()) {
NetworkInterface networkInterface = networkInterfaces.nextElement();
Enumeration<InetAddress> addrs = networkInterface.getInetAddresses();
while (addrs.hasMoreElements()) {
InetAddress addr = addrs.nextElement();
Socket s = null;
try {
s = new Socket();
s.bind(new InetSocketAddress(addr.getHostAddress(), port));
} catch (IOException e) {
throw e;
} finally {
if (s != null) {
s.close();
}
}
}
}
}
2)设置绑定端口事件的Listener
ChannelFuture f = bootstrap.bind(port).sync();
f.addListener(new GenericFutureListener<ChannelFuture>() {
@Override
public void operationComplete(ChannelFuture future) throws Exception {
if (future.isSuccess()) {
System.out.println(name + " bind on port: "+ port);
}
}
});
这里推荐使用第二种方法,毕竟是netty原生支持的,可以优雅的解决端口绑定失败的问题。