C#实现每天凌晨1点定时任务的完整示例详解
1. 凌晨1点这个需求,坑全藏在细节里
"C# 每天定时执行任务(每天凌晨1点执行)"这句话看着像一道送分题,写起来也就十几行代码,但真放到生产环境里跑上半年,你会发现出问题的地方从来不是"怎么写代码",而是"时间怎么算、进程还在不在、出事了怎么补"。我这些年接手的运维排障单子里,跟定时任务有关的能占到三成,症状高度雷同:日志里连续几天都有记录,某一天突然断了;或者同一个任务一天跑了两遍,数据被重复处理;又或者服务器重启之后任务就再也不触发了。这些问题单独看都是小事,堆在一起就变成一个没人敢碰的黑盒。
这篇东西覆盖面会稍微宽一点,从时间计算的地基开始,到手写轻量调度器,再到 Windows 服务、Quartz.NET、任务计划程序这几种落地方式的取舍,最后给一份我自己在用的排查速查表。适合刚接手后台定时任务的新同学,也适合已经写过几版但被坑过的老手对照着看看哪里还能再收一收。代码全部基于 .NET 6 及以上,语法层面在 .NET Framework 4.6+ 上稍作调整也能用。
1.1 四种实现路径的取舍
先说选型。同样一句"每天凌晨1点执行",至少有四条路可以走,各自的适用边界差别很大,选错了后期维护成本会翻好几倍。
| 方案 | 依赖 | 精度 | 抗重启 | 适合场景 | 主要短板 |
|---|---|---|---|---|---|
System.Threading.Timer 手写 | 无 | 秒级 | 否 | 单机小工具、内部脚本 | 逻辑要自己兜底 |
BackgroundService + PeriodicTimer | 宿主 | 秒级 | 配合服务可 | 常驻 Windows 服务 | 需要宿主支撑 |
| Quartz.NET | NuGet 包 | 秒级 | 可持久化 | 多任务、需要 misfire 策略 | 学习曲线陡 |
| Windows 任务计划程序 | 系统自带 | 分钟级 | 是 | 一次性批处理、独立 exe | 权限和交互坑多 |
我的经验判断很简单:任务数量少于五个、只在一台机器上跑、失败了重跑不要紧,那就手写一个 Timer 足够,别为了"架构感"上 Quartz。反过来,如果任务列表会随着业务增长、需要可视化的执行历史、需要在集群里只让一台机器跑,那从第一天就该上 Quartz.NET 或者 Hangfire,后面改起来省事得多。
还有一个经常被忽略的维度是"任务本身要不要访问用户态资源",比如读取映射的网络盘、调用需要登录用户身份的组件。这一类必须跑在交互式会话里,那就只能走任务计划程序,配成 Windows 服务反而会因为 Session 0 隔离而失败。这条边界先想清楚,后面能省掉一堆"为什么本地跑得好好的,做成服务就报错"的困惑。
1.2 为什么"每天执行"的代码三个月后就不灵了
我见过太多这样的代码:
var timer = new Timer(DoWork, null, TimeSpan.Zero, TimeSpan.FromHours(24));
这段代码在开发机上跑一晚上没问题,上线之后问题会慢慢浮出来。第一,启动时刻决定了执行时刻,你今天下午三点部署,它就永远下午三点跑,跟"凌晨1点"没有半点关系。第二, TimeSpan.FromHours(24) 这个周期是固定间隔,不是"每天的某个时刻",服务器时间被 NTP 校准跳了几秒,误差会随着天数累积,一个月后可能偏到凌晨1点零几分甚至跨到 0 点 59 分。第三,进程重启之后计时器归零重来,如果重启发生在凌晨0点50分,那当天的任务就直接丢了,没人知道。
还有个更隐蔽的问题: System.Threading.Timer 的构造参数里, dueTime 和 period 最终会被转成毫秒的整数,单次等待的上限大概在 24.8 天(int 毫秒上限)这个量级, TimeSpan 重载则受 uint 毫秒上限约束,约 49.7 天。每天执行碰不到这个天花板,但只要业务方哪天说"改成每月1号执行",这段代码就会在构造时直接抛 ArgumentOutOfRangeException ,而且是那种上线才发现、日志里只有一行堆栈的崩溃。所以我现在写调度代码,从来不直接用长周期,一律是"短间隔轮询 + 每次重新计算目标时刻",把周期控制在几十秒到一分钟,规避掉所有溢出和漂移问题。
2. 把"下一次几点"算准,方案就成了一半
定时任务的核心其实不是"定时器",是"时间计算"。定时器只是个闹钟,什么时候响取决于你算出来的时间差对不对。这块写对了,整个方案就稳了一大半。
2.1 下次触发时间的三行算法和三个边界
最基础的计算长这样:
public static DateTime GetNextRunTime(DateTime now, TimeSpan target)
{
var todayTarget = now.Date + target;
return now < todayTarget ? todayTarget : todayTarget.AddDays(1);
}
三行,但边界要一个一个抠。
边界一: <还是 <=。 用 < 的时候,如果当前时间恰好等于 1:00:00.000,函数会返回明天的 1 点,也就是说这一毫秒整点被跳过了。实践中基本不会碰上(除非你手动构造时间做测试),但测试用例里会,我建议统一写成 now <= todayTarget ,语义是"到点即执行",测起来更符合直觉。
边界二: now.Date的时区语义。 DateTime.Now.Date 拿的是本地时间的零点,如果服务器时区配置错了(比如默认 UTC 没改),凌晨1点的含义就完全变了。这个坑在容器化部署里特别常见,镜像默认 UTC,代码里写 DateTime.Now ,结果任务在北京时间早上9点跑。检查方法很土但有效:启动时打一行日志,把 DateTime.Now 和 TimeZoneInfo.Local.Id 一起输出,出问题第一眼看这里。
边界三:2 月 29 日和月末。 每天执行不受影响,但如果你把这个算法推广到"每月31号执行",2 月就会跳过去或者溢出。 AddDays(1) 本身是安全的(它不会因为月末出错),真正危险的是 new DateTime(y, m, 31) 这种硬构造。我的习惯是永远用 Date + TimeSpan 或者 AddDays 做偏移,不去手拼年月日。
再补一个实用变体,一次性算出未来 N 次的触发时刻,方便做对账和监控告警:
public static IEnumerable<DateTime> GetNextRuns(DateTime from, TimeSpan target, int count)
{
var current = from;
for (var i = 0; i < count; i++)
{
current = GetNextRunTime(current, target);
yield return current;
current = current.AddSeconds(1); // 让下一次计算跳过当前时刻
}
}
AddSeconds(1) 那一行是个小技巧,不加的话第一次算出 1:00,第二次拿 1:00 去算又会得到同一个 1:00,直接死循环。这类"看起来显然"的地方,恰恰是新人最容易卡住的地方。
2.2 时区、夏令时与系统校时
国内没有夏令时,所以本地的"凌晨1点"是恒定偏移。但如果服务部署在有时区切换的地区, DateTime.Now 在切换那天会出现 1 点不存在或者出现两次的情况, DateTimeOffset 配合 TimeZoneInfo 才能正确处理。我的建议是:内部计算统一用 DateTimeOffset.UtcNow 加上目标时区的转换,只在日志输出时转成当地时间。这样即使服务器时区被人误改,业务语义也不会跑偏。
var tz = TimeZoneInfo.FindSystemTimeZoneById("China Standard Time");
var nowLocal = TimeZoneInfo.ConvertTime(DateTimeOffset.UtcNow, tz);
var nextLocal = GetNextRunTime(nowLocal.DateTime, TimeSpan.FromHours(1));
注意 FindSystemTimeZoneById 在不同平台上的 ID 不一样,Linux 容器里要用 "Asia/Shanghai" ,.NET 6 之后支持 TimeZoneInfo.FindSystemTimeZoneById 同时接受两种写法,但 .NET Framework 上只认 Windows ID。跨平台部署的项目,我一般会把时区 ID 做成配置项,避免硬编码。
系统校时的影响容易被低估。Windows 默认会跟时间服务器同步,一次校时跳个几秒很正常,跳几分钟也不是没发生过(尤其是虚拟机动过挂起恢复之后)。如果你的实现是"算出距离目标时刻还有多少毫秒,然后 Thread.Sleep 这么久",一次跳变就能让任务提前或延后。所以我在调度循环里永远不信任单次长等待,全部用轮询:每隔 30 秒醒来看一眼当前时间和目标时刻的关系,到窗口了就执行。代价是精度降到 30 秒级别,对于"凌晨1点跑批"这种需求完全够用。
2.3 精确时刻与时间窗口:选哪个
这是设计上最值得纠结的一点。两个选择:
精确时刻触发。 只在 now == 1:00:00 那一秒内执行,其他时间一律不管。优点是绝对不会重复执行,缺点是开机晚了一秒就漏掉。
时间窗口触发。 定一个窗口,比如 [1:00, 1:10) ,在这个区间内只要"今天还没跑过"就执行,跑完标记。优点是容忍度极高,机器重启、进程卡顿都能捞回来,缺点是需要一个"今天是否跑过"的持久化标记,否则进程重启会重复跑。
我的实际选择是窗口模式,窗口长度取 5 到 10 分钟,标记落到文件或者数据库里。理由很简单:跑批任务最怕的是"漏",而不是"晚十分钟"。业务方看到数据晚到会接受,看到数据缺失会直接打电话。窗口模式把可用性从"准时"变成了"当天必然执行",这是本质区别。
标记的存储方式看场景。单机小任务写一个文本文件就够了,内容是上次执行的日期 yyyy-MM-dd ;多实例部署就得上数据库或者 Redis,用行锁或 SETNX 保证只有一个实例抢到执行权。我见过最省事的做法是往一张 job_execution_log 表里插一条唯一键为 (job_name, run_date) 的记录,插入成功才执行,天然幂等,出错还能查历史。
3. 三十行手写一个能用的调度器
讲完原理,落地一个能直接抄的最小版本。这套代码在我手里跑了两年多,中间经历过服务器重启、系统校时、进程异常,没出过漏执行。
3.1 最小可用版本
using System;
using System.IO;
using System.Threading;
internal static class Program
{
private static readonly TimeSpan Target = new TimeSpan(1, 0, 0);
private static readonly TimeSpan CheckInterval = TimeSpan.FromSeconds(30);
private static readonly TimeSpan Window = TimeSpan.FromMinutes(10);
private const string StampFile = @"D:\Jobs\last-run.txt";
private static volatile bool _stopping;
private static void Main()
{
AppDomain.CurrentDomain.ProcessExit += (_, _) => _stopping = true;
Console.WriteLine($"启动于 {DateTime.Now:yyyy-MM-dd HH:mm:ss},时区 {TimeZoneInfo.Local.Id}");
while (!_stopping)
{
try { Tick(); }
catch (Exception ex) { Console.WriteLine($"[{DateTime.Now:HH:mm:ss}] 调度异常:{ex}"); }
Thread.Sleep(CheckInterval);
}
}
private static void Tick()
{
var now = DateTime.Now;
var windowStart = now.Date + Target;
var windowEnd = windowStart + Window;
if (now < windowStart || now >= windowEnd) return;
if (LastRunDate() == now.ToString("yyyy-MM-dd")) return;
Console.WriteLine($"[{now:HH:mm:ss}] 开始执行");
try
{
RunJob();
WriteStamp(now.ToString("yyyy-MM-dd"));
Console.WriteLine($"[{DateTime.Now:HH:mm:ss}] 执行完成");
}
catch (Exception ex)
{
Console.WriteLine($"[{now:HH:mm:ss}] 执行失败:{ex.Message}");
// 不写时间戳,下一轮轮询会自动重试
}
}
private static string LastRunDate()
=> File.Exists(StampFile) ? File.ReadAllText(StampFile).Trim() : string.Empty;
private static void WriteStamp(string date)
=> File.WriteAllText(StampFile, date);
private static void RunJob()
{
// 实际业务写在这里
}
}
三十来行,核心就三个东西:轮询间隔、时间窗口、执行标记。读起来不费力,出问题一眼就能定位到是哪一块。
3.2 加固:防重、防漏、防崩
上面这段是最小骨架,直接上线还差几块。
防重。 失败重试的逻辑有个隐患:如果 RunJob() 执行到一半抛出异常,但数据其实已经处理完了,下一轮重试就会重复处理。所以业务侧必须做成幂等的,比如按日期删除再插入,而不是无脑追加。这一点没法靠调度框架解决,得在业务代码里保证。
防漏。 如果进程恰好在 [1:00, 1:10) 之外的时间启动,比如凌晨2点重启,当天就漏了。要不要补跑是个业务决策。我的默认做法是给一个宽限:窗口放宽到 [1:00, 6:00) ,只要当天没跑过就补。这样机器半夜重启也不影响。但有的任务对时效敏感,补跑反而会覆盖当天的正常数据,那就得压窄窗口。
防崩。 RunJob() 内部一定要有自己的异常隔离,别指望外层 catch 兜住一切。我见过 StackOverflowException 和 OutOfMemoryException 直接把进程干掉的场景,那种情况下调度器自己也救不了。所以在服务化部署时,我会在外面套一层 Windows 服务的自动恢复机制,配置"第一次失败重启服务,第二次失败重启服务,后续失败执行指定程序"。这个恢复配置在服务管理器里两分钟就能设好,价值比在代码里写多少 try-catch 都高。
还有一个细节: Thread.Sleep 会让主线程在关机时响应变慢。 ProcessExit 事件里把 _stopping 置为 true 是有效的,但如果睡眠中途收到关机信号,最多会拖 30 秒才退出,Windows 可能等不及直接强杀。稳妥做法是用 ManualResetEventSlim.Wait(CheckInterval) 替代 Thread.Sleep ,退出信号来了立刻返回。这个改动很小,但对服务优雅停止的体验提升明显。
4. 生产环境四种落地方式与配置细节
骨架有了,接下来解决"跑在哪"的问题。这一步选错了,后面所有的运维麻烦都从这里来。
4.1 Windows 服务 + BackgroundService
这是我最推荐的常驻方案。用 Microsoft.Extensions.Hosting 的通用宿主,把它包装成 Windows 服务,开机自启、崩溃可配自动恢复、日志接到现有的日志框架里,跟其他后台任务的运维方式统一。
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Hosting;
Host.CreateDefaultBuilder(args)
.UseWindowsService(options => options.ServiceName = "DailyBatchService")
.ConfigureServices(services =>
{
services.AddHostedService<DailyJobService>();
})
.Build()
.Run();
记得加 NuGet 包 Microsoft.Extensions.Hosting.WindowsServices ,否则 UseWindowsService 找不到。
服务本体:
public sealed class DailyJobService : BackgroundService
{
private static readonly TimeSpan Target = new TimeSpan(1, 0, 0);
private static readonly TimeSpan Window = TimeSpan.FromMinutes(10);
private static readonly TimeSpan CheckInterval = TimeSpan.FromSeconds(30);
private readonly ILogger<DailyJobService> _logger;
private readonly IJobStateStore _store;
public DailyJobService(ILogger<DailyJobService> logger, IJobStateStore store)
{
_logger = logger;
_store = store;
}
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
{
using var timer = new PeriodicTimer(CheckInterval);
while (await timer.WaitForNextTickAsync(stoppingToken))
{
var now = DateTime.Now;
var start = now.Date + Target;
if (now < start || now >= start + Window) continue;
if (await _store.HasRunAsync(now.Date, stoppingToken)) continue;
try
{
_logger.LogInformation("任务开始 {Time}", now);
await RunJobAsync(stoppingToken);
await _store.MarkRunAsync(now.Date, stoppingToken);
_logger.LogInformation("任务完成 {Time}", DateTime.Now);
}
catch (OperationCanceledException) when (stoppingToken.IsCancellationRequested)
{
_logger.LogWarning("服务停止,任务中断");
throw;
}
catch (Exception ex)
{
_logger.LogError(ex, "任务执行失败");
}
}
}
private Task RunJobAsync(CancellationToken ct) => Task.CompletedTask;
}
PeriodicTimer 是 .NET 6 引入的, WaitForNextTickAsync 支持取消,比 Thread.Sleep 优雅得多。有个坑要提前说: PeriodicTimer 不会累积错过的心跳,如果一次回调耗时超过间隔,它会跳过中间那些 tick,直接等下一个周期。对 30 秒间隔配 10 分钟窗口的场景毫无影响,但如果间隔和窗口都很短,要留意。
安装命令:
sc create DailyBatchService binPath= "D:\Jobs\DailyBatchService.exe" start= auto DisplayName= "每日批处理服务" sc description DailyBatchService "每天凌晨1点执行批处理" sc failure DailyBatchService reset= 86400 actions= restart/60000/restart/60000/restart/60000
注意 binPath= 后面必须带一个空格,这是 sc 的老毛病,少一个空格就报参数错误。另外服务跑在 Session 0,没有桌面交互,任何弹窗、 MessageBox 、依赖用户配置文件的路径(比如 %AppData% 下的映射盘)都会失败,路径一律用绝对路径。
4.2 Quartz.NET
任务数量上去了,或者需要 misfire(错过执行)策略、需要持久化到数据库、需要多实例互斥,上 Quartz.NET 更省心。
services.AddQuartz(q =>
{
var jobKey = new JobKey("daily-job", "batch");
q.AddJob<DailyJob>(opts => opts.WithIdentity(jobKey));
q.AddTrigger(opts => opts
.ForJob(jobKey)
.WithIdentity("daily-trigger", "batch")
.WithCronSchedule("0 0 1 * * ?", x => x
.InTimeZone(TimeZoneInfo.Local)
.WithMisfireHandlingInstructionDoNothing()));
});
services.AddQuartzHostedService(o => o.WaitForJobsToComplete = true);
几个必须记住的点。
第一,Quartz 的 cron 表达式是 六段或七段 (秒 分 时 日 月 周 [年]),跟 Linux crontab 的五段不一样。 0 0 1 * * ? 是每天1点整,那个 ? 在"日"和"周"字段里必须出现一个,不能同时指定。新人最容易把 crontab 的 0 1 * * * 直接贴过来,结果是每小时的第0分第1秒都在跑,一天跑24次。
第二, WithMisfireHandlingInstructionDoNothing() 表示错过就不补,等下一个周期。如果业务上不允许漏,用 WithMisfireHandlingInstructionFireAndProceed() ,它会在恢复后立刻补一次。这两个策略的差异直接影响数据完整性,选之前跟业务方确认清楚。
第三,多实例部署想要"只跑一台",需要配置 AdoJobStore 把调度信息落到共享数据库,Quartz 会自动用锁机制保证同一个 trigger 只被一台机器抢到。用内存模式的 RamJobStore 是完全做不到这一点的,几台机器各跑各的,数据会翻倍。
第四, InTimeZone 一定要显式指定。Quartz 默认用 UTC,不写这一行, 0 0 1 * * ? 就变成了北京时间早上9点,而且日志里看不出任何异常,只有业务数据的时间戳会暴露。
4.3 Windows 任务计划程序
如果需求就是"跑一个独立的 exe",那任务计划程序是最省事的,不用写宿主代码,不用打包安装,配好就完事。
schtasks /create /tn "DailyBatch" /tr "D:\Jobs\DailyBatch.exe" /sc daily /st 01:00 /ru "SYSTEM" /rl HIGHEST /f
/sc daily /st 01:00 就是每天1点,语义跟需求完全对应,运维一看就懂。
坑主要集中在权限和触发条件上。 /ru "SYSTEM" 意味着以系统身份运行,能读到大部分本地路径,但访问不了网络共享、读不到某些用户级证书,也不能操作需要登录会话的组件。如果任务需要这些,就得改成具体的服务账号,并且把密码一并配上,否则任务会因为"登录失败"静默不执行。
还有一个默认设置坑了不少人:任务属性里的"只在计算机使用交流电源时启动"和"唤醒计算机运行此任务"。笔记本或者配置了电源策略的服务器上,如果到点时正好因为空闲进入睡眠,任务就会被跳过,而且事件日志里只有一条不起眼的记录。解决办法是在"条件"标签页里把手动取消勾选那两个选项,同时在"设置"里勾选"如果错过计划开始,则尽快启动任务"。
日志方面,任务计划程序自带的执行历史是关着的,需要手动在"操作"里启用"启用所有任务历史记录",或者用事件查看器的 Microsoft-Windows-TaskScheduler/Operational 通道。我一般建议任务本身自己写日志文件,别依赖系统历史,因为系统历史保留时间短,排查一周前的问题时经常已经滚没了。
4.4 Web 进程里跑的定时任务
这是我最不推荐但见得太多的做法:在 ASP.NET 应用里开一个 Timer 或者 HostedService 跑定时任务。
问题的根源在 IIS 的应用池回收机制。默认配置下,应用池空闲 20 分钟就回收,定期回收默认是 1740 分钟(29小时),再加上各种内存阈值、配置文件变更触发的回收。回收的那一刻,进程被杀掉,你精心写的调度器连同 Timer 一起没了。下一次请求进来会重新启动一个 w3wp 进程,但如果是深夜没人访问,进程可能一直不启动,任务自然不跑。
有人会调 startMode="AlwaysRunning" 和 preloadEnabled="true" ,再关掉空闲超时,实测确实能缓解,但这是跟平台机制对着干,每次发布、每次服务器策略变更都得重新确认一遍。我现在的原则很明确:定时任务要么做成独立 Windows 服务,要么交给任务计划程序,坚决不塞进 Web 进程。真要塞,至少得接受"可能会漏,而且漏了没人知道"这个前提,然后在外层加一个"数据没更新就告警"的兜底监控。
5. 排查实录:到点不执行、重复执行、漏执行
这一段是我攒下来的排障笔记,按症状分类,遇到问题直接对着查比什么都快。
5.1 症状到原因的速查表
| 症状 | 高概率原因 | 验证方法 | 处理 |
|---|---|---|---|
| 从来没执行过 | 时区不对,实际在别的时段跑 | 启动日志打印 TimeZoneInfo.Local.Id | 显式指定时区 |
| 某天开始不执行 | 进程被回收 / 服务停了 | 查服务状态和进程启动时间 | 改常驻服务 + 自动恢复 |
| 执行时间越来越晚 | 固定周期累积误差 | 对比日志时间戳的偏移量 | 改成每次重算目标时刻 |
| 一天执行多次 | 多实例各自触发 | 统计不同机器 IP 的日志 | 加分布式锁 |
| 执行了但数据没变 | 异常被吞掉 | 检查 catch 块是否只打日志 | 失败不写标记,允许重试 |
| 重启后任务丢失 | 内存态标记 | 看标记是否落在文件/DB | 持久化执行记录 |
| 本地正常,服务器不跑 | Session 0 隔离或权限不足 | 用服务账号手动跑一次 | 换账号或改交互式 |
这张表里我标红最多的是"执行了但数据没变"。原因往往是 catch 块里写了一行 _logger.LogError(ex, "...") ,然后代码继续往下走,把执行标记写上了。下一轮轮询看到"今天已执行"就跳过,任务再也补不回来。正确做法是标记只在成功后写入,失败就让它下一轮重试,配合告警通知到人。
5.2 重复执行的三种来源
按我遇到的频率排个序。
第一种是部署了多台机器但没做互斥。测试环境单机跑得好好的,生产上两台机器同时到点,各跑一遍。排查方法很直接:日志里带上机器名,一查就知道。解决就是分布式锁,Redis 的 SET key value NX EX 是最轻量的做法,抢到锁的那台执行,抢不到的跳过并记录一行"未获得执行权"。
第二种是"补跑"逻辑写得太宽。窗口设成 [1:00, 23:59) ,只要当天没跑过就执行,结果某天任务在1点跑完后因为异常没写标记,接下来的每一轮轮询都在重试,一天跑了几十遍。窗口的意义是"容忍一定程度的延迟",不是"全天候随时补"。
第三种是重试和定时共用一个入口。某个任务失败后进了一个"5分钟后重试"的队列,恰好这时正常的定时触发也到了,两路同时进业务逻辑。解决办法是给每次执行加一个业务幂等键,比如订单日期或者批次号,在数据库层做唯一约束,从根上堵住。
5.3 我踩过的几个具体坑
先说一个最坑的。某次给一个后台服务加了日志,跑了一周发现每天的执行时间都在往后挪,从 1:00:00 挪到 1:00:47。一开始以为是系统负载,查了半天 CPU 和 GC 都很正常。最后发现是代码里这么写的:
var delay = next - DateTime.Now; _timer.Change(delay, Timeout.InfiniteTimeSpan);
delay 计算完之后到 Change 真正生效之间有几毫秒的间隔,而 OnTick 回调里又是先执行任务再重新计算下一次的时间,任务本身的耗时被算进了下一次的间隔里。任务跑 40 秒,时间就往后挪 40 秒,日积月累。改成"每次执行前先把下一次的目标时刻算好,用绝对时间而不是相对间隔",问题就消失了。
第二个坑是时间窗口判断写成了 now.Minute == 0 && now.Hour == 1 。看着挺对,但如果某一轮轮询刚好在 1:00:59 和 1:01:29 之间,就跳过了整整一分钟,任务直接漏掉。轮询间隔是 30 秒,判断条件必须用区间,不能用等值。这也是为什么我坚持用 [start, start+window) 这种半开区间。
第三个坑跟日志和告警有关。有个任务连续三天没执行,但没人发现,因为所有人都默认"定时任务会自己跑"。后来我在每台机器上加了这么个小东西:任务成功时往一个约定的目录写一个当天的空文件,另外配一个独立的巡检脚本,每天早上8点检查"今天的标记文件在不在",不在就发告警。这套东西加起来不到二十行,但之后所有"静默失败"的情况都能在当天上午被发现,比任何复杂的监控体系都实在。
最后一个体会上,定时任务的可靠性其实是"三层保险"叠出来的:第一层是调度本身,用轮询加窗口保证不错过;第二层是状态持久化,保证重启不丢、不重;第三层是外部巡检,保证即使前两层都失效,也一定有另一双眼睛看到。这三层少任何一层,迟早都会在某天凌晨一点,以一种你完全想不到的方式给你一个惊喜。
以上就是C#实现每天凌晨1点定时任务的完整示例详解的详细内容,更多关于C#定时任务的资料请关注脚本之家其它相关文章!
相关文章
C#实现winform用子窗体刷新父窗体及子窗体改变父窗体控件值的方法
这篇文章主要介绍了C#实现winform用子窗体刷新父窗体及子窗体改变父窗体控件值的方法,涉及C#窗体交互的相关技巧,具有一定参考借鉴价值,需要的朋友可以参考下2015-07-07


最新评论