2023 年,一次"我的服务,手动启永远成功、服务器一重启它就挂"的事故,把我对"启动一个服务"这件事的理解,从头到尾翻新了一遍。那台服务器上,我把自己写的一个 Java 应用,做成了一个 systemd 服务,设了开机自启。这应用要连本机的 MySQL。我测的时候,systemctl restart myapp,一次成功;再来一次,还是成功。我连着试了十几次,稳如磐石,我就放心地上线了。可上线后我发现一个诡异的规律:只要服务器【整机重启】,这个 myapp 服务,十有八九是 failed 状态。我登上去看日志,清一色是连数据库报 Connection refused。我手动 systemctl restart myapp——立刻就好了,一次成功。我整个人迷住了:同样是一条 systemctl start,同样的服务、同样的配置、同样的命令,凭什么我【手动】敲就成功,系统在【开机时】替我敲就失败?它在开机时,到底遇到了什么我没遇到的东西?这中间,差的到底是什么?这件事逼着我把 systemd 的并行启动、After 与 Requires 的区别、还有"启动了"和"就绪了"的天壤之别,彻底理清了。本文复盘这次实战。
问题背景
环境:CentOS 7,自写 Java 应用做成 systemd 服务,要连本机 MySQL
事故现象:
- systemctl restart myapp —— 手动跑,永远成功
- ★ 服务器整机重启后,myapp 十有八九是 failed
- 日志清一色:连 MySQL 报 Connection refused
- 手动 systemctl restart myapp —— 立刻又好了
现场排查:
# 1. 重启后看服务状态 —— failed
$ systemctl status myapp
● myapp.service - My Java App
Active: failed (Result: exit-code) # ★ 挂了
$ journalctl -u myapp -b | tail
... java.net.ConnectException: Connection refused
... 连 127.0.0.1:3306 失败 # ★ 连不上数据库
# 2. ★ 这时手动启,立刻成功
$ systemctl restart myapp
$ systemctl is-active myapp
active # ★ 手动就好了
# 3. ★ 关键:看我的 service 文件,怎么写的
$ cat /etc/systemd/system/myapp.service
[Unit]
Description=My Java App
# ★★ 这里【什么依赖都没声明】—— 没 After 没 Requires
[Service]
ExecStart=/usr/bin/java -jar /opt/myapp/app.jar
# ★ Type 没写,默认 Type=simple
[Install]
WantedBy=multi-user.target
# 4. ★ 看开机时,myapp 和 mysqld 谁先启
$ systemd-analyze plot > boot.svg # 看启动时间线
# ★ 发现:开机时,myapp 和 mysqld 几乎【同时】被拉起。
根因(后来想清楚的):
1. ★ systemd 不是"从上往下跑的开机脚本"。它是
【并行】启动 —— 没声明先后关系的服务,它会
【同时】启动,以求开机快。
2. ★ 我的 service 文件里,【没写任何依赖】。所以
systemd 不知道 myapp 和 mysqld 有关系 ->
开机时把它俩【同时】拉起来。
3. ★ 这就成了一场赛跑:myapp 进程起得快,它去连
3306 时,mysqld 还在做启动初始化,根本没开始
监听 3306 -> Connection refused -> myapp 崩。
4. ★ 而我【手动】 systemctl restart myapp 时,
mysqld 早就启动好、稳定运行很久了 -> 一连就上
-> 所以手动【永远成功】。
5. ★ 手动成功和开机失败,差的不是命令,是【环境】:
手动时数据库"早就就绪了",开机时数据库"还在
赛跑中"。
6. 真相:开机不是"一个一个按顺序启",是"一窝蜂
并行启"。我没告诉 systemd"myapp 要等数据库",
它就让它俩一起跑,我的服务输了这场赛跑。
不是服务有 bug,是我没声明依赖,让一个"必须
等数据库"的服务,和数据库一起抢跑。
修复 1:systemd 不是开机脚本——它是并行启动 + 依赖图
# === ★ 先纠正一个根本的认知错误 ===
# === ★ 老印象:开机 = 一份从上往下跑的脚本 ===
# 老的 SysV init 时代,开机确实像跑一份脚本:服务
# 按编号(S10、S20、S30…)【一个接一个】启动,
# 顺序是写死的,前一个跑完才跑下一个。
# ★ 我脑子里一直是这个画面 —— 所以我下意识以为
# "数据库编号小、先启,我的服务后启" 是天然的。
# === ★ 真相:systemd 是【并行】启动 ===
# systemd 为了让开机【尽量快】,做法完全不同:
# ★ 它会把所有要启动的服务,【尽可能同时】拉起来。
# 只有当两个服务之间有【明确声明的先后关系】时,
# 它才会让一个等另一个。
# ★ 没声明关系的服务 —— 在 systemd 眼里就是"互不
# 相干",于是【一窝蜂并行启动】。
# === ★ systemd 靠"依赖关系图"决定先后 ===
# systemd 不看"脚本编号",它看每个服务(unit)文件
# 里【声明】的依赖关系,在内存里拼出一张
# 【依赖关系图】,按图决定:谁能并行、谁必须等谁。
$ systemctl list-dependencies myapp.service # 看一个服务的依赖
$ systemd-analyze critical-chain myapp.service # 看启动关键路径
# === ★ 于是,本文事故的机理清楚了 ===
# ★ 我的 myapp.service 里,没写任何依赖声明。
# ★ 在 systemd 的依赖图里,myapp 和 mysqld 之间
# 【没有任何连线】—— systemd 认为它俩毫不相干。
# ★ 结果:开机时它俩被【并行】拉起,谁先就绪纯靠
# 运气。myapp 跑得快,数据库还没就绪,它就崩了。
# === ★ 为什么手动 restart 从不出事 ===
# ★ 我手动敲 systemctl restart myapp 时,系统早已
# 开机完毕,mysqld 已经稳定运行了几小时 —— 此刻
# "并行"还是"顺序"根本不重要,因为数据库【早就
# 在那儿了】。赛跑的另一个选手,早就到终点了。
# ★ 这解释了那个最迷惑的现象:手动永远对,开机
# 常常错 —— 命令一样,差的是"数据库就绪了没"。
# === 认知 ===
# ★ systemd 不是从上往下跑的开机脚本,它为了开机快,
# 会把没有声明先后关系的服务【并行】同时启动,
# 靠各 unit 文件里声明的依赖拼成关系图来决定先后。
# 我没在 myapp 里声明对数据库的依赖,systemd 就
# 让它俩并行抢跑 —— 这才是开机失败、手动成功的
# 根本原因。
修复 2:After / Before——只管"启动顺序",不管"要不要启"
# === ★ 第一类依赖声明:排顺序 ===
# === ★ After= / Before=:声明先后次序 ===
# 在 [Unit] 段里写:
$ vi /etc/systemd/system/myapp.service
[Unit]
Description=My Java App
After=mysqld.service # ★ 我,排在 mysqld 之后启动
# ★ After=X:如果 X 和我【这次都要启动】,那么
# systemd 会先启 X,再启我。
# ★ Before=X 则相反。两者是一个意思的两个方向。
# === ★ 关键的、最容易误解的一点 ===
# ★ After= 【只排顺序】,它【不会把 mysqld 拉进来】!
# ★ 意思是:如果因为某种原因,mysqld 这次开机根本
# 没被列入启动清单 —— 那 After=mysqld.service
# 就【等于没写】,systemd 不会因为这行字,就去
# 把 mysqld 启起来。
# ★ After 回答的是"如果两个都要启,谁先";它【不】
# 回答"要不要启 mysqld"。
# === ★ 一个形象的说法 ===
# After= 像是排队规则:"如果你和他都来排队,他排
# 你前面"。但它【管不着】"他到底来不来排队"。
# ★ 要确保"他一定来",得用下一节的 Requires/Wants。
# === ★ 所以,只写 After 仍可能出事 ===
# 假设你只写了 After=mysqld.service,但没启用 mysqld
# 开机自启(systemctl enable):
$ systemctl is-enabled mysqld
disabled # ★ mysqld 没设开机自启
# ★ 这种情况下,开机时 mysqld 根本不在启动清单里,
# After= 形同虚设,你的服务照样连不上数据库。
# ★ 所以 After 几乎总要和 Wants/Requires 【配对】用。
# === ★ 顺带:别忘了让依赖的服务也开机自启 ===
$ systemctl enable mysqld # ★ 确保数据库也开机自启
$ systemctl enable myapp
# === 认知 ===
# ★ After=/Before= 只声明【启动顺序】:After=X 表示
# "若 X 和我这次都要启,先启 X"。它【不会】把 X
# 拉进启动清单 —— 若 X 这次根本不启动,After 形同
# 虚设。它回答"谁先",不回答"要不要启",所以
# 几乎总要和 Wants/Requires 配对使用。
修复 3:Requires / Wants——声明"我需要它"
# === ★ 第二类依赖声明:要不要一起启 ===
# === ★ Requires=:强依赖 ===
[Unit]
Requires=mysqld.service # ★ 我【必须】有 mysqld
# ★ Requires=X 的含义:
# - 启动我时,systemd 会【把 X 也一起启动】;
# - ★ 如果 X 启动失败,那我也【不启动 / 被停掉】;
# - X 后来被停止 / 崩溃,我也会被连带停止。
# ★ 它很"刚":把我和 X 的命运,绑死在一起。
# === ★ Wants=:弱依赖(更常用)===
[Unit]
Wants=mysqld.service # ★ 我【希望】有 mysqld
# ★ Wants=X 的含义:
# - 启动我时,systemd 也会【尝试启动 X】;
# - ★ 但如果 X 启动失败,我【仍然继续启动】,不受
# 牵连。
# ★ 它更宽松、更解耦,是【官方推荐的默认选择】。
# === ★ 极其重要:Requires/Wants 不含"顺序"! ===
# ★ Requires= 和 Wants= 只解决"要不要一起启",它们
# 【完全不保证顺序】!只写 Requires=mysqld.service,
# systemd 还是可能和 mysqld【同时并行】启动我。
# ★ 顺序,永远是 After=/Before= 的事。
# === ★ 所以,正确的写法是【两个一起写】 ===
[Unit]
Description=My Java App
Wants=mysqld.service # ★ 把 mysqld 也带上一起启
After=mysqld.service # ★ 并且,排在它后面启
# ★ Wants 解决"它得启动",After 解决"它先启"。
# 两行配合,才完整表达了"我依赖 mysqld"。
# ★ 这是 systemd 依赖声明的【标准范式】,记牢。
# === ★ Requires 还是 Wants:怎么选 ===
# ★ Requires:依赖方一崩,你也跟着崩 —— 适合"没了
# 它我根本没意义"的死依赖。但它会让"mysqld 重启"
# 连带"myapp 重启",有时太刚。
# ★ Wants:大多数场景的推荐值。它保证"尽量一起
# 启",又不把命运焊死。本文这种,Wants 就够了。
# === ★ 改完别忘了 reload ===
$ systemctl daemon-reload # ★ 改了 .service 文件必须 reload
$ systemctl restart myapp
# === 认知 ===
# ★ Requires=X 强依赖(连 X 一起启,X 失败我也失败、
# 命运绑死),Wants=X 弱依赖(连 X 一起启,但 X
# 失败我仍继续)—— Wants 是推荐默认。关键:这两者
# 【都不含顺序】!正确范式是 Wants= 加 After= 两行
# 一起写:Wants 保证它被启动,After 保证它先启。
修复 4:最核心的坑——"启动了"不等于"就绪了"
# === ★ 即使写对了 Wants+After,可能还是连不上 ===
# === ★ 一个让我再次卡住的现象 ===
# 我加上了 Wants=mysqld.service + After=mysqld.service,
# 满心以为稳了。可重启服务器,myapp 【偶尔】还是
# Connection refused。
# ★ 明明 After 让我排在 mysqld 后面了,怎么还连不上?
# === ★ 真相:After 等的是"被启动",不是"可用" ===
# ★ After=mysqld.service 保证的是:systemd 把 mysqld
# 这个 unit 【标记为 started】之后,才启动我。
# ★ 但是 —— ★ "systemd 认为 mysqld started" 和
# "mysql 真的能接受 3306 连接",是【两件事】!
# ★ mysqld 的进程被拉起来了(systemd 说 started),
# 可它还要花几秒做初始化(加载表、恢复日志、
# 开始监听端口)。这几秒里,它【还不能服务】。
# ★ 我的 myapp 排在"started"之后启动,正好撞进
# 这几秒的空窗 -> 连 3306 -> 还没监听 -> refused。
# === ★ 根源:systemd 怎么判定一个服务"started" ===
# 这取决于 service 的 Type=:
[Service]
Type=simple # ★ 默认。ExecStart 进程一 fork 出来,
# 立刻算 started —— 进程刚拉起就算"好"
Type=forking # 主进程 fork 完、父进程退出,算 started
Type=notify # ★ 服务【自己】通过 sd_notify 明确
# 告诉 systemd"我真的就绪了",才算 started
# ★ Type=simple 的"started",含金量很低:它只代表
# "进程被拉起来了",【完全不代表】"服务能用了"。
# ★ 只有 Type=notify(服务得自己支持),systemd 才
# 知道一个服务【真正就绪】的时刻。
# === ★ 同一个坑:network.target 也不是"网络通了" ===
# ★ After=network.target 只表示"网络配置的动作做完
# 了",【不代表】网卡真拿到 IP、网络真的通。
# ★ 要等"网络真就绪",得用 network-online.target:
[Unit]
Wants=network-online.target
After=network-online.target
# ★ (且需启用 NetworkManager-wait-online 这类服务,
# 它才会真正"等到网络在线"。)
# === ★ 一句话点透 ===
# ★ "started" 是【行政状态】:systemd 在它的账本上,
# 给这个 unit 盖了个"已启动"的章。
# ★ "ready / 可用" 是【事实状态】:这个服务真的能
# 响应请求了。
# ★ After= 等的,只是前者那个【章】。本文的偶发
# 失败,就栽在"盖了章"和"真能用"中间那道缝里。
# === 认知 ===
# ★ After= 等的是 systemd 把依赖 unit【标记为
# started】,而"started"≠"服务真的可用"。
# Type=simple 下进程一拉起就算 started,但服务还
# 在初始化、还没监听端口。network.target 同理,
# 不代表网络真通(要用 network-online.target)。
# "盖了已启动的章" 和 "真能响应请求" 是两件事。
修复 5:真正的解法——别赌"别人就绪",让自己扛得住
# === ★ 既然"等就绪"靠不住,就让服务自己有韧性 ===
# === ★ 解法 1(最稳):应用自己重试连接 ===
# ★ 最健壮的做法,在【应用代码】层面:启动时连数据库,
# 连不上不要直接崩 —— 等几秒,重试,直到连上。
# (Spring Boot 等框架,大多有连接池重试 / 启动
# 重试的配置项。)
# ★ 思路:不假设"数据库此刻一定就绪",而是"我有
# 能力等它就绪"。这把命运,握回了自己手里。
# === ★ 解法 2:systemd 兜底 —— 失败自动重启 ===
[Service]
Type=simple
ExecStart=/usr/bin/java -jar /opt/myapp/app.jar
Restart=on-failure # ★ 非正常退出,自动重启
RestartSec=5 # ★ 重启前等 5 秒
StartLimitIntervalSec=300
StartLimitBurst=10 # ★ 5 分钟内最多重启 10 次
# ★ 道理很朴素:就算第一次启动撞上数据库空窗、崩了,
# systemd 5 秒后自动重启它 —— 这时数据库八成好了。
# 靠"重试",绕过了"赛跑"。
# === ★ 解法 3:ExecStartPre 探测,确认依赖真就绪 ===
[Service]
# ★ 正式进程启动前,先跑一个探测:等数据库端口通
ExecStartPre=/bin/bash -c 'until nc -z 127.0.0.1 3306; do sleep 1; done'
ExecStart=/usr/bin/java -jar /opt/myapp/app.jar
# ★ ExecStartPre 这条命令不结束,ExecStart 就不会
# 开始。它用 nc 反复探测 3306,真通了才放行。
# ★ 这是把"等就绪"这件事,自己【动手做实】,而不是
# 依赖 systemd 的 started 状态。
# === ★ 解法 4:让被依赖方暴露"真就绪"信号 ===
# ★ 如果被依赖的服务支持 Type=notify(很多官方服务
# 如新版 mysqld 支持),那它的"started"就是【真
# 就绪】。这时 After= 等它,才真正可靠。
$ systemctl show mysqld -p Type # 看 mysqld 是什么 Type
# === ★ 几种解法的取舍 ===
# ★ 最推荐:解法 1(应用自己重试)—— 最根本、最健壮,
# 不管部署在哪都成立。
# ★ 退而求其次:解法 2(Restart=on-failure)—— 改
# 一行配置就能大幅缓解,性价比极高。
# ★ 解法 3 适合"应用没法改、又必须确保依赖就绪"。
# ★ 核心思想一句话:★ 不要把"我启动成功",赌在
# "另一个服务恰好已经就绪"上。要么自己等到它真
# 就绪,要么自己崩了能重来。
# === 认知 ===
# ★ 真正的解法不是更精细地"等别人就绪",而是让自己
# 扛得住:① 应用自己重试连接(最根本);②
# Restart=on-failure + RestartSec 让 systemd 失败
# 自动重启(性价比最高);③ ExecStartPre 用 nc
# 探测依赖端口真通了再放行;④ 依赖方支持 Type=
# notify 时 After 才真可靠。别把启动成功赌在别人
# 恰好就绪上。
修复 6:systemd 启动依赖排查纪律
# === 这次事故暴露的认知盲区,定几条纪律 ===
# === 1. ★ systemd 是并行启动,没声明先后关系的服务会被同时拉起 ===
# === 2. ★ After/Before 只排启动顺序,不会把依赖服务拉进启动清单 ===
# === 3. ★ Requires/Wants 声明"要不要一起启",但它俩都不含顺序 ===
# === 4. ★ 表达"我依赖 X"的标准范式:Wants=X 和 After=X 两行一起写 ===
[Unit]
Wants=mysqld.service
After=mysqld.service
# === 5. ★ After 等的是 systemd 标记的 started,不等于服务真的可用 ===
# === 6. ★ Type=simple 进程一拉起就算 started,Type=notify 才是真就绪 ===
# === 7. network.target 不代表网络通,要等真就绪用 network-online.target ===
# === 8. ★ 别赌依赖就绪:应用自己重试,或 Restart=on-failure 自动重启 ===
[Service]
Restart=on-failure
RestartSec=5
# === 9. 改完 .service 文件必须 systemctl daemon-reload ===
# === 10. 排查"手动启成功 开机启失败"的步骤链 ===
$ systemctl status 服务 ; journalctl -u 服务 -b # ① 看失败原因
$ systemctl cat 服务 # ② 看 unit 有没有声明依赖
$ systemd-analyze critical-chain 服务 # ③ 看启动关键路径
$ systemctl is-enabled 依赖服务 # ④ 依赖服务设开机自启没
# 手动成功+开机失败,几乎必是"依赖未声明/未就绪"。
# 按此顺序,基本能定位、能根治。
命令速查
需求 命令
=============================================================
看服务状态 systemctl status 服务
看服务本次开机的日志 journalctl -u 服务 -b
看 unit 文件完整内容 systemctl cat 服务
看一个服务的依赖 systemctl list-dependencies 服务
看启动关键路径耗时 systemd-analyze critical-chain 服务
看整体开机耗时 systemd-analyze blame
画开机时间线 systemd-analyze plot > boot.svg
查服务设没设开机自启 systemctl is-enabled 服务
看服务的 Type systemctl show 服务 -p Type
改完 unit 文件后必须 systemctl daemon-reload
口诀:systemd 并行启动 没声明依赖就一起抢跑,Wants 加 After 两行才完整
After 等的 started 不等于真就绪,别赌别人就绪 自己重试或失败自动重启
避坑清单
- systemd 不是从上往下跑的开机脚本,它为了开机快会把没声明先后关系的服务并行同时启动
- 服务文件不声明依赖,systemd 就不知道你和数据库有关系,开机时让你俩一起抢跑
- After 和 Before 只排启动顺序,它不会把依赖的服务拉进本次启动清单
- Requires 是强依赖连一起启且命运绑死,Wants 是弱依赖连一起启但对方失败你仍继续
- Requires 和 Wants 都不含顺序,表达依赖的标准范式是 Wants 加 After 两行一起写
- After 等的是 systemd 标记的 started,而 started 不等于那个服务真的能响应请求了
- Type=simple 进程一拉起就算 started 含金量很低,Type=notify 才是服务自己确认的真就绪
- After=network.target 不代表网络真通,要等网络真就绪得用 network-online.target
- 别把启动成功赌在别人恰好已就绪上,应用自己重试连接或配 Restart=on-failure 自动重启
- 改完 service 文件必须 systemctl daemon-reload,依赖的服务也要记得 systemctl enable
总结
这次"手动启永远成功、开机启十有八九挂"的事故,纠正了我一个关于"先后"的、藏得极深的错觉。在我过去的脑子里,事情发生的"顺序",是一件【天然存在、不用我操心】的事。我装数据库在先,写应用在后;数据库是底层,应用是上层——在我朴素的想象里,系统启动时,自然就会"先把底层的数据库铺好,再把上层的应用搭上去",就像盖房子,地基总在墙之前。这个"先后",在我看来,是事物本身的属性,是【不言自明】的。所以我写那个 service 文件时,压根没想过要去"声明"什么顺序——你需要声明一件本来就成立的事吗?可现场给我看的,是一幅我从没设想过的画面:开机的那一刻,我的应用和数据库,不是排着队一前一后,而是【肩并肩,同时起跑】。systemd 根本不知道它俩谁该在前——因为我从没告诉过它。在它眼里,这两个服务,只是两个互不相干的任务,它出于一片好意(让开机更快),把它们一起放了出去。我以为"理所当然"的那个先后,在 systemd 那里【根本不存在】,因为顺序不是事物的属性,顺序是一种【需要被显式表达出来的关系】——我心里有,不等于系统里有。现场逼着我承认:我脑子里那张清晰的"底层在先、上层在后"的图,从来只存在于【我自己的脑子里】。我以为它是客观事实,其实它只是我的一个【默认假设】。而系统,不会读我的假设;系统只执行我【写下来】的东西。我没写,这个先后就没有;没有先后,就有了赛跑;有了赛跑,就有了我那个总在空窗期里撞墙的应用。更深一层,等我补上了 After,我又栽进第二个、几乎一样的坑里:我以为"它启动了"就等于"它能用了"。可 systemd 给一个服务盖上"已启动"的章,和那个服务真的能响应我的请求,中间还隔着一段它默默初始化的时间。我又一次,把"一个状态被宣告"当成了"一件事实已成立"。复盘到根上我才明白,我反复栽跟头的,是同一件事:我总把【我以为的】当成【系统知道的】,把【行政上的宣告】当成【事实上的就绪】。这次最大的收获,是我对"理所当然"这四个字,生出了一种近乎本能的不信任。下一次,当我心里觉得"这个先后顺序,不用说也成立""这个依赖关系,明摆着的"——我会强迫自己停下来,问一句:这件"明摆着"的事,我【究竟有没有,在某个地方,把它真正写下来、声明出来】?系统、协作者、任何不在我脑子里的东西,都读不到我的"想当然";它们只认我显式交付的约定。同时,当某个东西报告"我好了""我启动了""我完成了",我也不会再照单全收,而会多追一步:你说的"好了",是真的能干活了,还是只是有人给你盖了个章?因为这次它教得很清楚——你心里那个清晰的秩序,只要没被说出来、写下来,对这个世界就【等于不存在】;而一句"已就绪"的宣告,只要背后的事实还没追上来,它就只是一句【迟早要露馅】的空话。
—— 别看了 · 2026