
简介这套 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 ILoggerSchedulerLoop _logger; protected override async Task ExecuteAsync(CancellationToken stoppingToken) { while (!stoppingToken.IsCancellationRequested) { try { // 每轮新建 scope避免长生命周期 DbContext 导致跟踪实体越积越多 using var scope _scopeFactory.CreateScope(); var repo scope.ServiceProvider.GetRequiredServiceIJobRepository(); var runner scope.ServiceProvider.GetRequiredServiceIJobRunner(); 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 HashSetint ParseField(string field, int min, int max) { var set new HashSetint(); 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 执行器反射调用、超时控制与异常兜底执行器要做四件事从容器里拿到任务实例、把参数传进去、套上超时、把结果写回。任务接口设计得越简单越好只留一个ExecuteAsyncpublic 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 GetParamT(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.DeserializeT() ?? 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 SDK6.0 或 8.0编译与运行宿主程序8.0 需要对应的运行时SQL Server2016 及以上存任务定义、实例、日志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: Server127.0.0.1,1433;DatabaseJobCenter;User Idsa;PasswordYourPassword;TrustServerCertificateTrue }, 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.AddHostedServiceSchedulerLoop(); builder.Services.AddSingletonIJobRepository, JobRepository(); builder.Services.AddSingletonIJobRunner, JobRunner(); // 任务实现注册成瞬态每次执行都要新实例避免状态串味 builder.Services.AddTransientCleanExpiredTokenJob(); builder.Services.AddTransientDailyReconcileJob(); 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 binPathC:\apps\JobCenter\JobCenter.Host.exe startauto sc.exe start JobCenter挂成服务之后日志只写文件不写控制台这是正常的别以为服务没启动。检查服务状态的命令是sc.exe query JobCenter跑起来后去数据库查JobInstance表有没有新记录比看日志更直接。4.4 写第一个任务并验证实现一个最典型的清理任务感受一下参数是怎么传进来的public class CleanExpiredTokenJob : IJob { private readonly ITokenRepository _repo; private readonly ILoggerCleanExpiredTokenJob _logger; public CleanExpiredTokenJob(ITokenRepository repo, ILoggerCleanExpiredTokenJob 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 StatusFailed, ErrorMessage节点重启执行中断 WHERE StatusRunning AND NodeIdNodeId 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 条重复执行记录业务方已经按重复数据出了报表。从那以后我每次改调度逻辑上线前都强制走一遍“双进程压测 重复记录计数”的验证宁可多花半小时也不留后悔药。希望帮到你。本文还有配套的精品资源点击获取