看好了,第二遍,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等终止信号,实现无缝切换的目的,做到真正的零延时。

相关推荐
caibixyy5 小时前
Spring Boot 整合 Redisson 实现分布式锁:实战指南
spring boot·分布式·后端
码事漫谈6 小时前
C++编程陷阱:悬空引用检测方法与防范指南
后端
码事漫谈6 小时前
缓存友好的数据结构设计:提升性能的关键技巧
后端
sheji34167 小时前
【开题答辩全过程】以 springboot高校社团管理系统的设计与实现为例,包含答辩的问题和答案
java·spring boot·后端
聆风吟º7 小时前
远程录制新体验:Bililive-go与cpolar的无缝协作
开发语言·后端·golang
野犬寒鸦8 小时前
从零起步学习Redis || 第四章:Cache Aside Pattern(旁路缓存模式)以及优化策略
java·数据库·redis·后端·spring·缓存
Terio_my8 小时前
Spring Boot 缓存技术详解
spring boot·后端·缓存
豆浆whisky8 小时前
netpoll性能调优:Go网络编程的隐藏利器|Go语言进阶(8)
开发语言·网络·后端·golang·go
蓝天白云下遛狗8 小时前
go环境的安装
开发语言·后端·golang
@大迁世界9 小时前
Go 会成为“老生态”的新引擎吗?
开发语言·后端·golang