C#+SQL任务调度平台实战:Quartz.NET持久化、幂等与补偿避坑

发布时间:2026/10/24 2:42:44
C#+SQL任务调度平台实战:Quartz.NET持久化、幂等与补偿避坑 简介面向 C# 与 SQL 技术栈开发者、运维人员及任务调度初学者提供一套可学习与部署参考的任务调度平台资料覆盖 Web 管理站点、数据库脚本、服务节点协同、Windows 服务配置及系统级任务运行监控等场景适合搭建内部定时任务、错误通知与长时间任务检测环境。压缩包约 71.89MB当前未提供文件总数与类型明细从资源说明看包含数据库安装脚本、站点配置、Node Windows 服务及系统级任务等模块。已有 104 人学习下载。读者可据此理解平台由管理端、数据库与多节点服务构成的整体结构掌握任务发布、节点部署、错误邮件发送、长耗时任务检测等配置思路并积累 C# 服务端调度、SQL 数据支撑与运维排错经验对中初级开发者与运维人员有较高参考价值。1. 凌晨三点的定时脚本C#SQL 任务调度平台值不值得自己搭接手一套业务系统时最容易被忽略的是散落在服务器上的定时脚本一个批处理文件bat调一个 exe再配一张 Excel 记录谁跑过。等到某天凌晨对账没跑、报表没出来才发现没人说得清它到底有没有执行。C#SQL 任务调度平台要解决的就是这件事把任务定义、触发规则、执行结果全部收进数据库用一套统一的调度服务驱动脚本不必各自为政。这份资源适合已经会用 C# 写业务代码、被定时任务折腾过的后端也适合想把运维手里的 crontab 收编进可视化管理的中小型团队。2. 拆开调度内核Quartz.NET 与 SQL 持久化是怎么配合的拿到一个调度平台的源码包先别急着跑先搞清楚它是用哪几个部件协作完成一次任务调度。否则一旦出问题你连该看哪张表、该查哪段日志都不知道。2.1 调度内核为什么不用 Timer 自己撸很多人第一次写定时任务都是从System.Threading.Timer或while(true) Task.Delay开始的十分钟能写完然后就开始踩坑。这类写法的根本问题在于调度状态只存在于进程内存中。进程一重启下次触发时间就丢了服务器一扩到两台任务就变成跑两遍任务跑到一半机器宕机没有任何记录能证明它跑过。更麻烦的是 clock drift 和夏令时Task.Delay(24h)累计几百次之后执行时间能从凌晨两点漂到四点半。所以合格的调度平台内核一般不会自己写常见做法是接 Quartz.NET 这条成熟路线。它提供三样自研很难补齐的能力一是 cron 表达式驱动的触发器模型Trigger二是可插拔的持久化存储JobStore三是集群模式下的节点互斥。这份资源包的内核就是这条路线SQL 在这里承担两个角色业务侧的任务定义与执行日志以及调度器自身的触发器持久化表。两者共用同一个库用不同的表前缀区分部署时不用维护两套连接串。选型上给一个判断依据做决策时可以直接套方案适用规模持久化集群互斥排错成本Windows 计划任务 / crontab单机、个位数任务无只有系统日志不支持高日志分散自研 Timer 循环3 个以内、允许漏跑需自己写需自己写中但坑都在自己身上调度平台 SQL 持久化10 个以上、要求可追溯内置内置低一切落表判断标准很简单只要你有“任务漏跑了要能查出来”“两台机器不能重复跑”“改了 cron 要能立刻生效”这三条里的任意一条就值得上平台。2.2 存储层业务表和调度表各管什么平台里实际上并存两套表很多人第一次看会被绕晕。第一套是调度器自己的持久化表通常带统一前缀这份包里是QRTZ_记录触发器状态、已触发的任务、节点心跳。这套表由调度器自己维护不要手写 SQL 去改改坏了轻则任务不触发重则整个集群卡死。第二套是业务表也就是你能动的部分核心是三张TaskDefinition任务定义。一行一个任务包含任务编码、cron 表达式、实现类名、参数 JSON、重试次数、超时时间、是否允许并发。TaskLog执行日志。一次触发写一行记录开始结束时间、耗时、状态、异常摘要。TaskLock跨节点互斥锁。任务执行前抢锁抢不到就跳过本次。这三张表的设计要点在于TaskDefinition是“配置”TaskLog是“事实”TaskLock是“状态”。配置可以随时改事实只追加不修改状态带过期时间。把这三者分开后面做补偿调度、做漏跑检测才有依据。2.3 一次任务从触发到落库的完整链路搞清楚链路排错时才能定位到具体环节。一次触发大致经过六步调度器按 cron 算出下一次触发时间写入持久化表到达触发时刻集群中抢到该触发器的节点开始执行反序列化ParamJson注入JobDataMap尝试获取TaskLog的幂等键任务编码 计划触发时间已存在则直接跳过抢TaskLock抢到后执行IJob.Execute全程带CancellationToken无论成功失败写回EndTime、ElapsedMs、Status异常摘要截断到 2000 字符。第 4 步的幂等键是整套设计里最关键的一环。任务编码加计划触发时间构成唯一索引重复插入会直接报主键冲突等于在数据库层面兜住了“重复执行”这个最恶心的问题。第 5 步的锁则是为了跨节点因为调度器的集群模式只保证同一个触发器不被两个节点同时 fire不保证你的业务逻辑不会被手动重跑接口触发第二次。提示这两道保险要同时存在幂等键防“同一计划时间重复跑”分布式锁防“不同来源并发跑”少一个都会在灰度期间出问题。3. 把平台跑起来从建库到第一个 C# 任务触发的四步3.1 环境对齐与依赖还原这一步最容易被跳过也最容易翻车。C# 项目对环境版本比较敏感.NET SDK版本和项目里的TargetFramework对不上编译期报的错往往和真实原因隔着十万八千里。数据库侧同理DATETIME2和DATETIME混用在跨库查询时会出隐式转换索引直接失效。先确认版本再还原# 确认 SDK 版本与项目 TargetFramework 匹配例如 net8.0 需要 8.x SDK dotnet --list-sdks # 还原并编译先编 Host 项目它是调度服务的入口 dotnet restore ./src/Scheduler.Host/Scheduler.Host.csproj dotnet build ./src/Scheduler.Host/Scheduler.Host.csproj -c Release # 本地以前台控制台方式运行日志直接打屏方便看触发过程 dotnet run --project ./src/Scheduler.Host -- --urls http://localhost:5080这三条命令的含义分别是列出可用 SDK 防止版本错配只还原 Host 项目缩短首次还原时间以前台方式启动而不是注册成服务这样CtrlC就能停调试阶段不要一上来就装成 Windows 服务否则每次改代码都要卸载重装。启动前还要看一眼配置文件。连接串是整份资源里最需要按环境改的地方{ ConnectionStrings: { Scheduler: Server127.0.0.1,1433;DatabaseTaskCenter;User Idscheduler_app;Password***;TrustServerCertificateTrue;Max Pool Size100;, Business: Server127.0.0.1,1433;DatabaseBizDb;User Idapp_reader;Password***;TrustServerCertificateTrue;Max Pool Size50; }, Scheduler: { InstanceName: sched-node-01, MaxConcurrency: 8, MisfireThresholdSeconds: 30, DefaultTimeoutSeconds: 600 } }两个连接串分开是有意为之调度库需要写权限业务库通常只给读权限权限隔离之后即使调度逻辑写错也伤不到业务数据。Max Pool Size建议和MaxConcurrency保持同一量级池子开太大只会把压力推给数据库。InstanceName在集群里必须唯一两台机器用同一个名字会导致心跳互相覆盖。MisfireThresholdSeconds是“错过多久算 misfire”的判定阈值30 秒是个偏保守的起点。3.2 建库与初始化脚本业务表用脚本初始化幂等写法重复执行不报错-- 任务定义表一行一个任务cron 和参数都在这里 CREATE TABLE dbo.TaskDefinition ( Id INT IDENTITY(1,1) PRIMARY KEY, TaskCode NVARCHAR(64) NOT NULL UNIQUE, -- 业务唯一编码如 biz.daily.reconcile TaskName NVARCHAR(128) NOT NULL, CronExpression NVARCHAR(64) NOT NULL, HandlerType NVARCHAR(256) NOT NULL, -- 与代码中的实现类映射 ParamJson NVARCHAR(MAX) NULL, -- 传给 JobDataMap 的参数 RetryTimes TINYINT NOT NULL DEFAULT 0, TimeoutSeconds INT NOT NULL DEFAULT 600, AllowConcurrent BIT NOT NULL DEFAULT 0, Status TINYINT NOT NULL DEFAULT 1, -- 1 启用 0 停用 CreateTime DATETIME2(0) NOT NULL DEFAULT SYSUTCDATETIME() ); -- 执行日志表只追加不更新幂等键靠唯一索引兜底 CREATE TABLE dbo.TaskLog ( Id BIGINT IDENTITY(1,1) PRIMARY KEY, TaskCode NVARCHAR(64) NOT NULL, FireTime DATETIME2(0) NOT NULL, -- 计划触发时间幂等键的一半 StartTime DATETIME2(0) NOT NULL, EndTime DATETIME2(0) NULL, ElapsedMs INT NULL, Status TINYINT NOT NULL, -- 0 运行中 1 成功 2 失败 3 超时 4 跳过 RetryCount TINYINT NOT NULL DEFAULT 0, Message NVARCHAR(2000) NULL, CONSTRAINT UQ_TaskLog_Code_Fire UNIQUE (TaskCode, FireTime) ); CREATE INDEX IX_TaskLog_Code_Fire ON dbo.TaskLog (TaskCode, FireTime DESC); -- 分布式锁表TaskCode 主键ExpireTime 保证宕机后锁能自动释放 CREATE TABLE dbo.TaskLock ( TaskCode NVARCHAR(64) PRIMARY KEY, OwnerId NVARCHAR(64) NOT NULL, ExpireTime DATETIME2(0) NOT NULL );参数说明UQ_TaskLog_Code_Fire这个唯一约束是整个方案的基石它让“重复执行”在插入日志的第一步就被数据库拒绝不需要在代码里写一堆判断。ExpireTime必须有否则节点在持锁期间宕机这把锁就永远解不开任务从此再不执行。Message截断到 2000 字符是为了避免异常堆栈把日志表撑爆完整堆栈应该落到文件日志或日志平台。3.3 写一个任务并注册任务实现遵循一个约定实现IJob接口从MergedJobDataMap取参数不做任何调度相关的事只关心业务逻辑本身。public class DailyReconcileJob : IJob { private readonly ILoggerDailyReconcileJob _logger; private readonly IReconcileService _service; public DailyReconcileJob(ILoggerDailyReconcileJob logger, IReconcileService service) { _logger logger; _service service; } public async Task Execute(IJobExecutionContext context) { var map context.MergedJobDataMap; // 业务日期默认取昨天允许通过参数覆盖方便补跑 var bizDate map.GetString(bizDate) ?? DateTime.Today.AddDays(-1).ToString(yyyy-MM-dd); var batchSize map.GetInt(batchSize); if (batchSize 0) batchSize 500; _logger.LogInformation(对账开始 date{BizDate} batch{BatchSize}, bizDate, batchSize); // 全链路传 CancellationToken超时取消才能真正生效 await _service.RunAsync(bizDate, batchSize, context.CancellationToken); } }参数部分的逻辑要说清bizDate允许覆盖是为了支持“昨天跑失败了今天手动按昨天的日期补一次”如果写死成DateTime.Today.AddDays(-1)补跑就只能改系统时间。batchSize给默认值而不是直接取是因为GetInt在键不存在时返回 0不加兜底就会变成一条都不处理却不报错这种静默失败最难查。注册环节把任务编码和实现类对上新增任务只加一行// 任务编码 - 实现类型编码与 TaskDefinition.TaskCode 保持一致 var jobTypes new Dictionarystring, Type(StringComparer.OrdinalIgnoreCase) { [biz.daily.reconcile] typeof(DailyReconcileJob), [biz.report.export] typeof(ReportExportJob) }; services.AddQuartz(q { q.SchedulerId AUTO; // 集群模式下必须 AUTO节点 ID 自动生成 q.UsePersistentStore(s { s.UseProperties true; s.UseSqlServer(sql { sql.ConnectionString cfg.GetConnectionString(Scheduler); sql.TablePrefix QRTZ_; }); s.UseSystemTextJsonSerializer(); // 参数 JSON 序列化统一用 System.Text.Json s.UseClustering(c { c.CheckinInterval TimeSpan.FromSeconds(10); c.CheckinMisfireThreshold TimeSpan.FromSeconds(20); }); }); }); services.AddQuartzHostedService(o o.WaitForJobsToComplete true);WaitForJobsToComplete true这行值得单独说它让服务停止时等待正在执行的任务收尾而不是直接掐断。没有它你会在日志里看到大量“运行中”状态的僵尸记录因为进程退出时来不及写回结束时间。4. 参数怎么配Cron、重试、并发这三处决定平台稳不稳4.1 Cron 表达式的六个字段与三个高频误写调度器的 cron 是六字段秒 分 时 日 月 周和 Linux crontab 的五字段不一致这是最高频的翻车点——从运维手里迁移表达式时直接照抄结果任务一天跑一次变成一秒跑一次。表达式含义备注0 0 2 * * ?每天 02:00:00日常批处理基准写法0 0/5 * * * ?每 5 分钟0/5表示从 0 开始每 5 单位0 30 1 ? * MON-FRI工作日 01:30周字段用英文缩写0 0 3 L * ?每月最后一天 03:00L只用于日字段0 0 4 ? * SUN#2每月第二个周日 04:00#表示第几个三个高频误写日字段和周字段同时写*。六字段模型里这两个字段必须有一个是?否则解析直接抛异常想写“每 5 分钟”却写成0 */5 * * * ?同时又漏了秒位变成每天 0 点的第 5 分钟用L-3表示“倒数第三天”时忘了先确认调度器版本是否支持该语法不支持时不会报错只会永远不触发。时区也别忘了。服务器在 UTC、业务在东八区0 0 2 * * ?实际是北京时间上午十点。稳妥做法是给触发器显式指定时区而不是改服务器时间。4.2 超时与重试参数取值要有理由重试不是越多越好。批处理任务的重试成本很高一次失败往往意味着数据库压力或下游不可用连续重试三次只是把雪崩提前。我的经验取值参数建议取值理由RetryTimes02超过 2 次说明是系统性问题重试无意义TimeoutSeconds单批 300600超过 10 分钟的任务应该拆批而不是加超时退避间隔5s / 10s / 20s指数退避给下游恢复窗口MaxConcurrency48与数据库连接池同量级不按 CPU 核数算超时不重试是刻意设计。超时的语义是“不确定它到底做完没有”此时重试可能造成重复写入比失败更危险。重试只针对明确抛出的异常。protected async Task RunWithRetryAsync( FuncCancellationToken, Task action, int retryTimes, int timeoutSeconds, CancellationToken ct) { for (var attempt 0; attempt retryTimes; attempt) { using var cts CancellationTokenSource.CreateLinkedTokenSource(ct); cts.CancelAfter(TimeSpan.FromSeconds(timeoutSeconds)); // 单次尝试的硬超时 try { await action(cts.Token).WaitAsync(cts.Token); return; } catch (OperationCanceledException) when (cts.IsCancellationRequested !ct.IsCancellationRequested) { // 超时不重试此时无法判断业务是否已部分写入 throw new TimeoutException($执行超过 {timeoutSeconds}s 被取消); } catch (Exception) when (attempt retryTimes) { // 指数退避给下游喘息时间 await Task.Delay(TimeSpan.FromSeconds(5 * Math.Pow(2, attempt)), ct); } } }CreateLinkedTokenSource的作用是把外部取消服务停止和内部超时合并成一个 token缺了它服务优雅停止时正在跑的任务不会被中断进程会一直挂到超时。when (cts.IsCancellationRequested !ct.IsCancellationRequested)这个过滤条件区分了“超时取消”和“外部主动取消”前者要转成超时异常后者应该静默退出不标失败。4.3 并发控制单机靠特性跨节点靠数据库很多人以为给 Job 加上[DisallowConcurrentExecution]就万事大吉实际上这个特性只在单个调度实例内生效。集群部署两台机器时两个节点会各自持有自己的实例缓存特性形同虚设。跨节点的互斥必须落到数据库。抢锁用一条带UPDLOCK的更新语句原子的-- 抢占锁只有过期或自己持有的锁能被更新成功 UPDATE dbo.TaskLock WITH (UPDLOCK, ROWLOCK) SET OwnerId ownerId, ExpireTime DATEADD(SECOND, ttlSeconds, SYSUTCDATETIME()) WHERE TaskCode taskCode AND (ExpireTime SYSUTCDATETIME() OR OwnerId ownerId); IF ROWCOUNT 1 SELECT CAST(1 AS BIT) AS Acquired; ELSE SELECT CAST(0 AS BIT) AS Acquired;UPDLOCK保证在事务内这行被锁住其他会话必须等待避免两个节点同时读到“锁已过期”然后双双更新成功。OwnerId ownerId这个分支是重入保护同一个任务在同一个节点内二次进入时不需要重新抢锁直接用即可否则长任务的锁会在执行中途过期被别的节点抢走。ttlSeconds取任务超时时间加 60 秒留出写日志的余量。注意首次执行时TaskLock里没有对应行UPDATE影响 0 行会误判为抢锁失败。初始化逻辑要先IF NOT EXISTS INSERT或者直接用MERGE这一点在源码里如果没处理第一次上线会全部任务跳过。5. 避坑与排查上线后最容易翻车的五个点5.1 任务重复执行两次现象本地跑一切正常部署到两台机器后日志里同一个计划触发时间出现两条记录业务数据被重复处理。原因部署了两个节点但没开集群模式两个调度实例各自从数据库加载了同一批触发器各跑各的。也有人开了集群但SchedulerId写成固定值两个节点 ID 冲突心跳互相覆盖。解决确认SchedulerId AUTO检查调度器的节点状态表正常应该看到两个不同实例名。同时确认TaskLog的唯一约束确实建上了——如果这条约束因为历史脏数据创建失败集群也帮不了你。5.2 停机后任务被一次性补跑现象服务维护停了 20 分钟重启后日志里瞬间涌入十几条执行记录数据库连接被打满业务接口开始超时。原因misfire 判定阈值设得过大调度器认为停机期间错过的每个触发点都需要补于是挨个 fire 一遍。默认的恢复策略是“全部补跑”这在分钟级任务上是灾难。解决把MisfireThresholdSeconds降到 30并给触发器设置WithMisfireHandlingInstructionDoNothing()周期任务或FireOnceNow()日终批处理。判断依据是幂等的周期任务选 DoNothing宁缺勿滥对账这类必须跑的选 FireOnceNow只补最近一次。5.3 任务永远停在“运行中”现象日志表里一条记录的Status 0EndTime是 NULL跑了几个小时也不结束后续同任务全部被跳过。原因Execute里有同步阻塞调用比如.Result或.Wait()CancellationToken传不下去超时取消根本没生效。或者异常发生在写日志之前catch 块没兜住。解决全链路 async 到底禁止.Result。任务执行外面套一层 try/finally把写回EndTime的动作放在 finally 里保证任何路径都会落状态。另外加一个巡检任务把运行时长超过TimeoutSeconds * 2的记录强制标为超时。5.4 改了 cron 不生效现象在管理端把任务从每天 02:00 改成 03:00等到第二天发现还是 02:00 跑的。原因只更新了TaskDefinition表没有重新调度触发器。持久化模式下触发器有自己的状态改表不等于改调度。解决所有 cron 变更走统一的RescheduleJob接口先DeleteJob再ScheduleJob或者用触发器 key 做原地重排。管理端和调度服务之间不要共享数据库直连要通过接口否则绕过缓存改数据一定会出现“表里是对的、内存里是旧的”。5.5 凌晨两点集体卡死现象每天 02:00 前后所有任务同时触发数据库 CPU 打满任务互相等待最后大批超时。原因所有人复制粘贴同一个0 0 2 * * ?没有错峰。加上每个任务各开一个连接、各自大批量写连接池瞬间耗尽。解决分钟位打散把 20 个任务分布到0 0/1 2 * * ?的 20 个分钟点上按依赖关系排先后。MaxConcurrency从 4 起步往上调不要一上来设 32。连接串的Max Pool Size要和并发数对齐池子里排队和数据库里排队后者更难查。6. 进阶玩法用执行日志表反推漏跑任务并做补偿调度平台跑稳之后真正的价值不在“能定时”而在于日志表变成了一份可查询的调度事实。我一般会在管理端挂一个巡检页面两段 SQL 就够用。第一段看趋势找出稳定性最差的任务-- 近 7 天各任务的成功率与最大耗时成功率低的排在前面 SELECT TaskCode, COUNT(*) AS RunCount, SUM(CASE WHEN Status 1 THEN 1 ELSE 0 END) * 1.0 / COUNT(*) AS SuccessRate, MAX(ElapsedMs) AS MaxElapsedMs, MAX(RetryCount) AS MaxRetry FROM dbo.TaskLog WHERE FireTime DATEADD(DAY, -7, SYSUTCDATETIME()) GROUP BY TaskCode ORDER BY SuccessRate ASC, MaxElapsedMs DESC;第二段找漏跑。逻辑是“启用中的任务在过去一个 cron 周期内没有任何执行记录”这种查询会误报周任务所以要按任务粒度限制窗口-- 检查分钟级任务是否在最近 30 分钟内缺失执行记录 SELECT d.TaskCode, d.CronExpression, d.TimeoutSeconds FROM dbo.TaskDefinition d WHERE d.Status 1 AND d.CronExpression LIKE 0 0/% % * * ? -- 先只圈出分钟级任务避免周月任务误报 AND NOT EXISTS ( SELECT 1 FROM dbo.TaskLog l WHERE l.TaskCode d.TaskCode AND l.FireTime DATEADD(MINUTE, -30, SYSUTCDATETIME()) );查出来的结果不要自动补跑先落到一张待确认表人工确认后再调补偿接口。因为漏跑的成因可能是任务被停用、可能是节点挂了、也可能是 cron 本身配错自动补跑只会把错误放大。还有一个我常看的细节对比MAX(ElapsedMs)的周环比。任务耗时突然翻三倍通常是数据量涨了或者索引失效这时候调大超时只是掩盖问题真正该做的是拆批或者补索引。血泪经验是这套东西的自监控一定要先于业务任务上线。我见过一次调度服务自己挂了三天没人发现直到财务催报表。从那以后我每次部署调度平台第一件事就是给“心跳任务”配告警——它每 5 分钟写一条日志超过 15 分钟没有新记录就发通知。调度器监控别人也得有人监控它。希望帮到你。本文还有配套的精品资源点击获取

关于本文作者

来自尧图内容编辑团队

尧图内容编辑团队 内容团队

尧图内容编辑团队

本文由尧图网络内容编辑团队执笔。团队由资深项目经理、前端工程师与设计师组成,所有内容均来自亲手交付的真实项目,先讲清问题、再给出可落地的解法。尧图深耕北京网站建设十年,服务过京华建材集团、智造科技等各行业客户,把一线经验沉淀为可复用的行业观察。

  • 十年建站经验,覆盖建材、制造、服务、文创等
  • 项目经理把关选题与事实准确性
  • 工程师与设计师联合撰写专业细节
  • 统一编辑规范,保证文风与排版一致
  • 每月复盘转化数据,迭代选题方向

延伸阅读

相关资讯与近期热门内容

深度阅读推荐

建站决策前值得细读的三篇

网站改版的5个关键决策
2024-08-12

网站改版的5个关键决策

什么时候该改版、改到什么程度、如何避免流量掉光,京华建材集团改版复盘给出答案。

获取专属建站方案

看完文章,把您的行业与预算告诉我们,免费获取一份量身定制的官网建设方案与报价。

立即免费咨询