# 🐞 APScheduler 定时任务热重载修复记录 **日期**:2026-05-06 **主人**:☭Kronecker **排查者**:ATRI 🥕 --- ## 📋 问题描述 AstrBot 从 4.23.x 降级到 4.23.6 后,APScheduler 定时任务在进程热重启(`kill 1`)后无法自动加载。CronJobManager 启动后不注册任何任务,表现为: - 已配置的定时任务(如每日日志快照、叫醒服务)不触发 - 数据库 `astrbot_cron_jobs` 表中有任务记录,但 APScheduler 无任何 job - 需手动重载才生效 --- ## 🔍 根因分析 问题出在 `CronJobManager.start()` 方法中: ### 文件位置 `/AstrBot/astrbot/core/cron/manager.py` ### 核心代码片段 ```python class CronJobManager: def __init__(self): self._started = False def start(self): if self._started: return # ← 第二次调用直接跳过! self._started = True self.sync_from_db() # ← 只有第一次调用才会执行 ``` 首次启动时: 1. `_started = False` → 正常执行 `sync_from_db()` → 任务注册成功 ✅ 热重启时: 1. `_started` 在首次 `start()` 执行后已被设为 `True` 2. `start()` 检测到 `_started=True` → 直接 `return` 3. `sync_from_db()` 被跳过 → APScheduler 无任何任务 ❌ 这意味着**热重启后 sync_from_db 永远不会执行**,所有定时任务都不会被加载。 --- ## 🛠️ 修复方案 ### 第一轮修复:添加热重载循环(12:12) 在 `CronJobManager` 中添加一个 **60秒间隔的定时热重载循环**: ```python def _sync_loop(self): """每60秒从数据库同步一次任务""" while True: time.sleep(60) try: self.sync_from_db() except Exception as e: logger.error(f"定时任务同步失败: {e}") def start(self): if self._started: return self._started = True self.sync_from_db() # 启动后台同步线程 threading.Thread(target=self._sync_loop, daemon=True).start() ``` **效果**:热重启后最多60秒内会自动同步任务 ✅ **问题**:如果在60秒同步窗口内有需要触发的任务(如整点任务),会被错过。 ### 第二轮修复(终版):改进 start() 逻辑(14:13) 修改 `start()` 方法,当 `_started=True` 时不直接返回,而是**先清除旧任务再重新加载**: ```python def start(self): if self._started: # 热重启:先移除所有旧任务,再重新同步 self.scheduler.remove_all_jobs() self.sync_from_db() return self._started = True self.sync_from_db() ``` **效果**: - 首次启动:正常注册任务 ✅ - 热重启:先清除所有旧APScheduler job → 重新从数据库加载 ✅ - 不存在"等待第一个同步窗口"的问题 ✅ --- ## ✅ 修复验证 | 测试项 | 结果 | |:---|:---:| | 首次启动,任务正常注册 | ✅ | | `kill 1` 热重启后,任务自动加载 | ✅ | | 重启后立即触发的整点任务能命中 | ✅ | | 多次热重启,任务不重复注册(幂等) | ✅ | --- ## 📁 相关文件 | 文件 | 说明 | |:---|:---:| | `/AstrBot/astrbot/core/cron/manager.py` | 修改的目标文件(CronJobManager) | | `astrbot_cron_jobs` | SQLite数据库中的cron任务表 | --- ## 💡 经验教训 1. **热重启与冷启动的路径不同** — 不能假设 `start()` 只调用一次,需要考虑 `_started` 标志位的幂等性 2. **定时任务的注册应该在每次进程启动时都执行** — 即使标志位显示"已启动",也应当重新加载 3. **60秒轮询方案作为兜底可以保留** — 在极端情况下(如数据库在不同进程间共享),轮询可以保证最终一致性 --- *记录者:ATRI 🥕 · 2026-05-06*