ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

ASP.NET Core WebAPI任务调度实战:从BackgroundService到分布式锁

ASP.NET Core WebAPI任务调度实战:从BackgroundService到分布式锁 前阵子接了个需求后台服务里要加定时任务每天凌晨两点跑对账、每五分钟扫描一次待支付订单、每小时清理一次缓存日志。ASP.NET Core 的 WebAPI 里做任务调度表面看挺简单BackgroundService继承一下、ExecuteAsync重写一下定时跑完就完事。可真把服务部署上去各种坑接踵而至任务偶尔不跑了、下半夜开始丢周期、数据库里莫名出现重复处理的数据、发布到 IIS 之后任务干脆消失……这篇文章把我在 WebAPI 任务调度上踩过的坑、排查链路和最终方案完整记录下来。不管你是用 VS 还是 VS Code 把服务跑起来这些坑大概率都会遇到。适合正在做后台定时任务、把 ASP.NET Core 当调度底座、或者准备从“循环 Sleep”升级成正经调度方案的人参考。1. 任务调度选型先把需求想清楚别急着上外部库1.1 “先上 Quartz.NET”的惯性冲动很多从 Java 转过来的人第一反应是“定时任务上 Quartz 吧”。这个思路不坏毕竟 Quartz.NET 的 CRON 表达式、任务持久化、集群模式都成熟。但 WebAPI 里的定时任务真的需要这些吗我在项目里就犯过这样的错误。一开始的需求只是“每天凌晨备份一次数据库表”我兴冲冲引入 Quartz.NET配了IScheduler、IJob、IJobDetail、ITrigger任务本身十行代码调度框架的配置写了上百行。后来需求变了从“每天一次”改成“每五分钟扫描一次”Quartz 的 CRON 改写起来还行但每次调试都要看调度日志确认下次触发时间成本明显偏高。如果你的需求是“周期固定、逻辑简单、不需要失败重试、不需要管理界面”那 ASP.NET Core 内置的BackgroundService就是最合适的选择。少一层抽象少一堆概念出问题时排查链路也短。1.2 主流的几种方案到底怎么选我整理了一下当前 .NET 生态里常用的几种任务调度方案直接列个对比表方案学习成本调度能力持久化重试机制适用场景BackgroundServicePeriodicTimer低固定周期执行无无周期固定、逻辑简单的后台任务Quartz.NET中高CRON 表达式灵活支持数据库可自行实现复杂调度规则、需要任务持久化Hangfire中CRON 队列触发支持数据库内置自动重试需要任务面板、失败重试、可视化监控IHostedService自定义中完全可控无无需要精细控制宿主生命周期这里不是否定 Quartz.NET 和 Hangfire 的价值。Hangfire 内置的 Dashboard、失败重试、任务队列做业务型后台任务确实方便。但要注意Hangfire 自身需要存储Redis 或 SQL Server 都行多一层依赖部署时就多一个需要考虑的点。我的默认结论是只是“周期执行一段逻辑”BackgroundService就够本文后面讲的都是围绕这条线展开的。需要 CRON 表达式表达复杂的调度日历比如“每月最后一个工作日”且不想自己写时间计算上 Quartz.NET。需要失败自动重试、任务进度可视化、团队里非开发人员也能看着面板重跑任务直接上 Hangfire别自己造轮子。上面这些方案本身没有高下之分关键是你的场景需不需要它的复杂度。2. 第一个坑Task.Delay循环导致的时间漂移2.1 典型的错误写法Task.Delay定时器这是我在代码评审里见过最多的后台任务实现public class CleanupTask : BackgroundService { private readonly ILoggerCleanupTask _logger; public CleanupTask(ILoggerCleanupTask logger) { _logger logger; } protected override async Task ExecuteAsync(CancellationToken stoppingToken) { while (!stoppingToken.IsCancellationRequested) { try { await DoWorkAsync(stoppingToken); await Task.Delay(TimeSpan.FromMinutes(5), stoppingToken); } catch (OperationCanceledException) { break; } catch (Exception ex) { _logger.LogError(ex, CleanupTask 执行失败); await Task.Delay(TimeSpan.FromMinutes(5), stoppingToken); } } } }这段代码表面看没问题干完活等五分钟再干活。但仔细算一下执行周期问题立刻暴露。2.2 周期失真DoWork耗时被悄悄加进了间隔如果DoWorkAsync的执行时间是均匀的比如每次都是 1 分钟那实际周期是 5 1 6 分钟不是 5 分钟。如果任务耗时不稳定比如数据库偶发慢查询某一次跑了 8 分钟那这一轮的周期就是 13 分钟。长期运行下来任务真正执行的时间点会不断往后漂移而且无法预测。这个影响在“每五分钟扫描一次待支付订单”的场景下特别明显。订单在 12:00 创建扫描任务本来应该 12:05 处理但因为前面几轮任务都慢了一点点实际扫描时间变成了 12:09业务上就出现“订单超过五分钟了还没被扫描到”的投诉。更隐蔽的问题在 Windows 机器休眠后恢复。Task.Delay依赖的是系统计时器休眠期间计时器是否继续计时、恢复后会不会立即触发在不同 Windows 版本上行为并不一致。我实测过机器休眠唤醒之后总是出现“任务补跑”的现象本该在休眠期间执行的周期被跳过唤醒后一次性连续执行好几次。如果你的任务不是幂等的这种突发连续执行很容易造成数据问题。2.3 正确做法用PeriodicTimer而不是Task.Delay.NET 6 开始提供了PeriodicTimer解决的就是“按固定周期触发”这个需求。它跟我之前用Task.Delay的最大区别是PeriodicTimer以固定的时间基准计算下一次触发时间不受任务执行耗时影响如果任务执行时间超过了周期它会跳过中间的多次触发只在下一次到期时继续不会累积排队。干净的写法如下public class CleanupTask : BackgroundService { private readonly ILoggerCleanupTask _logger; public CleanupTask(ILoggerCleanupTask logger) { _logger logger; } protected override async Task ExecuteAsync(CancellationToken stoppingToken) { using var timer new PeriodicTimer(TimeSpan.FromMinutes(5)); while (await timer.WaitForNextTickAsync(stoppingToken)) { try { await DoWorkAsync(stoppingToken); } catch (Exception ex) { _logger.LogError(ex, CleanupTask 执行失败); } } } }注意WaitForNextTickAsync返回ValueTaskboolstoppingToken被取消时返回falsewhile循环自动退出。PeriodicTimer本身实现了IDisposable用using包住即可。换成PeriodicTimer之后至少“周期漂移”这个问题彻底消失。但我得提醒一句如果你需要的是“每天凌晨 2 点执行”而不是“每 24 小时执行一次”PeriodicTimer并不直接解决这个问题你还是要自己计算到下一个 2 点的间隔。这时候要么引入 Quartz.NET要么自己写一个小的NextOccurrence计算别在PeriodicTimer上硬凑。3. 第二个坑BackgroundService 里的依赖注入和作用域陷阱3.1 报错现场回放单例服务里注入了 Scoped 服务项目里做数据清理我需要在后台任务里访问DbContext于是很自然地写了这样的代码public class CleanupTask : BackgroundService { private readonly AppDbContext _dbContext; public CleanupTask(AppDbContext dbContext) { _dbContext dbContext; } protected override async Task ExecuteAsync(CancellationToken stoppingToken) { // 直接用 _dbContext 操作数据库 } }然后在Program.cs里注册builder.Services.AddHostedServiceCleanupTask();结果一启动应用直接崩了报错信息包含类似这样的关键句Cannot consume scoped service AppDbContext from singleton CleanupTask.我当时第一反应是“我明明注册了 DbContext 啊”后来才意识到这是生命周期问题。3.2 生命周期规则单例不能依赖 Scoped要理解这个报错需要先讲清楚 ASP.NET Core 的依赖注入生命周期。Singleton单例整个应用只创建一个实例生命周期最长。Scoped作用域通常对应一次 HTTP 请求有独立的DbContext。Transient瞬态每次解析都创建新实例。BackgroundService本身被注册为Singleton它会在应用启动时创建一次并一直存活。而DbContext注册的是Scoped它的设计假设是“一个请求作用域内使用”不能安全地被单例长期持有。单例依赖 Scoped本质上是要求一个生命周期更长的对象去管理一个生命周期更短的对象这在依赖注入容器里是不允许的所以容器在启动验证时就拒绝了。这种设计的底层原因是DbContext内部有ChangeTracker设计上不应该在多个并发操作中共享一个实例。如果让单例后台任务持有一个DbContext它在后台线程里被多个周期任务并发使用轻则数据错乱重则内存泄漏、连接池耗尽。3.3 用 IServiceScopeFactory 手动创建作用域正确做法是让后台任务持有IServiceScopeFactory每次周期执行时手动创建一个新的 scope再从这个 scope 里解析AppDbContext。这样每一个执行周期都有独立的DbContext跟处理一个 HTTP 请求时的情况一致。代码是这样写的public class CleanupTask : BackgroundService { private readonly IServiceScopeFactory _scopeFactory; private readonly ILoggerCleanupTask _logger; public CleanupTask(IServiceScopeFactory scopeFactory, ILoggerCleanupTask logger) { _scopeFactory scopeFactory; _logger logger; } protected override async Task ExecuteAsync(CancellationToken stoppingToken) { using var timer new PeriodicTimer(TimeSpan.FromMinutes(5)); while (await timer.WaitForNextTickAsync(stoppingToken)) { try { using var scope _scopeFactory.CreateScope(); var dbContext scope.ServiceProvider.GetRequiredServiceAppDbContext(); await DoWorkAsync(dbContext, stoppingToken); } catch (Exception ex) { _logger.LogError(ex, CleanupTask 执行失败); } } } }这里有几个细节容易踩CreateScope()返回的 scope 必须手动Dispose用using包住是标准写法。忘记释放会导致 scope 和里面的 DbContext 一直得不到回收。scope 不能跨多个执行周期共用。有人图省事在ExecuteAsync外面创建一个 scope然后循环里一直用这和单例持有 DbContext 没区别换汤不换药。如果你在任务里还要访问HttpClient不要每次都new HttpClient()应该注入IHttpClientFactory。IHttpClientFactory注册的是 Singleton可以在后台任务里直接注入不用走 scope。4. 第三个坑任务重叠执行与并发保护4.1 重叠是怎么发生的用PeriodicTimer定时每 5 分钟跑一次任务看起来任务不可能重叠因为下一次触发要在 5 分钟之后。但现实是如果某一次任务执行时间超过 5 分钟PeriodicTimer的行为是跳过中间周期、等待任务执行完再触发下一次吗不是。PeriodicTimer的机制是每次等待结束就触发回调不管上一次回调是否还在执行。也就是说如果任务执行了 8 分钟第 5 分钟的时候新周期的回调照样会进入跟上一个任务并发执行。PeriodicTimer只保证“触发时间准确”不保证“回调不并发”。这个问题在开发环境几乎不会暴露因为本地数据量小、任务秒级完成。到了生产环境某次大批量数据操作拖慢了任务下一轮触发就来了两个任务同时跑同时查待处理订单、同时更新状态数据就乱了。4.2 一次真实的重复处理事故当时我们有一个订单超时关单任务每 5 分钟跑一次找到超时未支付的订单把状态改成关闭再推送通知。有一次数据库出现慢查询单次任务执行时间从正常的几十秒拖到了 7 分钟。结果新的周期触发后两个任务实例同时扫描订单都查出同一批超时订单都执行了关单操作然后各自发了一次通知。用户收到的体验是“连续两条订单关闭通知”。排查日志的时候才发现两个任务实例的执行时间确实重叠了 2 分钟以上。这个问题的根因就是我上面分析的触发周期与执行耗时之间没有互斥保护。4.3 用 SemaphoreSlim 做进程内互斥解决方式不复杂核心就是“同一时间只允许一个任务实例执行”。进程内最简单的方式是用SemaphoreSlim(1, 1)public class CleanupTask : BackgroundService { private readonly SemaphoreSlim _isRunning new SemaphoreSlim(1, 1); private readonly IServiceScopeFactory _scopeFactory; private readonly ILoggerCleanupTask _logger; public CleanupTask(IServiceScopeFactory scopeFactory, ILoggerCleanupTask logger) { _scopeFactory scopeFactory; _logger logger; } protected override async Task ExecuteAsync(CancellationToken stoppingToken) { using var timer new PeriodicTimer(TimeSpan.FromMinutes(5)); while (await timer.WaitForNextTickAsync(stoppingToken)) { // WaitAsync(0) 表示非阻塞获取获取不到直接返回 false if (!await _isRunning.WaitAsync(0, stoppingToken)) { _logger.LogWarning(上一个周期还未执行完成本次触发跳过); continue; } try { using var scope _scopeFactory.CreateScope(); var dbContext scope.ServiceProvider.GetRequiredServiceAppDbContext(); await DoWorkAsync(dbContext, stoppingToken); } catch (Exception ex) { _logger.LogError(ex, CleanupTask 执行失败); } finally { _isRunning.Release(); } } } }WaitAsync(0)是非阻塞尝试获取信号量。能获取到说明没有任务在跑进入工作获取不到说明上一个周期还没结束直接跳过本次避免并发进入。这里有几个注意点finally里释放信号量是必须的否则任务执行中抛异常信号量永远不会被释放之后所有周期都会因为等不到信号量而被跳过任务变相“哑火”。跳过周期时一定要打日志否则以后排查“为什么任务没跑”的时候光看结果完全想不起来是重叠跳过了。这个方案只解决单进程内的重叠。多个实例部署时进程内的SemaphoreSlim管不是另一个进程里的任务这种情况我在后面第五节专门讲。5. 第四个坑发布到 IIS 之后任务“消失”5.1 本地正常、发布到服务器就罢工任务在本机和 IIS Express 里跑得好好的一发布到 Windows Server 的 IIS 上就出现各种诡异现象有时候任务运行一段时间后彻底不跑了有时候服务器半夜回收后任务不见但有用户访问一下网站任务又“复活”了。一开始我怀疑是代码问题加了一堆日志发现日志在某一个时间点之后就再也没有输出过但是w3wp.exe进程还活着。这说明任务不是报错崩溃而是整个托管进程被回收了。5.2 应用池回收机制BackgroundService 跟着进程陪葬ASP.NET Core 应用在 IIS 上是以w3wp.exe进程运行的。IIS 应用池有一个默认行为如果应用在 20 分钟内没有收到任何 HTTP 请求应用池会进入空闲状态IIS 回收整个工作进程。进程都没了后台任务自然就不跑了。这还不是最坑的。IIS 的工作进程回收是“先创建新进程再关闭旧进程”理论上新进程启动后应该重新执行AddHostedService注册的BackgroundService。但回收之后如果没有新的 HTTP 请求进来新进程确实创建了应用却不一定完成初始化因为 ASP.NET Core 的托管模块需要收到请求才会触发应用的完整启动后台任务也就没有真正跑起来。这就能解释“偶尔停了一访问又好了”的现象用户访问了一个页面IIS 把请求转给 ASP.NET Core应用完成初始化BackgroundService再次启动。5.3 治标方案调整 IIS 应用池配置如果短期不想迁移部署方式可以先在 IIS 管理器里调整应用池配置把“回收”里的“固定时间间隔”调大或者设置为 0 表示不按时间回收。把“回收”里的“虚拟/专用内存限制”设置为 0禁用内存回收。在“高级设置”中把“启动模式”设为AlwaysRunning。把“空闲超时分钟”设为 0禁用空闲回收。打开“禁用重叠回收”避免新旧进程切换时任务状态丢失。这些配置能缓解问题但 IIS 的回收机制太复杂即使这么配置了系统更新、服务器重启、应用池崩溃等情况依然会让任务罢工。所以我最终的结论是WebAPI 里跑长期后台任务别依赖 IIS 托管。5.4 推荐方案Windows 服务或容器我的最终方案是把 API 和后台任务一起宿存在 Windows 服务里不经过 IIS。实现方式很简单在Program.cs里加入using Microsoft.Extensions.Hosting.WindowsServices; var builder WebApplication.CreateBuilder(args); builder.Host.UseWindowsService(options { options.ServiceName MyApiService; });UseWindowsService会做几件事让应用作为 Windows 服务运行服务生命周期和宿主生命周期绑定停止服务时优雅触发CancellationToken。发布之后用管理员权限创建 Windows 服务sc create MyApiService binPath C:\publish\MyApi.exe start auto然后在服务管理器里设置“恢复”选项让服务在意外退出后自动重启。如果你平时直接用dotnet MyApi.dll跑在服务器上问题同样存在——一个普通的控制台进程没人看管崩溃了不会被拉起来机器重启也不会自动启动。Windows 服务或者容器Docker --restart unless-stopped二选一长期跑的任务必须有守护者。5.5 另一个隐蔽陷阱未捕获异常会让任务“无声死亡”还有一个跟宿主生命周期强相关的坑ExecuteAsync中如果有未捕获的异常在 .NET 6 及之后的版本里应用的宿主会默认停止整个应用。也就是说任务代码突然抛了一个你没接住的异常理论上宿主会关闭整个服务。有人会问“我不是在ExecuteAsync里 try/catch 了吗”这里有个细节如果你 catch 的是所有异常但忘了处理OperationCanceledException会在应用停止时把取消信号当成错误吞掉任务无法优雅退出。相反有些异常是你 catch 不到或者 catch 后重新抛出的比如StackOverflowException、OutOfMemoryException这些进程级异常它们本来就不应该被业务代码接住。我的做法是ExecuteAsync的最外层 catchException并记录日志任务主体内部再根据业务需要细分异常处理。保证任何可控异常都不能从ExecuteAsync逃逸出去。遇到进程级异常与其硬扛不如让守护进程Windows 服务或 Docker把它拉起来至少比“带病运行”强。6. 第五个坑多实例部署下的重复执行6.1 从进程锁失效说起如果你用了上文说的SemaphoreSlim互斥然后部署了两个实例比如一台服务器跑了两个服务进程或者负载均衡后面挂了两台机器会发现互斥完全失效两个实例各自持有一个信号量互不可见任务照样在两个进程里同时执行。这个问题的本质是进程内互斥能管住的只是一个进程里的多个线程跨进程就必须用分布式锁。那有哪些方案我按依赖从少到多排一下数据库乐观锁/行锁RedisSETNX专门的任务调度框架Quartz.NET 集群、Hangfire6.2 零依赖方案数据库行锁如果项目里已经有 SQL Server并且不想额外引入 Redis用数据库锁是最低成本方案。核心思路在任务表里记录任务当前是否被某个实例锁定只有拿到锁的实例才执行任务。表结构可以是这样CREATE TABLE TaskLocks ( TaskName NVARCHAR(128) PRIMARY KEY, LockOwner NVARCHAR(64) NOT NULL, ExpireTime DATETIME NOT NULL );任务执行前尝试“抢占”锁public async Taskbool TryAcquireLockAsync(string taskName, string owner, TimeSpan duration) { var sql UPDATE TaskLocks SET LockOwner owner, ExpireTime DATEADD(SECOND, seconds, GETDATE()) WHERE TaskName taskName AND (ExpireTime GETDATE()); var affected await _dbContext.Database .ExecuteSqlRawAsync(sql, new SqlParameter(taskName, taskName), new SqlParameter(owner, owner), new SqlParameter(seconds, duration.TotalSeconds)); return affected 0; }但这里有个前提TaskLocks表里必须已经有对应任务的初始行。如果行不存在UPDATE永远影响 0 行锁永远拿不到。所以初始化时要预置一行或者改用MERGE/先INSERT后UPDATE的逻辑。拿到锁之后执行任务执行完释放锁把ExpireTime改成过去时间同时要处理崩溃场景如果任务执行到一半进程挂了锁一直不释放后续所有实例都拿不到锁。所以锁一定要带过期时间上面 SQL 里的ExpireTime就是这个作用。过期时间不能太短否则长任务会被误判为“锁过期”被另一个实例抢走也不能太长否则进程崩溃后要等很久才能恢复调度。经验值是“任务预期最大耗时的 2-3 倍”。数据库锁是分布式场景下最简单可靠的方案。它有一个缺点如果任务执行时间特别长数据库连接要一直占着对连接池有压力。但绝大多数后台任务场景这是完全够用的。6.3 到底什么时候才需要“真正的”分布式锁如果你的项目已经有 Redis用StackExchange.Redis做分布式锁更轻快var db _redis.GetDatabase(); var lockKey task:cleanup:lock; var owner Guid.NewGuid().ToString(N); var acquired await db.StringSetAsync(lockKey, owner, TimeSpan.FromMinutes(10), When.NotExists); if (acquired) { try { await DoWorkAsync(); } finally { await db.KeyDeleteAsync(lockKey); } }StringSetAsync带上When.NotExists等价于SETNX同时设置过期时间避免进程崩溃后锁一直不释放。但我要泼一盆冷水大多数业务场景根本不需要分布式锁。如果你只是在一台服务器上部署一个服务进程数据库锁和 Redis 锁都是在给自己增加复杂度。我见过最离谱的情况是两个人为了“多实例部署”这个还不存在的场景引入了一套 Redis 集群最后锁的 bugs 比任务本身还多。正确的判断顺序是是不是只有一个实例是就用进程内SemaphoreSlim。是不是多个实例是有没有数据库有就用数据库锁。多个实例且已有 Redis是再用 Redis 分布式锁。多个实例且任务非常多、需要统一管理上 Hangfire 或 Quartz.NET 集群。7. 手动触发与状态可见性给调度装一个“遥控器”7.1 需求来了运维和前端都想看任务状态定时任务上线一段时间后总会收到新需求运维想在服务发生问题时手动重跑一次任务运营想在前端页面上看到“上次对账是什么时候、失败了多少条”前端因为要做状态展示需要一个 API 查任务执行情况。这些需求乍看跟“任务调度”没什么关系但如果你一开始没有给后台任务预留接口后面加会非常痛苦。BackgroundService是后台运行的它跟 WebAPI 的请求处理是两个世界要让外部触发它必须设计一个通道。7.2 用 Channel 实现任务触发System.Threading.Channels是 .NET 官方提供的高性能生产者/消费者队列非常适合做这个场景。先定义一个任务调度服务public class TaskScheduler { private readonly Channelstring _channel Channel.CreateUnboundedstring(); public void Trigger(string taskName) { _channel.Writer.TryWrite(taskName); } public ChannelReaderstring Reader _channel.Reader; }把它注册成单例builder.Services.AddSingletonTaskScheduler();在BackgroundService里同时监听周期触发和手动触发public class CleanupTask : BackgroundService { private readonly TaskScheduler _scheduler; private readonly IServiceScopeFactory _scopeFactory; private readonly ILoggerCleanupTask _logger; public CleanupTask( TaskScheduler scheduler, IServiceScopeFactory scopeFactory, ILoggerCleanupTask logger) { _scheduler scheduler; _scopeFactory scopeFactory; _logger logger; } protected override async Task ExecuteAsync(CancellationToken stoppingToken) { var timerTask RunOnTimerAsync(stoppingToken); var triggerTask RunOnTriggerAsync(stoppingToken); await Task.WhenAll(timerTask, triggerTask); } private async Task RunOnTimerAsync(CancellationToken stoppingToken) { using var timer new PeriodicTimer(TimeSpan.FromMinutes(5)); while (await timer.WaitForNextTickAsync(stoppingToken)) { await ExecuteOnceAsync(timer, stoppingToken); } } private async Task RunOnTriggerAsync(CancellationToken stoppingToken) { await foreach (var taskName in _scheduler.Reader.ReadAllAsync(stoppingToken)) { if (taskName cleanup) { await ExecuteOnceAsync(manual, stoppingToken); } } } private async Task ExecuteOnceAsync(string source, CancellationToken stoppingToken) { try { using var scope _scopeFactory.CreateScope(); var dbContext scope.ServiceProvider.GetRequiredServiceAppDbContext(); _logger.LogInformation(清理任务开始触发来源: {Source}, source); await DoWorkAsync(dbContext, stoppingToken); _logger.LogInformation(清理任务完成触发来源: {Source}, source); } catch (Exception ex) { _logger.LogError(ex, 清理任务执行失败触发来源: {Source}, source); } } }然后在 Controller 里开一个接口[ApiController] [Route(api/tasks)] public class TasksController : ControllerBase { private readonly TaskScheduler _scheduler; public TasksController(TaskScheduler scheduler) { _scheduler scheduler; } [HttpPost({taskName}/trigger)] public IActionResult Trigger(string taskName) { _scheduler.Trigger(taskName); return Accepted(); } }这样前端和运维就能通过POST /api/tasks/cleanup/trigger手动触发任务后台任务也会在下一次调度周期外响应这个调用。7.3 任务执行状态的记录与查询只看触发还不够还得让外部知道任务到底跑没跑、跑没跑完、成功还是失败。我通常在调度服务里放一个简单的状态存储public class TaskStatusStore { private readonly ConcurrentDictionarystring, TaskSnapshot _states new(); public void MarkRunning(string taskName) { _states[taskName] new TaskSnapshot { TaskName taskName, IsRunning true, LastStartTime DateTimeOffset.UtcNow }; } public void MarkFinished(string taskName, bool success, string? error null) { var snapshot _states.GetOrAdd(taskName, new TaskSnapshot { TaskName taskName }); snapshot.IsRunning false; snapshot.LastEndTime DateTimeOffset.UtcNow; snapshot.LastSuccess success; snapshot.LastError error; } public IReadOnlyListTaskSnapshot GetAll() _states.Values.ToList(); }后台任务每次执行前调用MarkRunning执行完在finally里调用MarkFinished。Controller 加一个查询接口[HttpGet(status)] public IActionResult GetStatus([FromServices] TaskStatusStore store) { return Ok(store.GetAll()); }Vue 前端就可以定时轮询这个接口拿到任务状态。在实际对接时有几个点很值得注意TaskStatusStore里保存的状态是内存态服务重启后状态会丢失。如果前端需要“历史执行记录”还是得落到数据库我一般会建一张task_execution_logs表每次任务执行都写一条记录。如果前端轮询状态时发现上次执行失败了最好提供一个“重新触发”的按钮配合上面的POST /api/tasks/{taskName}/trigger接口使用。任务状态接口返回的时间字段建议统一使用 UTC前端再根据本地时区转换避免不同服务器时区导致的展示混乱。这节内容也可以顺手解决另一个常见需求如果定时任务生成了文件比如对账报表前端要下载注意在返回File时通过Content-Disposition里的filename做 URL 编码保证中文文件名不乱码。这个坑不大但很磨人这里提一句就当避雷了。7.4 状态查询接口的进阶动态注册任务如果任务数量很多逐个配置状态展示会很繁琐。可以考虑把任务注册信息做成动态的每个后台任务启动时往TaskStatusStore里注册自己的名称、描述、调度周期、上次执行时间前端直接通过一个接口拿到所有任务的元信息和实时状态。这个设计的好处是新增一个后台任务时前端页面不需要改代码状态面板会自动多出对应的卡片。文本格式上前台展示的任务列表直接由后台返回前端只做渲染前后端解耦更彻底。我实测下来这个做法在中小型项目里性价比很高。写在最后的一点体会整套跑下来我最大的体会是任务调度出问题从来不是“任务没有启动”这种低级问题而是“任务不规则地不执行”、“任务重叠执行”、“任务在多个实例里重复执行”这些隐蔽问题。排查到最后原因往往不是某个库的功能缺失而是对宿主生命周期、依赖注入生命周期、部署模型的理解不够完整。现在我再接新的后台任务需求会先问三个问题单实例还是多实例任务可不可以重叠失败要不要重试答案都清楚了代码写起来也就清楚了。最后分享一个实用小技巧给每个任务加一条开始和结束的结构化日志包含任务名、触发来源、耗时、结果。平时这些日志看着烦但一旦要排查“任务到底跑没跑、跑了多久、成没成功”这种问题这就是你最快定位问题的抓手。别嫌日志多关键时候它能救命。
返回列表