如何实现 Spring Boot 不停机更新?附完整源码
目录
- 设计思路
- 实现代码
- 测试
一、前言
在个人或者企业服务器上,总归有要更新代码的时候。普通做法必须先终止原有进程,因为新进程和老进程端口冲突,新进程启动会报端口占用。
还有一种黑科技可以让两个 SpringBoot 进程真正共用同一个端口,该方案下文暂不展开,下回分解。
传统停机更新存在痛点:
线上大量用户正在访问时,如果直接停服务更新,会出现一段服务不可用窗口,不可用时长取决于项目启动速度。
现有折中方案
新代码先用其他端口启动,启动完成后修改 Nginx 转发地址,Nginx 重载速度极快,减少用户报错,最后再杀掉老进程。
缺点:需要频繁切换端口,即使写脚本也比较繁琐。
本文方案
新进程直接启动,程序自动完成端口切换、老进程销毁、容器热切换,实现近乎无缝更新。
二、设计思路
方案底层依赖 SpringBoot 内嵌 Tomcat 容器相关源码,先梳理核心知识点:
- SpringBoot 内嵌 Servlet 容器底层原理
- DispatcherServlet 如何注入 Servlet 容器
1. Tomcat 原生启动示例
Tomcat 提供 org.apache.catalina.startup.Tomcat 类,可直接手动创建、启动容器,手动添加 Servlet、配置连接器:
public class Main {
public static void main(String[] args) {
try {
Tomcat tomcat = new Tomcat();
tomcat.getConnector();
tomcat.getHost();
Context context = tomcat.addContext("/", null);
tomcat.addServlet("/","index",new HttpServlet(){
@Override
protected void doGet(HttpServletRequest req, HttpServletResponse resp) throws ServletException, IOException {
resp.getWriter().append("hello");
}
});
context.addServletMappingDecoded("/","index");
tomcat.init();
tomcat.start();
}catch (Exception e){}
}
}
2. SpringBoot 创建 Web 容器工厂
SpringBoot 根据引入的 Servlet 容器依赖,自动创建对应容器工厂,以 Tomcat 为例,工厂类为 TomcatServletWebServerFactory:
private static ServletWebServerFactory getWebServerFactory(ConfigurableApplicationContext context) {
String[] beanNames = context.getBeanFactory().getBeanNamesForType(ServletWebServerFactory.class);
return context.getBeanFactory().getBean(beanNames[0], ServletWebServerFactory.class);
}
调用 ServletWebServerFactory.getWebServer() 可获取 Web 服务实例,提供 start()、stop() 启停容器。
3. DispatcherServlet 注入容器原理
SpringBoot 不会像原生 Tomcat 示例手动 addServlet,依靠 ServletContainerInitializer 回调接口实现动态注册 Servlet、Filter:
- SpringBoot 内置实现类
TomcatStarter,存入容器; TomcatStarter聚合所有ServletContextInitializer;- Tomcat 启动后回调接口,批量执行初始化逻辑,完成 DispatcherServlet 注册;
getWebServer入参就是ServletContextInitializer集合。
4. 获取 ServletContextInitializer 集合
ServletContextInitializerBeans 实现 Collection 接口,可直接从上下文获取所有初始化器:
protected static Collection<ServletContextInitializer> getServletContextInitializerBeans(ConfigurableApplicationContext context) {
return new ServletContextInitializerBeans(context.getBeanFactory());
}
完整执行流程
- 判断目标端口是否被占用;
- 端口占用则先用备用端口启动新版本程序;
- 新版本完全启动后,杀掉占用目标端口的老进程;
- 修改容器端口为默认端口,重新创建 WebServer 并绑定 DispatcherServlet;
- 关闭备用端口容器,对外仅保留默认端口提供服务。
三、完整实现代码
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.boot.web.embedded.tomcat.TomcatServletWebServerFactory;
import org.springframework.boot.web.servlet.ServletContextInitializer;
import org.springframework.boot.web.servlet.context.ServletWebServerApplicationContext;
import org.springframework.boot.web.servlet.server.ServletWebServerFactory;
import org.springframework.boot.web.servlet.server.WebServer;
import org.springframework.context.ConfigurableApplicationContext;
import org.springframework.scheduling.annotation.EnableScheduling;
import java.io.IOException;
import java.net.ServerSocket;
import java.lang.reflect.Method;
import java.util.Collection;
@SpringBootApplication()
@EnableScheduling
public class WebMainApplication {
public static void main(String[] args) {
String[] newArgs = args.clone();
int defaultPort = 8088;
boolean needChangePort = false;
// 判断默认端口是否占用
if (isPortInUse(defaultPort)) {
newArgs = new String[args.length + 1];
System.arraycopy(args, 0, newArgs, 0, args.length);
newArgs[newArgs.length - 1] = "--server.port=9090";
needChangePort = true;
}
// 启动Spring上下文
ConfigurableApplicationContext run = SpringApplication.run(WebMainApplication.class, newArgs);
if (needChangePort) {
// 杀掉占用默认端口的老进程(Linux/macOS)
String command = String.format("lsof -i :%d | grep LISTEN | awk '{print $2}' | xargs kill -9", defaultPort);
try {
Runtime.getRuntime().exec(new String[]{"sh", "-c", command}).waitFor();
// 等待端口释放
while (isPortInUse(defaultPort)) {
}
// 获取容器工厂,修改端口
ServletWebServerFactory webServerFactory = getWebServerFactory(run);
((TomcatServletWebServerFactory) webServerFactory).setPort(defaultPort);
// 获取初始化器,重新创建Web服务
WebServer webServer = webServerFactory.getWebServer(invokeSelfInitialize(((ServletWebServerApplicationContext) run)));
webServer.start();
// 关闭备用端口服务
((ServletWebServerApplicationContext) run).getWebServer().stop();
} catch (IOException | InterruptedException ignored) {
}
}
}
/**
* 反射获取上下文自初始化方法
*/
private static ServletContextInitializer invokeSelfInitialize(ServletWebServerApplicationContext context) {
try {
Method method = ServletWebServerApplicationContext.class.getDeclaredMethod("getSelfInitializer");
method.setAccessible(true);
return (ServletContextInitializer) method.invoke(context);
} catch (Throwable e) {
throw new RuntimeException(e);
}
}
/**
* 判断端口是否被占用
*/
private static boolean isPortInUse(int port) {
try (ServerSocket serverSocket = new ServerSocket(port)) {
return false;
} catch (IOException e) {
return true;
}
}
/**
* 获取所有Servlet初始化Bean
*/
protected static Collection<ServletContextInitializer> getServletContextInitializerBeans(ConfigurableApplicationContext context) {
return new ServletContextInitializerBeans(context.getBeanFactory());
}
/**
* 获取内嵌容器工厂
*/
private static ServletWebServerFactory getWebServerFactory(ConfigurableApplicationContext context) {
String[] beanNames = context.getBeanFactory().getBeanNamesForType(ServletWebServerFactory.class);
return context.getBeanFactory().getBean(beanNames[0], ServletWebServerFactory.class);
}
}
四、测试验证
1. 测试接口
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
@RestController()
@RequestMapping("port/test")
public class TestPortController {
@GetMapping("test")
public String test() {
return "1";
}
}
2. 打包两个版本
- v1版本:接口返回
1; - 修改代码返回
2,打包为 v2 新版本 jar。
3. 测试步骤
- 先启动 v1 旧版本 jar,使用接口工具正常访问,返回
1; - 不关闭 v1 进程,直接启动 v2 新版本 jar;
- 持续循环调用接口,观察返回值变化;
4. 效果
新版本启动完成后,接口会快速切换返回 2,服务中断时间极短,不超过1秒,实现近乎零停机更新。
妙不妙?




