Skip to content

定时任务 ​

每天 8 点发日报、零点重置签到、每 5 分钟探活——这类到点自动执行的活不必挂在某条消息上。官方插件 core_plugins/scheduler 基于 APScheduler AsyncIOScheduler 提供定时能力,用户插件通过 ctx.task() 注册,开箱即用。

基本用法:ctx.task() ​

python
def register(ctx):
    ctx.task("0 8 * * *", daily_report, description="每日 8 点报告")
    ctx.task("*/5 * * * *", heartbeat, description="每 5 分钟")
    ctx.task("0 0 1 * *", monthly, description="每月 1 日 0 点")
    ctx.task("30 9 * * 1-5", weekday, description="工作日 9:30")

cron 表达式 ​

标准 5 段:分 时 日 月 周,由 APScheduler CronTrigger 解析:

位置取值例子
分0–59、*/n、a,b,c、a-b*/30
时0–239
日1–311
月1–12*
周0–6(0=周一,APScheduler 约定)或 mon-fri1-5

周字段注意

APScheduler 的 day_of_week 用 0=monday … 6=sunday,也接受 mon,tue,wed,thu,fri,sat,sun,和部分 crontab「0=周日」的习惯不同。

处理函数要求 ​

python
async def daily_report():           # 同步 def / async def 都支持
    caller = ctx.services.get("api_caller")   # 需要框架能力时从服务注册表取
    if caller:
        await caller.call("send_group_msg", group_id=123456, message="日报")
    ctx.log("日报已发送")
  • 任务函数无参数,也不接收 event/match;
  • 函数必须定义在插件主模块顶层(调度器按 handler.__name__ 从主模块取函数对象);
  • async def 在事件循环里 await;普通 def 转专用线程池执行,不阻塞事件循环;
  • 单任务异常被捕获并记录(带插件名与 handler 名),不影响其他任务;
  • 任务补触发宽限 misfire_grace_time=600 秒(进程短暂卡住后,错过 10 分钟内的任务仍补跑一次)。

时区:为什么任务会差 8 小时 ​

cron 表达式里的 9 是哪个时区的 9 点,由 scheduler.timezone 决定:

yaml
scheduler:
  timezone: local          # local(默认,系统时区)/ utc / Asia/Shanghai 等 IANA 名

local 表示跟随系统时区。容器镜像的 TZ 通常是 UTC,于是写 0 9 * * * 的任务 实际在北京时间 17:00 执行 —— 而日志里的时间戳看不出任何异常。这是这类框架最常见的静默故障。

框架启动时会打印实际生效的时区,带偏移量:

text
INFO 定时任务时区: 本地时区 (UTC+00:00)(配置 scheduler.timezone,默认 local)

看到 UTC+00:00 而你在用北京时间,就说明要显式配 Asia/Shanghai。

为什么默认不改

默认值保持 local 是为了不静默改变已有部署的任务时间。升级后行为不变, 但启动日志会把实际时区打出来,让你能一眼判断对不对。

Windows 需要 tzdata

ZoneInfo('Asia/Shanghai') 在 Windows 上依赖 tzdata 包。缺失时框架会告警并退回本地时区 (不会拒绝启动),修法:pip install tzdata。

执行模型与超时 ​

两条执行路径共用同一套标准(与命令 handler 对齐):

配置项默认说明
scheduler.task_timeout300单任务超时(秒),0 = 不限
scheduler.slow_task_threshold10耗时超该值打 warning,0 = 不告警
scheduler.workers4同步 handler 专用线程池大小

超时只是「不再等待」

Python 无法强杀线程。超时后框架不再等这个任务,记为 timeout 并发 error 日志, 但线程仍在后台把活干完。要真正可中断,必须写成 async def 并在内部支持取消。

所以:超时值是「止损」,不是「资源回收」。长任务请自己拆段并检查取消信号。

同步 handler 用的线程池与命令 handler 的默认线程池互相隔离, 一个插件的慢任务不会挤占消息处理能力。

排查:任务到底跑没跑、慢在哪 ​

get_jobs() 返回的每项都带运行记录:

json
{
  "id": "myplugin:daily_report",
  "next_run": "2026-11-05 08:00:00+08:00",
  "plugin": "myplugin",
  "handler": "daily_report",
  "cron": "0 8 * * *",
  "last_run": "2026-11-04 08:00:01",
  "last_duration_ms": 1832,
  "last_status": "ok",
  "runs": 27
}

last_status 取 ok / error / timeout。Web 面板「定时任务」页与 GET /api/runtime/stats 的 scheduler 字段同源。

  • 任务压根没注册:看启动日志里的 定时任务 handler 不存在 / cron 表达式必须是 5 段;
  • 任务注册了但没跑:对比 next_run 与当前时间,重点查时区;
  • 任务跑了但很慢:last_duration_ms 直接给出耗时,超过阈值会打 warning。

定时任务慢 ≠ 定时任务慢

如果定时任务拖慢了消息回复,看 可观测性与运维 里的 事件循环看门狗:同步任务跑在事件循环上(v1.19.0 之前的行为)时, 消息会被整段冻住,两条日志一对就清楚。

注册与同步机制 ​

  • register(ctx) 里声明的任务先写入 tasks 表,再注册到 APScheduler;
  • APScheduler 任务 ID 为 <插件名>:<函数名>,重复注册 replace_existing, 因此同一函数多次注册不会产生重复任务;
  • 插件卸载/重载时,属于该插件的任务会被整体移除后按新代码重建;
  • 心跳重新 register(ctx) 时同样先清后建,保持代码与调度一致。

高级:直接使用底层 APScheduler ​

ctx.task() 只覆盖最常用的 cron 触发。需要 interval(固定间隔)、 date(指定时刻执行一次)等触发器时,可取到底层原生 Scheduler:

python
def register(ctx):
    scheduler = ctx._framework.services.get("scheduler")
    aps = scheduler._scheduler      # 原生 apscheduler.schedulers.asyncio.AsyncIOScheduler

    from apscheduler.triggers.interval import IntervalTrigger
    from datetime import datetime
    from apscheduler.triggers.date import DateTrigger

    aps.add_job(my_job, IntervalTrigger(seconds=30),
                id="myplugin:poll_30s", replace_existing=True)
    aps.add_job(once_job, DateTrigger(run_date=datetime(2026, 1, 1, 0, 0)),
                id="myplugin:once")

底层原生 API(来自 APScheduler):

方法作用
add_job(fn, trigger, id=..., replace_existing=True)添加任务
remove_job(job_id)移除任务
pause_job(job_id) / resume_job(job_id)暂停 / 恢复
reschedule_job(job_id, trigger=..., **kw)修改触发器
get_jobs()列出全部任务
scheduler.get_jobs()(封装层)返回任务 + 下次执行时间 + 最近一次运行记录

直接 add_job 绕过执行保护

走原生 aps.add_job 注册的任务不经过框架的线程池、超时与耗时统计, 同步函数会直接跑在事件循环上。除非确实需要 interval/date 触发器, 否则优先用 ctx.task()。

任务 ID 约定

自己 add_job 时请用 <插件名>:<业务名> 前缀,这样插件卸载时 remove_plugin_tasks 才能按前缀把它一起清掉,避免幽灵任务。

管理接口 ​

  • Web 面板「定时任务」页可查看任务、下次执行时间、最近耗时并手动启停;
  • 封装层方法:scheduler.remove_task(job_id)、scheduler.get_jobs()、scheduler.stats();
  • 孤儿任务(插件已删除但任务残留)会被框架周期性自检清理。

注意事项 ​

常见坑

  • 任务函数里不要依赖某次消息的 event,任务没有消息上下文;
  • 任务里发消息优先用 services.get("api_caller") 或 ctx.onebot;
  • 任务要能幂等重跑(补触发/重启后可能立即执行一次);
  • 耗时操作写成 async def 并主动让出(await asyncio.sleep(0) / 分块), 同步长任务即使在线程池里也会占住一个 worker;
  • 部署在容器里先确认 scheduler.timezone —— 这是「任务时间差 8 小时」的唯一来源。

基于 MIT + Apache 2.0 双协议发布