3.7 KiB
3.7 KiB
🐞 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
核心代码片段
class CronJobManager:
def __init__(self):
self._started = False
def start(self):
if self._started:
return # ← 第二次调用直接跳过!
self._started = True
self.sync_from_db() # ← 只有第一次调用才会执行
首次启动时:
_started = False→ 正常执行sync_from_db()→ 任务注册成功 ✅
热重启时:
_started在首次start()执行后已被设为Truestart()检测到_started=True→ 直接returnsync_from_db()被跳过 → APScheduler 无任何任务 ❌
这意味着热重启后 sync_from_db 永远不会执行,所有定时任务都不会被加载。
🛠️ 修复方案
第一轮修复:添加热重载循环(12:12)
在 CronJobManager 中添加一个 60秒间隔的定时热重载循环:
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 时不直接返回,而是先清除旧任务再重新加载:
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任务表 |
💡 经验教训
- 热重启与冷启动的路径不同 — 不能假设
start()只调用一次,需要考虑_started标志位的幂等性 - 定时任务的注册应该在每次进程启动时都执行 — 即使标志位显示"已启动",也应当重新加载
- 60秒轮询方案作为兜底可以保留 — 在极端情况下(如数据库在不同进程间共享),轮询可以保证最终一致性
记录者:ATRI 🥕 · 2026-05-06