主题
界面
主题
让代码"能跑"的是 《Maven 基础》(构建)与 《Maven 进阶:依赖管理与多模块》(依赖)。本节让它**"稳定地跑在服务器上"**:
本地: java -jar app.jar (Ctrl+C 就结束了,可接受)
服务器: 服务要 7×24 运行、开机自启、挂了能自动拉起、能优雅停机 ★本节最重要的一个概念是"优雅停机" ✓ ——它直接决定你的应用"重启时会不会丢数据/报错"。
完成本节后,你应该能够:
nohup + & + PID 文件让进程在后台常驻,并知道这"手写方案"缺了什么 ★;ExecStart / User / Restart / WantedBy)★★;systemctl 管理服务(start / stop / restart / status / enable);journalctl 看服务日志;先看"只在本地跑过"的人第一次部署会踩什么:
| 问题 | 原因 |
|---|---|
| SSH 一断开,服务就停了 ✗ | 进程是终端的子进程,终端关闭就被杀 ✗ |
| 服务器重启后要手动启动 ✗ | 没有"开机自启" |
| 进程挂了没人知道 ✗✗ | 没有守护/自动重启 |
| 重启时用户看到一堆 500 ✗ | 没有优雅停机(请求被直接掐断)✗✗ |
前三个是"运维不方便",第四个是"影响用户" ✓ ——本节的重点是第四个 ✓
六件事(记住这个清单,部署时不会漏):
| # | 事项 | 说明 |
|---|---|---|
| ① | 装 JDK | 版本与本地一致(《JDK 与 Java 版本》)✓ |
| ② | 建专用用户 | 不要用 root(《Linux 文件、用户与权限管理》)★ |
| ③ | 放好 jar 与配置 | 如 /opt/myapp/;配置外置(《Spring Boot 配置管理与多环境》)✓ |
| ④ | 配置服务(systemd) | 开机自启、自动重启、日志 ✓ |
| ⑤ | 开放端口 | 防火墙 ✓ |
| ⑥ | 验证 | 健康检查、看日志 ✓ |
nohup + & + PID 这是理解"服务管理到底在管什么"最好的方式 ✓ —— 本节的演示脚本就是这么做的。
nohup java -jar app.jar > app.log 2>&1 &
echo $! > app.pid四个要素:
| 要素 | 作用 |
|---|---|
nohup | 忽略 SIGHUP(终端断开时进程不被杀)★ |
> + 2>&1 | 日志重定向(《Linux 常用命令与部署》)✓ |
& | 放到后台 ✓ |
$! ★ | 刚启动的后台进程的 PID ——记下来才能管理它 ✓ |
实测(本项目的 deploy-demo.sh):
启动 demo-service ...
启动成功,PID=1835
日志文件:/tmp/demo-service/app.log
demo-service 运行中(PID=1835)
运行中 status 退出码=0
--- 最后 10 行 ---
[2026-09-21 16:44:16] heartbeat
[2026-09-21 16:44:19] heartbeat
[2026-09-21 16:44:21] heartbeat
[2026-09-21 16:44:24] heartbeat
停止 demo-service(PID=1835)...
已停止 ✓
demo-service 未运行
停止后 status 退出码=1★ 注意两处退出码:运行中 = 0,未运行 = 1 ✓ ——这就是监控与 CI 能判断服务状态的依据(《代码规范与自动校验》 的"退出码是关键")✓
检查进程存活的标准技巧:
kill -0 "$pid" # ★ 不发任何信号,只检查"进程是否存在"这个手写方案缺了什么(这正是 systemd 存在的理由):
| 缺的 | systemd 怎么补 |
|---|---|
| 开机不自启 ✗ | systemctl enable ✓ |
| 挂了不会自动重启 ✗✗ | Restart=always ★ |
| 没有统一的启停命令 | systemctl start/stop/restart ✓ |
| 日志要自己管文件(轮转、清理)✗ | journalctl + 日志轮转 ✓ |
| 没有资源限制 | MemoryMax= / CPUQuota= ✓ |
| 依赖顺序(如先启数据库) | After= / Requires= ✓ |
⚠️ 环境说明:systemd 只存在于 Linux(且需要是 systemd 发行版)。本机是 Windows,无法演示
systemctl——本节这部分为概念与配置讲解,并给出可直接使用的配置模板 ✓
一个 unit 文件(/etc/systemd/system/myapp.service)★:
[Unit]
Description=My Java Application
After=network.target # 网络就绪后再启动 ✓
# After=postgresql.service # 如果依赖数据库,写这里 ★
[Service]
Type=simple
User=deploy # ★ 专用用户,不用 root(S3-033)
Group=deploy
WorkingDirectory=/opt/myapp
ExecStart=/usr/bin/java -jar /opt/myapp/app.jar --spring.profiles.active=prod
# ★ 不要用 shell 包装(如 ExecStart=/bin/sh -c "...")—— 会让信号传递出问题,见 5.6
Restart=always # ★ 挂了自动重启
RestartSec=5 # 重启前等 5 秒(避免"疯狂重启")✓
StandardOutput=append:/var/log/myapp/app.log
StandardError=append:/var/log/myapp/app.log
[Install]
WantedBy=multi-user.target # enable 后开机自启 ✓关键配置项:
| 配置 | 含义 | 建议 |
|---|---|---|
User= ★ | 以哪个用户运行 | 专用用户 ✓ |
ExecStart= | 启动命令 | 绝对路径 ✓ |
Restart= ★ | 什么时候重启 | always / on-failure ✓ |
RestartSec= | 重启间隔 | 3~10 秒(避免瞬间重启打爆日志)✓ |
After= | 启动顺序 | 依赖的服务 |
WorkingDirectory= | 工作目录 | 相对路径的基准 ✓ |
Environment= | 环境变量 | 或 EnvironmentFile=(推荐,便于管理)★ |
常用命令:
systemctl start myapp # 启动
systemctl stop myapp # ★ 停止(会发 SIGTERM,见 5.6)
systemctl restart myapp # 重启
systemctl status myapp # ★ 看状态(还会显示最近几行日志)
systemctl enable myapp # ★ 开机自启
systemctl disable myapp # 取消开机自启
systemctl daemon-reload # ★ 改了 unit 文件后必须执行 ✓
journalctl -u myapp -f # ★ 实时看日志(-f = follow)
journalctl -u myapp --since "10 min ago" # 看最近 10 分钟 ✓⚠️ 一个新手必踩的坑:改了 .service 文件后不执行 daemon-reload → 改动不生效 ✗
这是本节最重要的内容 ✓
Linux 用"信号"通知进程发生了什么 ✓
| 信号 | 编号 | 含义 | 进程能"拒绝"吗 |
|---|---|---|---|
SIGTERM | 15 | 请求退出(礼貌)★ | 能(可以处理、可以清理)✓ |
SIGKILL | 9 | 强制杀死 ✗ | 不能(内核直接干掉)✗ |
SIGHUP | 1 | 终端挂断(常用来"重新加载配置")✓ | 能 ✓ |
SIGINT | 2 | Ctrl+C | 能 ✓ |
① 发 SIGTERM(15) ← systemctl stop / kill <pid> 的默认行为 ★
② 应用收到后:
停止接收新请求
等待正在处理的请求完成(给一个超时,如 30 秒)
关闭连接池、刷写缓存、释放资源
自己退出
③ 如果超时还没退出 → 发 SIGKILL(9) ← 强制(可能有请求被掐断)✗本项目的脚本就是这么实现的(5.2 的实测)✓:
kill "$pid" # ① 先礼貌请求(SIGTERM)
for _ in 1 2 3 4 5; do # ② 最多等 5 秒
sleep 1
kill -0 "$pid" || break # 还在吗?不在就结束
done
if kill -0 "$pid"; then
kill -9 "$pid" # ③ 超时才强制 ✗
fiSpring Boot 默认已经处理了 SIGTERM ✓ —— 它会:
@PreDestroy / DisposableBean(你可以在这里关资源)ApplicationContext关键的配合点(这是后端必须知道的):
| 要点 | 说明 |
|---|---|
| 宽限期 | server.shutdown=graceful + spring.lifecycle.timeout-per-shutdown-phase=30s ★ |
| K8s / systemd 的超时要大于应用的宽限期 ★★ | 否则外部先发 SIGKILL,优雅停机就白做了 ✗ |
别用 ExecStart=/bin/sh -c "java ..." ⚠️ | sh 会"吃掉"信号(不转发给 java)→ 优雅停机失效 ✗✗ |
★ 第三条最容易踩:
# ✗ 错误:sh 收到 SIGTERM,但它不会转发给 java
ExecStart=/bin/sh -c 'java -jar /opt/myapp/app.jar'
# ✓ 正确:直接启动 java
ExecStart=/usr/bin/java -jar /opt/myapp/app.jar后果:每次重启都有请求被掐断 ——用户看到 500,而你以为"我配了优雅停机" ✗
| 不做优雅停机的后果 | 说明 |
|---|---|
| 用户看到 500 ✗ | 正在处理的请求被中断 |
| 数据不一致 ⚠️ | 事务未提交、消息未确认 |
| 监控误报 | 重启期间的错误率尖峰 |
| 发版不敢在白天做 ✗ | 只能半夜停机发布(这是"技术债"影响业务的典型例子)✓ |
| 方式 | 优点 | 缺点 |
|---|---|---|
应用写文件(如 /var/log/myapp/app.log)★ | 简单、可用 tail -f、便于采集 | 要自己管轮转(否则撑爆磁盘)✗ |
journald(systemd 的日志) | 自动管理、可按服务查 | 与"应用自己打文件"容易分裂 ⚠️ |
| 两者结合 ★ | 应用写文件(给采集用)+ journalctl 看启动/崩溃 | — |
日志轮转(必须配,否则磁盘会被撑爆):
# /etc/logrotate.d/myapp
/var/log/myapp/*.log {
daily # 每天轮转
rotate 14 # 保留 14 份 ✓
compress # 压缩旧日志
missingok # 文件不存在也不报错
copytruncate # ★ Java 进程持有文件句柄时用这个(或让应用支持重开)
# 或者:postrotate 里 systemctl reload / 发信号让应用重开日志
}⚠️ copytruncate 的取舍:
copytruncate | 通知应用重开 | |
|---|---|---|
| 做法 | 复制一份再清空原文件 | 发信号让应用重新打开日志 |
| 优点 | 不需要应用配合 ✓ | 不丢日志(几乎无窗口)✓ |
| 缺点 | 复制与清空之间有一小段窗口,可能丢几行 ⚠️ | 需要应用支持 |
现代做法:用 Logback 的 RollingFileAppender(《日志框架与日志规范》 讲过)——应用自己按大小/日期滚动,logrotate 只做兜底 ✓
一次发布的标准步骤:
① 备份当前版本(jar 与配置)★
② 停服务(优雅停机)
③ 放新 jar
④ 启动
⑤ 健康检查(curl 一个 /health 接口)★
⑥ 观察日志与指标(错误率、响应时间)
⑦ 有问题 → 回滚(把备份放回去,重启)✓回滚为什么必须提前设计 ★:
"发布时才发现没有回滚方案"是最糟的时刻 ——那时你既慌又没时间 ✗
所以"备份"与"回滚命令"要在发布前就准备好 ✓
两种减小风险的做法:
| 做法 | 说明 |
|---|---|
| 蓝绿部署 | 两套环境,切换流量(回滚 = 切回去,最快)✓ |
| 滚动发布 | 逐个实例更新(K8s 默认)✓ |
| 金丝雀 | 先放 1% 流量,观察后再全量 ★ |
健康检查接口(Spring Boot Actuator)★:
curl -f http://localhost:8080/actuator/health || echo "健康检查失败 ✗"-f 很重要:HTTP 非 2xx 时 curl 返回非 0 退出码 → 脚本能判断成败 ✓
⚠️ 注意:/actuator/health 默认只显示"UP/DOWN",不暴露细节 ✓ ——不要为了"看得更清楚"把它暴露到公网(会泄漏数据库、磁盘等信息)✗
nohup java -jar app.jar > app.log 2>&1 &
echo $! > app.pid见 5.3 的 unit 文件 + 5.4 的优雅停机配置 ✓
kill -9 停服务 kill -9 $(cat app.pid) # ✗后果:
| 后果 | 说明 |
|---|---|
| 正在处理的请求被掐断 ✗ | 用户看到 500 |
| 资源没释放 | 连接池、临时文件、锁 |
| 数据可能不一致 ⚠️ | 事务未提交 |
正确做法:先 kill(SIGTERM),等一会儿,实在不行才 kill -9 ✓(5.2 的脚本就是这么做的)
⚠️ 环境说明:systemd 无法在 Windows 上演示。 本节提供了一个"手写版服务管理脚本"
labs/engineering/deploy-demo.sh,它演示的是服务管理的核心机制(PID、后台、日志、优雅停机的信号流程)——这些在 Git Bash 上可以跑通;systemd 部分见 5.3 的配置模板 ✓
运行(Windows 用 Git Bash):
& "C:\Program Files\Git\bin\bash.exe" -c 'cd <仓库根目录>; bash labs/engineering/deploy-demo.sh start; bash labs/engineering/deploy-demo.sh status; sleep 4; bash labs/engineering/deploy-demo.sh log; bash labs/engineering/deploy-demo.sh stop; bash labs/engineering/deploy-demo.sh status'预期输出:
启动 demo-service ...
启动成功,PID=1835
日志文件:/tmp/demo-service/app.log
demo-service 运行中(PID=1835)
运行中 status 退出码=0
--- 最后 10 行(用 tail -f 可以实时跟踪)---
[2026-09-21 16:44:16] heartbeat
[2026-09-21 16:44:19] heartbeat
[2026-09-21 16:44:21] heartbeat
[2026-09-21 16:44:24] heartbeat
停止 demo-service(PID=1835)...
已停止 ✓
demo-service 未运行
停止后 status 退出码=1四处值得注意:
| 观察 | 说明 |
|---|---|
| PID 被写进文件 ★ | 之后才能 stop / status($! 的价值)✓ |
| 日志在持续写入 | nohup ... >> log 2>&1 & 的效果 ✓ |
| status 返回 0 / 1 ★ | 运行中 = 0、未运行 = 1 ——可被监控/CI 判断 ✓ |
| stop 先 TERM 再 KILL ★ | 优雅停机的信号流程(5.4)✓ |
ps -ef | grep java:确认进程真的在 ✓;kill -0 $(cat app.pid):练习"检查存活"的标准方式 ✓;status:观察它报"未运行"(PID 文件与实际进程可能不一致 ⚠️)★;RestartSec:观察"崩溃后多久才重启" ✓;systemctl stop 后立刻 ps ——观察进程不是立刻消失(它在优雅退出)✓✓ ——这个过程就是 5.4 的意义 ✓。原因:没用 nohup / 没托管给 systemd ✗
修:用 systemd(或至少 nohup ... &)✓
ExecStart 用 sh -c 包一层 见 5.4。信号被 sh 吃掉,优雅停机失效 ✗✗
daemon-reload 见 5.3。改动不生效 ✗
# systemd 默认 TimeoutStopSec=90s(但有些环境更短)
# K8s 的 terminationGracePeriodSeconds 默认 30s如果应用的宽限期是 60s 而外部只给 30s → 30 秒后被 SIGKILL → 优雅停机白做 ✗
修:让两边对齐(且外部 > 应用)✓
见 5.5。磁盘会被撑爆 ✗✗ ——而磁盘满会导致数据库等一切服务异常 ✓
见 《Linux 文件、用户与权限管理》 的 5.8。
见 5.6。出事时才发现没退路 ✗✗
目标:把"后台运行、PID、日志、优雅停机"亲手做一遍。
步骤:
deploy-demo.sh 的完整生命周期 ✓;cat /tmp/demo-service/app.pid —— 理解"为什么要记 PID" ✓;kill -9 <pid> 后不删 PID 文件 → 跑 status —— 观察它怎么判断(kill -0 的作用)★;tail -f 跟日志:启动时另开一个终端 tail -f /tmp/demo-service/app.log ✓;.service 文件并 systemctl 管理一遍 ✓;systemctl stop 后立刻 ps —— 观察进程不是瞬间消失 ✓✓。验收方式:能说出第 3 步"kill -0 为什么比"看文件在不在"可靠"。
扩展任务:给 deploy-demo.sh 加两个功能:
restart(stop + start)✓基础题
kill -9 是让进程优雅退出的正确方式。(对 / 错)ExecStart=/bin/sh -c 'java -jar app.jar' 会导致优雅停机失效?代码阅读题
[Unit]
Description=Order Service
After=network.target
[Service]
Type=simple
User=root
ExecStart=/bin/sh -c 'java -jar /opt/order/app.jar --spring.profiles.active=prod'
Restart=on-failure
RestartSec=1
[Install]
WantedBy=multi-user.target编程题
综合任务
java -jar 启动,没有监控,发版要半夜停机;提示 1(方向):第 1 题的方向是"9 是强制,15 才是请求"。
提示 2(关键点):第 4 题至少有三处问题,注意 User 和 ExecStart。
提示 3(接近答案):第 7 题的"应用改造"——多实例最大的障碍是"本地状态"(session、本地文件、本地缓存、定时任务跑多次)。
kill -9(SIGKILL)是强制杀死,进程无法做任何清理 ✗正确的流程(5.4):
kill <pid> # SIGTERM(15):请求退出,应用可以清理 ★
↓ 等待(给一个宽限期)
如果还在 → kill -9 <pid> # 最后手段 ✗为什么 -9 不能做"正常手段":
| 后果 | 说明 |
|---|---|
| 正在处理的请求被掐断 | 用户看到 500 ✗ |
| 事务未提交 | 数据不一致 ⚠️ |
| 连接池/文件句柄没释放 | 资源泄漏(如果是分布式锁,可能长期占着)✗ |
一个真实的场景:定时任务执行到一半被 KILL → 数据处于"改了一半"的状态 ✗✗ ——这就是为什么"优雅停机 + 幂等"都很重要 ✓
nohup(配合 &);$!;kill -0 <pid> ✓三个要点展开:
| 命令 | 说明 |
|---|---|
nohup cmd & | 忽略 SIGHUP + 后台运行 ✓ |
echo $! > app.pid | $! = 最近一个后台进程的 PID ★ ——记下来才能管理 |
kill -0 <pid> | 不发信号,只检查存在性 ✓ ——脚本里判断"是否还活着"的标准写法 ✓ |
⚠️ kill -0 的一个注意点:PID 会被复用 ——如果进程早就死了、PID 被分配给别的进程,kill -0 可能"误报存活" ⚠️
更稳的做法:结合"启动时间"或"进程名"再确认一次(生产环境的服务管理工具/systemd 已经处理了这些细节 —— 这就是用 systemd 而不是手写脚本的理由之一 ✓)
sh 收到 SIGTERM 后,默认不会把它转发给子进程(java) ✗流程对比:
正确(直接启动 java):
systemd --SIGTERM--> java → java 收到 → 优雅停机 ✓
错误(sh 包一层):
systemd --SIGTERM--> sh → sh 收到,但**不转发给 java**
→ java 收不到 → 一直跑
→ 超时后 systemd 发 SIGKILL → java 被强杀 ✗✗所以:"优雅停机"需要"信号能一路传到应用进程" ✓ ——中间任何一层"吃掉"信号都会让它失效 ✓
这个坑很常见,因为:
| 为什么大家会这么写 | 问题 |
|---|---|
| 想要"环境变量 + 管道 + 重定向" | 那些可以用 systemd 的 Environment= / StandardOutput= 代替 ✓ |
| 想要"java -jar $(ls *.jar)" 这种动态 | 不如在 unit 里写死版本,发布时改 unit 或用软链接(如 app.jar -> app-1.2.0.jar) ★ |
★ 推荐做法:用软链接 ——/opt/myapp/app.jar 指向实际版本,发布时只改软链接的指向,unit 文件不用动 ✓
ln -sfn /opt/myapp/app-1.2.0.jar /opt/myapp/app.jar
systemctl restart myapp这也是"回滚"最方便的方式:把软链接指回上一个版本 ✓✓
第 4 题:三处问题
| # | 问题 | 后果 |
|---|---|---|
| 1 | User=root ✗ | 应用有漏洞时整台机器沦陷(《Linux 文件、用户与权限管理》 5.8)✗✗ |
| 2 | ExecStart=/bin/sh -c '...' ✗✗ | 信号被 sh 吃掉 → 优雅停机失效 ✗(见基础题 3) |
| 3 | RestartSec=1 ⚠️ | 重启间隔太短 —— 如果应用启动就失败(如配置错),会每秒重启一次,日志被刷爆 ✗ |
还有两处可改进:
| # | 改进 | 理由 |
|---|---|---|
| 4 | 没写 WorkingDirectory | 相对路径(如 ./logs)的基准不明确 ⚠️ |
| 5 | 没有 StandardOutput | 日志只会进 journald,应用自己的日志文件不会生成 ⚠️(取决于你想要哪种) |
第 5 题:合格版本
[Unit]
Description=Order Service
After=network.target
# 如果依赖数据库:
# After=postgresql.service
# Requires=postgresql.service
[Service]
Type=simple
User=deploy # ★ ① 专用用户
Group=deploy
WorkingDirectory=/opt/order # ★ ② 明确工作目录
# ★ ③ 直接启动 java,不用 sh 包装
ExecStart=/usr/bin/java -jar /opt/order/app.jar --spring.profiles.active=prod
# 或者用软链接:ExecStart=/usr/bin/java -jar /opt/order/app.jar
Restart=on-failure # 只在失败时重启(always 会让"手动 stop"也被重启 ⚠️)
RestartSec=5 # ★ ④ 合理的间隔
TimeoutStopSec=60s # ★ ⑤ 给优雅停机留出时间(要 ≥ 应用宽限期)
StandardOutput=append:/var/log/order/app.log
StandardError=append:/var/log/order/app.log
[Install]
WantedBy=multi-user.target几个改动点的说明:
| 改动 | 说明 |
|---|---|
User=deploy | 最小权限 ✓(但要确保 /opt/order 与 /var/log/order 属主是 deploy)✓ |
Restart=on-failure ⚠️ | always 会导致你 systemctl stop 后它又起来 ✗ ——排障时会很困扰;on-failure 更符合直觉 ✓ |
TimeoutStopSec=60s ★★ | 必须 ≥ 应用内部的宽限期(5.4 的错误 4)——否则外部先 KILL ✗ |
StandardOutput=append: | append 表示追加(不覆盖已有日志)✓ |
对应的 Spring Boot 配置(让两边对齐):
server:
shutdown: graceful # ★ 优雅停机
spring:
lifecycle:
timeout-per-shutdown-phase: 30s # ★ 宽限期 30s < TimeoutStopSec=60s ✓★ 这个"30 < 60"的不等式就是优雅停机的全部要点 ✓
#!/usr/bin/env bash
# deploy.sh —— 发布并自动回滚
set -uo pipefail
APP_DIR=/opt/order
JAR="$APP_DIR/app.jar"
BACKUP_DIR="$APP_DIR/backup"
PID_FILE="$APP_DIR/app.pid"
LOG_DIR=/var/log/order
HEALTH_URL="http://127.0.0.1:8080/actuator/health"
NEW_JAR="${1:?用法:deploy.sh <新 jar 路径>}"
mkdir -p "$BACKUP_DIR" "$LOG_DIR"
log() { echo "[$(date '+%F %T')] $*"; }
# ① 备份(★ 带时间戳,且在停止服务之前)★
if [ -f "$JAR" ]; then
backup="$BACKUP_DIR/app-$(date '+%Y%m%d-%H%M%S').jar"
cp "$JAR" "$backup" || { log "✗ 备份失败,中止"; exit 1; }
log "已备份到 $backup"
else
log "当前没有已部署的 jar(首次发布)"
backup=""
fi
# ② 优雅停止
if [ -f "$PID_FILE" ] && kill -0 "$(cat "$PID_FILE")" 2>/dev/null; then
pid=$(cat "$PID_FILE")
log "停止服务(PID=$pid,先 SIGTERM)"
kill "$pid"
for _ in $(seq 1 30); do # ★ 最多等 30 秒
sleep 1
kill -0 "$pid" 2>/dev/null || break
done
if kill -0 "$pid" 2>/dev/null; then
log " 超时,强制终止 ✗"
kill -9 "$pid" 2>/dev/null
fi
fi
# ③ 替换 jar
cp "$NEW_JAR" "$JAR" || { log "✗ 替换失败"; exit 1; }
log "已替换 jar"
# ④ 启动
nohup java -jar "$JAR" --spring.profiles.active=prod >> "$LOG_DIR/app.log" 2>&1 &
echo $! > "$PID_FILE"
log "已启动,PID=$(cat "$PID_FILE")"
# ⑤ 健康检查(★ 重试,因为应用启动需要时间)
healthy=0
for attempt in $(seq 1 10); do
sleep 3
if curl -fsS --max-time 3 "$HEALTH_URL" > /dev/null 2>&1; then
log "健康检查通过(第 $attempt 次)✓"
healthy=1
break
fi
log " 健康检查未通过,重试 $attempt/10 ..."
done
# ⑥ 失败则回滚
if [ "$healthy" -ne 1 ]; then
log "✗ 健康检查失败,开始回滚"
pid=$(cat "$PID_FILE" 2>/dev/null)
[ -n "$pid" ] && kill -9 "$pid" 2>/dev/null
if [ -n "$backup" ]; then
cp "$backup" "$JAR"
nohup java -jar "$JAR" --spring.profiles.active=prod >> "$LOG_DIR/app.log" 2>&1 &
echo $! > "$PID_FILE"
log "已回滚到 $backup,PID=$(cat "$PID_FILE")"
else
log "首次发布失败,没有可回滚的版本 ✗"
fi
exit 1
fi
log "发布完成 ✓"
exit 0两个关键问题的回答:
因为应用启动不是瞬间的 ✓
| 阶段 | 耗时 |
|---|---|
| JVM 启动 | 1~2 秒 |
| Spring 容器初始化 | 几秒到几十秒(取决于 Bean 数量、是否初始化连接池) |
| 首次请求(懒加载、JIT 预热) | 可能更慢 |
如果"启动后立刻检查一次":几乎必然失败(那时应用还没初始化完)→ 明明发布成功了,脚本却判定失败并回滚 ✗✗
所以要有"重试 + 超时":
最多 10 次 × 间隔 3 秒 = 最多等 30 秒这个"总超时"要与应用的启动时间匹配(应用启动要 60 秒,你只等 30 秒 → 误判)⚠️
还有一个细节:每次 curl 也要 --max-time(否则一个卡住的请求会拖垮整个流程)✓
两个理由:
| 理由 | 说明 |
|---|---|
| ① 备份的是"正在运行的版本" ★ | 停止后 jar 不会被修改,但在停止前备份才能保证"备份的就是刚才在跑的那个" ✓ |
| ② 万一停止/替换步骤出问题 | 备份已经安全了,随时能恢复 ✗ |
更根本的一点:
"备份"是"回滚能力"的前提 ——没有备份就没有回滚,发布就是在赌 ✗
所以备份要放在"任何破坏性操作之前" ✓
这与 《Git 分支、合并与回退》 的"
reset --hard前先记下 HEAD"、《Linux 文件、用户与权限管理》 的"打包成功再删原文件"是同一种谨慎 ✓
补充:生产环境的备份还应该:
演进路线(五个阶段)
| 阶段 | 做什么 | 停机时间 | 解决了什么 |
|---|---|---|---|
| ① 服务化 | systemd 托管 + 优雅停机 + 日志轮转 | 几秒(一次重启) | SSH 断开不停、崩溃自启、优雅停机 ✓ |
| ② 脚本化发布 | 发布脚本(备份 + 健康检查 + 自动回滚) | 几秒,且失败自动回滚 ✓ | 发版不再靠人记得每一步 ✓ |
| ③ 多实例 + 负载均衡 ★ | 两台服务器 + Nginx | 0(滚动重启)✓✓ | 单机故障不影响服务 ✓ |
| ④ 容器化 | Docker 镜像 | 0 | 环境一致、部署单元标准化 ✓ |
| ⑤ 编排 | K8s | 0 | 自动扩缩、自愈、滚动发布 ★ |
★ 阶段 ③ 是"能白天发版"的分水岭 ✓ ——因为它是第一个能做到"零停机"的 ✓
阶段的判断标准:不要跳步 ——如果阶段 ① 没做(没有优雅停机),即使上了 K8s,重启时还是会掐断请求 ✗
工具不能替代应用自身的准备 ✓
这与 《MySQL 性能优化基础》 的"先归档再考虑分库分表"同理:先做成本低的基础项 ✓
应用要做什么改造才能支持多实例 ★
核心:消除"本地状态" ✓
| 本地状态 | 问题 | 改造 |
|---|---|---|
| Session ★ | 用户 A 的请求打到实例 1,下次打到实例 2 → 登录状态没了 ✗ | ① Token(JWT)(无状态)★ ② Session 存 Redis(Spring Session)✓ |
本地缓存(如 HashMap 缓存配置)★ | 实例间不一致 ⚠️ | 用 Redis / 配置中心 ✓ |
| 本地文件(上传的文件)★ | 实例 1 存的,实例 2 读不到 ✗ | 对象存储(S3/OSS)✓ |
| 定时任务 ★★ | 每个实例都跑一次 → 重复执行 ✗✗ | ① 分布式锁 ② 独立的调度服务 ③ 只让一个实例跑(如 K8s 的 CronJob)✓ |
本地锁(synchronized) | 只能锁本实例 ✗ | 分布式锁(Redis / DB)⚠️ |
| 内存计数器 | 各实例各算各的 ✗ | Redis 计数 ✓ |
★ 定时任务这一条最容易被忘,后果也最严重:
判断"你的应用能否多实例"的最快方法:
问自己:如果同一个用户的两次连续请求打到不同实例,会有问题吗?再问:如果这个应用同时在两台机器上跑,定时任务会怎样? ✓
什么时候引入什么
| 引入 | 什么时候 |
|---|---|
| 负载均衡 ★ | 只要需要"高可用"或"零停机发布" ——这是最优先的 ✓ |
| 容器化 | ① 环境不一致的痛很明显 ② 要标准化部署 ③ 有 CD 需求 ⚠️(不是必需) |
| K8s ⚠️ | ① 实例多(几十个以上)② 需要自动扩缩 ③ 有专门的运维能力 ——小团队不要为了"先进"上 K8s ✗✗ |
⚠️ 关于 K8s 的提醒:
K8s 的运维复杂度很高 ——8 人团队维护 5 个服务,"两台机器 + Nginx + 脚本"往往比 K8s 更合适 ✓
这与 《Git Flow 与团队协作》 的"别为了规范选重分支模型"、《MySQL 性能优化基础》 的"归档优于分库"是同一类判断 ✓
技术选型要匹配"团队规模与运维能力",而不是"听起来先进" ✓
预算只做一件事,先做哪件
答:阶段 ③ —— 加一台服务器 + 负载均衡(并配合优雅停机与滚动发布) ✓
理由:
| 理由 | 说明 |
|---|---|
| 直接达成目标 ★★ | "白天能发版"(零停机)+ "单机故障不影响服务"(高可用)——这两个目标它一步到位 ✓✓ |
| 成本可控 | 一台机器 + 一个 Nginx(比引入 K8s 便宜太多)✓ |
| 不依赖应用大改造 ⚠️ | 至少读多写少的服务可以先把"无状态的部分"摘出来 ✓ |
| 为后续铺路 ★ | 有了 LB 之后,容器化、K8s 都是"在上面的演进",不会推翻重来 ✓ |
⚠️ 但有一个前提:必须先做阶段 ①(优雅停机) ✓
有 LB + 没有优雅停机:
滚动重启时,被摘掉的实例上"正在处理的请求"还是会被掐断 ✗
→ 用户仍然会看到 500所以完整答案是:
"先做优雅停机(阶段 ①,成本最低),再加负载均衡(阶段 ③,达成目标)" ✓
如果只能选一件"花钱的",选负载均衡;但优雅停机必须先补上 ✓
这也体现了本知识库反复出现的思路:
先做成本低、收益明确的基础项(优雅停机、脚本化), 再上"看起来高级"的东西(容器、编排)✓
因为前者往往是后者能否发挥作用的前提 ✓
nohup + & + $! 让进程后台常驻并记录 PID ✓;kill -0 检查进程存活 ✓;daemon-reload;sh -c 包 java",以及它为什么会让优雅停机失效 ★★;《Shell 脚本基础》:把本节的手工步骤变成可重复执行的脚本。