前言
这篇文章是在juejin.cn/post/738590... 之后续写,上文中说过,还有黑科技可以让两个SpringBoot进程真正的共用同一个端口,其实不是两个,n个都可以。
这两篇文章都只讨论无负载,无nginx,无任何第三方工具支撑下,完成单个springboot程序零停机无缝更新代码。
无缝更新代码指的是,如果要更新代码,我们必须先停止原来启动的进程,那么在新的进程启动时,必将有段时间服务不可用,如果这段时间正好有用户访问,那么会影响体验。
目前一些解决办法是,启动两个不同的端口,让nginx做一个转发等。
还有上篇文章我们演示的一种奇技淫巧,但是上篇文章只是一招偷天换日的做法。
而本文是利用linux的一个特性,完成单体零停机无缝更新代码,所以说只能在linux上运行。
实现原理
其实,这就是一个端口无法被多个程序同时使用的问题,那么有没有办法让端口被多个程序使用呢,答案是有的,我们只需要给socket设置SO_REUSEPORT
选项即可,这个选项是这么描述的:允许同一主机上的多个套接字绑定到同一端口,只要第一个服务器在绑定之前设置了这个选项,那么其他任意数量的socket都可以绑定相同的端口,前提是它们也设置了这个选项。
但是如果第一个socket的uid是A,那么其他非A运行的就无法绑定。
Nginx在1.9.1上也引用了这个功能,只需要在listen 后面加上reuseport可以了。
ini
server {
listen 6060 reuseport;
charset utf-8;
}
而linux会随机挑选一个可用的进程,把流量转发给它。
但是jdk没有直接提供对ServerSocket设置SO_REUSEPORT选项的api(可能其他较新的jdk有),即使有,Tomcat等容器也没有相关的设置,注意这里是直接,直接不行,间接是可以完成的。
那么我们如何做到?
即通过javaagent在运行时动态修改ServerSocket的创建,间接对他设置SO_REUSEPORT选项。
下面是一段c代码,演示了SO_REUSEPORT的使用,因为最终java创建socket的方法,也是要通过下面方法实现。
编译下面代码后,多次运行程序,不会出现端口被占用的问题。
c
#include <unistd.h>
#include <stdio.h>
#include <sys/socket.h>
#include <stdlib.h>
#include <netinet/in.h>
#include <string.h>
int main(int argc, char const *argv[]) {
int new_socket;
struct sockaddr_in address;
int opt = 1;
int addrlen = sizeof(address);
char buffer[1024] = {0};
int server_fd = socket(AF_INET, SOCK_STREAM, 0);
setsockopt(server_fd, SOL_SOCKET, SO_REUSEPORT,&opt, sizeof(opt));
address.sin_family = AF_INET;
address.sin_addr.s_addr = INADDR_ANY;
address.sin_port = htons(8080);
if (bind(server_fd, (struct sockaddr *) &address,
sizeof(address)) < 0) {
perror("绑定出错");
exit(EXIT_FAILURE);
}
if (listen(server_fd, 3) < 0) {
perror("监听出错");
exit(EXIT_FAILURE);
}
printf("监听中...\n");
while (1){
new_socket = accept(server_fd, (struct sockaddr *) &address,
(socklen_t *) &addrlen);
printf("accept...\n");
char *hello = "Hello";
send(new_socket, hello, strlen(hello), 0);
}
return 0;
}
而有了javaagent,我们可以修改要被加载的字节码,做一些手脚,Tomcat在创建ServerSocket的时候,是在NioEndpoint下的initServerSocket方法,其实主要看最后一个else,其他情况一般是用不到。

ServerSocketChannel
的实现类下有一个字段private final FileDescriptor fd;
,他就是c代码中socket
函数的返回值,我们只要重新创建一个FileDescriptor,并对他设置SO_REUSEPORT选项,在通过反射修改这个值即可,如何创建FileDescriptor,跟踪下ServerSocketChannel.open()
就知道了,通过调用sun.nio.ch.Net#serverSocke
来创建,Net类下还有一个setIntOption0
方法,用来设置Socket的选项,同setsockopt
一样。
arduino
private static native void setIntOption0(
FileDescriptor fd, // 文件描述符,对应底层 socket 的 fd
boolean mayNeedConversion, // 是否需要转换(可能用于字节序或 IPv4/IPv6 兼容处理)
int level, // 套接字选项的层级(如 SOL_SOCKET, IPPROTO_TCP 等)
int opt, // 具体的选项(如 SO_REUSEADDR, TCP_NODELAY 等)
int arg, // 选项的值(整数,例如 1 表示开启,0 表示关闭)
boolean isIPv6 // 是否是 IPv6 socket
) throws IOException;
而SO_REUSEPORT选项得值是15。
具体的源码放在github(github.com/houxinlin/m...%25EF%25BC%258C "https://github.com/houxinlin/multi-bind-boot)%EF%BC%8C") clone下来后执行./gradlew jar
打包成jar,输出的结果在build/libs目录下。
下面我们进行一个测试,这段代码,单体应用下,返回值肯定都是一样的数。
java
@RestController()
@RequestMapping("simple")
public class TestController {
private static final int value = new Random().nextInt();
@GetMapping("test")
public String test() {
return value + "";
}
}
把上面程序打包成jar后,通过下面命令启动。
java
java --add-opens java.base/sun.nio.ch=ALL-UNNAMED -javaagent:/home/LinuxWork/project/java/multi-bind-noot/build/libs/agent-test-1.0-SNAPSHOT.jar -jar springboot-test-0.0.1-SNAPSHOT.jar
--add-opens java.base/sun.nio.ch=ALL-UNNAMED
这个参数是Java 9 之后才出现的,用来解决 模块访问限制的问题。
-javaagent
参数指定agent的编译结果,用于在运行时候动态修改字节码。
启动后我们使用Idea最好用的接口调试插件Cool Request测试,没有任何问题。

这个时候,你要更新代码,要在返回值前面增加一个"随机数是".
java
@RestController()
@RequestMapping("simple")
public class TestController {
private static final int value = new Random().nextInt();
@GetMapping("test")
public String test() {
return "随机数是:"+value ;
}
}
代码修改成功后,我们不要停止老进程,而是直接启动,你会发现,不会出现端口被占用的错误提示。
使用Idea最好用的接口调试插件Cool Request再次测试,可以发现代码已经更新成功。

但是这就有问题了,老进程会得到请求吗,答案是会的,Linux会随机分配,至于是不是随机分配,目前不太清楚,总之我还没有找到规律。
使用Cool Request的压力测试模块,设置总共请求500次,使用8个线程,从这些请求中可以得到以下两个不同结果,证明了v1和v2版本的系统都同时在运行。


这时候只要停止老进程就可以了,可以通过javaagent自动完成,启动时,先获取到老进程的pid,等启动完成后,向老进程发送9、15等终止信号,实现无缝切换的目的,做到真正的零延时。