- 任务调度
- 后端
【免费下载链接】quartznet
Quartz Enterprise Scheduler .NET
Quartz.Benchmark.Competitors 是 Quartz.NET 仓库内一个专门用于横向对比的基准测试工程,它把 Quartz.NET 与另外两个 .NET 调度器——TickerQ 10.4.0 与 Hangfire 1.8.25——放在同一套工作负载下,只测量调度器真正该做的事:执行作业。读完本文,你将掌握这套对比框架的定位、五个基准场景(S1–S5)的完整定义与运行方法、三个引擎的统一设置与各自差异、它如何在作业体内计数以保证可比性,以及它是如何回答"一次触发在内存、数据库、时延、周期准确性与写入成本上各值多少"的。
它为什么存在:回应一份"没有执行任何作业"的第三方对比
这个工程诞生的直接动因,是回应 TickerQ 官方基准套件(benchmarks/TickerQ.Benchmarks/Comparisons/)中一份被广泛引用的三方对比。该对比存在三方面问题:
- 测的不是同一件事。
ConcurrentThroughputComparison的基线是FrozenDictionary<string, TickerFunctionDelegate>查找后调用一个函数体为Task.CompletedTask的委托;旁边的 Hangfire 分支创建后台作业;Quartz 分支则构建IJobDetail与ITrigger并调用ScheduleJob。JobCreationComparison的基线干脆是"TickerQ:FrozenDictionary 函数查找"。三列混在同一张表里,彼此不可比。 - 基准进程行为不可控。各分支跑在
Parallel.For配合.GetAwaiter().GetResult()下;Quartz 侧通过new StdSchedulerFactory()引用Quartz3.14.*包;迭代之间不清空调度器也不清空存储,两个存储随运行时长持续增长,给作业命名的计数器一路攀升。 - 没有任何作业被执行。整张表测的都是"提交调用"而不是"作业运行",一个调度器可以靠把提交路径做快而显得任意快。
Quartz.Benchmark.Competitors 的设计目标因此非常明确:每个分支都在运行中的作业内部计数——作业体递增一个Interlocked计数器,调度前发布绝对目标值,等待端用带超时的ManualResetEventSlim把"挂死"变成异常。一次基准调用就是"等待 N 次执行真实发生"。Completion.cs 是这套计数的实现:Arm在调度前清零并发布目标,Await阻塞到目标达成(超时 5 分钟即抛异常判为坏基准),Record由作业体第一行指令调用,Disarm在引擎拆除后丢弃残留目标。计数器必须是进程级静态的,因为 Hangfire 通过静态方法的表达式树运行作业、TickerQ 通过源生成委托来 new 出声明类,两者的作业体都无法拿到实例引用。
项目定位:刻意游离在解决方案之外
从 Quartz.Benchmark.Competitors.csproj 的注释可以看到它的边界设计:
- 不在
Quartz.slnx中。Compile、UnitTest、BenchmarkSmoke、ExamplesSmoke、裁剪 canary 和 Sonar 都走解决方案,因此本工程对这些流水线完全不可见,允许它引用两个未签名的第三方调度器与一套 EF Core 栈而不污染任何受门禁的构建。 IsPackable=false,分析关闭(SonarQubeExclude、AnalysisLevel=none),产物不发布。- 以
ProjectReference引用Quartz.csproj而非已发布包,所以它测量的是工作树——这正是文档强调的:"what it measures is the working tree"。这避免了第三方对比因引用旧包而过期的问题。
构建与运行必须显式点名该项目,例如dotnet build -c Release src/Quartz.Benchmark.Competitors/Quartz.Benchmark.Competitors.csproj。
运行方式:从单场景到数据库普查
README 给出的命令矩阵如下:
dotnet build -c Release src/Quartz.Benchmark.Competitors/Quartz.Benchmark.Competitors.csproj # 单个场景,带测量 dotnet run -c Release --project src/Quartz.Benchmark.Competitors -- --filter '*S1*' # 一切无需数据库的场景,各执行一次,不测量任何东西 dotnet run -c Release --project src/Quartz.Benchmark.Competitors -- --smoke # S4 是普通运行器而非基准,单独跑 dotnet run -c Release --project src/Quartz.Benchmark.Competitors -- --recurring几点说明:
- Release 不可省略:BenchmarkDotNet 拒绝非优化程序集。
--smoke是整场运行而非修饰符,不能与其他参数混用;它通过 Program.cs 的SmokeConfig用 InProcess 无发射工具链执行所有不含RequiresDatabase类别的场景各一次,且不强制电源计划——这是改动引擎后手工验证"引擎还在执行作业"的手段,因为一个停止执行作业的引擎看起来就像一个慢引擎,直到有东西等待一个永远不会到来的执行。- 另外三个整场运行是
--recurring、--recurring-postgres和--commits,均不接受其他参数(Program.cs)。 --help会打印这些运行器的说明。退出码不等于健康:BenchmarkDotNet 的 switcher 对失败的 case 只输出 NA 行,Program.cs 的Report会把校验错误与失败 case 汇总成非零退出码,并让"过滤器匹配不到任何基准"也失败而不是假装成功。
S2 与--commits需要 PostgreSQL,由进程外启动、通过环境变量命名。文档给出容器与连接串:
docker run -d --name quartz-competitors-pg -p 55432:5432 \ -e POSTGRES_DB=quartznet -e POSTGRES_USER=quartznet -e POSTGRES_PASSWORD=quartznet \ postgres:15.1 -c shared_preload_libraries=pg_stat_statements $env:QUARTZ_BENCHMARK_POSTGRES='Host=localhost;Port=55432;Database=quartznet;Username=quartznet;Password=quartznet' dotnet run -c Release --project src/Quartz.Benchmark.Competitors -- --filter '*S2*' dotnet run -c Release --project src/Quartz.Benchmark.Competitors -- --commits # S4 对阵 Quartz 的 ADO 存储:每次获取一轮对阵自动批处理 dotnet run -c Release --project src/Quartz.Benchmark.Competitors -- --recurring-postgres环境变量名QUARTZ_BENCHMARK_POSTGRES由 PostgresFixture.cs 读取,未设置即抛异常。为什么数据库必须在进程外?因为 BenchmarkDotNet 每个 case 跑一个进程,若容器归基准所有,每个 case 都要启动再丢弃一次。shared_preload_libraries参数仅为语句普查服务;不带它--commits仍会统计提交数,但语句列显示"未采集"。三个库共享同一个数据库、各自住在自己的默认 schema 下:Quartz 的 shipped DDL 创建不带 schema 限定的qrtz_*表,落在public;TickerQ 默认ticker;Hangfire 默认hangfire(见 PersistentEngines.cs)。ResetQuartz通过重放 database/tables/tables_postgres.sql 重置 Quartz 表(该脚本执行两次即等价于 drop 再建),ResetSchema对其它库直接DROP SCHEMA ... CASCADE。
五个场景:S1–S5 各自测量什么
| 测量内容 | 规模 | |
|---|---|---|
| S1 | 内存吞吐:每次执行的纳秒数与字节数 | 20,000 个同时到期的一次性作业 |
| S2 | 同样的工作负载放到 PostgreSQL 上,外加每次执行的提交数 | 2,000 个一次性作业 |
| S3 | 空闲引擎上的"调度到执行"时延 | 200 次重复 |
| S4 | 周期准确性:"每秒一次"是否真的每秒触发 | 100 个调度 × 60 秒;PostgreSQL 上另加 20 的轻载档 |
| S5 | 写入一条调度要花多少钱;两个仅 Quartz 的分支把简单行拆成构建器与ScheduleJob两半 | 向空存储写入 50,000 条 |
场景实现分别位于 S1_InMemoryThroughput.cs、S2_PostgresThroughput.cs、S3_ScheduleLatency.cs、S4_RecurringAccuracy.cs、S5_ScheduleCost.cs,全部经由 IEngine 这一刻意保持单薄的接口驱动——它只提供Start、ScheduleOneOff、ScheduleRecurring、Clear四个能力。接口文档说明得很直白:任何更丰富的抽象都会要求三库都能表达,一旦某个形状必须在一侧被模拟,对比就从"关于库"变成"关于模拟"。公平性不在公共抽象里,而在设置与计数位置上:各实现用自己的官方 API——IScheduler.ScheduleJob、ITimeTickerManager.AddAsync、BackgroundJob.Schedule。
设置与它们为什么是这些值
三库对齐的核心设置如下:
| Quartz | TickerQ | Hangfire | |
|---|---|---|---|
| 工作线程上限 | MaxConcurrency10(其默认值) | MaxConcurrency10(默认ProcessorCount= 此处 32) | WorkerCount10(默认ProcessorCount × 5= 此处 160) |
| 轮询 / 批获取 | MaxBatchSize自动,无提前触发窗口(默认);池大小、无窗口(batched);池大小 + 1 秒窗口(tuned) | MinPollingInterval100 ms(默认 1 秒) | SchedulePollingInterval内存50 ms、PostgreSQL100 ms(默认 15 秒) |
| 其余一切 | shipped 默认 | shipped 默认 | shipped 默认 |
10 个 worker 是三库唯一都必须被明确告知的数字,也是整个设置文件中影响最大的一个:Harness.cs 的注释指出,若不设置,Hangfire 将以 16 倍于 Quartz 的 worker 跑这份负载——那样的行谈的不是调度器。轮询间隔对 TickerQ 与 Hangfire 都是#3802 规格指定的、有利于各自库的取值,README 对此直言不讳。
Quartz 的三种配置档(为什么它占多行)
| 档 | MaxBatchSize | 窗口 | 含义 |
|---|---|---|---|
Defaults | 不设置:内存为 1,PostgreSQL 上自动跟随池(#3862 起) | 零 | AddQuartz直接给你的样子 |
Batched | 池大小 | 零 | #3862 在内存中测量后选定该默认前测过的样子 |
Tuned | 池大小 | 一秒 | ../Quartz.Benchmark/README.md中 2026-09-02 吞吐数字所用的设置 |
从 QuartzEngine.cs 的实现可见:非Defaults档设置MaxBatchSize = maxConcurrency,Tuned额外设置BatchTriggerAcquisitionFireAheadTimeWindow = TimeSpan.FromSeconds(1)。为什么一档是"读取表格的读者需要知道自己属于哪一档"——这是 README 特意发布三个 Quartz 行的原因。一个批次在"now 与首个触发器触发时间中较晚者"加窗口处结束,所以窗口为零时取走所有已到期且仅已到期的触发器,任何触发器都不会被提前触发;调度器拒绝大于承载它的线程池的批次。
窗口的代价在 QuartzSchedulerOptions.cs 的注释里讲得更细:批次上限只是上界,BatchTriggerAcquisitionFireAheadTimeWindow默认TimeSpan.Zero时批次只含已到期触发器;加宽窗口会连首个触发器之后短时间内到期的触发器一起取走,并把它们提前最多那么多触发。MaxBatchSize还不能超过ThreadPoolOptions.MaxConcurrency——QuartzOptionsValidators.cs 正是这条校验的实现:超池的触发器会被本节点握住、其它节点无法触发,直到池排空。
整秒对齐:这不是装饰
Harness.DueAt把到期时间对齐到下一个整秒(Harness.cs),原因是 Hangfire 把计划作业的到期时间存成整秒 Unix 时间戳——ScheduledState.Handler.Apply把JobHelper.ToTimestamp(EnqueueAt)当分数存储,DelayedJobScheduler拿它和ToTimestamp(now)比较——于是一个小数瞬间到期的作业会在所在秒的开始就变得合格,最多提前一秒。对齐前,Hangfire 在测量窗口开启前就干掉了 20,000 批次中的 4,319 个。TickerQ 也按整秒分桶:GetEarliestTimeTickers取最早到期 ticker 的秒并返回该秒内所有条目。因此对齐让"此瞬间到期"在三库语义一致。
配套防线是 Harness 的两个守卫:EnsureScheduledBeforeDue在调度耗时超过 lead 导致作业在窗口开启前就到期时抛异常(静默发布数字不被允许);EnsureNothingRanEarly在窗口开启前执行量超过批次的 1% 时判失败——最初的未守卫 Hangfire 行就是这么跑掉整批的,现在只容忍个位数。
两秒 lead、逐迭代重建引擎、进程级 Allocated
- 两秒 lead(PostgreSQL 上二十秒):把工作放在 TickerQ 的即时分发阈值之上——
TickerManager.AddTimeTickerAsync会把ExecutionTime落在now.AddSeconds(1)内的 ticker 直接在调用线程上获取并分发,绕开调度循环;两秒让三个分支都走各自调度器自己的路径。短路路径本身也被测量,各有独立行:S1 的 HangfireEnqueue与 S3 的 TickerQ 空ExecutionTime。 - 每迭代重建引擎:S1、S2、S5 都是如此,因为其中两个存储会塞满——TickerQ 保留每个已完成的 ticker 且每次轮询 LINQ 扫描全部持有条目;Hangfire 的已完成作业存活到过期清扫。Quartz 是唯一不这样做的:完成一次性触发器即删除它,且由于作业明细没有其它触发器、又非 durable,连明细一并删除。第二轮迭代若对着另两家的残留测量,测的就是残留了。
[MemoryDiagnoser]在每个类上,Allocated是进程级的:BenchmarkDotNet 读取GC.GetTotalAllocatedBytes,统计所有线程,因此该列是一次执行对整个引擎(轮询循环、worker 全算上)的代价,而非某个线程的代价;它也是精确的,不管机器此刻还在干什么——这正是不稳定的Mean列比不上的。
计数在作业第一条指令,排空即"最后一个作业开始"
这是三库可比的根基:没有任何一方能把"队列写入"算作一次执行。代价是每个引擎对最后一次执行的簿记落在窗口之外——两万次执行里这是五万分之一的影响,被忽略;但一次普查窗口里不行,所以--commits会在最后一个作业开始后再让计数器多跑 3 秒,把最后一次完成的写入收进计数(S2_CommitCensus.cs)。
--commits读取前先清空连接池
PostgreSQL 15 在 backend 变为空闲时(最多每秒一次)发布其提交计数,若 backend 在最近一次发布后一秒内空闲则保留 10 秒(PGSTAT_IDLE_INTERVAL)。backend 退出时也会发布,所以普查先NpgsqlConnection.ClearAllPools()并等待服务过的 backend 消失(PostgresFixture.Commits)。不这么做的话,TickerQ 半秒的爆发结束后 3 秒读取,计数器显示每次执行 0.05–0.21 次提交,而真相是 2.0(#3861);语句计数因为pg_stat_statements在语句结束时即计数,一直是对的。代价是冷连接池:每个引擎下次要连接时重开连接,PostgreSQL 把连接启动也记为一次事务,Npgsql 默认 100 连接一池,即每窗口至多约 100 次提交。"写入数"则来自事务 ID 计数器pg_snapshot_xmax(pg_current_snapshot()),事务一写入即移动、无需等待发布——那是付过 flush 代价的提交(PostgresFixture.Writes)。
调度不被测量,所以无批量 API 的库并发调度
Hangfire 是三库中唯一没有批量创建 API 的,两千次顺序往返 PostgreSQL 约需 16 秒——比 harness 需要的 lead 还长。于是用 8 路并发在迭代 setup 中完成(HangfireEngine.cs),完全处于任何测量窗口之外。Quartz 的批量 API 是ScheduleJobs(schedule, ScheduleJobOptions.Replacing, ...),TickerQ 是AddBatchAsync。
PostgreSQL 上迭代之间清空 schema
内存引擎每迭代免费得到新存储(新引擎即新存储);数据库不会——Hangfire 的成功作业活到过期清扫,TickerQ 保留全部已完成 ticker。S2 还因为每次迭代都是两千次往返、接近一分钟,所以跑 5 次迭代而非 7 次(S2_PostgresThroughput.cs 的IterationCleanup里引擎拆除后按分支重置 schema)。
一次触发内部发生了什么
以下均按本 harness 锁定的各库版本源码描述。
Quartz.NET
调度线程从存储获取一批触发器,等到首个触发器的触发时间,调用TriggersFired标记它们并读取作业明细,然后在JobRunShell内把每个交给线程池。QuartzEngine.cs 的注释给出完整链路:shell 创建 DI 作用域、经它构建作业实例、跑中间件管线、通知注册的监听器、执行作业,然后调用TriggeredJobComplete把触发器前移——或对一次性作业删除它及其成为孤儿的作业明细——再释放。RAMJobStore上是"一个 monitor 加一个有序集合";#3802 的剖析把稳态重复触发器路径定为约 2.5 KB 一次触发,此处多出的是一次性作业的移除。ADO 存储上约 9.7 条语句、1.24 次提交。调度器由QuartzSchedulerBuilder构建、自建容器,所以测的是AddQuartz给应用的布局,而不是手工拼装的内部布局。
S1 的 Quartz 行有个刻意的形状选择:一个作业明细对应一个触发器,与另两库的独立单元形状一致(QuartzEngine.ScheduleOneOff)。另一种布局——一个作业明细挂 20,000 个触发器——对 Quartz 显著更差且被记录为独立发现(见下文 Findings),不该混进表里冒充调度器测量。
TickerQ
一个后台服务轮询:向持久化提供者询问最早到期的 tickers(内存提供者上是对其持有的全部条目做 LINQ 扫描、过滤、排序、两次物化——这正是已完成 ticker 为何要紧的原因),把整秒的一批标记Queued,睡到它们到期(从不短于MinPollingInterval),标记InProgress,再各自排进自己的任务调度器。运行一次要付出CreateAsyncScope、链接的CancellationTokenSource、TickerFunctionContext、两个Stopwatch、一次静态取消令牌管理器注册,以及经提供者的最终状态写——内存与数据库一视同仁。作业实例由源生成代码 new 出来而非从 scope 解析(TickerQEngine.cs)。
一秒内到期的 ticker 根本不进那个循环:TickerManager.AddTimeTickerAsync拿ExecutionTime与now.AddSeconds(1)比较,落在窗内就在调用线程上获取并分发——S3 的 TickerQ 行测的正是这条最快捷径。发现机制同样依赖源生成:生成器向程序集发出带[ModuleInitializer]的TickerQInstanceFactoryExtensions.Initialize,模块加载时函数自动注册,console 工程无需任何 host 装配(该行实现见 TickerQEngine.cs)。
在 EF Core 存储上(c6ed1e7daa),数据库侧的动作是:
| 步骤 | 语句 | 时机 |
|---|---|---|
认领每个 ticker(QueueTimeTickers) | 每 ticker 一条UPDATE … WHERE Id = @id | 循环一看到它即执行,无论离到期还有多远 |
标记整批InProgress(SetTickersInProgress) | 一条UPDATE … WHERE Id = ANY(@ids) | 到期瞬间 |
完成(UpdateTimeTicker) | 每次执行一条UPDATE … WHERE Id = @id | 函数返回之后 |
每一步都是自己的隐式事务,之后跟着 Npgsql 在池化连接下次使用时的DISCARD ALL。没有synchronous_commit设置,也没有完成操作的批处理。
循环苏醒会晚掉与认领耗时相当的时间:GetNextTickers读时钟、算出距离最早 ticker 到期还有多久,然后一条条UPDATE认领那一秒的所有 ticker(InternalTickerManager.cs:35、:60、:103),循环再睡它先前算好的时间(TickerQSchedulerBackgroundService.cs:160)。这台机器上两千次认领要 6–7 秒,所以 S2 的两千次执行全部在到期后 6–7 秒才开始、并在接下来的半秒内跑完——这个"迟到"就是 S2 TickerQ 行的主体(#3861)。
Hangfire
计划作业躺在有序集合里,直到DelayedJobScheduler——按SchedulePollingInterval轮询——把它移到Enqueued状态并把 id 放进队列。worker 取到 id 后从存储读回InvocationData、反序列化方法与其参数,转到Processing,跑过滤器管线,经JobActivator激活类型(静态方法无需激活),反射调用,再转Succeeded。每个状态转换都是一次带状态历史条目的存储写。Hangfire 没有单独的调度线程:轮询与 worker 都是同一个 server 上的BackgroundProcess。Enqueue创建的作业完全跳过轮询——所以它有独立行(HangfireEngine.cs)。
实现细节上,引擎用IBackgroundJobClient与RecurringJobManager而非静态门面BackgroundJob/RecurringJob:那些门面是JobStorage.Current查找,其 client 缓存于一个把首个见到的存储绑死的Lazy里,会让第一轮之后的每一轮都写进上一轮的存储(HangfireEngine.cs)。S1 的HangfireEnqueued行还刻意在测量窗口内才启动 server——否则已入队的作业会在批次尚未写完时就被 drain 大半(S1_InMemoryThroughput.cs)。
结果在哪里看
本 README 刻意不重复数字:结果在 ../Quartz.Benchmark/README.md 中按日期归档的"Against TickerQ and Hangfire (2026-09-19, AMD Ryzen 9 5950X)"一节,与仓库其余测量放在一处,只有一个地方需要保持更新。该节也说明了测量环境:BenchmarkDotNet v0.15.8,AMD Ryzen 9 5950X 3.40 GHz、32 逻辑 / 16 物理核,.NET 10,PostgreSQL 15.1 Docker(loopback、shipped 持久化设置、预加载pg_stat_statements)。
核心结论如下(该文档同样声明:读Error列,这在工作中的机器上跑,安全读法是行与行之间的比值而非任何一行的绝对值;Allocated与数据库普查不受负载影响,可放心搬用):
- S1 内存吞吐:Quartz(defaults)3.89–5.54 µs / 3.46 KB,Quartz(tuned)5.51–6.57 µs / 3.26 KB,TickerQ 8.68–10.25 µs / 4.92–5.39 KB,Hangfire(scheduled)14.90–17.12 µs / 24.29–24.39 KB,Hangfire(enqueued)10.83–15.07 µs / 19.24 KB。Quartz 在这份负载上最快且分配最少——时间上约为 TickerQ 的 2×、Hangfire 的 3×,字节上为 1.5× 与 7×;tuned 档反而比 defaults 慢,因为 20,000 个同时到期触发器的瓶颈是存储锁的争用而非获取轮数。
- S2 PostgreSQL 吞吐:Quartz(defaults)11.30–11.77 ms / 89.9 KB,Quartz(tuned)10.18–10.47 ms / 76.5 KB,TickerQ 2.91–3.15 ms / 48.7–50.0 KB,Hangfire 15.78–15.84 ms / 101.5 KB。Quartz 此行输给 TickerQ 3.5–4×,但 TickerQ 的行是"迟到"而非"速率":普查显示它首个执行在到期后 5.8–7.0 秒才开跑、全部 2,000 次在接下来半秒内完成。这里 tuned 比 defaults 快(数据库上获取一轮是往返与提交,摊薄到一批 10 个真实划算)。
- S2 数据库普查(每执行):提交/写入/语句——Quartz(defaults)6.00 / 3.00 / 26.00,Quartz(tuned)2.80 / 1.40 / 19.59,TickerQ 2.01–2.02 / 1.00 / 1.96,Hangfire 29.6–29.8 / 11.36 / 62.0–62.3。按作业完整生命周期(窗口 + ahead)合算,TickerQ 约 4 条语句、4 次提交、2 次写入;Quartz tuned 2.8 次提交、1.4 次写入但语句量是 TickerQ 的 5 倍——Quartz 的语句多是三笔多语句事务 vs TickerQ 的两笔单语句事务。Hangfire 最大,且约七分之一来自 server 空闲轮询(每秒约 520 条语句)。Quartz 的 6.0/3.0/26.0 是一次性作业最贵形态:完成即删触发器、简单触发器行与孤儿作业明细;#3802 在重复触发器上测的触发路径是 9.7 语句、1.24 提交。
- S3 调度到执行时延(p50):Quartz
StartNow58–70 µs;HangfireEnqueue94–235 µs;TickerQ 空ExecutionTime14.7–14.9 ms;HangfireSchedule(TimeSpan.Zero)30.6–30.8 ms。Quartz 快出一个数量级。TickerQ 的即时路径不慢在分发而慢在被拾取:它分发进TickerQTaskScheduler,其 worker 在连续三次抢不到活后await Task.Delay(Math.Min(consecutiveStealFailures * 2, 50))退避,空闲引擎上每个 worker 都在延迟里。Hangfire 的Schedule行受SchedulePollingInterval(50 ms)约束,读数应理解为"最多一个轮询间隔"。 - S4 周期准确性(100 个每秒调度 × 60 秒):Quartz simple defaults 6,000/6,000、100% 落在 ±50 ms、最大偏差 15–23 ms;Quartz cron defaults 6,000/6,000、100%、15 ms;Quartz fire-ahead 1 s 5,999–6,000、98.2%、最大 999 ms(#3861 复测后 cron 行为 21.8 ms);TickerQ 6,000/6,000、73–78% 在 ±50 ms、74–75 ms;Hangfire 5,900–6,000、0–15% 在 ±50 ms、494–497 ms。三库都触发了正确次数,差别在"何时"——fire-ahead 窗口是 Quartz 自己的权衡被明码标价:它是吞吐行的正确设置、准时调度的错误设置,一个部署不该两者兼得。
- S5 单条调度写入成本(50,000 条,逐 API):TickerQ
AddAsync1.15–1.93 µs / 562–666 B,ICronTickerManager.AddAsync1.90–2.55 µs / 450 B;HangfireAddOrUpdate6.88–7.98 µs / 5,158 B,Schedule6.99–7.25 µs / 7,416 B;QuartzScheduleJobsimple 6.85–8.22 µs / 3,378–3,514 B,cron 9.67–10.45 µs / 4,299–4,497 B。Quartz 此行输给 TickerQ 约 4.5 倍,但三个调用存储的不是同一东西——Quartz 写作业明细 + 触发器,Hangfire 写带序列化方法与参数的作业,TickerQ 写一条命名编译期注册函数的 ticker(没有作业可存);且 Quartz 的数字是一次调度而非一次触发,一个触发器写好可触发多年(S1 才是触发的成本)。
无法验证的事项(该文档的诚实边界)
pg_stat_statements默认关闭。普通postgres:15.1容器没有shared_preload_libraries,--commits会输出"未采集"而非估算。- TickerQ 在 SQLite 上未被测量。#3802 要求"若 spike 发现可行"才加 SQLite 行;spike 未跑,因为 PostgreSQL 场景加数据库普查已用尽预算,且双写者的 SQLite 行问的是 SQLite 写锁而非调度器。列为后续事项。
- Hangfire 的周期行测的是与最近整秒的偏差,而非与某个计划瞬间的偏差。周期作业被转成普通后台作业,来源 occurrence 不传给作业,且默认
MisfireHandlingMode.Relaxed会在建作业前把 occurrence 改写为当前瞬间(RecurringJobEntity.ScheduleNext)——没有可迟到的计划瞬间。Quartz 与 TickerQ 的行用引擎交给作业的值(IJobExecutionContext.ScheduledFireTimeUtc、TickerFunctionContext.ScheduledFor)。 - S3 的 Hangfire
Schedule行与轮询相位锁定:20 ms 静默对 50 ms 轮询稳定成固定相位,p50 接近 30 ms 且几乎无散布——按"受SchedulePollingInterval界定"读,别当精确数字。 - 争用触发被哪个锁挡住在本 harness 中未建立——那是 #3802 D1 剖析为 Quartz 准备的题目,本 harness 不剖析三库中的任何一方。
这个 harness 暴露的发现(记录而非处置)
- Quartz 的非 durable 作业若挂大量触发器,排空是二次方级。最初 S1 让一个作业明细挂全部 20,000 个触发器:每次完成要移除一个触发器,
RAMJobStore.RemoveTriggerNoLock对该作业的触发器列表做线性List<TriggerWrapper>.Remove,再物化剩余触发器键成数组判定是否孤儿——20,000 个触发器时每次触发读 83 KB、二次方运行。场景现在改为独立单元(一个作业明细对应一个触发器,与另两库同形),读 3.3–3.5 KB。README 认为这值得单独研究:扇形扩展调度正是这种形状。 - TickerQ 的生成器编译不过
voidticker 函数。[TickerFunction]挂在返回void的方法上,会在TickerQInstanceFactory.g.cs产出无 return 的asynclambda(CS1643)。所以本 harness 的计数作业返回Task。 - TickerQ 的 worker 池空闲时退避最多 50 ms。
TickerQTaskScheduler的 worker 循环连续三次抢不到活后await Task.Delay(Math.Min(consecutiveStealFailures * 2, 50))。空闲引擎上每个 worker 都在延迟内,即时路径分发的工作要等一个 worker 出来——S3 的 TickerQ 行就是这个组成的。 - TickerQ 在 EF Core 上,同秒批次要晚"认领耗时"那么久才跑。此处约每 ticker 3 ms,2,000 个同时到期会晚 6–7 秒,20,000 个约晚一分钟。睡眠在认领前算好、认领后不再重算。S2 的 TickerQ 行就是这个等待除以 2,000。
pg_stat_database.xact_commit在 PostgreSQL 15 上不是按窗口的仪器。backend 的提交最晚延迟 10 秒到达,爆发后立刻读会少计爆发量。首个 S2 普查给出 TickerQ 每次执行 0.21 次提交,真相是 2.0。- Quartz 的提前触发窗口用准时性换批处理,S4 为它定价。一秒窗口允许批次持有晚至一秒到期的触发器,它们会在批次最早触发时间触发——所以 tuned 档在一秒周期上的最坏偏差约 1,000 ms,而 defaults 档约 15–23 ms。两行都在 S4 表里。
与 #3802 D2 规格的偏差
- SQLite 行没有:原定"仅当 spike 发现可行才取",spike 未跑,列为后续事项而非已发布表的缺口。
- Hangfire 的每调度行调用
IBackgroundJobClient与RecurringJobManager而非静态门面:门面的JobStorage.Current查找与Lazy缓存会让每轮迭代写进上一轮的存储。 - S3 同时报告 BenchmarkDotNet 的 P50/P95 与作业内时间戳的 P50/P95/P99:BenchmarkDotNet 没有 P99 列,发布的分位是作业内的那个——它止于作业开始处,而非等待线程醒来的位置。
- S4 的 Quartz 行用默认档,tuned 档是第三个独立行——用一秒窗口测准时性等于测窗口本身。
- S2 的 lead 是二十秒而非两秒,Hangfire 的调度是并发的:两千次往返顺序要约 16 秒,
EnsureScheduledBeforeDue会判失败而非容忍;8 路并发把它带进窗内。Quartz 的两千作业明细 + 两千触发器批量写曾在十秒 lead 时触发一次该守卫,所以 lead 定二十。调度属迭代 setup,永远在测量窗口之外。 - S2 跑 5 次迭代而非 7 次:每次都是两千次数据库往返、近一分钟。
- 数字取自
89fbadcdc2,在 #3801 的 cron 快速路径之前;S5、S2 普查与 Quartz 的 S4 cron 行已在936bf26e69为 #3861 复测,cron 行没有移动。
结语:这套 harness 的方法论价值
无论结果如何,Quartz.Benchmark.Competitors最重要的遗产是它的方法论:在被比较的作业内部计数、让一次基准调用等于等待 N 次执行、把可比的设置(worker、轮询、批获取)显式对齐并逐项声明、给 Quartz 发布多个配置档以对应不同部署、用数据库侧计数而非客户端秒表来回答持久化成本、并为每一个无法验证或偏离规格的角落留下书面记录。它回答的第三方对比只发布了作者赢的那一半;而 ../Quartz.Benchmark/README.md 的结语强调,"这两个半场都被放在同一页上是有意的"——输的行(S2、S5)与赢的行(S1、S3、S4)并列,才是读者可以引用的完整证据。
- 任务调度
- 后端
【免费下载链接】quartznet
Quartz Enterprise Scheduler .NET
相关推荐
终极指南:如何为GNOME桌面打造动态视频壁纸体验
终极指南:如何为GNOME桌面打造动态视频壁纸体验 在静态壁纸统治桌面环境的今天,你是否曾幻想过让桌面"活"起来?当传统的静态图片已经无法满足个性化需求,动态桌
桌面应用音视频SuperAGI性能基准测试:与同类框架的执行效率对比
SuperAGI性能基准测试:与同类框架的执行效率对比 引言:AI代理框架的性能瓶颈与突破方向 在AI代理框架快速发展的今天,开发者面临一个关键挑战: 如何在保
AI Agent自主智能体后端RAGPhotino.NET:轻量级跨平台桌面应用开发新方案
Photino.NET:轻量级跨平台桌面应用开发新方案 想象一下,你正在构建一个现代化的桌面应用,既想利用熟悉的Web技术栈,又不希望应用体积臃肿、内存占用过高
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考