简介面向.NET开发者的Furion内置定时任务学习示例围绕任务定义、调度、持久化与配置展开适合正在学习Furion或需要在项目中快速接入后台任务调度的读者。压缩包共12个文件大小仅9KB包含7个C#源码、3个JSON配置文件、1个项目文件及数据库持久化备份源码涵盖任务类、触发模型、持久化实现配置对应环境与启动参数整体目录精简结构一目了然可按文件逐一对照学习。已有1101人学习下载。示例通过多个任务类演示继承IFurionJob接口、BackgroundJob.Enqueue调度、Cron周期设置、任务依赖等核心写法还提供数据库持久化相关实现可在本地直接运行并观察启动日志也可作为实际项目中定时任务注册、存储与管理的改写参考。对想快速理解Furion任务模块的开发者来说这是一份轻量且实用的起步材料。1. Furion内置定时任务为什么我放弃了单独引入QuartzFurion框架内置的定时任务模块我前后拆过几次结论很直接它把最常用的调度场景封装得很收敛90%的普通业务用内置的就够了没必要为了一次清理动作单独引一套Quartz再维护它的生命周期。它解决的核心问题是——任务类能不能拿到依赖注入的服务、任务怎么按声明周期启动和停止、以及在不重启服务的前提下调整触发时间。新手容易被Trigger、RunId这些术语劝退但把第一个任务跑起来之后你会发现整个调度模型就是围绕ITask接口的三个方法展开的。适合项目已经用了Furion、又不想再引一套调度框架的团队。2. 执行模型先搞明白ITask接口、任务注册方式与触发器的取舍2.1 ITask接口的三个方法Start、Execute、Stop各管什么Furion的定时任务模型和Quartz那套Job/Trigger/Calendar的抽象相比收敛到了三个方法上。定义一个任务类只需要实现ITask接口接口本身极其简单public interface ITask { Task Start(ITaskContext context, CancellationToken stoppingToken); Task Stop(ITaskContext context, CancellationToken stoppingToken); Task Execute(ITaskContext context, CancellationToken stoppingToken); }三个方法各管一段生命周期。Start在调度器加载任务时调用一次适合做初始化动作比如打开数据库连接、加载配置、预热缓存。Stop在任务被移除或服务停止时调用适合做资源释放。Execute则是每次触发时执行的核心逻辑。这里有一个新手容易误解的点在任务还没到触发时间之前Start已经执行了。也就是说Start并不是“第一次执行”而是“任务注册到调度器那一刻”的初始化回调。我把这个顺序画出来之后团队里再没人把Start当成第一次Execute来用了。public class OrderCleanTask : ITask { private readonly ILoggerOrderCleanTask _logger; private readonly IOrderService _orderService; public OrderCleanTask(ILoggerOrderCleanTask logger, IOrderService orderService) { _logger logger; _orderService orderService; } public Task Start(ITaskContext context, CancellationToken stoppingToken) { _logger.LogInformation(订单清理任务启动TriggerId{TriggerId}, context.TriggerId); return Task.CompletedTask; } public Task Stop(ITaskContext context, CancellationToken stoppingToken) { _logger.LogInformation(订单清理任务停止); return Task.CompletedTask; } public async Task Execute(ITaskContext context, CancellationToken stoppingToken) { var expiredOrders await _orderService.GetExpiredOrdersAsync(); await _orderService.CleanOrdersAsync(expiredOrders); _logger.LogInformation(清理过期订单 {Count} 条, expiredOrders.Count); } }这里有个细节需要注意ITaskContext里能拿到TriggerId、TriggerName、TaskId这些元数据。多触发器场景下同一个任务类可以被多个Cron表达式驱动Execute里通过context.TriggerId就能判断“这次是哪条规则把我叫起来的”。2.2 持久化任务与动态任务两种注册方式对应不同场景Furion里任务的注册路径有两条平时容易混。持久化任务写在配置文件里服务启动时调度器自动加载不写代码就能跑。适合那些规则相对固定、部署后不频繁变动的任务比如每天凌晨清理日志、每季度生成报表。动态任务则是在代码里手动注册到调度器适合需要从管理后台或数据库动态创建任务的场景比如用户自定义提醒。两条路径的适用边界要分清。持久化任务的优点是运维友好改配置、重启、完事代码不用动。缺点是需要重启才能生效。动态任务的优点是可以运行期调整缺点是任务注册逻辑散在业务代码里出了问题要顺着调用链找。// 动态任务注册常见做法是通过调度器服务在运行时指定TaskId、Cron和任务类型 var scheduler app.Services.GetRequiredServiceIScheduler(); await scheduler.AddTaskAsyncOrderCleanTask( order-clean-task, 订单清理任务, 0 0 2 * * ? );依赖注入在动态任务里同样有效。Furion的任务实例是从容器里拿的构造器里声明的IOrderService、ILogger这类服务都能正常注入不需要手动new。这比传统Quartz里Job无法直接拿构造函数注入的方式好用得多。2.3 触发器配置拆解Cron换算、并发开关和起止时间Furion的触发器配置用的还是Quartz那套Cron格式六位是基础七位带年。最容易混淆的是“周”字段Quartz的Cron里1代表周日2代表周一和Linux Crontab的0代表周日不一样。我之前在配置“每周五执行”时写成0 0 10 * * 5结果发现任务在周六早上跑了——因为Quartz的5对应的是周四晚上也就是周五凌晨的时间域搞错一位就是血泪教训。{ Furion: { PersistTaskScheduler: { Enabled: true, Tasks: [ { TaskId: order-clean-task, TaskName: 订单清理任务, Cron: 0 0 2 * * ?, AllowConcurrency: false, AssemblyName: MyApp.Api, TypeName: MyApp.Api.Tasks.OrderCleanTask, Description: 每天凌晨2点清理过期订单, StartTime: 2024-01-01 00:00:00, EndTime: 2025-12-31 23:59:59 } ] } } }这套配置里AllowConcurrency值得单独说。默认是true意思是上一次Execute还没跑完下一次触发又进来了两个执行实例会重叠。对清理类任务来说重叠执行可能导致数据竞争。设成false之后调度器会在上一次执行完之前跳过下一次触发。这个开关是防重入的第一道防线很多人不知道它存在。StartTime和EndTime限制的是任务的有效执行窗口。过了EndTime之后触发器不再触发Execute。这个字段在临时任务里特别有用比如“从今天开始到月底每天跑一次”不需要到期后手动删任务。3. 把一个定时任务跑起来注册、依赖注入与日志落地的完整实现3.1 任务类的实现细节异步方法、取消令牌与幂等设计画出完整实现之前先把几个容易写错的点指出来。Execute方法是Task返回类型如果里面用了await就必须声明成async。很多人把异步方法写成了async void异常一旦抛出整个进程可能直接崩掉。取消令牌stoppingToken是调度器在服务关闭时发给任务的信号。任务里的长循环要适当检查这个令牌否则服务停止时任务还在跑回收线程一直等它结束就可能拖慢停机流程。public class DataArchiveTask : ITask { private readonly IArchiveService _archiveService; private readonly ILoggerDataArchiveTask _logger; public DataArchiveTask(IArchiveService archiveService, ILoggerDataArchiveTask logger) { _archiveService archiveService; _logger logger; } public async Task Execute(ITaskContext context, CancellationToken stoppingToken) { var batchSize 1000; var offset 0; while (!stoppingToken.IsCancellationRequested) { var rows await _archiveService.GetExpiredRowsAsync(offset, batchSize); if (rows.Count 0) break; await _archiveService.ArchiveAsync(rows); offset rows.Count; _logger.LogInformation(归档进度已处理 {Offset} 条, offset); } } }幂等设计是这一步的重点。定时任务的Execute可能因为网络抖动、服务重启、调度器补偿机制而重复执行。任务逻辑里要尽量保证重复执行不会产生重复数据。常见的做法是记录每批数据的处理游标或者用业务主键做去重。3.2 注册任务的完整流程从appsettings到调度器加载配置持久化任务之后还需要确保Furion的调度器在服务启动时被激活。你在Program.cs里要做的其实就一行var builder WebApplication.CreateBuilder(args); builder.Services.AddFurion(); var app builder.Build(); app.Run();AddFurion()会在启动流程里自动读取Furion节点下的PersistTaskScheduler配置把Enabledtrue的任务注册到调度器。不需要显式调用StartAsync之类的方法这是框架替你做的。如果任务没跑起来先检查三个地方。第一Furion:PersistTaskScheduler:Enabled是否为true漏配这个开关的话调度器根本不会启动。第二AssemblyName和TypeName是否拼写正确TypeName必须是全名称包含命名空间。第三任务类的程序集是否被加载进了当前应用域如果任务类放在单独的项目里要确认那个项目被引用了。{ Furion: { PersistTaskScheduler: { Enabled: true, Tasks: [ { TaskId: data-archive-task, TaskName: 数据归档任务, Cron: 0 30 3 * * ?, AssemblyName: MyApp.Api, TypeName: MyApp.Api.Tasks.DataArchiveTask, AllowConcurrency: false } ] } } }我一般会在任务类里写一段启动日志来确认是否被加载成功。Start方法里输出一条信息级别的日志启动后看服务日志如果没看到就说明配置没生效或者类型没找到。3.3 日志追踪执行记录怎么打到独立文件里定时任务比普通接口更难排查问题因为它的执行时间不固定出问题的时候你未必在电脑前。日志做强一点能省很多事。Furion的定时任务可以用ILogger直接在任务类里打印。但默认日志配置下任务日志和服务日志混在一起排查时很不方便。我一般会单独给任务日志开一个独立文件配置Serilog或NLog按TaskName做过滤。public class DataArchiveTask : ITask { private readonly ILoggerDataArchiveTask _logger; public async Task Execute(ITaskContext context, CancellationToken stoppingToken) { var startTime DateTime.UtcNow; _logger.LogInformation(任务开始执行任务ID{TaskId}触发器ID{TriggerId}, context.TaskId, context.TriggerId); try { // 执行任务主体逻辑 await DoArchiveAsync(); _logger.LogInformation(任务执行成功耗时 {Elapsed}ms, (DateTime.UtcNow - startTime).TotalMilliseconds); } catch (Exception ex) { _logger.LogError(ex, 任务执行失败任务ID{TaskId}耗时 {Elapsed}ms, context.TaskId, (DateTime.UtcNow - startTime).TotalMilliseconds); throw; } } }这里要把异常记录完再重新抛出抛出去的异常调度器会捕获并标记本次执行失败。如果不重新抛出调度器会以为执行成功了你会在监控里看到虚假的成功记录。4. 定时任务避坑不执行、重复执行、异常丢失的4条实战记录4.1 任务不执行Cron看着没问题但就是没触发现象任务配置都写在appsettings里了启动日志也没报错但到了预定时间就是没反应。原因排查到最后发现Cron写的是0 0 12 * * *这是六位格式但Quartz的Cron格式要求至少六位其中前两位是秒和分。0 0 12 * * *表示每天12点整其实是对的。真正的问题是TypeName写的是不带命名空间的类名程序集里找不到这个类型加载失败但没报明显错误。解决把TypeName改成命名空间加类名的完整格式启动后检查Start方法是否有日志输出。如果日志没有说明类型加载阶段已经失败优先查TypeName和AssemblyName。另外Cron的“日”和“周”字段不能同时用*否则调度器可能解析异常。标准写法是日字段用?周字段用具体值或者反过来。4.2 任务重复执行多实例部署下的并发问题现象同一套服务部署了两个实例数据库里发现定时任务生成的数据变成了两倍每一条都重复执行了一次。原因Furion自带的调度器是进程内的它不知道集群里还有其他实例。每个实例都独立加载了同样的配置都在跑同一个任务。解决这是分布式调度问题Furion内置调度器不解决跨实例协调。我一般用两种手段处理。第一种任务里用数据库锁或Redis分布式锁做互斥拿到锁的实例才执行。第二种把任务执行入口收敛到独立部署的调度节点上业务API只管提供数据接口不直接跑任务。public async Task Execute(ITaskContext context, CancellationToken stoppingToken) { // 用Redis分布式锁保证同一时间只有一个实例在执行 var lockKey task:order-clean:lock; var lockToken Guid.NewGuid().ToString(N); var acquired await _redis.LockTakeAsync(lockKey, lockToken, TimeSpan.FromMinutes(30)); if (!acquired) { _logger.LogInformation(任务被其他实例占用本次跳过); return; } try { // 任务核心逻辑 } finally { await _redis.LockReleaseAsync(lockKey, lockToken); } }4.3 异常日志为空Execute里的异常被吞掉了现象任务接口返回成功数据库里也有记录但业务上该处理的数据没处理日志里什么都没有。原因Execute方法里没有try/catch但你在配置里开了某个全局异常过滤器把调度器的异常捕获之后没重新记录。或者任务类里用了async void的事件处理异常发生在另一个上下文里。解决任务类里必须有完整的try/catch/finally结构。记录日志后是否抛出异常取决于你要不要调度器知道执行失败。如果任务失败不影响后续触发可以吞掉异常。如果失败需要被监控务必在日志记录后重新抛出。4.4 改配置后不生效新旧触发器标记的缓存问题现象改动了appsettings里的Cron表达式重启服务之后任务还在按旧计划执行。原因Furion的持久化任务会在数据库表里记录任务元数据。如果之前运行过一次调度器从数据库读取到旧的Cron会用数据库里的值覆盖配置文件里的新值。配置文件只在第一次注册时写入数据库后续以数据库为准。解决改配置后不只要更新appsettings.json还要同步更新数据库里对应的任务记录。或者把任务删掉重来。我在本地调试时试过直接改配置文件翻车之后才发现任务表里还留着一份Trigger记录。从那以后我每次改Cron表达式都会先查一下持久化表里有没有旧数据两边同步改完再重启。5. 超出基础用法动态启停、并发控制与执行观测的实战public class TaskManageService { private readonly IScheduler _scheduler; public TaskManageService(IScheduler scheduler) { _scheduler scheduler; } public async Task PauseTaskAsync(string taskId) { await _scheduler.PauseTaskAsync(taskId); } public async Task ResumeTaskAsync(string taskId) { await _scheduler.ResumeTaskAsync(taskId); } }动态启停解决的是“任务正在跑但发现跑错了”的应急场景比改配置文件再重启要轻得多。我一般把PauseTask和ResumeTask封装成API接口接入管理后台运营同学自己就能操作。并发控制除了分布式锁还可以利用配置里的AllowConcurrencyfalse做单实例防重入。但这两个是不同层级的问题AllowConcurrency管的是同一实例内不重叠分布式锁管的是跨实例互斥。生产环境我两个都开配置层面防重入锁层面防多实例竞争。最后说一个我自己的习惯。我会给每个任务都加一个last_execute_time记录表任务启动时写一行执行完更新状态和耗时。配合一个简单的查询接口就能看到每个任务的执行历史不用翻原始日志。这个小表帮我省了不少排查时间尤其是那些凌晨才跑的任务。从那以后我每次接新任务都强制走一遍完整的验证流程先确认Cron的有效性再确认任务类能被加载最后看一次日志输出确认Start和Execute都正常。希望帮到你。本文还有配套的精品资源点击获取