定时任务
每天 8 点发日报、零点重置签到、每 5 分钟探活——这类到点自动执行的活不必挂在某条消息上。官方插件 core_plugins/scheduler 基于 APScheduler AsyncIOScheduler 提供定时能力,用户插件通过 ctx.task() 注册,开箱即用。
基本用法:ctx.task()
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–23 | 9 |
| 日 | 1–31 | 1 |
| 月 | 1–12 | * |
| 周 | 0–6(0=周一,APScheduler 约定)或 mon-fri | 1-5 |
周字段注意
APScheduler 的 day_of_week 用 0=monday … 6=sunday,也接受 mon,tue,wed,thu,fri,sat,sun,和部分 crontab「0=周日」的习惯不同。
处理函数要求
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 决定:
scheduler:
timezone: local # local(默认,系统时区)/ utc / Asia/Shanghai 等 IANA 名local 表示跟随系统时区。容器镜像的 TZ 通常是 UTC,于是写 0 9 * * * 的任务 实际在北京时间 17:00 执行 —— 而日志里的时间戳看不出任何异常。这是这类框架最常见的静默故障。
框架启动时会打印实际生效的时区,带偏移量:
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_timeout | 300 | 单任务超时(秒),0 = 不限 |
scheduler.slow_task_threshold | 10 | 耗时超该值打 warning,0 = 不告警 |
scheduler.workers | 4 | 同步 handler 专用线程池大小 |
超时只是「不再等待」
Python 无法强杀线程。超时后框架不再等这个任务,记为 timeout 并发 error 日志, 但线程仍在后台把活干完。要真正可中断,必须写成 async def 并在内部支持取消。
所以:超时值是「止损」,不是「资源回收」。长任务请自己拆段并检查取消信号。
同步 handler 用的线程池与命令 handler 的默认线程池互相隔离, 一个插件的慢任务不会挤占消息处理能力。
排查:任务到底跑没跑、慢在哪
get_jobs() 返回的每项都带运行记录:
{
"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:
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 小时」的唯一来源。