看好了,第二遍,SpringBoot单体应用真正的零停机无缝更新代码

前言

这篇文章是在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等终止信号,实现无缝切换的目的,做到真正的零延时。

相关推荐
楽码20 分钟前
底层技术SwissTable的实现对比
数据结构·后端·算法
m0_480502641 小时前
Rust 入门 泛型和特征-特征对象 (十四)
开发语言·后端·rust
程序员爱钓鱼1 小时前
Go语言实战案例-使用ORM框架 GORM 入门
后端
M1A11 小时前
TCP协议详解:为什么它是互联网的基石?
后端·网络协议·tcp/ip
一枚小小程序员哈1 小时前
基于微信小程序的家教服务平台的设计与实现/基于asp.net/c#的家教服务平台/基于asp.net/c#的家教管理系统
后端·c#·asp.net
楽码2 小时前
自动修复GoVet:语言实现对比
后端·算法·编程语言
石榴树下2 小时前
00. 马里奥的 OAuth 2 和 OIDC 历险记
后端
uhakadotcom2 小时前
开源:subdomainpy快速高效的 Python 子域名检测工具
前端·后端·面试
似水流年流不尽思念2 小时前
容器化技术了解吗?主要解决什么问题?原理是什么?
后端
Java水解2 小时前
Java中的四种引用类型详解:强引用、软引用、弱引用和虚引用
java·后端