using System.Globalization;
using SQLInsight.Core.Data;
using SQLInsight.Repository;
namespace SQLInsight.Reports;
public enum AssessmentLevel
{
/// Not enough data to say.
Unknown,
Ok,
/// "Bemærk": worth knowing, not urgent.
Note,
/// "Reagér": act on it.
Act,
}
/// What the numbers say, in one or two sentences.
/// The concrete next action; null when there is nothing to do.
/// A page in SQLInsight Pro (relative to the instance page) or documentation for the next step.
public sealed record Assessment(AssessmentLevel Level, string Summary, string? NextStep = null, string? Link = null)
{
public string LevelText => MetricAssessment.LevelText(Level);
}
/// What is known about the instance besides the measurement itself.
/// The memory SQL Server may use for data: max server memory, or else the server's memory.
/// False when max server memory is SQL Server's default (unlimited).
public sealed record MetricContext(
double? BufferPoolGb = null,
bool? MaxServerMemorySet = null,
IReadOnlyList? BatchRequests = null,
IReadOnlyList? CpuSql = null,
IReadOnlyList? CpuOther = null)
{
public static MetricContext From(ConfigurationSnapshot? configuration, ServerSnapshot? server,
IReadOnlyList? batchRequests = null, IReadOnlyList? cpuSql = null, IReadOnlyList? cpuOther = null)
{
var maxMb = configuration?.Find("max server memory (MB)")?.ValueInUse;
bool? set = maxMb is null ? null : maxMb < 2147483647;
double? poolMb = set == true ? maxMb : server?.PhysicalMemoryMb is > 0 and var physical ? physical : null;
if (set == true && server?.PhysicalMemoryMb is > 0 and var total)
{
poolMb = Math.Min(poolMb!.Value, total);
}
return new MetricContext(poolMb / 1024.0, set, batchRequests, cpuSql, cpuOther);
}
}
///
/// What a measurement means for the customer and how it looks: OK, "Bemærk" or "Reagér", with a concrete next step.
/// Shared by the instance page, the time window analysis and the PDFs, so they all say the same.
///
public static class MetricAssessment
{
/// The classic rule of thumb, scaled: 300 seconds per 4 GB of buffer pool.
public const double PleSecondsPer4Gb = 300;
/// A count above 0 in this many consecutive samples is sustained, not a blip.
public const int Sustained = 3;
private static readonly CultureInfo Danish = CultureInfo.GetCultureInfo("da-DK");
public static string LevelText(AssessmentLevel level) => level switch
{
AssessmentLevel.Ok => "OK",
AssessmentLevel.Note => "Bemærk",
AssessmentLevel.Act => "Reagér",
_ => "Ukendt",
};
/// "Hvad betyder det?" for a metric, in plain Danish.
public static string Explanation(string metric) => metric switch
{
Metrics.CpuSqlServer =>
"Hvor meget af serverens samlede CPU SQL Server bruger. Høj CPU er ikke i sig selv et problem, men vedvarende over 80 % "
+ "betyder, at forespørgsler må vente på at komme til, og at der ikke er luft til spidsbelastninger.",
Metrics.CpuOther =>
"CPU brugt af andre programmer på samme server (antivirus, backup-agenter, applikationer, AOS). Den CPU mangler SQL Server. "
+ "På en dedikeret databaseserver bør den være lav.",
Metrics.BatchRequestsPerSecond =>
"Hvor mange forespørgsler (batches) applikationerne sender pr. sekund. Det er et mål for, hvor travlt serveren har det, "
+ "ikke for et problem i sig selv. Brug det til at se, om en langsom periode skyldes mere arbejde end normalt.",
Metrics.CompilationsPerSecond =>
"Hvor mange gange pr. sekund SQL Server skal lave en ny forespørgselsplan. Planer genbruges normalt; kompilering koster CPU. "
+ "Sammenhold med batch requests: over ca. 10–20 % af batches tyder på forespørgsler, der ikke genbruger planer (literaler i stedet for parametre).",
Metrics.PageLifeExpectancy =>
"Hvor længe (sekunder) en dataside i gennemsnit bliver i hukommelsen, før den skal læses fra disk igen. Højt er godt. "
+ "Tommelfingerreglen er mindst 300 sekunder pr. 4 GB buffer pool. Pludselige fald betyder, at en forespørgsel har læst store mængder data og skubbet andet ud.",
Metrics.UserConnections =>
"Antal forbindelser fra brugere og applikationer. Et mål for brugen; et pludseligt hop kan betyde, at forbindelser ikke lukkes (connection leak), eller at sessioner hænger og venter.",
Metrics.MemoryGrantsPending =>
"Forespørgsler, der venter på at få hukommelse til sortering og hash, før de overhovedet kan starte. Bør altid være 0; "
+ "vedvarende over 0 betyder, at hukommelsen er en flaskehals lige nu (ventetypen RESOURCE_SEMAPHORE).",
Metrics.ProcessesBlocked =>
"Sessioner, der lige nu venter på en lås, som en anden session holder. Kortvarig blokering er normal; vedvarende blokering betyder, at brugere venter.",
Metrics.TempdbUsedMegabytes =>
"Hvor meget af tempdb der er i brug: temp-tabeller, sorteringer og hash-tabeller, der ikke er plads til i hukommelsen, og version store. "
+ "Pludselige toppe skyldes typisk en enkelt tung forespørgsel eller et indexvedligehold; se Lige nu og version store.",
Metrics.VersionStoreMegabytes =>
"Gamle udgaver af rækker, som snapshot isolation og READ_COMMITTED_SNAPSHOT (AX 2009/2012) holder i tempdb. Den bør gå op og ned; "
+ "vokser den hele tiden, holder en lang transaktion de gamle versioner fast.",
"disk.read" =>
"Gennemsnitlig tid pr. læsning fra datafilerne på drevet. Under 10 ms er godt for de fleste systemer, over 20 ms mærkes af brugerne.",
"disk.write.log" =>
"Gennemsnitlig tid pr. skrivning til transaktionsloggen. Hver commit venter på den, så den skal være lav: under 5 ms, helst 1–2 ms.",
"disk.write" =>
"Gennemsnitlig tid pr. skrivning til datafilerne. Skrivninger til datafiler sker mest i baggrunden (checkpoint), så de tåler mere end loggen; over 20 ms er højt.",
_ => "Ingen forklaring.",
};
/// The assessment of a metric's samples in a period.
public static Assessment Assess(string metric, IReadOnlyList points, MetricContext? context = null)
{
ArgumentNullException.ThrowIfNull(points);
context ??= new MetricContext();
if (points.Count == 0)
{
return new(AssessmentLevel.Unknown, "Ingen målinger i perioden.");
}
return metric switch
{
Metrics.CpuSqlServer => Cpu(points, context.CpuOther),
Metrics.CpuOther => CpuOther(points, context.CpuSql),
Metrics.PageLifeExpectancy => Ple(points, context),
Metrics.CompilationsPerSecond => Compilations(points, context.BatchRequests),
Metrics.MemoryGrantsPending => SustainedCount(points,
"Ingen forespørgsler har ventet på hukommelse.",
"forespørgsler ventede på hukommelse",
"Find forespørgslerne med store memory grants (ventetypen RESOURCE_SEMAPHORE), opdatér statistikker og tilføj indexes, så de sorterer mindre; tjek max server memory.",
"ventetyper/RESOURCE_SEMAPHORE"),
Metrics.ProcessesBlocked => SustainedCount(points,
"Ingen blokerede processer.",
"sessioner var blokeret",
"Se blocking-kæderne for, hvem der blokerer (head blocker), og hvilken sætning den kører; hold transaktioner korte.",
"analyse#blocking"),
Metrics.BatchRequestsPerSecond => new(AssessmentLevel.Ok,
$"Gennemsnitligt {N(Average(points), 1)} pr. sekund, højst {N(Peak(points), 1)}. Et mål for travlhed; sammenlign med normalen i analysen."),
Metrics.UserConnections => Connections(points),
Metrics.VersionStoreMegabytes => VersionStore(points),
Metrics.TempdbUsedMegabytes => new(AssessmentLevel.Ok, $"I gennemsnit {N(Average(points), 0)} MB i brug, højst {N(Peak(points), 0)} MB."),
_ => new(AssessmentLevel.Unknown, "Ingen vurdering."),
};
}
/// Disk latency on a drive: data reads, data writes, log writes.
public static Assessment AssessDisk(DiskActivity disk)
{
ArgumentNullException.ThrowIfNull(disk);
var worst = AssessmentLevel.Unknown;
var parts = new List();
if (disk.AverageReadMs is { } read && disk.Reads >= 100)
{
var level = read > 20 ? AssessmentLevel.Act : read > 10 ? AssessmentLevel.Note : AssessmentLevel.Ok;
worst = Max(worst, level);
parts.Add($"læsning {N(read, 1)} ms");
}
if (disk.AverageWriteMs is { } write && disk.Writes >= 100)
{
var logOnly = disk.DataFiles == 0;
var level = logOnly
? write > 15 ? AssessmentLevel.Act : write > 5 ? AssessmentLevel.Note : AssessmentLevel.Ok
: write > 20 ? AssessmentLevel.Act : write > 10 ? AssessmentLevel.Note : AssessmentLevel.Ok;
worst = Max(worst, level);
parts.Add($"skrivning {N(write, 1)} ms{(logOnly ? " (log)" : "")}");
}
if (worst == AssessmentLevel.Unknown)
{
return new(AssessmentLevel.Unknown, "For lidt I/O i perioden til at vurdere.");
}
var summary = $"{disk.Volume}: {string.Join(", ", parts)}.";
return worst switch
{
AssessmentLevel.Act => new(worst, summary + " Brugerne mærker det.",
"Tjek om disken er overbelastet (mange forespørgsler, der læser meget) eller langsom (storage, SAN, virtuel disk). Find de forespørgsler, der læser mest, før der købes hurtigere disk.",
"analyse"),
AssessmentLevel.Note => new(worst, summary + " Lidt højt.", "Hold øje med, om det følger bestemte tidspunkter (backup, indexvedligehold, batchjob).", "analyse"),
_ => new(worst, summary + " Godt."),
};
}
private static Assessment Cpu(IReadOnlyList sql, IReadOnlyList? other)
{
IReadOnlyList total = other is { Count: > 0 } ? Combine(sql, other) : sql;
var avg = Average(total);
var otherAvg = other is { Count: > 0 } ? Average(other) : 0;
var highRun = LongestRun(total, v => v > 80);
var summary = $"I gennemsnit {N(avg, 0)} % samlet CPU, højst {N(Peak(total), 0)} %"
+ (other is { Count: > 0 } ? $"; heraf {N(Average(sql), 0)} % SQL Server og {N(otherAvg, 0)} % andre processer." : ".");
if (highRun >= Sustained)
{
return otherAvg >= 20 || otherAvg > Average(sql)
? new(AssessmentLevel.Act, summary + " CPU'en er vedvarende over 80 %, og en stor del bruges af andre processer.",
"Find hvilke programmer på serveren der bruger CPU'en (Jobliste/Ressourceovervågning): antivirus uden undtagelser for databasefiler, backup-agenter eller en AOS på samme server.")
: new(AssessmentLevel.Act, summary + " SQL Server bruger vedvarende over 80 % af CPU'en.",
"Find forespørgslerne med højest CPU i analysen af tidsrummet og optimér dem (indexes, implicitte konverteringer); tjek MAXDOP og cost threshold for parallelism.",
"analyse?sortering=cpu");
}
return avg > 60
? new(AssessmentLevel.Note, summary + " Højt, men ikke vedvarende over 80 %.", "Hold øje med spidsbelastninger, og se hvilke forespørgsler der bruger CPU'en.", "analyse?sortering=cpu")
: new(AssessmentLevel.Ok, summary);
}
private static Assessment CpuOther(IReadOnlyList other, IReadOnlyList? sql)
{
var avg = Average(other);
var summary = $"Andre processer brugte i gennemsnit {N(avg, 0)} % af CPU'en, højst {N(Peak(other), 0)} %.";
if (LongestRun(other, v => v > 30) >= Sustained || avg >= 20 && (sql is not { Count: > 0 } || avg > Average(sql)))
{
return new(AssessmentLevel.Act, summary + " Det er meget for en databaseserver.",
"Find programmet på serveren (Jobliste/Ressourceovervågning). Typisk antivirus uden undtagelser for .mdf/.ldf, backup-software eller en applikation på samme maskine.");
}
return avg >= 10
? new(AssessmentLevel.Note, summary, "Undersøg hvilke programmer der kører på serveren ud over SQL Server.")
: new(AssessmentLevel.Ok, summary);
}
private static Assessment Ple(IReadOnlyList points, MetricContext context)
{
var threshold = Math.Max(PleSecondsPer4Gb, PleSecondsPer4Gb * (context.BufferPoolGb ?? 4) / 4);
var avg = Average(points);
var min = points.Min(p => p.Value);
var median = Median(points);
var dip = median > 0 && min < median * 0.5;
var memory = context.MaxServerMemorySet == false
? " max server memory er ikke sat, så SQL Server kan tage hukommelse fra Windows og andre programmer; sæt den (se konfigurationen)."
: "";
var summary = $"I gennemsnit {N(avg, 0)} s, laveste {N(min, 0)} s. Grænsen er {N(threshold, 0)} s"
+ (context.BufferPoolGb is { } gb ? $" ({N(PleSecondsPer4Gb, 0)} s pr. 4 GB af {N(gb, 0)} GB buffer pool)." : " (300 s pr. 4 GB buffer pool).");
if (avg < threshold)
{
return new(AssessmentLevel.Act, summary + " Data bliver ikke længe i hukommelsen, så der læses meget fra disk." + memory,
"Find forespørgslerne, der læser mest (analysen sorteret efter læsninger), og giv dem bedre indexes; overvej mere hukommelse, når de er effektive.",
"analyse?sortering=laesninger");
}
if (dip || min < threshold)
{
return new(AssessmentLevel.Note, summary + $" Faldt{(dip ? " med mere end 50 %" : "")} undervejs: en forespørgsel har læst store mængder data og skubbet andet ud af hukommelsen." + memory,
"Find tidspunktet for faldet i grafen og se, hvilke forespørgsler der læste mest i det tidsrum.", "analyse?sortering=laesninger");
}
return memory.Length > 0
? new(AssessmentLevel.Note, summary + memory, "Sæt max server memory.", "konfiguration")
: new(AssessmentLevel.Ok, summary + " Godt.");
}
private static Assessment Compilations(IReadOnlyList compilations, IReadOnlyList? batches)
{
var avg = Average(compilations);
if (batches is not { Count: > 0 } || Average(batches) <= 0)
{
return new(AssessmentLevel.Unknown, $"I gennemsnit {N(avg, 1)} kompileringer pr. sekund; batch requests mangler til sammenligning.");
}
var ratio = avg / Average(batches);
var summary = $"I gennemsnit {N(avg, 1)} kompileringer pr. sekund = {N(100 * ratio, 0)} % af batches.";
if (Average(batches) < 5)
{
return new(AssessmentLevel.Ok, summary + " Så lidt arbejde, at andelen ikke betyder noget.");
}
return ratio switch
{
> 0.2 => new(AssessmentLevel.Act, summary + " Mange forespørgsler får en ny plan hver gang, hvilket koster CPU.",
"Find ad hoc-forespørgsler med literaler i plan cache; brug parametre, slå 'optimize for ad hoc workloads' til, og overvej forced parameterization for databasen. I AX: undgå 'Use literals'.",
"ventetyper/RESOURCE_SEMAPHORE_QUERY_COMPILE"),
> 0.1 => new(AssessmentLevel.Note, summary + " Lidt højt.", "Hold øje med CPU; se efter forespørgsler med literaler."),
_ => new(AssessmentLevel.Ok, summary + " Planer genbruges fint."),
};
}
private static Assessment SustainedCount(IReadOnlyList points, string none, string what, string action, string link)
{
var run = LongestRun(points, v => v > 0);
var peak = Peak(points);
if (run == 0)
{
return new(AssessmentLevel.Ok, none);
}
var summary = $"{N(peak, 0)} {what} (højst); over 0 i {points.Count(p => p.Value > 0)} af {points.Count} målinger.";
return run >= Sustained
? new(AssessmentLevel.Act, summary + " Det varede ved.", action, link)
: new(AssessmentLevel.Note, summary + " Kortvarigt.", action, link);
}
private static Assessment VersionStore(IReadOnlyList points)
{
var summary = $"I gennemsnit {N(Average(points), 0)} MB, højst {N(Peak(points), 0)} MB.";
var growing = TempdbAnalysis.IsGrowing([.. points.OrderBy(p => p.At).Select(p => p.Value)]);
return Peak(points) >= 1024 || growing
? new(AssessmentLevel.Act, summary + (growing ? " Den vokser støt." : " Over 1 GB."),
"Find den ældste snapshot-transaktion og lange transaktioner (Lige nu på instansens side) og få dem afsluttet.", "#aktivitet")
: Peak(points) >= 512
? new(AssessmentLevel.Note, summary, "Hold øje med, om den falder igen.")
: new(AssessmentLevel.Ok, summary);
}
private static Assessment Connections(IReadOnlyList points)
{
var avg = Average(points);
var peak = Peak(points);
var summary = $"I gennemsnit {N(avg, 0)} forbindelser, højst {N(peak, 0)}.";
return avg >= 10 && peak > 2 * avg
? new(AssessmentLevel.Note, summary + " Antallet steg kraftigt undervejs.", "Se om det skyldes flere brugere, eller sessioner der hænger (blocking) eller ikke lukkes.", "analyse#blocking")
: new(AssessmentLevel.Ok, summary);
}
private static List Combine(IReadOnlyList a, IReadOnlyList b)
{
var other = b.GroupBy(p => p.At).ToDictionary(g => g.Key, g => g.First().Value);
return a.Select(p => p with { Value = p.Value + (other.TryGetValue(p.At, out var v) ? v : 0) }).ToList();
}
private static int LongestRun(IReadOnlyList points, Func predicate)
{
int best = 0, run = 0;
foreach (var p in points.OrderBy(p => p.At))
{
run = predicate(p.Value) ? run + 1 : 0;
best = Math.Max(best, run);
}
return best;
}
private static double Average(IReadOnlyList points) => points.Count == 0 ? 0 : points.Average(p => p.Value);
private static double Peak(IReadOnlyList points) => points.Count == 0 ? 0 : points.Max(p => p.Value);
private static double Median(IReadOnlyList points)
{
var sorted = points.Select(p => p.Value).Order().ToList();
return sorted.Count == 0 ? 0 : sorted.Count % 2 == 1 ? sorted[sorted.Count / 2] : (sorted[sorted.Count / 2 - 1] + sorted[sorted.Count / 2]) / 2;
}
private static AssessmentLevel Max(AssessmentLevel a, AssessmentLevel b) => (AssessmentLevel)Math.Max((int)a, (int)b);
private static string N(double value, int decimals) => value.ToString("N" + decimals, Danish);
}