十年匠心定制 · 商业建站与技术教学双线并行 咨询热线:400-886-1026 service@lmnt.cn
ARTICLE DETAIL

资讯详情

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

C#与SQL Server构建工业上位机任务调度平台:核心架构与踩坑实践

C#与SQL Server构建工业上位机任务调度平台:核心架构与踩坑实践 简介面向C#与SQL技术栈的运维及开发人员这份任务调度平台资源包提供了一套完整的系统部署与配置方案。内容涵盖数据库安装脚本、Web站点发布配置、Node服务端部署以及系统级任务设定适合需要构建错误邮件通知与长时间运行任务检测机制的企业项目。压缩包大小约71.89MB文件围绕部署脚本、站点配置与服务组件组织可帮助读者按部署流程快速定位所需内容。目前已有102人学习下载具备一定参考热度。资源中给出了数据库脚本、web.config与config文件的关键配置逻辑并特别提示部署节点时需手动配置而非直接运行安装脚本有助于规避常见配置遗漏同时通过发布错误邮件发送任务和长时间运行任务检测任务展示了调度平台核心功能的实际落地方式对理解任务调度整体架构、提升运维排错能力有直接帮助。 干过几年工厂上位机开发的朋友应该都有同感需求文档里最难搞的往往不是某个设备通讯协议怎么解析而是那些“每天凌晨三点跑一次数据汇总”“每五分钟轮询一次PLC状态”“月底最后一天自动生成报表”之类的调度要求。我早期用Windows计划任务硬顶脚本越堆越多后来整个C#SQL任务调度平台从裸奔状态逐步补成了一整套能看日志、能补跑、能告警的正式系统。这篇文章就把这套平台的设计思路、数据库表结构、调度器核心逻辑和上线后踩过的坑摊开聊适合正在做上位机、MES对接或者企业内部自动化报表的朋友参考。1. 为什么工业上位机场景需要一套“土生土长”的调度方案先说结论这套东西不是一个通用任务调度中间件的替代品而是面向工业内网环境下的一组轻量级基础设施。很多同行问我为什么不直接用Hangfire、Quartz.NET甚至上xxl-job这类分布式调度平台答案不是技术层面的优劣而是现场条件根本不允许。1.1 这类平台到底解决了什么问题在工厂车间里工控机和数据库服务器通常部署在隔离的OT网段没有外网权限装一个需要依赖Redis、RabbitMQ或者其他外部服务的调度框架本身就是安全隐患和运维负担。而且生产环境的数据库表结构、存储过程都是现成的调度任务的最终落点绝大多数就是执行某段SQL或者某个存储过程。这种情况下直接让调度平台和SQL Server共处一套环境用数据库自身的事务和锁机制来保证任务状态一致反而是最稳的方案。这套平台承担的主要职责包括按固定周期、固定时刻执行数据采集任务把设备数据写入指定数据库表调度报表生成、数据归档、过期数据清理等批量SQL操作支持调用外部exe程序或者C#封装的业务程序集记录每次任务执行结果失败自动重试并留下排查日志提供一个最简单的可视化查询入口让车间维护人员也能看到任务跑没跑1.2 为什么不用现成的分布式任务调度框架Hangfire和Quartz.NET本身都很优秀但它们默认假设的是“ASP.NET应用进程内调度”或“独立服务进程调度”的模型。而我们的场景里调度器需要跟SQL Server深度绑定还要被上位机软件、看板程序、报表服务多个进程共用一个任务规则配置。如果各写各的调度器配置就会分叉如果都去依赖同一个框架又会把重量级组件引到工控机上。最终我选择用C#写一个Windows服务作为唯一调度进程把任务元数据全部放在SQL Server里形成一个“以数据库为规则引擎”的自研轻量调度平台。这样既规避了外部组件依赖又让所有配置集中可控。这套方案另一个被低估的好处是排障直观。任务卡住了、失败了直接查SQL就能看到当前到了哪一步。不需要翻分布式调用链不需要看一堆中间件日志。对于车间环境的技术支持水平来说这是非常现实的优势。2. 整体架构拆解调度线程、执行线程与数据库规则引擎怎么分工这套平台的架构从上线到现在经历过一次比较大的调整最初所有代码堆在一个后台线程里任务一多就互相卡。后来按职责拆成四个模块整个系统才稳定下来。2.1 四个核心模块的职责边界规则配置层任务定义、调度周期、参数JSON、启停状态全部存SQL Server。上层通过简单的管理界面或者直接改表来维护。调度引擎运行在Windows服务中负责定时扫描数据库中的任务定义找出到期任务生成“运行实例”写入任务实例表然后把执行请求推给执行线程池。执行器从队列里取任务根据任务类型分别执行存储过程、程序集方法或者外部exe。执行结果回写实例表失败按策略重试。监控与补偿独立线程检查卡死任务、超时任务以及服务重启后错过的任务决定是补跑还是跳过。这里最关键的是把“调度线程”和“执行线程”彻底分离。调度线程只管生成待执行任务、判断是否到期执行线程只管真正干活。否则某个存储过程跑5分钟期间调度器就漏掉了几十个到期任务这是很多自研调度器最常见的失败原因。2.2 调度器的运行线程模型调度主循环每秒钟扫一次任务定义表将到期的任务生成运行实例。执行侧使用一个线程池默认设置最大并发数8保证存储过程任务和外部程序任务不会互相饿死。线程池内部用一个阻塞队列接收调度引擎推送过来的任务ID执行完毕后主动回写状态。整个系统跑下来CPU占用和内存占用都非常低真正吃资源的是那些复杂的统计存储过程。所以调度器本体的性能不是瓶颈数据库的死锁预防和任务重入防护才是后续真正要解决的问题。3. SQL侧三张核心表任务定义、运行实例、日志存档的建模细节任务调度平台能不能做好一半的功夫在数据库设计。我最早只建了一张“任务表”所有执行记录全往里面append结果跑了两个月表膨胀到几百万行查询越来越慢。后来拆成三张表各司其职才彻底解决。3.1 任务定义表只存规则不存过程任务定义表用来描述“做什么、什么时候做、怎么做”。核心字段如下CREATE TABLE T_Task_Job ( JobId INT IDENTITY(1,1) PRIMARY KEY, JobName NVARCHAR(100) NOT NULL, JobType TINYINT NOT NULL, -- 1存储过程 2程序集方法 3外部程序 TargetName NVARCHAR(200) NOT NULL, -- 存储过程名/类名/EXE路径 JobParamJson NVARCHAR(MAX) NULL, -- 参数以JSON字符串传入 TriggerType TINYINT NOT NULL, -- 1Cron表达式 2固定周期 CronExpr NVARCHAR(50) NULL, IntervalSec INT NULL, IsEnabled BIT NOT NULL DEFAULT 1, TimeoutSec INT NOT NULL DEFAULT 300, MaxRetryCount INT NOT NULL DEFAULT 3, Description NVARCHAR(500) NULL, LastUpdateTime DATETIME NOT NULL DEFAULT GETDATE() );参数为什么用JSON字符串而不单独建一张参数表因为任务类型差异太大。存储过程可能要传日期范围外部程序可能要传文件路径统一用JSON最灵活。执行器拿到JobParamJson后按具体类型反序列化即可。字段里特意加了LastUpdateTime调度器扫描时可以根据这个字段判断是否需要刷新内存里的任务快照。3.2 运行实例表记录每一次具体的“跑”任务定义是模板运行实例才是实实在在的一次执行记录。这个设计类似CronJob和JobRun的区分好处是所有历史执行情况有独立的空间存储不会污染任务定义本身。CREATE TABLE T_Task_Run ( RunId BIGINT IDENTITY(1,1) PRIMARY KEY, JobId INT NOT NULL, PlanStartTime DATETIME NOT NULL, ActualStartTime DATETIME NULL, FinishTime DATETIME NULL, RunStatus TINYINT NOT NULL DEFAULT 0, -- 0待执行 1执行中 2成功 3失败 4超时 5已取消 ErrorMessage NVARCHAR(MAX) NULL, RetryCount INT NOT NULL DEFAULT 0, ExecMachine NVARCHAR(50) NULL, CreateTime DATETIME NOT NULL DEFAULT GETDATE() );索引方面必须有(JobId, PlanStartTime)联合索引和(RunStatus, ActualStartTime)索引。前者用来查询某个任务的历史执行情况后者用来让监控线程快速找到“执行中但长时间没有结束”的异常任务。RunStatus是整个平台状态流转的核心所有线程都围绕这个字段做判断。3.3 日志存档表给排查留后路运行实例表只记录最终结果和错误信息但很多问题需要看过程日志。我把执行过程中的关键节点都追加到T_Task_Log表比如“开始执行存储过程”“返回行数1200”“第2次重试原因连接超时”。日志表按周做分区超过一个月的自动归档到历史库。别小看这个设计线上问题排查时没有过程日志就只能靠猜。归档策略也没用太复杂的方案就是每周日凌晨的清理任务自动把一个月前的日志导出到备份库然后从主表删除。对车间场景来说完全够用不需要引入专门的日志系统。4. C#调度器核心到期扫描、并发互斥与漏跑补偿的实现逻辑调度器是整台机器的发动机实现逻辑说复杂也复杂说简单也简单。核心就是一个轮询循环加若干状态判断。我直接贴关键代码然后解释每一段的意图。4.1 主循环与到期判断protected override async Task ExecuteAsync(CancellationToken stoppingToken) { while (!stoppingToken.IsCancellationRequested) { var now DateTime.Now; // 一次只取未来5秒内要执行的任务 var jobs await _jobRepository.GetDueJobsAsync(now, now.AddSeconds(5)); foreach (var job in jobs) { await _taskQueue.EnqueueAsync(new TaskRequest(job.JobId, job.JobType, job.TargetName, job.JobParamJson)); } await Task.Delay(1000, stoppingToken); } }为什么只取未来5秒内的任务因为轮询间隔是1秒如果一次性把今天所有任务全部捞出来内存里会积压大量不紧急的数据而且一旦服务重启这些“预加载”的任务很难判断到底跑没跑。只取5秒窗口配合下面的执行前二次校验可以把重入风险降到最低。4.2 并发互斥防止同一个任务被同时执行两次生产环境最恐怖的问题不是任务失败而是同一个任务被两个线程同时执行。为了防重入我用了两层保险第一层是数据库层面的状态判断。调度器在入队前执行一个存储过程用事务将任务实例插入T_Task_Run表并在JobId上加条件判断——如果同一JobId已经存在“待执行”或“执行中”状态的记录就放弃本次调度。这一步利用SQL Server的行锁保证了并发安全。第二层是进程内的ConcurrentDictionary。线程池真正开始执行前再检查一次字典里有没有对应的JobId。因为从入队到真正执行之间可能间隔几百毫秒数据库的状态可能还是“待执行”两个执行线程可能都通过了DB校验此时进程内锁就能拦住。4.3 错过的任务补偿Windows服务不是永不会挂的断电、蓝屏、补丁重启都可能导致调度器错过一批任务。补偿逻辑放在服务启动时执行扫描T_Task_Run表找出PlanStartTime早于当前时间、RunStatus0且超时未执行的记录根据任务定义的策略决定是补跑还是跳过。不是所有任务都应该补跑。报表生成这类任务错过了就错过了补跑反而产生错误数据数据采集类任务则要补跑否则数据链就断了。我在任务定义表里加了一个字段MissPolicyTINYINT0跳过1补跑调度器启动时按这个策略处理。这个设计是上线后第一次被用户投诉“凌晨的任务没跑”之后才加的属于血泪教训。5. 三类任务执行器的统一封装存储过程、程序集调用与外部EXE调度器只负责把任务按时推给执行器执行器才真正干活。为了让新增一种任务类型不动调度器代码我抽象了IJobExecutor接口所有具体执行逻辑都实现这个接口。5.1 执行器接口与存储过程执行器public interface IJobExecutor { TaskExecuteResult ExecuteAsync(TaskRequest request, CancellationToken ct); } public class ProcTaskExecutor : IJobExecutor { private readonly string _connString; public async TaskExecuteResult ExecuteAsync(TaskRequest request, CancellationToken ct) { using var conn new SqlConnection(_connString); await conn.OpenAsync(ct); using var cmd new SqlCommand(request.TargetName, conn) { CommandType CommandType.StoredProcedure, CommandTimeout request.TimeoutSec }; // 反序列化JobParamJson按参数名注入SqlParameter var rowCount await cmd.ExecuteNonQueryAsync(ct); return ExecuteResult.Success($影响行数:{rowCount}); } }存储过程执行器看起来简单实际有几个细节要注意。CommandTimeout必须从任务定义里读不能写死否则一个跑20分钟的大存储过程会被数据库连接默认超时干掉。参数注入前必须校验参数名防止SQL注入所有值都用SqlParameter而非字符串拼接。5.2 程序集调用执行器与外部EXE执行器程序集调用执行器通过反射加载指定DLL找到实现统一接口的类调用Execute方法。这种做法适合把一些复杂的业务逻辑封装成标准库由调度器进程加载。注意一点如果DLL里有静态状态或者非托管资源进程内反复加载卸载会有问题。我们目前的策略是不做运行时卸载所有被调用的程序集都采用独立接口加无状态设计需要更新时直接停服务替换文件再启动。外部EXE执行器相对简单用Process.Start启动然后WaitForExit带超时。重点是超时后必须Kill整个进程树否则很多程序会留下子进程继续跑。判断成功退出码是0其他情况一律视为失败并记录标准输出和错误输出。5.3 为什么参数统一走JSON而不走配置项很多自研系统喜欢给每个任务建一张配置表字段一大堆结果加一个新需求就要改表。JSON参数的好处是把“任务定义”和“业务参数”完全解耦。同一个存储过程不同参数组合就是不同的任务只要在JobParamJson里改内容即可。管理员用Notepad改也好用一个小管理页面改也好都不影响执行器逻辑。6. 投入生产后的四个典型故障完整排查链路与修复方案这里直接聊运行过程中真实踩过且花了不少时间才解决的四个问题。每一个都是可以直接复用的排查经验。6.1 任务停用了为什么还在跑现象在任务定义表把IsEnabled改成0但日志里第二天的任务照跑不误。排查链路先查T_Task_Run发现新记录确实还在生成。再查调度器日志发现主循环依然取到了这个任务。最后定位到内存缓存问题——调度器为了避免每秒都查数据库在内存里维护了一份任务快照每天只刷新一次而当天修改的IsEnabled状态没被感知。修复方案是把任务快照刷新逻辑改成“轮询前检查LastUpdateTime”一旦发现任何任务的LastUpdateTime晚于快照时间就全量刷新。这个改动很小但彻底解决了状态修改不生效的问题。另外在生成执行实例前增加一次DB状态校验双重保险。6.2 存储过程死锁导致任务队列堆积现象某天早上看到T_Task_Run表里同一个任务连续七八条“失败”记录而且失败原因都是“事务死锁”。排查链路死锁问题不是调度器引起的是多个存储过程内部更新同一组表导致的数据库级资源竞争。查看SQL Server的错误日志找到死锁牺牲者提取出两个存储过程发现它们更新表的顺序相反。修复方案是统一多个存储过程对同一张表的操作顺序同时给存储过程开头加SET LOCK_TIMEOUT 5000让死锁等待快速失败重试。调度器这边的配合是重试策略加上指数退避第一次失败等10秒第二次等30秒第三次等60秒避免高峰期集中重试把数据库打得更死。6.3 任务执行时间超过调度周期导致重入现象一个数据归档任务每10分钟跑一次但某次数据量大跑了15分钟结果第10分钟时调度器又生成了一次新任务两边同时写同一张归档表。排查链路这个问题出在互斥判断只拦截了“执行中”状态但第一次执行因为线程池繁忙还停留在“待执行”状态没有及时更新为“执行中”。修复方案是把状态更新提到线程池取到任务后立刻执行而不是等到真正调用存储过程前才更新。同时在Process内ConcurrentDictionary里以JobId为key锁粒度放大到整个执行阶段。6.4 网络抖动导致漏任务现象SQL Server连接串配置的是内网IP某次交换机重启产生几十秒抖动调度器刚好在那期间扫描数据库结果整个扫描周期异常退出任务全部漏掉。排查链路比较复杂因为日志里没有直接报错只看到某一段时间的T_Task_Run表完全没有新数据。后来在调度器里加了整体异常捕获和日志输出才发现连接超时。修复方案是主循环的GetDueJobs调用包了一层带重试的辅助方法遇到网络类异常会连续重试三次。同时监控线程每隔5分钟检查一次当前时间点附近是否存在应执行而未执行的任务一旦发现立即告警并补扫。7. 从单机到双机不重构也能实现的轻量高可用拓展当调度任务量大到一台工控机扛不住或者需要做冗余备份时这个平台可以比较平滑地升级成双机模式。前提是核心设计从一开始就没有把状态放在内存里所有任务状态都在数据库所以多一个调度器实例并不会破坏状态一致性。双机模式的做法是在两个Windows服务上部署同一套调度器它们连接同一个SQL Server。数据库层的互斥逻辑此时成为唯一的正确性保障。我在任务定义表里增加了OwnerMachine字段调度器启动时尝试抢占成为主节点抢到的实例每30秒续租一次备用实例则处于待命状态。如果主节点断线超过60秒备用节点自动接管。这套方案没有引入任何外部组件全部基于数据库表实现对于车间环境两台机器的规模已经足够了。如果未来要支持更多的机器或者更复杂的调度策略可以考虑引入正式的分布式协调组件但那是另一个量级的问题。从实际效果看我在生产环境最终留下的架构比第七节描述得还要简洁无外网依赖、无额外中间件、数据库表清晰可见整个平台跑了大半年最常出问题的反而是那些业务存储过程本身调度器本身很少需要人工干预。如果让我重新做一次我仍然会选择用C#和SQL Server这一套“朴素”的组合因为在一个追求可维护性和可排查性的工业环境里简单可靠比架构炫技重要得多。本文还有配套的精品资源点击获取
返回列表