最近在迭代我们公司的材料采购系统时,又遇到了那个让人头疼的老问题:每次后端打包发布,接口总要断开那么十多秒,前端页面轻则转圈圈,重则直接报 502。
对于一个正在频繁做数据对账、表单提交的系统来说,这十几秒的中断极易导致用户数据卡死或重复提交。虽然用的是单台服务器,但“服务重启中,请稍候”这种体验,在一线开发眼里确实是不及格的。
既然用了宝塔面板加 Nginx 反向代理,其实只需要稍微改变一下思路,用“单机双端口交替部署(蓝绿发布)”的方式,配合 Nginx 的容错机制,就能做到完全不断开、无感的平滑切换。
这篇文章我就把亲测通过的 Nginx 配置和全自动 Shell 脚本贴出来,希望能帮同样在做单机运维的兄弟少踩点坑。
1. 为什么单实例重启做不到“无感”?
之前我们的运维脚本很简单:kill 掉旧进程 -> 替换 Jar 包 -> nohup java -jar ...。
这种“先杀后启”的方式,存在两个无法调和的致命伤:
- 服务真空期:旧进程死了,新进程还在 JVM 初始化、加载 Spring 容器。在这漫长的十几秒到一分钟内,Nginx 找不到后端,进来的请求全部铁定遭遇 502。
- 硬切中断:旧进程被干掉时,它手里可能正处理到一半的数据库事务、正在上传的图片会直接被强行掐断,给用户返回网络连接拒绝。
所以,要做到接口不断开,核心只有一条:在新版本完全启动并成功监听端口之前,老版本必须死死钉在岗位上继续干活;新版本准备好了,Nginx 无感把流量切过去,老版本再优雅退场。
2. 核心思路:单机双端口的“伪滚动更新”
我们给项目分配两个内部端口,比如 9821(端口 A)和 9822(端口 B)。
- 常驻状态:平时线上只有其中一个端口(比如 A)在跑,Nginx 把流量正常转给 A。
- 发布时刻:我们上传新 Jar 包,脚本检测到当前是在跑 A,它绝对不去杀 A,而是默默在后台把新版代码在 端口 B 上启动起来。
- 健康检查:脚本在后台不断去探测端口 B,直到 B 的 Spring Boot 完全初始化完毕、Tomcat 端口握手成功。
- 无感切换:Nginx 的
proxy_next_upstream机制会自动判断。当新请求进来,如果新端口活了,流量自然平滑过渡;同时 Nginx 在检测到老端口关闭时,会自动把未处理完的残余请求悄悄转给新端口。 - 优雅退场:新版完全接管流量后,脚本向老端口发送普通的
kill信号,触发 Spring Boot 的优雅停机,老版本站完最后一班岗,安全退出。
⚠️ 唯一的硬性要求:在发布的这几十秒内,服务器内存要能短暂同时撑起两个 Java 实例(比如平时占 1G,发布时短暂需要 2G,等老的杀了就回落)。如果服务器内存只有 1G 且已经快爆了,请先去加内存,否则系统会因为 OOM 随机乱杀进程。
3. 第一步:改造宝塔网站的主配置文件
打开宝塔面板 -> 找到你的网站 -> 点击“设置” -> 找到 “配置文件”。
在配置文件的 最顶部(也就是 server { 块的外层),加入以下 upstream 负载均衡组的定义。这样 Nginx 才能动态认得这两个端口。
# 定义 Java 后端服务组,名字叫 procurement_servers
upstream procurement_servers {
server 127.0.0.1:9821 max_fails=3 fail_timeout=10s;
server 127.0.0.1:9822 max_fails=3 fail_timeout=10s;
}加好之后,点击保存即可。
4. 第二步:重写宝塔的反向代理规则
在宝塔面板的网站设置里,点击左侧的 “反向代理” -> 找到你原本创建好的那个反代条目,点击“配置文件”。
把里面原本写死 proxy_pass http://127.0.0.1:9821; 的内容完全清空,用下面这段更健全的反代规则整体覆盖:
#PROXY-START/
location ^~ /
{
# 1. 转发到我们在全局配置中定义的 upstream 服务组
proxy_pass http://procurement_servers;
# 2. 核心无感切换机制
# 当其中一个端口被脚本关掉、或正在重启报 502/504 时,Nginx 自动、无感地在 0.1 秒内把请求发给另一个健康的端口
proxy_next_upstream error timeout invalid_header http_502 http_503 http_504;
proxy_next_upstream_tries 2;
proxy_next_upstream_timeout 10s;
# 3. 基础反代透传配置(改为标准的 $host,比宝塔默认写死 127.0.0.1 兼容性更好)
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header REMOTE-HOST $remote_addr;
# 4. 支持 WebSocket 长连接(如果系统用到了通知或大文件分片,这几行重要)
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection $connection_upgrade;
proxy_http_version 1.1;
add_header X-Cache $upstream_cache_status;
# 5. 宝塔自带的动静分离与浏览器静态资源缓存策略
set $static_filekKGGvf8l 0;
if ( $uri ~* "\.(gif|png|jpg|css|js|woff|woff2)$" )
{
set $static_filekKGGvf8l 1;
expires 1m;
}
if ( $static_filekKGGvf8l = 0 )
{
add_header Cache-Control no-cache;
}
}
#PROXY-END/配置完成后,去宝塔的 Nginx 管理里点击 重载配置。
5. 第三步:编写双端口交替部署的 Shell 脚本
万事俱备,现在我们需要一个聪明的脚本来干苦力。它会自动判断当前哪个端口活着、哪个死了,并进行交替发布。
在你的应用目录(例如 /www/wwwroot/procurement-java/prod)下,创建一个脚本文件 deploy.sh,内容如下:
#!/bin/bash
# 遇到任何错误立即停止执行,确保发布安全
set -e
# ==================== 配置区域 ====================
APP_NAME="your-app-prod"
APP_DIR="/www/wwwroot/procurement-java/prod"
UPLOAD_JAR="${APP_DIR}/procurement-server.jar"
# 环境与 Java 路径
JAVA_PATH="/www/server/java/amazon-corretto-21.0.11.10.1-linux-x64/bin/java"
PROFILE="prod"
# 双端口定义(必须与 Nginx 里的 upstream 端口完全一致)
PORT_A="9821"
PORT_B="9822"
LOG_DIR="${APP_DIR}/logs"
BACKUP_DIR="${APP_DIR}/backup"
JAVA_OPTS="-Xms512m -Xmx1024m"
KEEP_BACKUP_COUNT=3
# ==================================================
# 环境基础检查与目录创建
check_env() {
[ -x "${JAVA_PATH}" ] || { echo "❌ 错误: Java 不存在或不可执行: ${JAVA_PATH}"; exit 1; }
[ -d "${APP_DIR}" ] || { echo "❌ 错误: 应用目录不存在: ${APP_DIR}"; exit 1; }
mkdir -p "${LOG_DIR}" "${BACKUP_DIR}"
}
# 精准检查某个端口是否处于监听状态
is_port_listening() {
local port="$1"
if command -v ss >/dev/null 2>&1; then
ss -lnt | awk '{print $4}' | grep -q ":${port}$"
elif command -v netstat >/dev/null 2>&1; then
netstat -lnt | awk '{print $4}' | grep -q ":${port}$"
else
# 备用方案:用 lsof 兜底
lsof -i:"${port}" -sTCP:LISTEN >/dev/null 2>&1
fi
}
# 根据端口精准获取其 Java 进程 PID
get_pid_by_port() {
local port="$1"
lsof -t -i:"${port}" -sTCP:LISTEN || true
}
# 备份新上传的 jar 包到历史库,方便异常时回滚
backup_jar() {
if [ -f "${UPLOAD_JAR}" ]; then
local backup_file="${BACKUP_DIR}/procurement-server-$(date +%Y%m%d%H%M%S).jar"
echo "📦 备份新包到历史库: ${backup_file}"
cp "${UPLOAD_JAR}" "${backup_file}"
# 保留最近指定数量的备份,其余删除
ls -1t "${BACKUP_DIR}/procurement-server-"*.jar 2>/dev/null | tail -n +"$((KEEP_BACKUP_COUNT + 1))" | xargs -r rm -f
fi
}
# 动态启动指定端口的实例
start_instance() {
local port="$1"
local run_jar="${APP_DIR}/procurement-server-${port}.jar"
local log_file="${LOG_DIR}/${APP_NAME}-${port}.log"
local pid_file="${APP_DIR}/${APP_NAME}-${port}.pid"
echo "▶️ 正在端口 ${port} 上部署新版本实例..."
# 独立复制一份当前要运行的独立 jar,防止端口 A 和 B 发生读写冲突
cp "${UPLOAD_JAR}" "${run_jar}"
chmod 755 "${run_jar}"
# 后台启动进程
nohup "${JAVA_PATH}" ${JAVA_OPTS} -jar "${run_jar}" --spring.profiles.active=${PROFILE} --server.port=${port} >> "${log_file}" 2>&1 &
local pid=$!
echo "${pid}" > "${pid_file}"
echo "⏳ 等待 Spring Boot 初始化并监听端口 ${port} (最多等待60秒)..."
for i in {1..30}; do
# 检查进程是否半路夭折
if ! ps -p "${pid}" > /dev/null 2>&1; then
echo "❌ 端口 ${port} 启动失败,Java 进程已意外退出!请查看日志:"
echo "tail -n 100 ${log_file}"
rm -f "${pid_file}"
exit 1
fi
# 检查端口是否成功就绪
if is_port_listening "${port}"; then
echo "✨ [成功] 端口 ${port} 已成功启动并进入就绪状态!"
return 0
fi
sleep 2
done
echo "❌ 错误:进程虽在运行,但端口 ${port} 未能在 60 秒内建立监听,更新中止。"
exit 1
}
# 优雅停止指定端口的旧实例
stop_instance() {
local port="$1"
local pid
pid=$(get_pid_by_port "${port}")
if [ -z "${pid}" ]; then
echo "ℹ️ 端口 ${port} 本就未运行,无需清理。"
return 0
fi
echo "⏹️ 正在向端口 ${port} 发送停机信号 (PID: ${pid})..."
kill "${pid}"
# 循环检查,给旧实例 30 秒时间执行 Spring Boot 的优雅停机
for i in {1..30}; do
if ps -p "${pid}" > /dev/null 2>&1; then
sleep 1
else
echo "✅ 端口 ${port} 的旧实例已安全平滑退出。"
rm -f "${APP_DIR}/${APP_NAME}-${port}.pid"
rm -f "${APP_DIR}/procurement-server-${port}.jar"
return 0
fi
done
# 顽固进程强制清理
echo "⚠️ 警告:端口 ${port} 未能优雅退出,执行强制 kill -9 释放空间..."
kill -9 "${pid}" || true
rm -f "${APP_DIR}/${APP_NAME}-${port}.pid"
rm -f "${APP_DIR}/procurement-server-${port}.jar"
}
# 核心滚动发布逻辑
deploy_smart() {
check_env
if [ ! -f "${UPLOAD_JAR}" ]; then
echo "❌ 错误:未在指定目录找到新上传的 jar 包!"
echo "请先上传新包到: ${UPLOAD_JAR}"
exit 1
fi
backup_jar
# 探测当前哪个端口在提供服务
local listen_a=0
local listen_b=0
is_port_listening "${PORT_A}" && listen_a=1 || listen_a=0
is_port_listening "${PORT_B}" && listen_b=1 || listen_b=0
echo "======================================"
echo " 当前线上常驻状态:"
echo " 端口 ${PORT_A} (A 组): $([ $listen_a -eq 1 ] && echo "🟢 正在运行" || echo "⚪ 未运行")"
echo " 端口 ${PORT_B} (B 组): $([ $listen_b -eq 1 ] && echo "🟢 正在运行" || echo "⚪ 未运行")"
echo "======================================"
if [ $listen_a -eq 1 ] && [ $listen_b -eq 1 ]; then
# 异常双活处理:通常是上次发布异常或者人工误操作
echo "⚠️ 异常提示: 检测到双端口同时在运行。"
echo "系统将默认信任 A 组,现在重新部署并刷新 B 组..."
stop_instance "${PORT_B}"
start_instance "${PORT_B}"
echo "💡 预留 5 秒让 Nginx 负载均衡器稳定识别新节点..."
sleep 5
stop_instance "${PORT_A}"
echo "🚀 双活异常已修复,新版已在端口 ${PORT_B} 稳定上线!"
elif [ $listen_b -eq 1 ]; then
# 当前是 B 在运行 -> 往 A 升级
echo "🎯 当前主力服务在 端口 B(${PORT_B}),将在 端口 A(${PORT_A}) 部署新版本..."
start_instance "${PORT_A}"
echo "💡 预留 5 秒让 Nginx 负载均衡器稳定识别新节点..."
sleep 5
stop_instance "${PORT_B}"
echo "🚀 【无感切换成功】新版本已在 A(${PORT_A}) 上线,旧版本 B 已功成身退。"
else
# 当前是 A 在运行,或者两个都没运行(首次初始化冷启动)
if [ $listen_a -eq 1 ]; then
# 当前是 A 在运行 -> 往 B 升级
echo "🎯 当前主力服务在 端口 A(${PORT_A}),将在 端口 B(${PORT_B}) 部署新版本..."
start_instance "${PORT_B}"
echo "💡 预留 5 秒让 Nginx 负载均衡器稳定识别新节点..."
sleep 5
stop_instance "${PORT_A}"
echo "🚀 【无感切换成功】新版本已在 B(${PORT_B}) 上线,旧版本 A 已功成身退。"
else
# 两个都没跑,纯冷启动
echo "❄️ 未检测到任何正在运行的实例,执行首次冷启动初始化..."
start_instance "${PORT_A}"
echo "🎉 项目首次初始化启动成功,运行在端口: ${PORT_A}"
fi
fi
}
# 查看双端口运行状态
status_app() {
echo "=== 采购服务运行状态汇总 ==="
if is_port_listening "${PORT_A}"; then
echo "● 端口 ${PORT_A} -> 🟢 正在运行 (PID: $(get_pid_by_port ${PORT_A}))"
else
echo "○ 端口 ${PORT_A} -> ⚪ 未运行"
fi
if is_port_listening "${PORT_B}"; then
echo "● 端口 ${PORT_B} -> 🟢 正在运行 (PID: $(get_pid_by_port ${PORT_B}))"
else
echo "○ 端口 ${PORT_B} -> ⚪ 未运行"
fi
echo "============================"
}
# 响应外部指令
case "$1" in
restart)
deploy_smart
;;
status)
status_app
;;
stop)
check_env
echo "⚠️ 正在安全停止全站(A/B组)所有后端 Java 实例..."
stop_instance "${PORT_A}"
stop_instance "${PORT_B}"
echo "⚙️ 所有实例已全部关停。"
;;
*)
echo "用法: $0 {restart|status|stop}"
echo " $0 restart -> 执行无感、零停机平滑升级"
echo " $0 status -> 检查当前双端口健康状态"
echo " $0 stop -> 一键关停所有端口的实例"
exit 1
;;
esac保存文件后,记得给脚本赋予执行权限
chmod +x deploy.sh
6. 复盘:不仅仅是省下了几秒钟
这套方案上线后,我的日常更新动作变成了极其标准的固定流:
- 每次本地打包完,直接把新的包重命名为
procurement-server.jar上传到服务器。 - 运行终端执行
./deploy.sh restart。
脚本在跑的时候,我特意挂着前端页面不停地连续刷新去请求数据,结果是没有任何一次报错,只有在新老交替、Nginx 刚刷新识别的那一下,某一个请求稍微迟钝了 0.5 秒,紧接着就恢复了流畅。
对于单机单实例的生产环境来说,这几行脚本和几行 Nginx 配置,算是用最低的成本,换来了最扎实的可用性体验。
评论0
暂时没有评论