using System.Globalization; using SQLInsight.Core.Data; using SQLInsight.Core.Findings; using SQLInsight.Core.Instances; using SQLInsight.Rules.Engine; namespace SQLInsight.Rules.Databases; internal static class Databases { public static IEnumerable Online(RuleContext context) => context.Data.Get().Databases.Where(d => d.State == "ONLINE"); public static string Subject(DatabaseInfo database) => $"database: {database.Name}"; public static TimeSpan Age(RuleContext context, DateTime? time) => context.Now.UtcDateTime - (time ?? DateTime.MinValue); } /// /// A database setting with a value that is a known problem. /// public sealed class DatabaseSettingRule( string id, string title, Func isProblem, Severity severity, Func findingTitle, string explanation, string recommendation, Func fixScript, Reference reference) : Rule { public override string Id => id; public override string Title => title; public override RuleEvaluation Evaluate(RuleContext context) => Result(Databases.Online(context).Where(isProblem).Select(d => CreateFinding( severity, findingTitle(d), explanation, recommendation, subject: Databases.Subject(d), fixScript: fixScript(d), references: reference))); } public static class SimpleDatabaseRules { private static string Name(DatabaseInfo d) => "[" + d.Name.Replace("]", "]]", StringComparison.Ordinal) + "]"; public static IEnumerable Create() => [ new DatabaseSettingRule( "SQLI-DB-002", "AUTO_SHRINK er slået til", d => d.IsAutoShrink, Severity.High, d => $"AUTO_SHRINK er slået til på {d.Name}", "Auto shrink flytter sider rundt for at frigive plads, hvilket fragmenterer indexes fuldstændigt og koster " + "CPU og I/O. Filerne vokser bagefter igen med autogrowth, så cyklussen gentager sig.", "Slå AUTO_SHRINK fra. Hvis der reelt skal frigives plads, gøres det én gang manuelt og efterfulgt af index-vedligehold.", d => $"ALTER DATABASE {Name(d)} SET AUTO_SHRINK OFF;", Docs.Learn("AUTO_SHRINK", "relational-databases/policy-based-management/set-the-auto-shrink-database-option-to-off")), new DatabaseSettingRule( "SQLI-DB-003", "AUTO_CLOSE er slået til", d => d.IsAutoClose, Severity.Medium, d => $"AUTO_CLOSE er slået til på {d.Name}", "Databasen lukkes, når den sidste forbindelse slipper den. Næste forbindelse skal åbne filerne og genopbygge " + "cachen, og plan cache for databasen tømmes hver gang. Det giver ujævne svartider og mange logbeskeder.", "Slå AUTO_CLOSE fra.", d => $"ALTER DATABASE {Name(d)} SET AUTO_CLOSE OFF;", Docs.Learn("AUTO_CLOSE", "relational-databases/policy-based-management/set-the-auto-close-database-option-to-off")), new DatabaseSettingRule( "SQLI-DB-004", "Sidekontrol er ikke CHECKSUM", d => d.PageVerify is not null && d.PageVerify != "CHECKSUM", Severity.Medium, d => $"{d.Name} bruger PAGE_VERIFY {d.PageVerify}", "Med CHECKSUM beregner SQL Server en kontrolsum for hver side, der skrives, og opdager dermed korruption fra disk, " + "controller eller driver, så snart siden læses. TORN_PAGE_DETECTION fanger kun en brøkdel, NONE intet.", "Skift til CHECKSUM. Det gælder kun sider, der skrives efterfølgende, så kør også DBCC CHECKDB regelmæssigt.", d => $"ALTER DATABASE {Name(d)} SET PAGE_VERIFY CHECKSUM;", Docs.Learn("ALTER DATABASE SET options", "t-sql/statements/alter-database-transact-sql-set-options")), new DatabaseSettingRule( "SQLI-DB-005", "TRUSTWORTHY er slået til", d => d.IsTrustworthy && !string.Equals(d.Name, "msdb", StringComparison.OrdinalIgnoreCase), Severity.Medium, d => $"TRUSTWORTHY er slået til på {d.Name}", "TRUSTWORTHY får SQL Server til at stole på kode i databasen, når den efterligner (impersonerer) brugere på " + "serverniveau. Ejes databasen af en sysadmin, kan en bruger med db_owner blive sysadmin.", "Slå TRUSTWORTHY fra, og brug certifikat- eller modulsignering til de få moduler, der har brug for adgang uden for databasen.", d => $"ALTER DATABASE {Name(d)} SET TRUSTWORTHY OFF;", Docs.Learn("TRUSTWORTHY database property", "relational-databases/security/trustworthy-database-property")), ]; } /// /// Databases that are not usable. /// public sealed class DatabaseStateRule : Rule { public override string Id => "SQLI-DB-001"; public override string Title => "Database er ikke tilgængelig"; public override RuleEvaluation Evaluate(RuleContext context) { ArgumentNullException.ThrowIfNull(context); return Result(context.Data.Get().Databases .Where(d => d.State is "SUSPECT" or "RECOVERY_PENDING" or "EMERGENCY" or "OFFLINE") .Select(d => CreateFinding( d.State == "OFFLINE" ? Severity.Low : Severity.Critical, $"{d.Name} er {d.State}", d.State switch { "SUSPECT" => "SQL Server kunne ikke gennemføre recovery, typisk på grund af korruption eller en manglende/ødelagt fil. Databasen kan ikke bruges.", "RECOVERY_PENDING" => "Recovery kunne ikke starte, typisk fordi en fil mangler, er låst eller disken er fuld. Databasen kan ikke bruges.", "EMERGENCY" => "Databasen er sat i EMERGENCY-tilstand, normalt som led i en nødreparation. Kun sysadmin kan læse den.", _ => "Databasen er taget offline. Det kan være bevidst, men den bliver hverken backet op eller tjekket.", }, d.State == "OFFLINE" ? "Bekræft, at det er bevidst. Ellers: sæt den online eller fjern den." : "Se SQL Server error log for årsagen. Gendan fra backup, hvis filerne er beskadigede. Kør ikke DBCC CHECKDB med REPAIR_ALLOW_DATA_LOSS uden at forstå konsekvensen: den sletter data.", [new Evidence("Tilstand", d.State)], subject: Databases.Subject(d)))); } } /// /// Compatibility level below the engine's native level. /// public sealed class CompatibilityLevelRule : Rule { public override string Id => "SQLI-DB-006"; public override string Title => "Database kører med et ældre compatibility level"; public override RuleEvaluation Evaluate(RuleContext context) { ArgumentNullException.ThrowIfNull(context); var native = context.Instance.Version.Major * 10; return Result(Databases.Online(context) .Where(d => !d.IsSystemDatabase && d.CompatibilityLevel < native) .Select(d => CreateFinding( Severity.Low, $"{d.Name} kører med compatibility level {d.CompatibilityLevel} (instansen understøtter {native})", "Databasen bruger query optimizer og T-SQL-adfærd fra en ældre version og går glip af forbedringer som " + "adaptive joins, batch mode på rowstore og bedre kardinalitetsestimering.", "Hæv compatibility level kontrolleret: slå Query Store til, saml en baseline, hæv niveauet, og brug Query Store til " + "at finde og låse eventuelle regredierede planer.", [new Evidence("Compatibility level", d.CompatibilityLevel.ToString(CultureInfo.InvariantCulture)), new Evidence("Instansens niveau", native.ToString(CultureInfo.InvariantCulture))], subject: Databases.Subject(d), references: Docs.Learn("Compatibility level", "relational-databases/databases/view-or-change-the-compatibility-level-of-a-database")))); } } /// /// Databases without a recent full backup. /// public sealed class FullBackupRule : Rule { public static readonly TimeSpan MaxAge = TimeSpan.FromDays(7); public override string Id => "SQLI-DB-007"; public override string Title => "Database mangler en nylig fuld backup"; public override IReadOnlyCollection RequiredCapabilities => [Capability.SelfManagedInfrastructure]; public override RuleEvaluation Evaluate(RuleContext context) { ArgumentNullException.ThrowIfNull(context); var findings = new List(); foreach (var d in Databases.Online(context)) { if (d.IsReplicaOrMirror || string.Equals(d.Name, "model", StringComparison.OrdinalIgnoreCase) || Databases.Age(context, d.CreateDate) < TimeSpan.FromDays(1)) { continue; } var age = Databases.Age(context, d.LastFullBackup); if (d.LastFullBackup is not null && age <= MaxAge) { continue; } var severity = d.LastFullBackup is null ? Severity.Critical : Severity.High; var agNote = string.Empty; if (d.IsInAvailabilityGroup) { severity = Severity.Medium; agNote = " Databasen er i en availability group, så backups kan være taget på en anden replika; tjek den."; } findings.Add(CreateFinding( severity, d.LastFullBackup is null ? $"{d.Name} har aldrig haft en fuld backup" : $"{d.Name}: seneste fulde backup er {Format.Age(age)} gammel", "Uden en nylig fuld backup kan databasen ikke gendannes efter en diskfejl, korruption eller en fejlagtig sletning, " + "og hver differentiel og log-backup er afhængig af den." + agNote, "Planlæg fulde backups mindst ugentligt (dagligt for vigtige databaser) til et andet medie end databasefilerne, " + "og test en gendannelse jævnligt. En backup, der aldrig er testet, er ikke en backup.", [new Evidence("Seneste fulde backup", Format.Date(d.LastFullBackup)), new Evidence("Recovery model", d.RecoveryModel)], subject: Databases.Subject(d), references: Docs.Learn("Backup and restore", "relational-databases/backup-restore/back-up-and-restore-of-sql-server-databases"))); } return Result(findings); } } /// /// FULL/BULK_LOGGED databases without log backups: the log grows forever and point-in-time recovery is lost. /// public sealed class LogBackupRule : Rule { public static readonly TimeSpan MaxAge = TimeSpan.FromHours(24); public override string Id => "SQLI-DB-008"; public override string Title => "Database i FULL recovery uden log-backups"; public override IReadOnlyCollection RequiredCapabilities => [Capability.SelfManagedInfrastructure]; public override RuleEvaluation Evaluate(RuleContext context) { ArgumentNullException.ThrowIfNull(context); return Result(Databases.Online(context) .Where(d => d.RecoveryModel is "FULL" or "BULK_LOGGED" && !d.IsReplicaOrMirror && !d.IsReadOnly && !string.Equals(d.Name, "model", StringComparison.OrdinalIgnoreCase) && d.LastFullBackup is not null // Before the first full backup the log truncates as in SIMPLE. && (d.LastLogBackup is null || Databases.Age(context, d.LastLogBackup) > MaxAge)) .Select(d => CreateFinding( d.IsInAvailabilityGroup ? Severity.Medium : Severity.High, d.LastLogBackup is null ? $"{d.Name} er i {d.RecoveryModel} recovery, men har aldrig haft en log-backup" : $"{d.Name}: seneste log-backup er {Format.Age(Databases.Age(context, d.LastLogBackup))} gammel", $"I {d.RecoveryModel} recovery frigives plads i transaktionsloggen først, når loggen er backet op. Uden log-backups " + "vokser loggen, til disken er fuld, og al data siden seneste fulde backup går tabt ved et nedbrud.", "Tag log-backups med et interval, der passer til det datatab, forretningen kan tåle (typisk 5-15 minutter). " + "Er point-in-time restore ikke nødvendigt, så skift til SIMPLE recovery i stedet.", [new Evidence("Recovery model", d.RecoveryModel), new Evidence("Seneste log-backup", Format.Date(d.LastLogBackup)), new Evidence("Seneste fulde backup", Format.Date(d.LastFullBackup))], subject: Databases.Subject(d), references: Docs.Learn("Transaction log backups", "relational-databases/backup-restore/transaction-log-backups-sql-server")))); } } /// /// DBCC CHECKDB not run recently. /// public sealed class CheckDbRule : Rule { public static readonly TimeSpan MaxAge = TimeSpan.FromDays(7); public override string Id => "SQLI-DB-009"; public override string Title => "DBCC CHECKDB er ikke kørt for nylig"; public override IReadOnlyCollection RequiredCapabilities => [Capability.SelfManagedInfrastructure, Capability.LastGoodCheckDbTime]; public override RuleEvaluation Evaluate(RuleContext context) { ArgumentNullException.ThrowIfNull(context); var findings = new List(); foreach (var d in Databases.Online(context)) { if (d.LastGoodCheckDb is not { } last || Databases.Age(context, d.CreateDate) < MaxAge) { continue; } var never = last.Year <= 1900; var age = Databases.Age(context, last); if (!never && age <= MaxAge) { continue; } findings.Add(CreateFinding( never || age > TimeSpan.FromDays(30) ? Severity.High : Severity.Medium, never ? $"DBCC CHECKDB har aldrig kørt fejlfrit på {d.Name}" : $"{d.Name}: seneste fejlfri DBCC CHECKDB er {Format.Age(age)} gammel", "Korruption opdages først, når en beskadiget side læses, og kan brede sig til backups i ugevis. Jo længere siden " + "seneste CHECKDB, jo længere tilbage skal man måske gendanne, og jo større datatab.", "Kør DBCC CHECKDB WITH NO_INFOMSGS, ALL_ERRORMSGS mindst ugentligt, gerne på en gendannet kopi, hvis vinduet er for kort. " + "Behold backups længere end intervallet mellem to CHECKDB.", [new Evidence("Seneste fejlfri CHECKDB", never ? "aldrig" : Format.Date(last))], subject: Databases.Subject(d), references: Docs.Learn("DBCC CHECKDB", "t-sql/database-console-commands/dbcc-checkdb-transact-sql"))); } return Result(findings); } } /// /// Pages SQL Server has recorded as corrupt. /// public sealed class SuspectPagesRule : Rule { public override string Id => "SQLI-DB-010"; public override string Title => "Korrupte sider registreret i suspect_pages"; public override IReadOnlyCollection RequiredCapabilities => [Capability.DynamicManagementViews]; public override RuleEvaluation Evaluate(RuleContext context) { ArgumentNullException.ThrowIfNull(context); return Result(context.Data.Get().Pages .Where(p => p.IsUnresolved) .GroupBy(p => p.DatabaseName, StringComparer.OrdinalIgnoreCase) .Select(g => CreateFinding( Severity.Critical, $"{g.Key}: {g.Count()} korrupte sider", "SQL Server har mødt sider med I/O-fejl, forkert kontrolsum eller torn pages (fejl 823/824/825). Det er næsten " + "altid et tegn på fejl i lagersystemet, og mere korruption følger ofte efter.", "Kør DBCC CHECKDB straks for at se omfanget. Gendan de berørte sider eller hele databasen fra backup (page restore er " + "muligt i FULL recovery). Undersøg disk, controller og drivere, og tjek Windows' systemlog.", g.Take(20).Select(p => new Evidence($"Fil {p.FileId}, side {p.PageId}", $"hændelse {p.EventType}, {p.ErrorCount} fejl, senest {Format.Date(p.LastUpdate)}")), subject: $"database: {g.Key}", references: Docs.Learn("suspect_pages", "relational-databases/backup-restore/manage-the-suspect-pages-table-sql-server")))); } } /// /// Too many virtual log files slow down recovery, restores and log backups. /// public sealed class VirtualLogFileRule : Rule { public const int Threshold = 1000; public override string Id => "SQLI-DB-011"; public override string Title => "Transaktionsloggen har for mange VLF'er"; public override IReadOnlyCollection RequiredCapabilities => [Capability.SelfManagedInfrastructure, Capability.LogInfoDmv]; public override RuleEvaluation Evaluate(RuleContext context) { ArgumentNullException.ThrowIfNull(context); return Result(Databases.Online(context) .Where(d => d.VirtualLogFileCount > Threshold) .Select(d => CreateFinding( d.VirtualLogFileCount > 10 * Threshold ? Severity.High : Severity.Medium, $"{d.Name} har {Format.Number(d.VirtualLogFileCount!.Value)} VLF'er i transaktionsloggen", "Loggen er vokset i mange små trin. Hver vækst opretter nye virtuelle logfiler, og mange VLF'er gør crash recovery, " + "restore, log-backups, replikering og AG-synkronisering markant langsommere.", "Tag en log-backup (eller CHECKPOINT i SIMPLE), shrink logfilen, og lad den vokse igen i få, store trin til den " + "nødvendige størrelse. Sæt en fast autogrowth på fx 1024 MB.", [new Evidence("VLF'er", Format.Number(d.VirtualLogFileCount!.Value))], subject: Databases.Subject(d), references: Docs.Learn("Transaction log architecture", "relational-databases/sql-server-transaction-log-architecture-and-management-guide")))); } } /// /// Autogrowth settings that cause many or unpredictable growths. /// public sealed class FileGrowthRule : Rule { public override string Id => "SQLI-DB-012"; public override string Title => "Uhensigtsmæssig autogrowth"; public override IReadOnlyCollection RequiredCapabilities => [Capability.SelfManagedInfrastructure]; public override RuleEvaluation Evaluate(RuleContext context) { ArgumentNullException.ThrowIfNull(context); var findings = new List(); foreach (var d in Databases.Online(context)) { foreach (var f in d.Files.Where(f => f.Growth > 0 && f.SizeMb >= 1024)) { string? problem = null; if (f.IsPercentGrowth) { problem = $"vokser med {f.Growth} %, dvs. ca. {Format.Number(f.SizeMb * f.Growth / 100)} MB næste gang og mere hver gang"; } else if (f.Growth < 64) { problem = $"vokser kun {f.Growth} MB ad gangen på en fil på {Format.Number(f.SizeMb)} MB"; } if (problem is null) { continue; } findings.Add(CreateFinding( Severity.Low, $"{d.Name}/{f.LogicalName} {problem}", "Procentvis vækst bliver større og langsommere, jo større filen er. Små faste trin giver mange vækster, " + "fragmentering og, for logfiler, mange VLF'er. Logfiler (og datafiler uden instant file initialization) " + "skal nulstilles ved vækst, så brugerne venter imens.", "Sæt en fast vækst i MB, fx 256-1024 MB for datafiler og 512-1024 MB for logfiler, og størrelsesplanlæg " + "filerne, så autogrowth kun er et sikkerhedsnet.", [new Evidence("Fil", f.PhysicalName), new Evidence("Størrelse (MB)", Format.Number(f.SizeMb)), new Evidence("Autogrowth", f.IsPercentGrowth ? $"{f.Growth} %" : $"{f.Growth} MB")], subject: $"file: {d.Name}/{f.LogicalName}", fixScript: $"ALTER DATABASE [{d.Name.Replace("]", "]]", StringComparison.Ordinal)}] MODIFY FILE (NAME = N'{f.LogicalName.Replace("'", "''", StringComparison.Ordinal)}', FILEGROWTH = {(f.Type == "LOG" ? 1024 : 512)}MB);")); } } return Result(findings); } } /// /// Files that will soon hit their maximum size. /// public sealed class FileMaxSizeRule : Rule { // The log file default "max" of 2 TB is effectively unlimited. private const long EffectivelyUnlimitedMb = 2 * 1024 * 1024; public override string Id => "SQLI-DB-013"; public override string Title => "Databasefil nærmer sig sin maksimale størrelse"; public override IReadOnlyCollection RequiredCapabilities => [Capability.SelfManagedInfrastructure]; public override RuleEvaluation Evaluate(RuleContext context) { ArgumentNullException.ThrowIfNull(context); return Result(Databases.Online(context).SelectMany(d => d.Files .Where(f => f.MaxSizeMb > 0 && f.MaxSizeMb < EffectivelyUnlimitedMb && f.SizeMb >= f.MaxSizeMb * 0.9) .Select(f => CreateFinding( f.SizeMb >= f.MaxSizeMb ? Severity.Critical : Severity.High, $"{d.Name}/{f.LogicalName} er {Format.Number(100.0 * f.SizeMb / f.MaxSizeMb)} % af sin maksimale størrelse", "Filen kan ikke vokse ud over MAXSIZE. Når den er fuld, fejler alle ændringer (fejl 1105 for data, 9002 for log), " + "selvom der er plads på disken.", "Hæv eller fjern MAXSIZE, eller frigør plads i filen. For logfiler: find ud af, hvad der forhindrer genbrug " + "(log_reuse_wait_desc i sys.databases).", [new Evidence("Størrelse (MB)", Format.Number(f.SizeMb)), new Evidence("Maks. (MB)", Format.Number(f.MaxSizeMb))], subject: $"file: {d.Name}/{f.LogicalName}")))); } } /// /// Query Store switched itself to read-only or error. /// public sealed class QueryStoreStateRule : Rule { public override string Id => "SQLI-DB-014"; public override string Title => "Query Store indsamler ikke længere data"; public override IReadOnlyCollection RequiredCapabilities => [Capability.QueryStore]; public override RuleEvaluation Evaluate(RuleContext context) { ArgumentNullException.ThrowIfNull(context); return Result(Databases.Online(context) .Where(d => d.QueryStore is { } qs && qs.DesiredState == "READ_WRITE" && qs.ActualState != "READ_WRITE") .Select(d => { var qs = d.QueryStore!; var full = (qs.ReadOnlyReason & 65536) != 0; return CreateFinding( qs.ActualState == "ERROR" ? Severity.High : Severity.Medium, $"Query Store på {d.Name} er {qs.ActualState}" + (full ? " (fuld)" : string.Empty), "Query Store er sat til READ_WRITE, men er selv gået over i " + qs.ActualState + ". Der indsamles ingen nye planer " + "eller statistikker, så planregressioner kan hverken opdages eller rettes med forcerede planer.", full ? "Hæv MAX_STORAGE_SIZE_MB, eller sænk opbevaringstiden (STALE_QUERY_THRESHOLD_DAYS), og sæt QUERY_CAPTURE_MODE = AUTO. Sæt derefter OPERATION_MODE = READ_WRITE igen." : "Se readonly_reason i sys.database_query_store_options. Ved ERROR: kør sp_query_store_consistency_check eller slå Query Store fra og til.", [new Evidence("Tilstand", $"ønsket {qs.DesiredState}, faktisk {qs.ActualState}"), new Evidence("Størrelse (MB)", $"{qs.CurrentStorageMb} af {qs.MaxStorageMb}"), new Evidence("readonly_reason", qs.ReadOnlyReason.ToString(CultureInfo.InvariantCulture))], subject: Databases.Subject(d), references: Docs.Learn("Query Store", "relational-databases/performance/monitoring-performance-by-using-the-query-store")); })); } } /// /// tempdb with too few, or unevenly sized, data files. /// public sealed class TempdbFilesRule : Rule { public override string Id => "SQLI-DB-015"; public override string Title => "tempdb har for få eller ulige store datafiler"; public override IReadOnlyCollection RequiredCapabilities => [Capability.SelfManagedInfrastructure]; public override RuleEvaluation Evaluate(RuleContext context) { ArgumentNullException.ThrowIfNull(context); var files = context.Data.Get().TempdbFiles.Where(f => f.Type == "ROWS").ToList(); var cpus = context.Data.Get().LogicalCpuCount; if (files.Count == 0) { return RuleEvaluation.Passed(); } var recommended = Math.Min(8, cpus); var findings = new List(); var evidence = files.Select(f => new Evidence(f.LogicalName, $"{Format.Number(f.SizeMb)} MB, vækst {(f.IsPercentGrowth ? $"{f.Growth} %" : $"{f.Growth} MB")}")).ToList(); if (files.Count < recommended && files.Count < 4) { findings.Add(CreateFinding( Severity.Medium, $"tempdb har {files.Count} datafil(er) på en server med {cpus} logiske CPU'er", "Hver datafil har egne allokeringssider (PFS, GAM, SGAM). Med få filer konkurrerer sessioner om de samme sider, " + "hvilket viser sig som PAGELATCH-ventetider på tempdb, når der oprettes mange midlertidige objekter.", $"Brug {recommended} lige store datafiler (én pr. logisk CPU op til 8). Tilføj flere i trin af 4, hvis der stadig er contention.", evidence, subject: "database: tempdb", references: Docs.Learn("tempdb", "relational-databases/databases/tempdb-database"))); } if (files.Count > 1 && (files.Select(f => f.SizeMb).Distinct().Count() > 1 || files.Select(f => (f.Growth, f.IsPercentGrowth)).Distinct().Count() > 1)) { findings.Add(CreateFinding( Severity.Low, "tempdb's datafiler er ikke ens", "SQL Server fordeler allokeringer efter ledig plads (proportional fill). Er filerne forskellige i størrelse eller " + "vækst, rammer det meste den største fil, og fordelen ved flere filer forsvinder.", "Giv alle tempdb-datafiler samme størrelse og samme faste autogrowth.", evidence, subject: "database: tempdb", references: Docs.Learn("tempdb", "relational-databases/databases/tempdb-database"))); } return Result(findings); } }