# 问题处理 — 定时任务并发执行失败

> 版本：1.0 ｜ 日期：2026-07-27 ｜ 作者：czj

---

## 问题描述

| # | 问题 | 严重度 |
|---|------|--------|
| 1 | 5.44 服务器定时任务（工作日 8:40）不执行，5.202 服务器定时任务（工作日 9:00）正常执行 | P0 功能 |
| 2 | `schedules.json` 并发读写无锁保护，多任务同时执行时可能丢失状态更新 | P1 数据安全 |
| 3 | `_execute_scheduled_job` 在 APScheduler 线程中同步阻塞执行，长时间巡检占用调度线程 | P1 性能 |

---

## 问题 1 根因分析（P0）

### 现象

- 5.44 定时任务配置"工作日 08:40 执行"，到了时间不执行，无报告生成
- 5.202 定时任务配置"工作日 09:00 执行"，正常执行，报告正常生成
- 服务器健康检查 status: ok, scheduler: running

### 根因

**根因 1（主因）：`_add_job()` 未设置 `misfire_grace_time`，APScheduler 默认 1 秒**

`schedule_service.py` 第 451-457 行：

```python
_scheduler.add_job(
    func=_execute_scheduled_job,
    trigger=trigger,
    id=job_id,
    args=[sched["id"]],
    replace_existing=True,
    # ❌ 缺少 misfire_grace_time、coalesce、max_instances
)
```

APScheduler 的 `BackgroundScheduler` 默认 `misfire_grace_time = 1` 秒。含义：如果调度器在预定时间后超过 1 秒才检查到该任务需要执行，该任务就被标记为 misfire 并**跳过不执行**。

**触发场景**：
- 5.44 任务设定 8:40 触发
- 8:40 时 APScheduler 的执行线程正忙于其他操作（GIL 竞争、垃圾回收、或其他任务正在同步执行巡检）
- 调度器延迟 1 秒以上才检查到 8:40 的任务
- 该任务被判定为 misfire，跳过不执行
- 5.202 的 9:00 任务正常，因为 9:00 时调度器线程空闲

**根因 2（次因）：`_execute_scheduled_job` 在 APScheduler 线程中同步阻塞执行**

`schedule_service.py` 第 512-521 行：

```python
def _execute_scheduled_job(schedule_id: str) -> None:
    sched = get_schedule(schedule_id)
    if not sched or not sched.get("enabled"):
        return
    _run_job_body(schedule_id)  # ❌ 同步阻塞！
```

对比 `run_now()`（手动触发）使用了 `threading.Thread` 后台执行（第 539 行），而定时触发直接在 APScheduler 线程中同步调用 `_run_job_body`。

`run_inspection_sync()` 执行全量巡检可能耗时数分钟（每个模块 90 秒超时 × 多个模块），在此期间 APScheduler 的执行线程被阻塞，无法触发其他定时任务，加剧 misfire 问题。

### 影响范围

- **所有定时任务**都可能受影响，不仅限于 5.44
- 任务执行时间越长，后续任务越容易 misfire
- 多个定时任务配置在相近时间段时问题更明显

---

## 问题 2 根因分析（P1）

### 现象

两个定时任务同时执行时，`schedules.json` 中的状态可能不正确（如一个任务完成后的状态更新被另一个任务的写入覆盖）。

### 根因

`_update_current_status()`（第 567-575 行）和 `update_run_status()`（第 357-368 行）都采用"读-改-写"模式操作 `schedules.json`，但**没有任何锁保护**：

```python
def _update_current_status(schedule_id: str, status: str) -> None:
    schedules = _load_schedules()        # 1. 读
    for i, s in enumerate(schedules):
        if s.get("id") == schedule_id:
            schedules[i]["current_status"] = status  # 2. 改
            _save_schedules(schedules)               # 3. 写
            return
```

**竞态场景**：
1. 5.44 任务完成，线程 A 调用 `update_run_status("sched_5.44", "success", ...)`
2. 同时 5.202 任务执行中，线程 B 调用 `_update_current_status("sched_5.202", "running")`
3. 线程 A 先 `_load_schedules()` 读到旧数据
4. 线程 B 也 `_load_schedules()` 读到同一份旧数据
5. 线程 A 写入，更新了 5.44 的状态
6. 线程 B 写入，基于旧数据更新 5.202 的状态，**覆盖了线程 A 对 5.44 状态的更新**
7. 结果：5.44 的 `last_run_status` 被回退，看起来像从未执行

### 影响范围

- 多个定时任务同时执行时，状态更新可能丢失
- 手动触发 + 定时触发同时进行时同样受影响
- `current_status` 可能永远停留在 "running"，导致手动触发被拒绝

---

## 问题 3 根因分析（P1）

### 现象

定时任务执行期间，APScheduler 调度线程被阻塞，影响其他任务触发。

### 根因

见问题 1 根因 2。`_execute_scheduled_job` 在 APScheduler 的工作线程中同步执行 `run_inspection_sync()`，该函数执行全量巡检可能耗时数分钟。

APScheduler 默认线程池大小为 10，如果多个定时任务同时占用线程，新的触发请求可能因线程池耗尽而延迟或失败。

---

## 修复方案

| 问题 | 修复方案 | 改动文件 |
|------|---------|---------|
| P0：misfire_grace_time 过短 | `_add_job()` 添加 `misfire_grace_time=3600`（1 小时容错）、`coalesce=True`、`max_instances=1` | `schedule_service.py` |
| P1：APScheduler 线程阻塞 | `_execute_scheduled_job` 改为 `threading.Thread` 后台执行，与 `run_now()` 保持一致 | `schedule_service.py` |
| P1：schedules.json 并发写入 | 添加模块级 `threading.Lock()`，`_update_current_status` / `update_run_status` / `_save_schedules` 等函数加锁 | `schedule_service.py` |

---

## 风险评估

| 风险 | 影响 | 缓解措施 |
|------|------|---------|
| misfire_grace_time 过大导致任务堆积 | 低频任务不会堆积；高频任务应设 max_instances=1 | 已设 max_instances=1 |
| 后台线程异常无法捕获 | 线程内已有 try/except 兜底 | 日志记录完整 |
| 锁粒度过大影响性能 | schedules.json 操作是毫秒级，锁持有时间极短 | 可接受 |

---

## 文档信息

- 创建时间：2026-07-27
- 创建人：Claude
- 关联问题：定时任务时区问题、定时任务与报告优化
- 关联提交：待修复
