跳转至

双启动器竞争导致服务卡死

一、现象

共享知识库服务在 13:20 突然不可用。去看机器:

  • 两个服务进程都在,一个也没退出。
  • 两个进程都不响应——接口超时、没有任何输出。
  • 两边的日志都停在完全相同的一行上:
Loaded RAGFS Rust binding
  • 日志之后没有任何内容,进程也不退出。进程还在,但已经卡住。
  • 现象在之前偶尔出现过,但每次重启一下就好了,所以一直没被当成问题。

关键线索有三个

  1. 两个进程同时存在——这个服务是单实例设计的,正常情况下不该出现两个。
  2. 两边都卡在同一个初始化步骤——说明它们卡在同一件事上。
  3. 时间点是 13:20——需要解释「为什么偏偏是这时候」。

二、环境

项 情况
操作系统 Windows 主机
服务 共享知识库服务(自托管,单实例设计)
数据目录 本机固定目录,内部有锁文件与索引文件,为单实例独占访问而设计
启动入口 1 计划任务:用户登录时自动启动服务
启动入口 2 看门狗脚本:每 5 分钟探测一次健康接口,发现不健康就启动服务
当天 13:20 发生了什么 一次登录触发了入口 1;同一分钟内,看门狗的探测恰好也判定「不健康」并触发了入口 2

两个入口各自都认为自己是唯一在做事的人。它们之间没有任何协调机制。


三、原因

3.1 直接原因

两个进程同时指向同一个数据目录。第二个启动的实例在加载存储层时,需要获取对数据文件的独占访问;而第一个实例已经持有它。于是:

进程 A 拿到数据目录的文件锁 → 继续初始化
进程 B 尝试拿同一把锁   → 等待 或 与 A 互相阻塞
两个进程卡在同一个初始化步骤 → 都停在那行日志上,都不退出

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,未再出现双实例并存。

验证设计的要点

第 ① 步必须主动制造竞争。间歇性故障的修复,必须用一次人为的必然触发来验证——否则你无法区分「修好了」和「这次没赶上」。


六、经验

可迁移的判断规则

  1. 「日志停在同一行不动」 = 卡在等待,不是崩溃。这时该查的是「谁还持有那个资源」:盯着一个正在等待的进程的日志,永远等不来答案。
  2. **单实例服务必须在服务内部实现全局单实例保护。**靠「调用方记得检查」的方案一定会失效。
  3. 命名互斥量必须带 Global\ 前缀(Windows 平台)。不带前缀的锁只在当前会话有效——这类错误的特征是「测试时好好的,换个人登录就复现」。
  4. **任何「服务可能被多个入口拉起」的架构,都要先问一句:两个入口同时触发会发生什么?**如果答不上来,就是在赌。
  5. 看门狗的正确形态是「先探测、再启动」,不是「定期启动」。
  6. **间歇性故障的最大敌人是「重启就好了」。**必须给它留下时间点记录(本例的 13:20 就是唯一有效的线索),否则它永远不会被解决。
  7. **修复方案里,「避免争抢」优先于「争抢失败后怎么办」。**特别要警惕那些「绕过独占访问」的方案——它们把可以发现的故障换成了不可发现的故障。

这个坑最反直觉的地方

现象看起来像「服务启动失败」,于是很自然会去查启动失败的原因——配置错了?依赖没装?端口被占?

但真正的问题是**「启动得太成功了」:它启动了两次。**如果只盯着「为什么起不来」,会一直找不到方向。

**排查时先问一句「这里是不是不该有两个」。**很多「起不来」,其实是「起来了两个」。


相关