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"))); } }