双启动器竞争导致服务卡死¶
一、现象¶
共享知识库服务在 13:20 突然不可用。去看机器:
- 两个服务进程都在,一个也没退出。
- 两个进程都不响应——接口超时、没有任何输出。
- 两边的日志都停在完全相同的一行上:
- 日志之后没有任何内容,进程也不退出。进程还在,但已经卡住。
- 现象在之前偶尔出现过,但每次重启一下就好了,所以一直没被当成问题。
关键线索有三个¶
- 两个进程同时存在——这个服务是单实例设计的,正常情况下不该出现两个。
- 两边都卡在同一个初始化步骤——说明它们卡在同一件事上。
- 时间点是 13:20——需要解释「为什么偏偏是这时候」。
二、环境¶
| 项 | 情况 |
|---|---|
| 操作系统 | Windows 主机 |
| 服务 | 共享知识库服务(自托管,单实例设计) |
| 数据目录 | 本机固定目录,内部有锁文件与索引文件,为单实例独占访问而设计 |
| 启动入口 1 | 计划任务:用户登录时自动启动服务 |
| 启动入口 2 | 看门狗脚本:每 5 分钟探测一次健康接口,发现不健康就启动服务 |
| 当天 13:20 发生了什么 | 一次登录触发了入口 1;同一分钟内,看门狗的探测恰好也判定「不健康」并触发了入口 2 |
两个入口各自都认为自己是唯一在做事的人。它们之间没有任何协调机制。
三、原因¶
3.1 直接原因¶
两个进程同时指向同一个数据目录。第二个启动的实例在加载存储层时,需要获取对数据文件的独占访问;而第一个实例已经持有它。于是:
3.2 「卡死」与「报错退出」的区别¶
因为双方都在等对方:
- 存储层在拿不到独占访问时,设计上没有超时、没有失败退出,只会继续等待。
- 进程 A 其实在等待某个后续资源,而那个资源与进程 B 的初始化动作相互依赖。
这就是死锁的典型形态:每个人都发现了问题,但每个人都在等别人先动。
3.3 为什么以前「重启一下就好」¶
因为重启只杀掉了一个进程(或先杀干净再起),锁自然释放,单实例恢复正常。重启掩盖了根因——这是这类间歇性故障最常见的存活方式:
它不需要被修,只需要被观察到发生的时间点——13:20 这个数字就是从这里来的。
3.4 三层归因¶
服务卡死
→ 为什么?两个进程在抢同一份数据目录
→ 为什么会有两个进程?两个启动入口在同一分钟内各自拉起了一个
→ 为什么会同时触发?登录任务与看门狗的定时探测撞在一起,且两者都不知道对方存在 ← 根因
**根因是「系统里存在两个互相不知道对方的启动入口」。「进程抢锁」只是它的表现。**加锁只是让其中一个入口变得无害。
四、解决¶
4.1 已采用的做法:全局命名互斥量¶
在服务启动的最前面加一把系统级命名互斥量,任何实例启动前先尝试获取它:
启动流程:
① 尝试创建并获取全局互斥量 Global\<服务标识>ServerStart
② 拿不到?说明已有实例在启动或运行中 → 打印一行日志,直接退出(退出码 0)
③ 拿到了?继续正常启动
④ 进程结束时自动释放(互斥量随进程句柄关闭而释放)
三个设计要点,缺一不可:
| 要点 | 为什么 |
|---|---|
名字带 Global\ 前缀 |
不带前缀的命名对象是按会话隔离的。不同会话里各有一把同名锁,等于没锁——这是最容易犯的错 |
| 拿不到就立刻退出,不等待、不重试 | 等待会重新制造「两个进程互相等」的局面;这里的目标是第二个实例无害化,不是让它排队 |
| 由服务自己在内部实现 | 靠调用方自律(「记得检查有没有在跑」)一定会失效。看门狗和登录任务都不会永远不犯错 |
4.2 看门狗也改了¶
原来的看门狗逻辑是定期启动(「到点了就确保它在」)。改成了:
「先探测、再启动」 是对的;「定期启动」 是错的——后者把「需要一个健康服务」错误地表达成了「需要启动一个服务」,于是在服务健康的时候也会去启动。
4.3 没有采用的方案¶
| 方案 | 为什么不采用 |
|---|---|
| 只去掉登录任务,只留看门狗 | 登录任务是为了「机器重启后尽快可用」;去掉它,服务要等最多 5 分钟。问题的根源是「两个入口不知道对方」,不是「入口太多」 |
| 只去掉看门狗,只留登录任务 | 失去了故障自愈能力,服务挂了没人管 |
| 让服务检测「数据目录被占用就换一个目录」 | 危险。会让两个实例各写各的数据,产生比卡死严重得多的后果(数据分裂) |
| 加超时让服务在卡住后自动退出 | 治标:两个实例仍然同时启动过、都写过日志、都占用过资源;且超时时间难以定准 |
判断标准:**卡死是可以被发现的,数据分裂不能。**所以任何「绕过独占访问」的方案都比「卡死」更糟。
五、验证¶
① 手工制造竞争:在同一分钟内同时触发两个启动入口
② 期望结果:只有一个进程存活;另一个进程打印"已有实例"并干净退出(退出码 0)
③ 健康接口在 30 秒内返回 200
④ 重复 3~5 次,每次都只有一份实例
⑤ 检查日志:两个入口都被触发过,但只有一个真正启动
⑥ 观察一段时间,确认看门狗的"恢复"日志不再出现(正常运行时它不该恢复任何东西)
实测结果:加上互斥量后,在同样的同时触发条件下,健康接口在 30 秒内返回 200,未再出现双实例并存。
验证设计的要点¶
第 ① 步必须主动制造竞争。间歇性故障的修复,必须用一次人为的必然触发来验证——否则你无法区分「修好了」和「这次没赶上」。
六、经验¶
可迁移的判断规则¶
- 「日志停在同一行不动」 = 卡在等待,不是崩溃。这时该查的是「谁还持有那个资源」:盯着一个正在等待的进程的日志,永远等不来答案。
- **单实例服务必须在服务内部实现全局单实例保护。**靠「调用方记得检查」的方案一定会失效。
- 命名互斥量必须带
Global\前缀(Windows 平台)。不带前缀的锁只在当前会话有效——这类错误的特征是「测试时好好的,换个人登录就复现」。 - **任何「服务可能被多个入口拉起」的架构,都要先问一句:两个入口同时触发会发生什么?**如果答不上来,就是在赌。
- 看门狗的正确形态是「先探测、再启动」,不是「定期启动」。
- **间歇性故障的最大敌人是「重启就好了」。**必须给它留下时间点记录(本例的 13:20 就是唯一有效的线索),否则它永远不会被解决。
- **修复方案里,「避免争抢」优先于「争抢失败后怎么办」。**特别要警惕那些「绕过独占访问」的方案——它们把可以发现的故障换成了不可发现的故障。
这个坑最反直觉的地方¶
现象看起来像「服务启动失败」,于是很自然会去查启动失败的原因——配置错了?依赖没装?端口被占?
但真正的问题是**「启动得太成功了」:它启动了两次。**如果只盯着「为什么起不来」,会一直找不到方向。
**排查时先问一句「这里是不是不该有两个」。**很多「起不来」,其实是「起来了两个」。
相关¶
- 服务进程脱离任务托管 —— 同一类问题的另一面:实例被外部清理掉
- Agent 平台部署 —— 常驻服务的托管方式
- 监控与备份 —— 为什么「自动恢复必须留痕」
- 设备与 Agent 统一管理 —— 这个服务在整体架构里的位置