Some checks are pending
CI / Integration (mcr.microsoft.com/mssql/server:2017-latest) (push) Blocked by required conditions
CI / Integration (mcr.microsoft.com/mssql/server:2019-latest) (push) Blocked by required conditions
CI / Integration (mcr.microsoft.com/mssql/server:2022-latest) (push) Blocked by required conditions
CI / Integration (mcr.microsoft.com/mssql/server:2025-latest) (push) Blocked by required conditions
CI / Build og unit tests (push) Waiting to run
Collectors (SQL Server 2000+, version-specific SQL, least privilege): server, configuration, trace flags, databases (settings, backups in UTC, CHECKDB, VLFs, Query Store, files, tempdb), suspect pages, plan cache, wait stats and file I/O as deltas with restart/clear detection, performance counters, CPU from the scheduler monitor ring buffer, blocking (sleeping head blockers with open transactions), and deadlocks from system_health continuing from the last file offset. Rules: configuration (memory, MAXDOP per Microsoft NUMA guidance, cost threshold, ad hoc bloat, risky options, pending changes), databases (state, settings, backups, log backups, CHECKDB, suspect pages, VLFs, autogrowth, max size, Query Store, tempdb), waits (THREADPOOL, signal waits, locks, I/O, WRITELOG, parallelism, PAGELATCH), I/O latency, CPU, memory grants, blocking and deadlocks. Missing data makes a rule not measurable with the collector's reason. Integration tests run every collector as the least-privilege login and reproduce real blocking and a real deadlock; green on 2017 and 2022. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01E3EExwndy8Z22EK2Qpt6BK
235 lines
12 KiB
C#
235 lines
12 KiB
C#
using System.Globalization;
|
||
using SQLInsight.Core.Collection;
|
||
using SQLInsight.Core.Data;
|
||
using SQLInsight.Core.Findings;
|
||
using SQLInsight.Rules.Engine;
|
||
|
||
namespace SQLInsight.Rules.Performance;
|
||
|
||
/// <summary>
|
||
/// Wait statistics for the last interval, summarized per category.
|
||
/// </summary>
|
||
internal sealed class WaitProfile
|
||
{
|
||
private WaitProfile(WaitStatsDelta delta)
|
||
{
|
||
Delta = delta;
|
||
Seconds = Math.Max(1, delta.Interval.TotalSeconds);
|
||
}
|
||
|
||
public WaitStatsDelta Delta { get; }
|
||
public double Seconds { get; }
|
||
|
||
public static WaitProfile From(RuleContext context)
|
||
{
|
||
var sample = context.Data.Get<CumulativeSample<WaitStatsSnapshot, WaitStatsDelta>>();
|
||
return new WaitProfile(sample.Delta
|
||
?? throw new DataUnavailableException("Ventetider kræver to målinger; første måling er taget (eller instansen er genstartet)."));
|
||
}
|
||
|
||
public IEnumerable<WaitStat> In(WaitCategory category) => Delta.Waits.Where(w => WaitCatalog.Categorize(w.WaitType) == category);
|
||
|
||
public long WaitMs(WaitCategory category) => In(category).Sum(w => w.WaitTimeMs);
|
||
|
||
/// <summary>Average number of tasks waiting on this category at any moment (wait ms per wall-clock ms).</summary>
|
||
public double AverageWaiters(WaitCategory category) => WaitMs(category) / (Seconds * 1000);
|
||
|
||
public double Share(WaitCategory category) => Delta.TotalWaitTimeMs == 0 ? 0 : (double)WaitMs(category) / Delta.TotalWaitTimeMs;
|
||
|
||
public IEnumerable<Evidence> Evidence(WaitCategory category) =>
|
||
new[]
|
||
{
|
||
new Evidence("Interval", $"{Format.Number(Seconds / 60)} min ({Delta.From:HH:mm}–{Delta.To:HH:mm} UTC)"),
|
||
new Evidence("Andel af alle ventetider", $"{Format.Number(Share(category) * 100)} %"),
|
||
new Evidence("Gns. ventende tasks", Format.Number(AverageWaiters(category), 2)),
|
||
}.Concat(In(category).Take(5).Select(w => new Evidence(w.WaitType,
|
||
$"{Format.Number(w.WaitTimeMs)} ms, {Format.Number(w.WaitingTasks)} waits, gns. {Format.Number(w.WaitingTasks == 0 ? 0 : (double)w.WaitTimeMs / w.WaitingTasks, 1)} ms")));
|
||
}
|
||
|
||
/// <summary>
|
||
/// A wait category that dominates the interval. Share alone is not enough (an idle server has 100 % of something),
|
||
/// so the average number of waiting tasks must also be meaningful.
|
||
/// </summary>
|
||
public sealed class DominantWaitRule(
|
||
string id,
|
||
WaitCategory category,
|
||
double minShare,
|
||
double minAverageWaiters,
|
||
double highAverageWaiters,
|
||
string title,
|
||
string explanation,
|
||
string recommendation,
|
||
Reference reference) : Rule
|
||
{
|
||
public override string Id => id;
|
||
public override string Title => title;
|
||
|
||
public override RuleEvaluation Evaluate(RuleContext context)
|
||
{
|
||
ArgumentNullException.ThrowIfNull(context);
|
||
var waits = WaitProfile.From(context);
|
||
var waiters = waits.AverageWaiters(category);
|
||
if (waits.Share(category) < minShare || waiters < minAverageWaiters)
|
||
{
|
||
return RuleEvaluation.Passed();
|
||
}
|
||
|
||
return RuleEvaluation.Found(CreateFinding(
|
||
waiters >= highAverageWaiters ? Severity.High : Severity.Medium,
|
||
title,
|
||
explanation,
|
||
recommendation,
|
||
waits.Evidence(category),
|
||
references: reference));
|
||
}
|
||
}
|
||
|
||
public static class WaitRules
|
||
{
|
||
private static readonly Reference WaitStatsDocs = Docs.Learn("sys.dm_os_wait_stats", "relational-databases/system-dynamic-management-views/sys-dm-os-wait-stats-transact-sql");
|
||
|
||
public static IEnumerable<IRule> Create() =>
|
||
[
|
||
new ThreadPoolWaitRule(),
|
||
new WriteLogLatencyRule(),
|
||
new SignalWaitRule(),
|
||
|
||
new DominantWaitRule(
|
||
"SQLI-WAIT-003", WaitCategory.Locks, 0.2, 0.5, 5,
|
||
"Sessioner venter meget på låse",
|
||
"En stor del af ventetiden er LCK_M_*: sessioner venter på låse, som andre sessioner holder. Det skyldes typisk "
|
||
+ "lange transaktioner, manglende indexes (så opdateringer låser flere rækker end nødvendigt) eller "
|
||
+ "læsninger, der blokeres af skrivninger.",
|
||
"Se blocking-fundene for, hvem der blokerer. Hold transaktioner korte, tilføj indexes til WHERE-betingelserne i "
|
||
+ "opdateringer, og overvej READ_COMMITTED_SNAPSHOT, så læsere ikke blokeres af skrivere (test først: det "
|
||
+ "belaster tempdb og ændrer adfærd for kode, der er afhængig af låse).",
|
||
Docs.Learn("Transaction locking guide", "relational-databases/sql-server-transaction-locking-and-row-versioning-guide")),
|
||
|
||
new DominantWaitRule(
|
||
"SQLI-WAIT-004", WaitCategory.BufferIo, 0.3, 1, 8,
|
||
"Forespørgsler venter meget på at læse data fra disk",
|
||
"PAGEIOLATCH-ventetider betyder, at sider skal læses fra disk, fordi de ikke er i buffer pool. Årsagen er "
|
||
+ "enten for lidt hukommelse, forespørgsler der læser langt mere end nødvendigt (scans, manglende indexes), "
|
||
+ "eller langsom storage. Høj ventetid er et symptom; årsagen skal findes.",
|
||
"Tjek først I/O-latens pr. fil (hvis den er lav, er problemet mængden af læsninger, ikke disken). Find de "
|
||
+ "forespørgsler, der læser mest, i Query Store eller sys.dm_exec_query_stats, og se på deres planer og indexes. "
|
||
+ "Tjek derefter, om max server memory er sat fornuftigt.",
|
||
WaitStatsDocs),
|
||
|
||
new DominantWaitRule(
|
||
"SQLI-WAIT-006", WaitCategory.Parallelism, 0.3, 2, 16,
|
||
"Parallelle forespørgsler venter meget på hinanden",
|
||
"CXPACKET er i sig selv normalt for parallelle planer. Når det dominerer, betyder det typisk mange små "
|
||
+ "forespørgsler, der unødigt går parallelt, eller skæv fordeling af rækker mellem trådene.",
|
||
"Tjek cost threshold for parallelism og MAXDOP (se konfigurationsfund). Find de parallelle forespørgsler med "
|
||
+ "højest CPU i Query Store; ofte fjerner et bedre index behovet for parallelisme.",
|
||
WaitStatsDocs),
|
||
|
||
new DominantWaitRule(
|
||
"SQLI-WAIT-007", WaitCategory.PageLatch, 0.2, 0.5, 4,
|
||
"Contention på sider i hukommelsen (PAGELATCH)",
|
||
"PAGELATCH-ventetider er konkurrence om sider, der allerede er i hukommelsen. De to klassiske årsager er "
|
||
+ "allokeringssider i tempdb (mange midlertidige objekter) og 'last page insert' på et index med stigende nøgle.",
|
||
"Tjek wait_resource i sys.dm_exec_requests under belastning: 2:x:y betyder tempdb (tilføj lige store datafiler, "
|
||
+ "se tempdb-fund). Ved en brugertabel: overvej OPTIMIZE_FOR_SEQUENTIAL_KEY (2019+) eller en anden nøgle.",
|
||
Docs.Learn("tempdb", "relational-databases/databases/tempdb-database")),
|
||
];
|
||
}
|
||
|
||
/// <summary>
|
||
/// THREADPOOL: SQL Server ran out of worker threads. New connections hang; this is an outage in progress.
|
||
/// </summary>
|
||
public sealed class ThreadPoolWaitRule : Rule
|
||
{
|
||
public override string Id => "SQLI-WAIT-001";
|
||
public override string Title => "SQL Server løber tør for worker threads";
|
||
|
||
public override RuleEvaluation Evaluate(RuleContext context)
|
||
{
|
||
ArgumentNullException.ThrowIfNull(context);
|
||
var waits = WaitProfile.From(context);
|
||
if (waits.WaitMs(Core.Data.WaitCategory.WorkerThreads) < 1000)
|
||
{
|
||
return RuleEvaluation.Passed();
|
||
}
|
||
|
||
return RuleEvaluation.Found(CreateFinding(
|
||
Severity.Critical,
|
||
Title,
|
||
"THREADPOOL-ventetider betyder, at nye opgaver ikke kunne få en worker thread. Imens hænger nye forbindelser og "
|
||
+ "forespørgsler, og serveren virker død udefra. Årsagen er næsten altid massiv blocking (mange sessioner, der "
|
||
+ "venter på den samme lås) eller mange samtidige parallelle forespørgsler.",
|
||
"Find head blocker (se blocking-fund) og løs den. Brug Dedicated Admin Connection (ADMIN:), hvis almindelige "
|
||
+ "forbindelser hænger. Hæv ikke 'max worker threads' som løsning; det flytter kun problemet.",
|
||
waits.Evidence(Core.Data.WaitCategory.WorkerThreads),
|
||
references: Docs.Learn("max worker threads", "database-engine/configure-windows/configure-the-max-worker-threads-server-configuration-option")));
|
||
}
|
||
}
|
||
|
||
/// <summary>
|
||
/// Slow transaction log writes, measured as average WRITELOG wait per commit.
|
||
/// </summary>
|
||
public sealed class WriteLogLatencyRule : Rule
|
||
{
|
||
public override string Id => "SQLI-WAIT-005";
|
||
public override string Title => "Commits venter på langsom skrivning til transaktionsloggen";
|
||
|
||
public override RuleEvaluation Evaluate(RuleContext context)
|
||
{
|
||
ArgumentNullException.ThrowIfNull(context);
|
||
var waits = WaitProfile.From(context);
|
||
var writelog = waits.Delta.Waits.FirstOrDefault(w => w.WaitType == "WRITELOG");
|
||
if (writelog is null || writelog.WaitingTasks < 1000)
|
||
{
|
||
return RuleEvaluation.Passed();
|
||
}
|
||
|
||
var average = (double)writelog.WaitTimeMs / writelog.WaitingTasks;
|
||
if (average < 5)
|
||
{
|
||
return RuleEvaluation.Passed();
|
||
}
|
||
|
||
return RuleEvaluation.Found(CreateFinding(
|
||
average >= 15 ? Severity.High : Severity.Medium,
|
||
$"{Title} (gns. {Format.Number(average, 1)} ms)",
|
||
"Hver commit venter, til loggen er skrevet til disk. Gennemsnitligt over 5 ms pr. WRITELOG-wait gør alle "
|
||
+ "skrivende transaktioner langsommere og begrænser, hvor mange commits serveren kan nå pr. sekund.",
|
||
"Tjek skrivelatens på logfilerne (se I/O-fund). Placér loggen på hurtig storage med lav latens, adskilt fra datafiler. "
|
||
+ "Undgå mange små transaktioner i løkker; saml dem i batches. Overvej delayed durability kun, hvis datatab ved nedbrud er acceptabelt.",
|
||
waits.Evidence(Core.Data.WaitCategory.TransactionLog),
|
||
references: Docs.Learn("Transaction log architecture", "relational-databases/sql-server-transaction-log-architecture-and-management-guide")));
|
||
}
|
||
}
|
||
|
||
/// <summary>
|
||
/// High signal wait ratio: tasks are ready to run but wait for a CPU.
|
||
/// </summary>
|
||
public sealed class SignalWaitRule : Rule
|
||
{
|
||
public override string Id => "SQLI-WAIT-002";
|
||
public override string Title => "Tasks venter på CPU (høj signal wait)";
|
||
|
||
public override RuleEvaluation Evaluate(RuleContext context)
|
||
{
|
||
ArgumentNullException.ThrowIfNull(context);
|
||
var waits = WaitProfile.From(context);
|
||
var total = waits.Delta.TotalWaitTimeMs;
|
||
var ratio = total == 0 ? 0 : (double)waits.Delta.TotalSignalWaitTimeMs / total;
|
||
if (ratio < 0.25 || total / (waits.Seconds * 1000) < 1)
|
||
{
|
||
return RuleEvaluation.Passed();
|
||
}
|
||
|
||
return RuleEvaluation.Found(CreateFinding(
|
||
ratio >= 0.4 ? Severity.High : Severity.Medium,
|
||
$"{Title}: {Format.Number(ratio * 100)} % af ventetiden",
|
||
"Signal wait er tiden fra en task har fået sin ressource, til den får en CPU igen. Over ca. 25 % betyder, at "
|
||
+ "schedulers er overbelastede: der er mere arbejde end CPU.",
|
||
"Find forespørgslerne med højest CPU-forbrug i Query Store eller sys.dm_exec_query_stats og optimér dem "
|
||
+ "(manglende indexes, implicitte konverteringer, skalare funktioner). Tjek MAXDOP og cost threshold for parallelism. "
|
||
+ "Mere CPU er først svaret, når forespørgslerne er effektive.",
|
||
[new Evidence("Signal wait", $"{Format.Number(ratio * 100)} %")],
|
||
references: Docs.Learn("sys.dm_os_wait_stats", "relational-databases/system-dynamic-management-views/sys-dm-os-wait-stats-transact-sql")));
|
||
}
|
||
}
|