using System.Globalization;
using SQLInsight.Core.Collection;
using SQLInsight.Core.Data;
using SQLInsight.Core.Findings;
using SQLInsight.Rules.Engine;
namespace SQLInsight.Rules.Performance;
///
/// Wait statistics for the last interval, summarized per category.
///
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>();
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 In(WaitCategory category) => Delta.Waits.Where(w => WaitCatalog.Categorize(w.WaitType) == category);
public long WaitMs(WaitCategory category) => In(category).Sum(w => w.WaitTimeMs);
/// Average number of tasks waiting on this category at any moment (wait ms per wall-clock ms).
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(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")));
}
///
/// 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.
///
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 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")),
];
}
///
/// THREADPOOL: SQL Server ran out of worker threads. New connections hang; this is an outage in progress.
///
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")));
}
}
///
/// Slow transaction log writes, measured as average WRITELOG wait per commit.
///
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")));
}
}
///
/// High signal wait ratio: tasks are ready to run but wait for a CPU.
///
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")));
}
}