简介:这套 C# 与 SQL 结合的任务调度平台,面向 .NET 开发者和运维人员,是一套可实际部署的分布式调度方案,由 Web 管理站点与 WinServer 节点服务两部分组成,用于集中发布、调度和监控后台作业,适合中小型系统统一管理后台任务的场景。压缩包约 71.89MB,内含数据库安装脚本、Web 站点程序、节点服务端程序及配套配置文件;部署时需依次执行数据库脚本、配置站点与 web.config、注册服务节点并调整 config 文件,同时保持至少两个节点在线,以便理解多节点协同的工作方式。平台内置错误邮件发送与长时间运行任务检测两类系统级任务,展示了任务监控、异常告警与运行状态检测的落地实现。已有 104 人学习下载,适合需要搭建任务调度基础设施或研究 .NET 分布式调度实现的中级开发者,可获得从环境准备到任务上线的完整思路,以及多节点部署与配置排错经验。
1. 从一张凌晨值班表说起:这套 C# + SQL 任务调度平台到底管什么
我见过一个挺典型的场景:十几台应用服务器上各挂着三五个 Windows 计划任务,有的跑数据库备份,有的调对账接口,有的清理临时表。改一个执行时间,得挨个远程登录;失败一次,没人知道,第二天业务方打电话来问数据为什么没出来,运维才开始一台台翻日志。这套 C# + SQL 任务调度平台解决的正是这件事——把散落在各机器上的定时任务收拢到一个控制台里,任务定义、Cron 触发规则、执行实例、失败重试、运行日志全部沉到 SQL 数据库,C# 编写的主控进程负责扫描到期任务、拉起执行线程、把结果写回表。它适合已经把业务代码写成 .NET 程序集、手上有一台 SQL Server、又不想为了几个定时任务去搭一整套重型调度中间件的团队。任务该几点跑、跑多久算超时、失败了重试几次,都变成表里的一行数据,而不是某个人脑子里的记忆。
2. 整体架构与数据模型:为什么把调度状态全塞进 SQL 里
2.1 进程不持有状态:调度平台的“活口”在数据库
很多自研调度器一开始都是这么写的:进程启动时把所有任务读进内存,用System.Threading.Timer或者一个SortedSet排好队,到点触发。单机跑着挺顺,等到需要重启升级就露馅了——进程一挂,内存里的队列全没了,重启后的第一件事是重新算所有任务的下次执行时间,这期间到点的任务直接漏掉。更麻烦的是没法查历史:三个月前那次对账任务到底跑没跑、跑了多久、报了什么错,只能翻 NLog 文件。
这套平台的做法是把“调度状态”全部外置到 SQL 里。数据库里存的是每个任务的NextRunTime,主控进程每轮只做一件事:查NextRunTime <= 当前时间且状态启用的任务,抢到之后把NextRunTime推进到下一次,然后异步执行。进程重启的代价变成“重新连上数据库继续扫”,内存里不保存任何不可恢复的东西。这个设计带来的直接好处是可观测性:任何一个任务的执行轨迹都是JobDefinition→JobInstance→JobLog三张表串起来的一条链,排查时不用登服务器。
提示:把状态外置并不等于无脑堆表。任务定义的变更频率很低,执行实例的增长速度却很快,两者的清理策略要分开设计,否则半年后实例表就是几千万行。
2.2 三层职责划分:宿主层、调度层、执行层
代码结构上,我一般会拆成三层,职责边界清楚,后面加节点或者换存储都不用大改。
宿主层(Host)负责进程生命周期:读appsettings.json、注册依赖注入容器、把自身挂成 Windows 服务或 Linux 的 systemd 单元、处理优雅停机。这一层不碰任何调度逻辑,只做“把服务跑起来”这一件事。挂在 Windows 服务下时用UseWindowsService(),Linux 下用UseSystemd(),同一份代码两边都能部署。
调度层(Scheduler)是核心,包含三个东西:轮询主循环、Cron 解析器、抢占逻辑。主循环按固定间隔(常见做法是 5 秒)扫一次库,批量取出到期任务;Cron 解析器根据表达式算出下一次触发时间;抢占逻辑保证同一条任务在同一时刻只有一个执行者——单机情况下靠“更新NextRunTime时的乐观并发”,多机情况下要靠数据库行锁,这部分在第 6 章展开。
执行层(Runner)负责把任务真正跑起来。它从JobDefinition.HandlerType里拿到程序集限定的类型名,通过依赖注入容器解析出实例,套上超时CancellationToken,捕获异常,最后把状态、耗时、错误堆栈回写到JobInstance。执行层和执行层之间互不感知,一个任务跑挂了不影响主循环继续扫下一批。
2.3 三张核心表:JobDefinition、JobInstance、JobLog
表结构是整个平台的地基,改动成本最高,所以一开始就要把字段想全。任务定义表存“这个任务是什么、什么时候跑、怎么跑”,执行实例表存“这一次跑的结果”,日志表存“跑的过程中说了什么”。
-- 任务定义表:一个任务一行,NextRunTime 是调度器唯一的扫描依据 CREATE TABLE JobDefinition ( Id BIGINT IDENTITY(1,1) PRIMARY KEY, JobName NVARCHAR(100) NOT NULL, -- 任务名,展示用 JobGroup NVARCHAR(50) NOT NULL DEFAULT 'default', CronExpression NVARCHAR(50) NOT NULL, -- 五段式:分 时 日 月 周 HandlerType NVARCHAR(300) NOT NULL, -- 形如 Namespace.Class, AssemblyName ParametersJson NVARCHAR(MAX) NULL, -- 执行参数,JSON 字符串 TimeoutSeconds INT NOT NULL DEFAULT 300, -- 超时阈值,到点强制取消 RetryCount INT NOT NULL DEFAULT 0, -- 失败后最多重试几次 Status TINYINT NOT NULL DEFAULT 1, -- 1 启用 0 停用 NextRunTime DATETIME2(0) NOT NULL, -- 下次触发时间,调度扫描字段 CreateTime DATETIME2(0) NOT NULL DEFAULT SYSDATETIME(), UpdateTime DATETIME2(0) NOT NULL DEFAULT SYSDATETIME() ); -- 调度主循环走这个索引,按 NextRunTime 升序取前 N 条 CREATE INDEX IX_JobDefinition_NextRun ON JobDefinition(Status, NextRunTime); -- 执行实例表:一次执行一行,状态机是 Running -> Success / Failed / Timeout CREATE TABLE JobInstance ( Id BIGINT IDENTITY(1,1) PRIMARY KEY, JobId BIGINT NOT NULL, FireTime DATETIME2(0) NOT NULL, -- 计划触发时间,不是实际开始时间 StartTime DATETIME2(0) NULL, EndTime DATETIME2(0) NULL, Status NVARCHAR(20) NOT NULL DEFAULT 'Running', RetryTimes INT NOT NULL DEFAULT 0, ElapsedMs INT NULL, ErrorMessage NVARCHAR(4000) NULL, NodeId NVARCHAR(50) NULL -- 哪个节点执行的,多机部署时必看 ); -- 多节点防重靠这个唯一索引,第 6 章会用到 CREATE UNIQUE INDEX UX_JobInstance_Job_FireTime ON JobInstance(JobId, FireTime); -- 执行日志表:一个实例可以有多行,按级别区分 CREATE TABLE JobLog ( Id BIGINT IDENTITY(1,1) PRIMARY KEY, InstanceId BIGINT NOT NULL, Level NVARCHAR(10) NOT NULL, -- Info / Warn / Error Message NVARCHAR(MAX) NOT NULL, CreateTime DATETIME2(0) NOT NULL DEFAULT SYSDATETIME() ); CREATE INDEX IX_JobLog_Instance ON JobLog(InstanceId);三张表的职责刻意分开了:JobDefinition是配置,JobInstance是执行事实,JobLog是过程细节。分开的好处是查询成本可控——列表页只查前两张表,点进详情再看日志;坏处是日志表会膨胀得最快,所以JobLog一定要配一个按天清理的归档任务,而这个清理任务本身也注册在这套调度系统里,形成自举。
FireTime和StartTime分开是有必要的。FireTime是 Cron 算出来的理论触发时间,用来做幂等和去重;StartTime是线程真正开始执行的时间。两者差几毫秒正常,差几分钟说明调度器被堵住了,这就是一个很直接的监控信号。HandlerType用类型全名, 程序集名这种格式,是为了兼容Type.GetType()的解析规则,跨程序集也能找到。
3. 调度核心链路拆解:一个任务从到期到写回结果
3.1 主循环:扫描、抢占、派发
主循环用BackgroundService实现,这是 .NET 里跑常驻逻辑最顺手的基类,和依赖注入、优雅停机天然配合。核心逻辑就是每隔几秒扫一次库,按NextRunTime升序取一批到期任务,逐条尝试推进时间戳,推进成功的才派发执行。
public class SchedulerLoop : BackgroundService { private readonly IServiceScopeFactory _scopeFactory; private readonly ILogger<SchedulerLoop> _logger; protected override async Task ExecuteAsync(CancellationToken stoppingToken) { while (!stoppingToken.IsCancellationRequested) { try { // 每轮新建 scope,避免长生命周期 DbContext 导致跟踪实体越积越多 using var scope = _scopeFactory.CreateScope(); var repo = scope.ServiceProvider.GetRequiredService<IJobRepository>(); var runner = scope.ServiceProvider.GetRequiredService<IJobRunner>(); var dueJobs = await repo.FetchDueJobsAsync(DateTime.Now, 50, stoppingToken); foreach (var job in dueJobs) { // 先算下一次触发时间,再执行。顺序不能反: // 执行可能耗时很久,期间如果不推进 NextRunTime,下一轮扫描会重复捞到同一条 var next = CronHelper.GetNextOccurrence(job.CronExpression, DateTime.Now); var affected = await repo.TryAdvanceNextRunAsync( job.Id, job.NextRunTime, next, stoppingToken); if (affected == 0) continue; // 被别的节点抢先更新了,跳过 var instanceId = await repo.CreateInstanceAsync(job.Id, job.NextRunTime, stoppingToken); // 派发到线程池,主循环不等待。job 是纯 POCO,脱离 scope 也能安全使用 _ = Task.Run(() => runner.RunAsync(job, instanceId, stoppingToken), stoppingToken); } } catch (Exception ex) { // 主循环绝对不能因为单次异常退出,否则整个平台停摆 _logger.LogError(ex, "调度主循环本轮执行异常"); } // 5 秒一轮:足够覆盖分钟级调度,又不会把数据库打满 await Task.Delay(TimeSpan.FromSeconds(5), stoppingToken); } } }这段代码里有两个关键取舍。第一个是“先推进NextRunTime再执行”。如果反过来,先执行再更新,那么执行期间(尤其是跑几分钟的长任务)下一轮扫描还会把这条任务捞出来,造成重复执行。先把时间戳推到下一轮,相当于提前“占位”。
第二个是TryAdvanceNextRunAsync的返回值必须检查。它的 SQL 是带条件的更新:
UPDATE JobDefinition SET NextRunTime = @Next, UpdateTime = SYSDATETIME() WHERE Id = @Id AND NextRunTime = @ExpectedNextRunTime -- 乐观锁:只有没被别人改过才算抢到 AND Status = 1;@ExpectedNextRunTime是扫描时读到的旧值。两个节点同时扫到同一条任务,只有一个能更新成功(影响行数 1),另一个影响行数 0,直接跳过。单机部署时这个条件永远成立,多机部署时它就是最轻量的抢占机制,不需要额外引入分布式锁组件。
BatchSize取 50 是个经验值。太小会导致任务密集时排队延迟,太大会让单次查询变慢、事务时间变长。实际项目里如果任务数量超过几千条,建议改成“按分钟分区扫描”,只查未来 60 秒内到期的任务,而不是全表按时间排序。
3.2 Cron 解析:五段表达式与时间对齐
Cron 表达式是这类平台的通用语言,格式是分 时 日 月 周五段,支持*、n、a-b、*/n、a-b/n和逗号分隔的多值。解析的核心不是匹配,而是“从当前时间往后找第一个命中的时间点”。
public static class CronHelper { public static DateTime GetNextOccurrence(string cron, DateTime from) { var parts = cron.Trim().Split(' ', StringSplitOptions.RemoveEmptyEntries); if (parts.Length != 5) throw new FormatException($"Cron 表达式必须是 5 段:{cron}"); var minutes = ParseField(parts[0], 0, 59); var hours = ParseField(parts[1], 0, 23); var days = ParseField(parts[2], 1, 31); var months = ParseField(parts[3], 1, 12); var weeks = ParseField(parts[4], 0, 6); // 从下一分钟整点开始找,秒和毫秒归零,避免同一分钟内重复触发 var cursor = new DateTime(from.Year, from.Month, from.Day, from.Hour, from.Minute, 0).AddMinutes(1); var limit = cursor.AddYears(1); // 保护:找不到就报错,不死循环 while (cursor < limit) { if (!months.Contains(cursor.Month)) { cursor = new DateTime(cursor.Year, cursor.Month, 1).AddMonths(1); continue; } if (!days.Contains(cursor.Day)) { cursor = cursor.Date.AddDays(1); continue; } if (!weeks.Contains((int)cursor.DayOfWeek)) { cursor = cursor.Date.AddDays(1); continue; } if (!hours.Contains(cursor.Hour)) { cursor = cursor.Date.AddHours(cursor.Hour + 1); continue; } if (!minutes.Contains(cursor.Minute)) { cursor = cursor.AddMinutes(1); continue; } return cursor; } throw new InvalidOperationException($"未来一年内找不到匹配时间:{cron}"); } private static HashSet<int> ParseField(string field, int min, int max) { var set = new HashSet<int>(); foreach (var seg in field.Split(',')) { var body = seg; var step = 1; if (seg.Contains('/')) { var kv = seg.Split('/'); body = kv[0]; step = int.Parse(kv[1]); } int start = min, end = max; if (body != "*") { if (body.Contains('-')) { var r = body.Split('-'); start = int.Parse(r[0]); end = int.Parse(r[1]); } else start = end = int.Parse(body); } for (int i = start; i <= end; i += step) set.Add(i); } return set; } }这里有一个必须说清的简化:标准 Cron 里“日”和“周”两个字段是或的关系——只要有一个满足就触发,而上面这段代码写成了与。也就是说0 2 1 * 1在标准 Cron 里表示“每月 1 号 或 每周一凌晨两点”,在这个实现里表示“既是 1 号又是周一的凌晨两点”,一年可能只命中一两次。这个差异在绝大多数业务场景下不会踩到,但如果有人在配置任务时用了“日 + 周”组合,行为就会和预期不一致。
我的处理办法是:任务定义表加一个校验,保存时如果日字段和周字段同时不是*,直接拒绝并提示。与其实现完整的“或”语义(会让查找循环复杂一倍),不如在入口处把这种写法挡掉,这也是我在几个项目里验证过的做法。至于L、W、#这些扩展语法,同理——不支持就在配置页面上写清楚,别让用户配了一个“看起来能跑其实不生效”的表达式。
性能上,这个算法最坏情况是逐分钟走一年,大约 52 万次循环。实际因为按天、按小时跳跃,绝大多数表达式几毫秒就能返回。但如果是* * * * *这种每分钟都触发的表达式,每次只走一分钟,也是毫秒级。
3.3 执行器:反射调用、超时控制与异常兜底
执行器要做四件事:从容器里拿到任务实例、把参数传进去、套上超时、把结果写回。任务接口设计得越简单越好,只留一个ExecuteAsync:
public interface IJob { Task ExecuteAsync(JobContext ctx, CancellationToken ct); } public class JobContext { public long JobId { get; } public long InstanceId { get; } private readonly string? _paramsJson; public JobContext(long jobId, long instanceId, string? paramsJson) => (JobId, InstanceId, _paramsJson) = (jobId, instanceId, paramsJson); // 参数读取带默认值,任务代码里不用到处判空 public T GetParam<T>(string key, T defaultValue) { if (string.IsNullOrWhiteSpace(_paramsJson)) return defaultValue; using var doc = System.Text.Json.JsonDocument.Parse(_paramsJson); return doc.RootElement.TryGetProperty(key, out var v) ? v.Deserialize<T>() ?? defaultValue : defaultValue; } }执行器的实现:
public async Task RunAsync(JobDefinition job, long instanceId, CancellationToken ct) { var sw = Stopwatch.StartNew(); var status = "Success"; string? error = null; try { using var scope = _scopeFactory.CreateScope(); var type = Type.GetType(job.HandlerType) ?? throw new InvalidOperationException($"找不到任务类型:{job.HandlerType}"); var handler = (IJob)scope.ServiceProvider.GetRequiredService(type); // 和外部的停机令牌联动:平台停机时,正在跑的任务也会收到取消信号 using var timeoutCts = CancellationTokenSource.CreateLinkedTokenSource(ct); timeoutCts.CancelAfter(TimeSpan.FromSeconds(job.TimeoutSeconds)); var ctx = new JobContext(job.Id, instanceId, job.ParametersJson); await handler.ExecuteAsync(ctx, timeoutCts.Token); } catch (OperationCanceledException) when (!ct.IsCancellationRequested) { // 外部没取消,只有超时令牌取消了,才判定为超时 status = "Timeout"; error = $"执行超时(阈值 {job.TimeoutSeconds} 秒)"; } catch (Exception ex) { status = "Failed"; error = ex.ToString(); } finally { sw.Stop(); await _repo.FinishInstanceAsync(instanceId, status, error, (int)sw.ElapsedMilliseconds); if (status == "Failed") await _retryPolicy.HandleAsync(job, instanceId); } }catch (OperationCanceledException) when (!ct.IsCancellationRequested)这个过滤条件很关键。CancellationTokenSource.CreateLinkedTokenSource把外部停机令牌和超时令牌连在一起,两者任一触发都会抛OperationCanceledException。如果不加when过滤,平台正常停机时正在跑的任务会被标记成“超时”,日志里一片误报,排查时就是纯纯的黑匣子。
任务实例从 DI 容器解析,意味着任务类可以正常注入仓储、HttpClient、日志等依赖,不需要自己Activator.CreateInstance。代价是任务类型必须在容器里注册过,这算一条纪律:新增任务时,注册容器和插数据库记录是两步,漏了任何一步,第一次执行就会报“找不到任务类型”。第 4 章会给一个完整的注册示例。
参数用 JSON 存而不是拆成多个列,是为了应对参数结构千变万化的现实。清理任务要keepDays,对账任务要bizDate和channelCode,同步任务要batchSize和sourceId,硬拆列最后一定是加了一堆可空字段。JSON 的代价是没法在 SQL 里按参数过滤,但这类需求本来就少。
3.4 结果回写与重试策略
FinishInstanceAsync只做一次更新,RetryTimes的递增放在重试处理器里:
public async Task HandleAsync(JobDefinition job, long instanceId) { if (job.RetryCount <= 0) return; // 查当前实例已经重试过几次,避免无限重试 var retried = await _repo.GetRetryTimesAsync(instanceId); if (retried >= job.RetryCount) return; // 退避重试:第 1 次等 30 秒,第 2 次等 2 分钟,第 3 次等 5 分钟 var delay = retried switch { 0 => 30, 1 => 120, _ => 300 }; var retryJob = job with { NextRunTime = DateTime.Now.AddSeconds(delay) }; await _repo.ScheduleRetryAsync(retryJob, instanceId, retried + 1); }重试的语义要提前定清楚:是“重新插一条JobInstance”,还是“复用原来的实例只更新RetryTimes”?我选后者,因为运维看列表时希望看到“这个任务失败了一次,重试两次后成功”,而不是三条独立的执行记录,还得自己去关联。复用实例意味着FireTime不变,这和第 6 章的唯一索引正好配套——重试不会因为唯一约束插入失败。
退避间隔不做成指数增长,是因为调度任务的失败大多是瞬时故障(网络抖动、数据库连接池耗尽、下游接口超时),30 秒到 5 分钟这个量级足够覆盖。真遇到下游长时间不可用,重试三次也只是把失败日志写三遍,不如直接告警让人介入。所以重试次数默认给 0,需要重试的任务手工配 2 到 3 次,这个默认值我建议别改。
4. 把项目跑起来:环境、建库、配置与第一个任务
4.1 环境与依赖清单
跑这个平台需要的组件不多,清单如下。SQL Server 用 Express 版就够,本地开发也可以用 Docker 起一个容器,省得装一堆东西。
| 组件 | 版本要求 | 用途 | 备注 |
|---|---|---|---|
| .NET SDK | 6.0 或 8.0 | 编译与运行宿主程序 | 8.0 需要对应的运行时 |
| SQL Server | 2016 及以上 | 存任务定义、实例、日志 | Express 版可用,注意库大小上限 |
| EF Core / Dapper | 与 .NET 版本匹配 | 数据访问 | 重查询用 Dapper,写操作用 EF Core |
| 系统 Cron 解析 | 无内置 | 五段表达式解析 | 自研实现见 3.2 节 |
| 日志组件 | Serilog 或 NLog | 控制台 + 文件双输出 | 文件按天滚动,保留 7 天 |
项目结构建议这样分:
JobCenter/ ├── src/ │ ├── JobCenter.Host/ # 宿主层:Program.cs、appsettings.json、服务注册 │ ├── JobCenter.Core/ # 调度层:SchedulerLoop、CronHelper、抢占逻辑 │ ├── JobCenter.Infrastructure/# 数据访问:仓储、SQL 脚本、迁移 │ └── JobCenter.Jobs/ # 执行层:具体任务实现,一个类一个任务 └── sql/ ├── 01_schema.sql # 建表脚本 └── 02_seed.sql # 初始化示例任务把任务实现单独放一个类库是个小技巧。业务系统往往已经有自己的类库,任务代码直接写在里面会互相污染;独立出来之后,JobCenter.Jobs只依赖JobCenter.Core里的IJob接口,编译快、部署也干净。
4.2 建库与初始化数据
建表脚本就是 2.3 节那三段 SQL,按顺序执行即可。初始化数据给一条能立刻验证的任务:
INSERT INTO JobDefinition (JobName, JobGroup, CronExpression, HandlerType, ParametersJson, TimeoutSeconds, RetryCount, Status, NextRunTime) VALUES ('清理过期令牌', 'maintenance', '*/2 * * * *', -- 每 2 分钟跑一次,方便本地验证 'JobCenter.Jobs.CleanExpiredTokenJob, JobCenter.Jobs', '{"keepDays": 30}', 120, 1, 1, SYSDATETIME());CronExpression这里故意配成每 2 分钟一次,是为了本地验证时不用等一整天。上线前记得改成0 3 * * *这类真实业务时间。HandlerType必须和程序集里的命名空间完全一致,大小写敏感,这是最容易写错的一处。
注意:
NextRunTime初始化成SYSDATETIME()意味着启动后第一轮扫描就会立刻触发一次。如果不想让它马上跑,把手动算好的下次时间填进去。
4.3 配置文件与启动参数
配置文件就一份,关键项如下:
{ "ConnectionStrings": { "JobDb": "Server=127.0.0.1,1433;Database=JobCenter;User Id=sa;Password=YourPassword;TrustServerCertificate=True" }, "Scheduler": { "PollIntervalSeconds": 5, "BatchSize": 50, "NodeId": "node-01", "MaxConcurrentJobs": 20, "LogRetentionDays": 7 }, "Serilog": { "MinimumLevel": "Information", "WriteTo": [ { "Name": "Console" }, { "Name": "File", "Args": { "path": "logs/jobcenter-.log", "rollingInterval": "Day" } } ] } }几个参数的含义和调整思路。PollIntervalSeconds控制扫描频率,设成 5 秒意味着任务实际触发时间最多比 Cron 算出来的晚 5 秒。对分钟级任务无所谓,如果有秒级精度需求,这套轮询架构本身就不合适,得换时间轮。MaxConcurrentJobs是并发上限,超过之后任务排队——这个值要结合数据库连接池大小来定,20 个并发任务如果每个都要开连接,池子至少给 30。NodeId单机部署时随便填,多机部署时每台机器必须不同,否则执行记录里分不清是谁跑的。
宿主程序的启动入口:
var builder = Host.CreateApplicationBuilder(args); builder.Services.AddWindowsService(o => o.ServiceName = "JobCenter"); builder.Services.AddHostedService<SchedulerLoop>(); builder.Services.AddSingleton<IJobRepository, JobRepository>(); builder.Services.AddSingleton<IJobRunner, JobRunner>(); // 任务实现注册成瞬态:每次执行都要新实例,避免状态串味 builder.Services.AddTransient<CleanExpiredTokenJob>(); builder.Services.AddTransient<DailyReconcileJob>(); var host = builder.Build(); await host.RunAsync();启动命令:
dotnet restore dotnet build -c Release # 前台跑,方便看日志 dotnet run --project src/JobCenter.Host # 发布后挂成服务 dotnet publish src/JobCenter.Host -c Release -o ./publish sc.exe create JobCenter binPath="C:\apps\JobCenter\JobCenter.Host.exe" start=auto sc.exe start JobCenter挂成服务之后,日志只写文件不写控制台,这是正常的,别以为服务没启动。检查服务状态的命令是sc.exe query JobCenter,跑起来后去数据库查JobInstance表有没有新记录,比看日志更直接。
4.4 写第一个任务并验证
实现一个最典型的清理任务,感受一下参数是怎么传进来的:
public class CleanExpiredTokenJob : IJob { private readonly ITokenRepository _repo; private readonly ILogger<CleanExpiredTokenJob> _logger; public CleanExpiredTokenJob(ITokenRepository repo, ILogger<CleanExpiredTokenJob> logger) => (_repo, _logger) = (repo, logger); public async Task ExecuteAsync(JobContext ctx, CancellationToken ct) { // 参数从 JobDefinition.ParametersJson 里读,没配就用默认值 var keepDays = ctx.GetParam("keepDays", 30); var threshold = DateTime.Now.AddDays(-keepDays); var rows = await _repo.DeleteExpiredAsync(threshold, ct); _logger.LogInformation("任务 {JobId} 清理过期令牌 {Rows} 条,阈值 {Threshold}", ctx.JobId, rows, threshold); } }验证分三步。第一步看JobInstance表:每 2 分钟应该新增一条Status = 'Success'的记录,ElapsedMs是个小数值。第二步看JobLog:有对应的 Info 日志,InstanceId对得上。第三步手动制造一次失败——把任务里的表名改错,重新编译部署,观察Status是否变成Failed、ErrorMessage里有没有堆栈、RetryTimes是否按预期递增。这三步走完,整条链路就算是通了。
5. 常见问题排查:上线后最容易翻车的五个点
5.1 任务重复执行:同一分钟跑了两遍
现象:JobInstance表里JobId和FireTime相同却有两行记录,业务侧出现重复扣款或重复推送。
原因:主循环里“推进NextRunTime”和“派发执行”之间没有原子性保护,两个节点或者两轮扫描同时捞到同一条。另一种可能是NextRunTime的更新条件写成了只按Id匹配,乐观锁失效。
解决:JobInstance上建(JobId, FireTime)唯一索引,插入冲突直接捕获异常跳过。这层兜底比任何应用层判断都可靠。同时检查UPDATE语句是否带上了AND NextRunTime = @ExpectedNextRunTime条件。
5.2 任务卡在 Running 再也不动
现象:进程被kill -9或者服务器重启后,几条Status = 'Running'的记录永远停在那里,报表里“运行中”的数量只增不减。
原因:实例的状态更新写在finally里,但进程被强杀时finally根本不会执行。数据库里留下的是“僵尸实例”。
解决:加一个启动时的自愈逻辑——进程启动后先执行一条UPDATE JobInstance SET Status='Failed', ErrorMessage='节点重启,执行中断' WHERE Status='Running' AND NodeId=@NodeId AND StartTime < DATEADD(HOUR,-1,SYSDATETIME())。这也是为什么NodeId字段必须填对,否则自愈会把别的节点正在跑的任务也误判。
5.3 时间对不上:服务器时区和数据库时区打架
现象:Cron 配的是凌晨 3 点,实际执行时间是上午 11 点,正好差 8 小时。
原因:C# 里DateTime.Now取的是服务器本地时间,SQL Server 的SYSDATETIME()取的是数据库服务器本地时间,容器化部署时两边时区经常不一致。更隐蔽的情况是数据列用了DATETIMEOFFSET,读出来是带偏移量的,和DateTime比较时行为不确定。
解决:全链路统一成一个时区,要么都用 UTC,要么都在配置里显式指定。我的习惯是数据库存 UTC,展示层按用户时区转换,Cron 表达式也按 UTC 解释,界面上加一句“所有时间均为 UTC”。中途改时区是灾难,一开始就定下来。
5.4 日志表把磁盘撑爆
现象:跑了一个月,JobLog表几百 GB,数据库备份时间从几分钟涨到半小时。
原因:每分钟一次的任务,一天 1440 条实例,每个实例打十几行日志,一个月就是几十万行。没有人清理,也没有归档。
解决:给JobLog配一个清理任务,按CreateTime删除 7 天前的数据,每次删 5000 行循环删,避免一次性大事务锁表。清理任务本身也注册在系统里,形成自举。如果你担心删太快不好追溯,就先归档到另一张历史表,但别只归档不清理。
5.5 改完 Cron 不生效
现象:在数据库里把CronExpression从0 3 * * *改成0 4 * * *,第二天还是 3 点跑。
原因:主循环只更新NextRunTime,但手上拿的job对象是扫描那一刻读出来的旧值。如果改表达式的同时没有手动更新NextRunTime,下一次触发仍然按旧表达式算出来的时间走。
解决:提供一个“重算下次执行时间”的接口,修改CronExpression时一起更新NextRunTime。或者在扫描时比较UpdateTime和缓存的表达式,不一致就重算。前者简单,要养成习惯;后者更稳,但每次扫描多一次比较。
6. 二次开发:从单机轮询到多节点抢占
单机跑久了必然会遇到瓶颈——不是性能瓶颈,是可用性瓶颈。这台机器要打补丁重启,所有任务都得停。往上加节点,第 3 章那个乐观锁方案只能保证“不会都抢到”,但会浪费一半的扫描开销,因为每个节点都在查全量到期任务然后大部分更新失败。更靠谱的做法是用数据库行锁做批量抢占。
-- 用 UPDLOCK + READPAST 一次性锁定一批到期任务,其他节点直接跳过被锁的行而不是等待 BEGIN TRAN; WITH due AS ( SELECT TOP (@BatchSize) Id, NextRunTime FROM JobDefinition WITH (UPDLOCK, READPAST) WHERE Status = 1 AND NextRunTime <= @Now ORDER BY NextRunTime ) UPDATE d SET NextRunTime = DATEADD(MINUTE, 1, d.NextRunTime) -- 占位,真实值由应用层回填 FROM JobDefinition d INNER JOIN due ON d.Id = due.Id; SELECT Id FROM due; -- 返回本次抢到的任务 Id COMMIT TRAN;READPAST是这段 SQL 的灵魂,它让其他节点跳过被锁定的行而不是排队等待,避免多节点互相阻塞。抢到Id之后应用层再算真实的NextRunTime并更新回去。这个方案把“抢占”从应用层挪到了数据库层,减少了往返次数。
扩展点上,我建议只保留两个:IJob接口和参数 JSON。想加“任务依赖”就让 B 任务在 A 任务成功后触发,别改调度核心,在FinishInstanceAsync之后挂一个事件处理即可。想加“任务分组并发控制”,在派发前按JobGroup查一下当前 Running 数量就行。核心循环保持“扫描 → 抢占 → 派发 → 回写”四步不变,任何新需求都往这四个点上挂,架构就不会腐化。
验证多节点是否正确,方法很土但有效:起两个进程,把NodeId改成不同值,跑一个每分钟触发的任务,一小时后统计JobInstance表里NodeId的分布和重复的(JobId, FireTime)组合数。重复数必须是 0,分布不必均匀。
血泪经验是:我头一回加节点时,忘了给JobInstance建唯一索引,线上跑了两周才发现有 37 条重复执行记录,业务方已经按重复数据出了报表。从那以后我每次改调度逻辑,上线前都强制走一遍“双进程压测 + 重复记录计数”的验证,宁可多花半小时,也不留后悔药。希望帮到你。
本文还有配套的精品资源,点击获取