Fase 2: index and statistics analysis
Some checks are pending
CI / Build og unit tests (push) Waiting to run
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
Some checks are pending
CI / Build og unit tests (push) Waiting to run
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
Collector (every 6 h, metadata and DMVs only, no scans): index inventory with usage and forwarded fetches, missing-index suggestions, statistics modifications via sysindexes.rowmodctr (needs only metadata visibility). Rules: - SQLI-IDX-001 missing index, evaluated against existing indexes: extends an index with the same keys instead of adding one; notes on write-heavy tables, many indexes, wide includes, short uptime - SQLI-IDX-002 unused index (needs 7 days of uptime; suggests DISABLE) - SQLI-IDX-003 duplicate or covered index (filters, uniqueness respected) - SQLI-IDX-004 heap with forwarded records - SQLI-STAT-001 stale statistics, SQLI-STAT-002 auto statistics off Column lists use U+E000 as separator: FOR XML cannot serialize NCHAR(1). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01E3EExwndy8Z22EK2Qpt6BK
This commit is contained in:
parent
f1d3f5188e
commit
8cde94038c
9 changed files with 890 additions and 2 deletions
|
|
@ -23,5 +23,6 @@ public static class BuiltInCollectors
|
||||||
new DeadlockCollector(),
|
new DeadlockCollector(),
|
||||||
new QueryStoreCollector(),
|
new QueryStoreCollector(),
|
||||||
new PlanAnalysisCollector(),
|
new PlanAnalysisCollector(),
|
||||||
|
new IndexCollector(),
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -107,7 +107,8 @@ public sealed class DatabaseCollector : Collector<DatabaseSnapshot>
|
||||||
CASE WHEN b.last_full >= DATEADD(second, -1, d.create_date) THEN b.last_full END,
|
CASE WHEN b.last_full >= DATEADD(second, -1, d.create_date) THEN b.last_full END,
|
||||||
CASE WHEN b.last_diff >= DATEADD(second, -1, d.create_date) THEN b.last_diff END,
|
CASE WHEN b.last_diff >= DATEADD(second, -1, d.create_date) THEN b.last_diff END,
|
||||||
CASE WHEN b.last_log >= DATEADD(second, -1, d.create_date) THEN b.last_log END,
|
CASE WHEN b.last_log >= DATEADD(second, -1, d.create_date) THEN b.last_log END,
|
||||||
{checkDb}
|
{checkDb},
|
||||||
|
d.is_auto_create_stats_on, d.is_auto_update_stats_on
|
||||||
FROM sys.databases d
|
FROM sys.databases d
|
||||||
{BackupJoin}
|
{BackupJoin}
|
||||||
WHERE d.name <> N'tempdb'
|
WHERE d.name <> N'tempdb'
|
||||||
|
|
@ -130,7 +131,9 @@ public sealed class DatabaseCollector : Collector<DatabaseSnapshot>
|
||||||
CASE WHEN b.last_full >= DATEADD(second, -1, d.crdate) THEN b.last_full END,
|
CASE WHEN b.last_full >= DATEADD(second, -1, d.crdate) THEN b.last_full END,
|
||||||
CASE WHEN b.last_diff >= DATEADD(second, -1, d.crdate) THEN b.last_diff END,
|
CASE WHEN b.last_diff >= DATEADD(second, -1, d.crdate) THEN b.last_diff END,
|
||||||
CASE WHEN b.last_log >= DATEADD(second, -1, d.crdate) THEN b.last_log END,
|
CASE WHEN b.last_log >= DATEADD(second, -1, d.crdate) THEN b.last_log END,
|
||||||
CAST(NULL AS datetime)
|
CAST(NULL AS datetime),
|
||||||
|
CAST(DATABASEPROPERTYEX(d.name, 'IsAutoCreateStatistics') AS int),
|
||||||
|
CAST(DATABASEPROPERTYEX(d.name, 'IsAutoUpdateStatistics') AS int)
|
||||||
FROM master.dbo.sysdatabases d
|
FROM master.dbo.sysdatabases d
|
||||||
{BackupJoin}
|
{BackupJoin}
|
||||||
WHERE d.name <> N'tempdb'
|
WHERE d.name <> N'tempdb'
|
||||||
|
|
@ -183,6 +186,8 @@ public sealed class DatabaseCollector : Collector<DatabaseSnapshot>
|
||||||
LastDifferentialBackup = r.NullableDateTime(14),
|
LastDifferentialBackup = r.NullableDateTime(14),
|
||||||
LastLogBackup = r.NullableDateTime(15),
|
LastLogBackup = r.NullableDateTime(15),
|
||||||
LastGoodCheckDb = r.NullableDateTime(16),
|
LastGoodCheckDb = r.NullableDateTime(16),
|
||||||
|
IsAutoCreateStatistics = r.Bool(17),
|
||||||
|
IsAutoUpdateStatistics = r.Bool(18),
|
||||||
};
|
};
|
||||||
|
|
||||||
private static DatabaseInfo Map2000(Row r) => Map2005(r) with { State = r.NullableString(2) ?? "UNKNOWN", RecoveryModel = r.NullableString(3) ?? "UNKNOWN" };
|
private static DatabaseInfo Map2000(Row r) => Map2005(r) with { State = r.NullableString(2) ?? "UNKNOWN", RecoveryModel = r.NullableString(3) ?? "UNKNOWN" };
|
||||||
|
|
|
||||||
156
src/SQLInsight.Collectors/Databases/IndexCollector.cs
Normal file
156
src/SQLInsight.Collectors/Databases/IndexCollector.cs
Normal file
|
|
@ -0,0 +1,156 @@
|
||||||
|
using System.Data.Common;
|
||||||
|
using SQLInsight.Core.Data;
|
||||||
|
using SQLInsight.Core.Instances;
|
||||||
|
|
||||||
|
namespace SQLInsight.Collectors.Databases;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Index inventory with usage, missing-index suggestions and statistics for every readable user database.
|
||||||
|
/// Everything is metadata and DMVs; no table data is read and nothing is scanned (no index_physical_stats).
|
||||||
|
/// </summary>
|
||||||
|
public sealed class IndexCollector : Collector<IndexSnapshot>
|
||||||
|
{
|
||||||
|
internal const string DatabasesSql = """
|
||||||
|
SELECT d.name FROM sys.databases d
|
||||||
|
WHERE d.state = 0 AND d.database_id > 4 AND HAS_DBACCESS(d.name) = 1
|
||||||
|
AND NOT EXISTS (SELECT 1 FROM sys.database_mirroring m WHERE m.database_id = d.database_id AND m.mirroring_role = 2)
|
||||||
|
""";
|
||||||
|
|
||||||
|
internal const string MissingSql = """
|
||||||
|
SELECT DB_NAME(d.database_id), d.statement, d.equality_columns, d.inequality_columns, d.included_columns,
|
||||||
|
gs.user_seeks, gs.user_scans, gs.avg_total_user_cost, gs.avg_user_impact, gs.last_user_seek
|
||||||
|
FROM sys.dm_db_missing_index_details d
|
||||||
|
JOIN sys.dm_db_missing_index_groups g ON g.index_handle = d.index_handle
|
||||||
|
JOIN sys.dm_db_missing_index_group_stats gs ON gs.group_handle = g.index_group_handle
|
||||||
|
""";
|
||||||
|
|
||||||
|
public override string Name => "indexes";
|
||||||
|
public override TimeSpan DefaultInterval => TimeSpan.FromHours(6);
|
||||||
|
public override IReadOnlyCollection<Capability> RequiredCapabilities => [Capability.DynamicManagementViews];
|
||||||
|
|
||||||
|
protected override async Task<IndexSnapshot> CollectAsync(CollectorContext context, IndexSnapshot? previous)
|
||||||
|
{
|
||||||
|
var connection = context.Connection;
|
||||||
|
var ct = context.CancellationToken;
|
||||||
|
var startTime = await Db.StartTimeAsync(connection, context.Instance.Version, ct).ConfigureAwait(false);
|
||||||
|
var names = await Db.QueryAsync(connection, DatabasesSql, r => r.String(0), ct).ConfigureAwait(false);
|
||||||
|
var missing = await Db.QueryAsync(connection, MissingSql, r => (Database: r.NullableString(0), Index: new MissingIndex
|
||||||
|
{
|
||||||
|
Table = r.String(1),
|
||||||
|
EqualityColumns = r.NullableString(2),
|
||||||
|
InequalityColumns = r.NullableString(3),
|
||||||
|
IncludedColumns = r.NullableString(4),
|
||||||
|
Seeks = r.Long(5),
|
||||||
|
Scans = r.Long(6),
|
||||||
|
AverageTotalUserCost = r.Double(7),
|
||||||
|
AverageUserImpact = r.Double(8),
|
||||||
|
LastUserSeek = r.NullableDateTime(9),
|
||||||
|
}), ct).ConfigureAwait(false);
|
||||||
|
|
||||||
|
var databases = new List<IndexDatabase>(names.Count);
|
||||||
|
foreach (var name in names)
|
||||||
|
{
|
||||||
|
databases.Add(await ReadAsync(connection, name, context.Instance.Version.Major >= 10, ct).ConfigureAwait(false) with
|
||||||
|
{
|
||||||
|
MissingIndexes = missing.Where(m => string.Equals(m.Database, name, StringComparison.OrdinalIgnoreCase)).Select(m => m.Index).ToList(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return new IndexSnapshot { CapturedAt = context.Now, SqlServerStartTime = startTime, Databases = databases };
|
||||||
|
}
|
||||||
|
|
||||||
|
private static async Task<IndexDatabase> ReadAsync(DbConnection connection, string database, bool filtered, CancellationToken ct)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var indexes = await Db.QueryAsync(connection, BuildIndexSql(database, filtered), r => new IndexInfo
|
||||||
|
{
|
||||||
|
Schema = r.String(0),
|
||||||
|
Table = r.String(1),
|
||||||
|
IndexId = r.Int(2),
|
||||||
|
Name = r.NullableString(3),
|
||||||
|
Type = r.String(4),
|
||||||
|
IsUnique = r.Bool(5),
|
||||||
|
IsPrimaryKey = r.Bool(6),
|
||||||
|
IsUniqueConstraint = r.Bool(7),
|
||||||
|
IsDisabled = r.Bool(8),
|
||||||
|
Filter = r.NullableString(9),
|
||||||
|
KeyColumns = Split(r.NullableString(10)),
|
||||||
|
IncludedColumns = Split(r.NullableString(11)),
|
||||||
|
Rows = r.NullableLong(12) ?? 0,
|
||||||
|
UsedPages = r.NullableLong(13) ?? 0,
|
||||||
|
UserSeeks = r.NullableLong(14) ?? 0,
|
||||||
|
UserScans = r.NullableLong(15) ?? 0,
|
||||||
|
UserLookups = r.NullableLong(16) ?? 0,
|
||||||
|
UserUpdates = r.NullableLong(17) ?? 0,
|
||||||
|
ForwardedFetches = r.NullableLong(18) ?? 0,
|
||||||
|
}, ct).ConfigureAwait(false);
|
||||||
|
|
||||||
|
var statistics = await Db.QueryAsync(connection, BuildStatisticsSql(database), r => new StatisticInfo
|
||||||
|
{
|
||||||
|
Schema = r.String(0),
|
||||||
|
Table = r.String(1),
|
||||||
|
Name = r.String(2),
|
||||||
|
LastUpdated = r.NullableDateTime(3),
|
||||||
|
Rows = r.NullableLong(4) ?? 0,
|
||||||
|
Modifications = r.NullableLong(5) ?? 0,
|
||||||
|
}, ct).ConfigureAwait(false);
|
||||||
|
|
||||||
|
return new IndexDatabase { Name = database, Indexes = indexes, Statistics = statistics };
|
||||||
|
}
|
||||||
|
catch (DbException ex)
|
||||||
|
{
|
||||||
|
return new IndexDatabase { Name = database, Error = ex.Message };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string[] Split(string? columns) =>
|
||||||
|
string.IsNullOrEmpty(columns) ? [] : columns.Split('\uE000', StringSplitOptions.RemoveEmptyEntries);
|
||||||
|
|
||||||
|
// Filtered indexes (filter_definition) exist from 2008.
|
||||||
|
// Column lists are joined with U+E000 (private use: valid in XML, not found in real names) using FOR XML PATH,
|
||||||
|
// which works from 2005. Control characters such as NCHAR(1) cannot be serialized by FOR XML.
|
||||||
|
internal static string BuildIndexSql(string database, bool filteredIndexes = true)
|
||||||
|
{
|
||||||
|
var db = Db.Identifier(database);
|
||||||
|
var id = $"DB_ID({Db.Literal(database)})";
|
||||||
|
string Columns(string included) => $"""
|
||||||
|
STUFF((SELECT NCHAR(57344) + c.name + CASE WHEN ic.is_descending_key = 1 THEN N' DESC' ELSE N'' END
|
||||||
|
FROM {db}.sys.index_columns ic JOIN {db}.sys.columns c ON c.object_id = ic.object_id AND c.column_id = ic.column_id
|
||||||
|
WHERE ic.object_id = i.object_id AND ic.index_id = i.index_id AND ic.is_included_column = {included}
|
||||||
|
{(included == "0" ? "AND ic.key_ordinal > 0" : string.Empty)}
|
||||||
|
ORDER BY {(included == "0" ? "ic.key_ordinal" : "c.name")}
|
||||||
|
FOR XML PATH(''), TYPE).value('.', 'nvarchar(max)'), 1, 1, N'')
|
||||||
|
""";
|
||||||
|
return $"""
|
||||||
|
SELECT s.name, o.name, i.index_id, i.name, i.type_desc, i.is_unique, i.is_primary_key, i.is_unique_constraint, i.is_disabled,
|
||||||
|
{(filteredIndexes ? "i.filter_definition" : "CAST(NULL AS nvarchar(max))")},
|
||||||
|
{Columns("0")},
|
||||||
|
{Columns("1")},
|
||||||
|
ps.row_count, ps.used_pages,
|
||||||
|
us.user_seeks, us.user_scans, us.user_lookups, us.user_updates,
|
||||||
|
fw.forwarded
|
||||||
|
FROM {db}.sys.indexes i
|
||||||
|
JOIN {db}.sys.objects o ON o.object_id = i.object_id AND o.type = 'U' AND o.is_ms_shipped = 0
|
||||||
|
JOIN {db}.sys.schemas s ON s.schema_id = o.schema_id
|
||||||
|
OUTER APPLY (SELECT SUM(p.row_count) AS row_count, SUM(p.used_page_count) AS used_pages
|
||||||
|
FROM {db}.sys.dm_db_partition_stats p WHERE p.object_id = i.object_id AND p.index_id = i.index_id) ps
|
||||||
|
LEFT JOIN sys.dm_db_index_usage_stats us ON us.database_id = {id} AND us.object_id = i.object_id AND us.index_id = i.index_id
|
||||||
|
OUTER APPLY (SELECT SUM(os.forwarded_fetch_count) AS forwarded
|
||||||
|
FROM sys.dm_db_index_operational_stats({id}, i.object_id, 0, NULL) os WHERE i.index_id = 0) fw;
|
||||||
|
""";
|
||||||
|
}
|
||||||
|
|
||||||
|
// sys.sysindexes.rowmodctr needs only metadata visibility, unlike sys.dm_db_stats_properties.
|
||||||
|
internal static string BuildStatisticsSql(string database)
|
||||||
|
{
|
||||||
|
var db = Db.Identifier(database);
|
||||||
|
return $"""
|
||||||
|
SELECT s.name, o.name, si.name, STATS_DATE(si.id, si.indid), si.rowcnt, si.rowmodctr
|
||||||
|
FROM {db}.sys.sysindexes si
|
||||||
|
JOIN {db}.sys.objects o ON o.object_id = si.id AND o.type = 'U' AND o.is_ms_shipped = 0
|
||||||
|
JOIN {db}.sys.schemas s ON s.schema_id = o.schema_id
|
||||||
|
WHERE si.indid > 0 AND si.indid < 255;
|
||||||
|
""";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -29,6 +29,8 @@ public sealed record DatabaseInfo
|
||||||
public string? PageVerify { get; init; }
|
public string? PageVerify { get; init; }
|
||||||
|
|
||||||
public bool IsAutoClose { get; init; }
|
public bool IsAutoClose { get; init; }
|
||||||
|
public bool IsAutoCreateStatistics { get; init; } = true;
|
||||||
|
public bool IsAutoUpdateStatistics { get; init; } = true;
|
||||||
public bool IsAutoShrink { get; init; }
|
public bool IsAutoShrink { get; init; }
|
||||||
public bool IsReadOnly { get; init; }
|
public bool IsReadOnly { get; init; }
|
||||||
public bool IsTrustworthy { get; init; }
|
public bool IsTrustworthy { get; init; }
|
||||||
|
|
|
||||||
90
src/SQLInsight.Core/Data/Indexes.cs
Normal file
90
src/SQLInsight.Core/Data/Indexes.cs
Normal file
|
|
@ -0,0 +1,90 @@
|
||||||
|
namespace SQLInsight.Core.Data;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Indexes, their usage, missing-index suggestions and statistics per user database.
|
||||||
|
/// Usage counters reset when SQL Server restarts, so <see cref="SqlServerStartTime"/> says how much history they hold.
|
||||||
|
/// </summary>
|
||||||
|
public sealed record IndexSnapshot
|
||||||
|
{
|
||||||
|
public required DateTimeOffset CapturedAt { get; init; }
|
||||||
|
public required DateTime SqlServerStartTime { get; init; }
|
||||||
|
public required IReadOnlyList<IndexDatabase> Databases { get; init; }
|
||||||
|
|
||||||
|
public TimeSpan Uptime => CapturedAt.UtcDateTime - SqlServerStartTime;
|
||||||
|
}
|
||||||
|
|
||||||
|
public sealed record IndexDatabase
|
||||||
|
{
|
||||||
|
public required string Name { get; init; }
|
||||||
|
public IReadOnlyList<IndexInfo> Indexes { get; init; } = [];
|
||||||
|
public IReadOnlyList<MissingIndex> MissingIndexes { get; init; } = [];
|
||||||
|
public IReadOnlyList<StatisticInfo> Statistics { get; init; } = [];
|
||||||
|
public string? Error { get; init; }
|
||||||
|
}
|
||||||
|
|
||||||
|
public sealed record IndexInfo
|
||||||
|
{
|
||||||
|
public required string Schema { get; init; }
|
||||||
|
public required string Table { get; init; }
|
||||||
|
public required int IndexId { get; init; }
|
||||||
|
|
||||||
|
/// <summary><see langword="null"/> for a heap.</summary>
|
||||||
|
public string? Name { get; init; }
|
||||||
|
|
||||||
|
/// <summary>HEAP, CLUSTERED, NONCLUSTERED, COLUMNSTORE …</summary>
|
||||||
|
public required string Type { get; init; }
|
||||||
|
|
||||||
|
public bool IsUnique { get; init; }
|
||||||
|
public bool IsPrimaryKey { get; init; }
|
||||||
|
public bool IsUniqueConstraint { get; init; }
|
||||||
|
public bool IsDisabled { get; init; }
|
||||||
|
public string? Filter { get; init; }
|
||||||
|
public IReadOnlyList<string> KeyColumns { get; init; } = [];
|
||||||
|
public IReadOnlyList<string> IncludedColumns { get; init; } = [];
|
||||||
|
public long Rows { get; init; }
|
||||||
|
public long UsedPages { get; init; }
|
||||||
|
public long UserSeeks { get; init; }
|
||||||
|
public long UserScans { get; init; }
|
||||||
|
public long UserLookups { get; init; }
|
||||||
|
public long UserUpdates { get; init; }
|
||||||
|
|
||||||
|
/// <summary>Heaps only: rows read through a forwarding pointer.</summary>
|
||||||
|
public long ForwardedFetches { get; init; }
|
||||||
|
|
||||||
|
public long Reads => UserSeeks + UserScans + UserLookups;
|
||||||
|
public double SizeMb => UsedPages * 8.0 / 1024;
|
||||||
|
public string QualifiedTable => $"{Schema}.{Table}";
|
||||||
|
}
|
||||||
|
|
||||||
|
public sealed record MissingIndex
|
||||||
|
{
|
||||||
|
/// <summary>[db].[schema].[table] as SQL Server reports it.</summary>
|
||||||
|
public required string Table { get; init; }
|
||||||
|
|
||||||
|
public string? EqualityColumns { get; init; }
|
||||||
|
public string? InequalityColumns { get; init; }
|
||||||
|
public string? IncludedColumns { get; init; }
|
||||||
|
public required long Seeks { get; init; }
|
||||||
|
public required long Scans { get; init; }
|
||||||
|
public required double AverageTotalUserCost { get; init; }
|
||||||
|
|
||||||
|
/// <summary>Estimated improvement in percent.</summary>
|
||||||
|
public required double AverageUserImpact { get; init; }
|
||||||
|
|
||||||
|
public DateTime? LastUserSeek { get; init; }
|
||||||
|
|
||||||
|
/// <summary>The usual ranking measure: cost × impact × uses.</summary>
|
||||||
|
public double ImprovementMeasure => AverageTotalUserCost * (AverageUserImpact / 100) * (Seeks + Scans);
|
||||||
|
}
|
||||||
|
|
||||||
|
public sealed record StatisticInfo
|
||||||
|
{
|
||||||
|
public required string Schema { get; init; }
|
||||||
|
public required string Table { get; init; }
|
||||||
|
public required string Name { get; init; }
|
||||||
|
public DateTime? LastUpdated { get; init; }
|
||||||
|
public long Rows { get; init; }
|
||||||
|
|
||||||
|
/// <summary>Row modifications since the last update (sysindexes.rowmodctr; approximate).</summary>
|
||||||
|
public long Modifications { get; init; }
|
||||||
|
}
|
||||||
|
|
@ -2,6 +2,7 @@ using SQLInsight.Rules.Concurrency;
|
||||||
using SQLInsight.Rules.Configuration;
|
using SQLInsight.Rules.Configuration;
|
||||||
using SQLInsight.Rules.Databases;
|
using SQLInsight.Rules.Databases;
|
||||||
using SQLInsight.Rules.Engine;
|
using SQLInsight.Rules.Engine;
|
||||||
|
using SQLInsight.Rules.Indexes;
|
||||||
using SQLInsight.Rules.Lifecycle;
|
using SQLInsight.Rules.Lifecycle;
|
||||||
using SQLInsight.Rules.Performance;
|
using SQLInsight.Rules.Performance;
|
||||||
|
|
||||||
|
|
@ -54,6 +55,14 @@ public static class BuiltInRules
|
||||||
new SpillRule(),
|
new SpillRule(),
|
||||||
new ExcessiveGrantRule(),
|
new ExcessiveGrantRule(),
|
||||||
|
|
||||||
|
// Indexes and statistics
|
||||||
|
new MissingIndexRule(),
|
||||||
|
new UnusedIndexRule(),
|
||||||
|
new DuplicateIndexRule(),
|
||||||
|
new ForwardedRecordsRule(),
|
||||||
|
new StaleStatisticsRule(),
|
||||||
|
new AutoStatisticsRule(),
|
||||||
|
|
||||||
// Concurrency
|
// Concurrency
|
||||||
new BlockingRule(),
|
new BlockingRule(),
|
||||||
new DeadlockRule(),
|
new DeadlockRule(),
|
||||||
|
|
|
||||||
383
src/SQLInsight.Rules/Indexes/IndexRules.cs
Normal file
383
src/SQLInsight.Rules/Indexes/IndexRules.cs
Normal file
|
|
@ -0,0 +1,383 @@
|
||||||
|
using System.Globalization;
|
||||||
|
using SQLInsight.Core.Collection;
|
||||||
|
using SQLInsight.Core.Data;
|
||||||
|
using SQLInsight.Core.Findings;
|
||||||
|
using SQLInsight.Core.Instances;
|
||||||
|
using SQLInsight.Rules.Engine;
|
||||||
|
|
||||||
|
namespace SQLInsight.Rules.Indexes;
|
||||||
|
|
||||||
|
internal static class IndexText
|
||||||
|
{
|
||||||
|
public const int MaxFindingsPerRule = 10;
|
||||||
|
public static readonly TimeSpan MinUptimeForUsage = TimeSpan.FromDays(7);
|
||||||
|
|
||||||
|
public static string Quote(string name) => "[" + name.Replace("]", "]]", StringComparison.Ordinal) + "]";
|
||||||
|
|
||||||
|
public static string Table(IndexInfo i) => $"{Quote(i.Schema)}.{Quote(i.Table)}";
|
||||||
|
|
||||||
|
public static string Use(string database) => $"USE {Quote(database)};";
|
||||||
|
|
||||||
|
public static string Columns(IEnumerable<string> columns) => string.Join(", ", columns.Select(c =>
|
||||||
|
c.EndsWith(" DESC", StringComparison.Ordinal) ? Quote(c[..^5]) + " DESC" : Quote(c)));
|
||||||
|
|
||||||
|
/// <summary>"[A], [B]" from the missing index DMVs to plain names.</summary>
|
||||||
|
public static List<string> ParseColumnList(string? list) => string.IsNullOrWhiteSpace(list)
|
||||||
|
? []
|
||||||
|
: list.Split(',').Select(c => c.Trim().TrimStart('[').TrimEnd(']').Replace("]]", "]", StringComparison.Ordinal)).ToList();
|
||||||
|
|
||||||
|
/// <summary>"[db].[schema].[table]" to (schema, table).</summary>
|
||||||
|
public static (string Schema, string Table) ParseTable(string statement)
|
||||||
|
{
|
||||||
|
var parts = statement.Split("].[", StringSplitOptions.None).Select(p => p.Trim('[', ']')).ToArray();
|
||||||
|
return parts.Length >= 3 ? (parts[^2], parts[^1]) : ("dbo", parts[^1]);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static Reference Docs(string title, string path) => Engine.Docs.Learn(title, path);
|
||||||
|
|
||||||
|
public static IndexSnapshot Snapshot(RuleContext context) => context.Data.Get<IndexSnapshot>();
|
||||||
|
|
||||||
|
public static IndexSnapshot RequireUptime(RuleContext context)
|
||||||
|
{
|
||||||
|
var snapshot = Snapshot(context);
|
||||||
|
if (snapshot.Uptime < MinUptimeForUsage)
|
||||||
|
{
|
||||||
|
throw new DataUnavailableException(
|
||||||
|
$"Index-brug nulstilles ved genstart; SQL Server har kun kørt i {Format.Age(snapshot.Uptime)}. Vurderes efter 7 dages oppetid.");
|
||||||
|
}
|
||||||
|
|
||||||
|
return snapshot;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Missing indexes from the optimizer, evaluated critically against what already exists.
|
||||||
|
/// </summary>
|
||||||
|
public sealed class MissingIndexRule : Rule
|
||||||
|
{
|
||||||
|
public const double MinImprovementMeasure = 50_000;
|
||||||
|
public const long MinUses = 100;
|
||||||
|
|
||||||
|
public override string Id => "SQLI-IDX-001";
|
||||||
|
public override string Title => "Manglende index med stor effekt";
|
||||||
|
public override IReadOnlyCollection<Capability> RequiredCapabilities => [Capability.DynamicManagementViews];
|
||||||
|
|
||||||
|
public override RuleEvaluation Evaluate(RuleContext context)
|
||||||
|
{
|
||||||
|
ArgumentNullException.ThrowIfNull(context);
|
||||||
|
var snapshot = IndexText.Snapshot(context);
|
||||||
|
var candidates = new List<(double Measure, Finding Finding)>();
|
||||||
|
foreach (var db in snapshot.Databases)
|
||||||
|
{
|
||||||
|
foreach (var m in db.MissingIndexes.Where(m => m.ImprovementMeasure >= MinImprovementMeasure && m.Seeks + m.Scans >= MinUses))
|
||||||
|
{
|
||||||
|
candidates.Add((m.ImprovementMeasure, Evaluate(snapshot, db, m)));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return Result(candidates.OrderByDescending(c => c.Measure).Take(IndexText.MaxFindingsPerRule).Select(c => c.Finding));
|
||||||
|
}
|
||||||
|
|
||||||
|
private Finding Evaluate(IndexSnapshot snapshot, IndexDatabase db, MissingIndex m)
|
||||||
|
{
|
||||||
|
var (schema, table) = IndexText.ParseTable(m.Table);
|
||||||
|
var equality = IndexText.ParseColumnList(m.EqualityColumns);
|
||||||
|
var inequality = IndexText.ParseColumnList(m.InequalityColumns);
|
||||||
|
var include = IndexText.ParseColumnList(m.IncludedColumns);
|
||||||
|
var keys = equality.Concat(inequality).ToList();
|
||||||
|
var existing = db.Indexes.Where(i => i.Schema == schema && i.Table == table).ToList();
|
||||||
|
var notes = new List<string>();
|
||||||
|
|
||||||
|
// An existing index whose leading keys are the equality columns: extend it instead of adding another.
|
||||||
|
var extend = existing.Where(i => i.Type == "NONCLUSTERED" && !i.IsDisabled && i.Filter is null && equality.Count > 0
|
||||||
|
&& i.KeyColumns.Count >= equality.Count
|
||||||
|
&& i.KeyColumns.Take(equality.Count).Select(k => k.Replace(" DESC", string.Empty, StringComparison.Ordinal)).ToHashSet(StringComparer.OrdinalIgnoreCase).SetEquals(equality))
|
||||||
|
.OrderBy(i => i.IncludedColumns.Count)
|
||||||
|
.FirstOrDefault();
|
||||||
|
|
||||||
|
var baseTable = existing.FirstOrDefault(i => i.IndexId <= 1);
|
||||||
|
if (baseTable is not null && baseTable.UserUpdates > 10 * Math.Max(1, existing.Sum(i => i.Reads)))
|
||||||
|
{
|
||||||
|
notes.Add("Tabellen skrives langt oftere, end den læses; hvert ekstra index gør alle skrivninger dyrere.");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (existing.Count(i => i.Type == "NONCLUSTERED") >= 10)
|
||||||
|
{
|
||||||
|
notes.Add($"Tabellen har allerede {existing.Count(i => i.Type == "NONCLUSTERED")} nonclustered indexes. Overvej at samle forslag frem for at tilføje flere.");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (include.Count > 5)
|
||||||
|
{
|
||||||
|
notes.Add($"Forslaget inkluderer {include.Count} kolonner. Et smallere index dækker ofte det meste af gevinsten til en brøkdel af pladsen.");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (snapshot.Uptime < IndexText.MinUptimeForUsage)
|
||||||
|
{
|
||||||
|
notes.Add($"Tallene dækker kun {Format.Age(snapshot.Uptime)} siden seneste genstart.");
|
||||||
|
}
|
||||||
|
|
||||||
|
var fullTable = $"{IndexText.Quote(schema)}.{IndexText.Quote(table)}";
|
||||||
|
var includeClause = include.Count > 0 ? $" INCLUDE ({IndexText.Columns(include)})" : string.Empty;
|
||||||
|
var name = $"IX_{table}_{string.Join('_', keys.Take(3))}";
|
||||||
|
string fix;
|
||||||
|
string recommendation;
|
||||||
|
if (extend is not null)
|
||||||
|
{
|
||||||
|
var mergedIncludes = extend.IncludedColumns.Union(include, StringComparer.OrdinalIgnoreCase).Except(extend.KeyColumns, StringComparer.OrdinalIgnoreCase).ToList();
|
||||||
|
fix = $"{IndexText.Use(db.Name)}\nCREATE INDEX {IndexText.Quote(extend.Name!)} ON {fullTable} ({IndexText.Columns(extend.KeyColumns)})"
|
||||||
|
+ (mergedIncludes.Count > 0 ? $" INCLUDE ({IndexText.Columns(mergedIncludes)})" : string.Empty)
|
||||||
|
+ " WITH (DROP_EXISTING = ON);";
|
||||||
|
recommendation = $"Udvid det eksisterende index {extend.Name}, som allerede har de rigtige nøglekolonner, i stedet for at oprette et nyt.";
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
fix = $"{IndexText.Use(db.Name)}\nCREATE INDEX {IndexText.Quote(name)} ON {fullTable} ({IndexText.Columns(keys)}){includeClause};";
|
||||||
|
recommendation = "Opret indexet efter at have vurderet det: lighedskolonnerne står først; sæt den mest selektive forrest. "
|
||||||
|
+ "Tjek i den aktuelle forespørgselsplan, at indexet bliver brugt, og hold øje med skrivelatens bagefter.";
|
||||||
|
}
|
||||||
|
|
||||||
|
var evidence = new List<Evidence>
|
||||||
|
{
|
||||||
|
new("Tabel", $"{db.Name}.{schema}.{table}"),
|
||||||
|
new("Lighed", m.EqualityColumns ?? "–"),
|
||||||
|
new("Ulighed", m.InequalityColumns ?? "–"),
|
||||||
|
new("Inkluder", m.IncludedColumns ?? "–"),
|
||||||
|
new("Brug", $"{Format.Number(m.Seeks)} seeks, {Format.Number(m.Scans)} scans siden genstart"),
|
||||||
|
new("Estimeret forbedring", $"{Format.Number(m.AverageUserImpact)} % af forespørgslernes omkostning (mål: {Format.Number(m.ImprovementMeasure)})"),
|
||||||
|
};
|
||||||
|
evidence.AddRange(notes.Select(n => new Evidence("Bemærk", n)));
|
||||||
|
|
||||||
|
return CreateFinding(
|
||||||
|
m.ImprovementMeasure >= 1_000_000 ? Severity.Medium : Severity.Low,
|
||||||
|
$"{db.Name}.{schema}.{table}: manglende index på ({string.Join(", ", keys)})",
|
||||||
|
"Optimizeren har gentagne gange kompileret planer, der ville være billigere med dette index. Forslagene er "
|
||||||
|
+ "et godt spor, men ikke et færdigt design: de ignorerer eksisterende indexes, kolonnerækkefølge og skriveomkostninger. "
|
||||||
|
+ "Derfor er forslaget her sammenholdt med tabellens nuværende indexes.",
|
||||||
|
recommendation,
|
||||||
|
evidence,
|
||||||
|
subject: $"missing-index: {db.Name}.{schema}.{table}({string.Join(",", keys)})",
|
||||||
|
fixScript: fix,
|
||||||
|
references: IndexText.Docs("Tune nonclustered indexes with missing index suggestions", "relational-databases/indexes/tune-nonclustered-missing-index-suggestions"));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Nonclustered indexes that are maintained on every write but never read.
|
||||||
|
/// </summary>
|
||||||
|
public sealed class UnusedIndexRule : Rule
|
||||||
|
{
|
||||||
|
public override string Id => "SQLI-IDX-002";
|
||||||
|
public override string Title => "Ubrugt index koster ved hver skrivning";
|
||||||
|
public override IReadOnlyCollection<Capability> RequiredCapabilities => [Capability.DynamicManagementViews];
|
||||||
|
|
||||||
|
public override RuleEvaluation Evaluate(RuleContext context)
|
||||||
|
{
|
||||||
|
ArgumentNullException.ThrowIfNull(context);
|
||||||
|
var snapshot = IndexText.RequireUptime(context);
|
||||||
|
return Result(snapshot.Databases
|
||||||
|
.SelectMany(db => db.Indexes.Select(i => (Db: db.Name, Index: i)))
|
||||||
|
.Where(x => x.Index.Type == "NONCLUSTERED" && !x.Index.IsPrimaryKey && !x.Index.IsUniqueConstraint && !x.Index.IsUnique
|
||||||
|
&& !x.Index.IsDisabled && x.Index.Reads == 0 && (x.Index.UserUpdates >= 1000 || x.Index.SizeMb >= 100))
|
||||||
|
.OrderByDescending(x => x.Index.UserUpdates + x.Index.UsedPages)
|
||||||
|
.Take(IndexText.MaxFindingsPerRule)
|
||||||
|
.Select(x => CreateFinding(
|
||||||
|
x.Index.SizeMb >= 1024 ? Severity.Medium : Severity.Low,
|
||||||
|
$"{x.Db}.{x.Index.QualifiedTable}: index {x.Index.Name} er ikke læst i {Format.Age(snapshot.Uptime)}",
|
||||||
|
"Indexet opdateres ved hver INSERT, UPDATE og DELETE på tabellen, men ingen forespørgsel har læst det siden seneste "
|
||||||
|
+ "genstart. Det koster skrivninger, transaktionslog, backup-størrelse og vedligeholdelsestid.",
|
||||||
|
"Deaktivér indexet (ALTER INDEX … DISABLE) i stedet for at slette det, så definitionen bevares, og vent en hel "
|
||||||
|
+ "forretningscyklus (måneds- og kvartalsafslutning, rapporter). Er det stadig ubrugt, kan det slettes. Genopret med ALTER INDEX … REBUILD.",
|
||||||
|
[
|
||||||
|
new Evidence("Index", $"{x.Index.Name} ({IndexText.Columns(x.Index.KeyColumns)})"),
|
||||||
|
new Evidence("Skrivninger / læsninger", $"{Format.Number(x.Index.UserUpdates)} / 0"),
|
||||||
|
new Evidence("Størrelse", $"{Format.Number(x.Index.SizeMb)} MB"),
|
||||||
|
new Evidence("Periode", $"siden genstart for {Format.Age(snapshot.Uptime)} siden"),
|
||||||
|
],
|
||||||
|
subject: $"index: {x.Db}.{x.Index.QualifiedTable}.{x.Index.Name}",
|
||||||
|
fixScript: $"{IndexText.Use(x.Db)}\nALTER INDEX {IndexText.Quote(x.Index.Name!)} ON {IndexText.Table(x.Index)} DISABLE;",
|
||||||
|
references: IndexText.Docs("sys.dm_db_index_usage_stats", "relational-databases/system-dynamic-management-views/sys-dm-db-index-usage-stats-transact-sql"))));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Indexes made redundant by another index on the same table.
|
||||||
|
/// </summary>
|
||||||
|
public sealed class DuplicateIndexRule : Rule
|
||||||
|
{
|
||||||
|
public override string Id => "SQLI-IDX-003";
|
||||||
|
public override string Title => "Duplikeret eller overlappende index";
|
||||||
|
public override IReadOnlyCollection<Capability> RequiredCapabilities => [Capability.DynamicManagementViews];
|
||||||
|
|
||||||
|
public override RuleEvaluation Evaluate(RuleContext context)
|
||||||
|
{
|
||||||
|
ArgumentNullException.ThrowIfNull(context);
|
||||||
|
var findings = new List<Finding>();
|
||||||
|
foreach (var db in IndexText.Snapshot(context).Databases)
|
||||||
|
{
|
||||||
|
foreach (var table in db.Indexes.Where(i => i.Type is "CLUSTERED" or "NONCLUSTERED" && !i.IsDisabled).GroupBy(i => (i.Schema, i.Table)))
|
||||||
|
{
|
||||||
|
var indexes = table.ToList();
|
||||||
|
var reported = new HashSet<int>();
|
||||||
|
foreach (var redundant in indexes.Where(i => i.Type == "NONCLUSTERED" && !i.IsPrimaryKey && !i.IsUniqueConstraint))
|
||||||
|
{
|
||||||
|
var cover = indexes.FirstOrDefault(other => other.IndexId != redundant.IndexId && !reported.Contains(other.IndexId) && Covers(other, redundant));
|
||||||
|
if (cover is null)
|
||||||
|
{
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
reported.Add(redundant.IndexId);
|
||||||
|
var exact = cover.KeyColumns.SequenceEqual(redundant.KeyColumns, StringComparer.OrdinalIgnoreCase);
|
||||||
|
findings.Add(CreateFinding(
|
||||||
|
exact ? Severity.Medium : Severity.Low,
|
||||||
|
$"{db.Name}.{redundant.QualifiedTable}: {redundant.Name} er {(exact ? "en dublet af" : "dækket af")} {cover.Name}",
|
||||||
|
exact
|
||||||
|
? "To indexes har samme nøglekolonner i samme rækkefølge. Begge vedligeholdes ved hver skrivning, men kun det ene er nødvendigt."
|
||||||
|
: $"Nøglekolonnerne i {redundant.Name} er de første kolonner i {cover.Name}, og dets øvrige kolonner findes også dér. "
|
||||||
|
+ $"Alle forespørgsler, der kan bruge {redundant.Name}, kan også bruge {cover.Name}.",
|
||||||
|
$"Deaktivér {redundant.Name}, og følg med i en forretningscyklus, før det slettes. Tjek, at det ikke er nævnt i index-hints.",
|
||||||
|
[
|
||||||
|
new Evidence(redundant.Name!, $"({IndexText.Columns(redundant.KeyColumns)})" + (redundant.IncludedColumns.Count > 0 ? $" INCLUDE ({IndexText.Columns(redundant.IncludedColumns)})" : string.Empty) + $", {Format.Number(redundant.SizeMb)} MB, {Format.Number(redundant.Reads)} læsninger"),
|
||||||
|
new Evidence(cover.Name ?? "(heap)", $"({IndexText.Columns(cover.KeyColumns)})" + (cover.IncludedColumns.Count > 0 ? $" INCLUDE ({IndexText.Columns(cover.IncludedColumns)})" : string.Empty) + $", {Format.Number(cover.Reads)} læsninger"),
|
||||||
|
],
|
||||||
|
subject: $"index: {db.Name}.{redundant.QualifiedTable}.{redundant.Name}",
|
||||||
|
fixScript: $"{IndexText.Use(db.Name)}\nALTER INDEX {IndexText.Quote(redundant.Name!)} ON {IndexText.Table(redundant)} DISABLE;",
|
||||||
|
references: IndexText.Docs("Index design guide", "relational-databases/sql-server-index-design-guide")));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return Result(findings.Take(IndexText.MaxFindingsPerRule));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// <paramref name="cover"/> makes <paramref name="candidate"/> redundant: same filter, candidate's keys are a leading
|
||||||
|
/// prefix of cover's keys, and every included column of candidate is somewhere in cover. A clustered index covers
|
||||||
|
/// all columns.
|
||||||
|
/// </summary>
|
||||||
|
internal static bool Covers(IndexInfo cover, IndexInfo candidate)
|
||||||
|
{
|
||||||
|
if (!string.Equals(cover.Filter, candidate.Filter, StringComparison.OrdinalIgnoreCase) || candidate.KeyColumns.Count == 0
|
||||||
|
|| cover.KeyColumns.Count < candidate.KeyColumns.Count)
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!cover.KeyColumns.Take(candidate.KeyColumns.Count).SequenceEqual(candidate.KeyColumns, StringComparer.OrdinalIgnoreCase))
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// A unique candidate enforces uniqueness on exactly its keys; a wider index cannot replace that.
|
||||||
|
if (candidate.IsUnique && cover.KeyColumns.Count != candidate.KeyColumns.Count)
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (cover.Type == "CLUSTERED")
|
||||||
|
{
|
||||||
|
return cover.KeyColumns.Count == candidate.KeyColumns.Count;
|
||||||
|
}
|
||||||
|
|
||||||
|
var available = cover.KeyColumns.Concat(cover.IncludedColumns).ToHashSet(StringComparer.OrdinalIgnoreCase);
|
||||||
|
return candidate.IncludedColumns.All(available.Contains);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Heaps where updates have moved rows and left forwarding pointers, so reads jump around.
|
||||||
|
/// </summary>
|
||||||
|
public sealed class ForwardedRecordsRule : Rule
|
||||||
|
{
|
||||||
|
public override string Id => "SQLI-IDX-004";
|
||||||
|
public override string Title => "Heap med mange forwarded records";
|
||||||
|
public override IReadOnlyCollection<Capability> RequiredCapabilities => [Capability.DynamicManagementViews];
|
||||||
|
|
||||||
|
public override RuleEvaluation Evaluate(RuleContext context)
|
||||||
|
{
|
||||||
|
ArgumentNullException.ThrowIfNull(context);
|
||||||
|
return Result(IndexText.Snapshot(context).Databases
|
||||||
|
.SelectMany(db => db.Indexes.Where(i => i.Type == "HEAP" && i.Rows >= 10_000 && i.ForwardedFetches >= 10_000).Select(i => (Db: db.Name, Heap: i)))
|
||||||
|
.OrderByDescending(x => x.Heap.ForwardedFetches)
|
||||||
|
.Take(IndexText.MaxFindingsPerRule)
|
||||||
|
.Select(x => CreateFinding(
|
||||||
|
Severity.Medium,
|
||||||
|
$"{x.Db}.{x.Heap.QualifiedTable}: {Format.Number(x.Heap.ForwardedFetches)} forwarded fetches",
|
||||||
|
"Tabellen er en heap (uden clustered index). Når en række vokser og ikke kan være på sin side, flyttes den og efterlader "
|
||||||
|
+ "en henvisning. Hver læsning af rækken skal derefter følge henvisningen, hvilket giver ekstra, tilfældig I/O.",
|
||||||
|
"Giv tabellen et clustered index (typisk den bedste løsning), eller genopbyg heapen med ALTER TABLE … REBUILD, "
|
||||||
|
+ "som fjerner henvisningerne indtil videre.",
|
||||||
|
[new Evidence("Rækker", Format.Number(x.Heap.Rows)), new Evidence("Forwarded fetches siden genstart", Format.Number(x.Heap.ForwardedFetches))],
|
||||||
|
subject: $"heap: {x.Db}.{x.Heap.QualifiedTable}",
|
||||||
|
fixScript: $"{IndexText.Use(x.Db)}\nALTER TABLE {IndexText.Table(x.Heap)} REBUILD;",
|
||||||
|
references: IndexText.Docs("Heaps", "relational-databases/indexes/heaps-tables-without-clustered-indexes"))));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Statistics on large tables with many changes since they were last updated.
|
||||||
|
/// </summary>
|
||||||
|
public sealed class StaleStatisticsRule : Rule
|
||||||
|
{
|
||||||
|
public const long MinRows = 10_000;
|
||||||
|
public const double MinModifiedShare = 0.2;
|
||||||
|
|
||||||
|
public override string Id => "SQLI-STAT-001";
|
||||||
|
public override string Title => "Forældede statistikker på store tabeller";
|
||||||
|
public override IReadOnlyCollection<Capability> RequiredCapabilities => [Capability.DynamicManagementViews];
|
||||||
|
|
||||||
|
public override RuleEvaluation Evaluate(RuleContext context)
|
||||||
|
{
|
||||||
|
ArgumentNullException.ThrowIfNull(context);
|
||||||
|
return Result(IndexText.Snapshot(context).Databases
|
||||||
|
.SelectMany(db => db.Statistics.Where(s => s.Rows >= MinRows && s.Modifications >= s.Rows * MinModifiedShare).Select(s => (Db: db.Name, Stat: s)))
|
||||||
|
.OrderByDescending(x => x.Stat.Modifications)
|
||||||
|
.Take(IndexText.MaxFindingsPerRule)
|
||||||
|
.Select(x => CreateFinding(
|
||||||
|
Severity.Low,
|
||||||
|
$"{x.Db}.{x.Stat.Schema}.{x.Stat.Table}: statistikken {x.Stat.Name} er {Format.Number(100.0 * x.Stat.Modifications / x.Stat.Rows)} % ændret",
|
||||||
|
"Optimizeren vælger planer ud fra statistikkernes billede af data. Når en stor del af rækkerne er ændret siden seneste "
|
||||||
|
+ "opdatering, rammer estimaterne ved siden af: for små memory grants, forkerte join-typer og spild til tempdb.",
|
||||||
|
"Opdatér statistikkerne som en del af det faste vedligehold (fx Ola Hallengrens IndexOptimize med @UpdateStatistics = 'ALL' og "
|
||||||
|
+ "@OnlyModifiedStatistics = 'Y'). For store tabeller med stigende nøgler hjælper hyppigere opdatering eller "
|
||||||
|
+ "compatibility level 130+ med den dynamiske tærskel.",
|
||||||
|
[
|
||||||
|
new Evidence("Rækker", Format.Number(x.Stat.Rows)),
|
||||||
|
new Evidence("Ændringer siden opdatering", Format.Number(x.Stat.Modifications)),
|
||||||
|
new Evidence("Senest opdateret", Format.Date(x.Stat.LastUpdated)),
|
||||||
|
],
|
||||||
|
subject: $"statistic: {x.Db}.{x.Stat.Schema}.{x.Stat.Table}.{x.Stat.Name}",
|
||||||
|
fixScript: $"{IndexText.Use(x.Db)}\nUPDATE STATISTICS {IndexText.Quote(x.Stat.Schema)}.{IndexText.Quote(x.Stat.Table)} {IndexText.Quote(x.Stat.Name)};",
|
||||||
|
references: IndexText.Docs("Statistics", "relational-databases/statistics/statistics"))));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Automatic creation or updating of statistics switched off.
|
||||||
|
/// </summary>
|
||||||
|
public sealed class AutoStatisticsRule : Rule
|
||||||
|
{
|
||||||
|
public override string Id => "SQLI-STAT-002";
|
||||||
|
public override string Title => "Automatisk statistik er slået fra";
|
||||||
|
|
||||||
|
public override RuleEvaluation Evaluate(RuleContext context)
|
||||||
|
{
|
||||||
|
ArgumentNullException.ThrowIfNull(context);
|
||||||
|
return Result(context.Data.Get<DatabaseSnapshot>().Databases
|
||||||
|
.Where(d => d.State == "ONLINE" && !d.IsReadOnly && (!d.IsAutoCreateStatistics || !d.IsAutoUpdateStatistics))
|
||||||
|
.Select(d =>
|
||||||
|
{
|
||||||
|
var off = new[] { d.IsAutoCreateStatistics ? null : "AUTO_CREATE_STATISTICS", d.IsAutoUpdateStatistics ? null : "AUTO_UPDATE_STATISTICS" }.OfType<string>().ToList();
|
||||||
|
return CreateFinding(
|
||||||
|
Severity.Medium,
|
||||||
|
$"{d.Name}: {string.Join(" og ", off)} er slået fra",
|
||||||
|
"Uden automatisk statistik har optimizeren ofte ingen eller forældede oplysninger om data, og planerne bliver derefter. "
|
||||||
|
+ "Det er kun rigtigt, hvis et gennemtestet vedligehold selv holder alle statistikker opdaterede, hvilket sjældent er tilfældet.",
|
||||||
|
"Slå automatisk statistik til. Oplever I kompileringspauser ved opdatering, så brug AUTO_UPDATE_STATISTICS_ASYNC i stedet for at slå det fra.",
|
||||||
|
off.Select(o => new Evidence(o, "OFF")),
|
||||||
|
subject: $"database: {d.Name}",
|
||||||
|
fixScript: string.Join('\n', off.Select(o => $"ALTER DATABASE {IndexText.Quote(d.Name)} SET {o} ON;")),
|
||||||
|
references: IndexText.Docs("Statistics", "relational-databases/statistics/statistics"));
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -267,6 +267,52 @@ public class CollectorTests(SqlServerFixture sql)
|
||||||
Assert.NotNull(Probe("plan-probe-udf").TotalGrantKb); // 2017 CU3+ and 2022
|
Assert.NotNull(Probe("plan-probe-udf").TotalGrantKb); // 2017 CU3+ and 2022
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Indexes_include_usage_missing_suggestions_duplicates_heaps_and_statistics()
|
||||||
|
{
|
||||||
|
await sql.ExecuteAsSaAsync($"""
|
||||||
|
USE [{SqlServerFixture.TestDatabase}];
|
||||||
|
IF OBJECT_ID(N'dbo.IdxProbe') IS NULL
|
||||||
|
BEGIN
|
||||||
|
CREATE TABLE dbo.IdxProbe (Id int IDENTITY CONSTRAINT PK_IdxProbe PRIMARY KEY, CustomerId int NOT NULL, Status int NOT NULL, Note varchar(10) NOT NULL);
|
||||||
|
INSERT dbo.IdxProbe (CustomerId, Status, Note) SELECT TOP (20000) ABS(CHECKSUM(NEWID())) % 5000, 1, 'x' FROM sys.all_objects a CROSS JOIN sys.all_objects b;
|
||||||
|
CREATE INDEX IX_IdxProbe_Status ON dbo.IdxProbe (Status);
|
||||||
|
CREATE INDEX IX_IdxProbe_Status_Copy ON dbo.IdxProbe (Status);
|
||||||
|
CREATE TABLE dbo.HeapProbe (Id int NOT NULL, Payload varchar(4000) NULL);
|
||||||
|
INSERT dbo.HeapProbe (Id, Payload) SELECT TOP (2000) ROW_NUMBER() OVER (ORDER BY (SELECT 1)), 'a' FROM sys.all_objects;
|
||||||
|
UPDATE dbo.HeapProbe SET Payload = REPLICATE('b', 3000);
|
||||||
|
END;
|
||||||
|
""");
|
||||||
|
await using (var connection = new Microsoft.Data.SqlClient.SqlConnection(sql.DatabaseConnectionString(SqlServerFixture.TestDatabase)))
|
||||||
|
{
|
||||||
|
await connection.OpenAsync();
|
||||||
|
await using var command = connection.CreateCommand();
|
||||||
|
command.CommandText = """
|
||||||
|
DECLARE @i int = 0, @n int;
|
||||||
|
WHILE @i < 20 BEGIN SELECT @n = COUNT(*) FROM dbo.IdxProbe WHERE CustomerId = @i; SET @i += 1; END;
|
||||||
|
SELECT @n = COUNT(*) FROM dbo.HeapProbe WHERE Payload LIKE 'b%';
|
||||||
|
""";
|
||||||
|
await command.ExecuteNonQueryAsync();
|
||||||
|
}
|
||||||
|
|
||||||
|
await using var session = await AgentSession.OpenAsync(sql);
|
||||||
|
var snapshot = await session.CollectAsync<IndexSnapshot>(new IndexCollector());
|
||||||
|
|
||||||
|
var db = Assert.Single(snapshot.Databases, d => d.Name == SqlServerFixture.TestDatabase);
|
||||||
|
Assert.Null(db.Error);
|
||||||
|
var pk = Assert.Single(db.Indexes, i => i.Name == "PK_IdxProbe");
|
||||||
|
Assert.True(pk.IsPrimaryKey);
|
||||||
|
Assert.Equal(["Id"], pk.KeyColumns);
|
||||||
|
Assert.Equal(20000, pk.Rows);
|
||||||
|
Assert.True(pk.UserScans > 0);
|
||||||
|
Assert.Equal(2, db.Indexes.Count(i => i.Table == "IdxProbe" && i.KeyColumns.SequenceEqual(["Status"])));
|
||||||
|
Assert.Contains(db.MissingIndexes, m => m.Table.Contains("IdxProbe", StringComparison.Ordinal) && m.EqualityColumns == "[CustomerId]");
|
||||||
|
var heap = Assert.Single(db.Indexes, i => i.Table == "HeapProbe");
|
||||||
|
Assert.Equal("HEAP", heap.Type);
|
||||||
|
Assert.True(heap.ForwardedFetches > 0);
|
||||||
|
Assert.Contains(db.Statistics, s => s.Table == "IdxProbe" && s.Name == "PK_IdxProbe");
|
||||||
|
}
|
||||||
|
|
||||||
private static async Task<DeadlockSnapshot> WaitForDeadlockAsync(AgentSession session, DeadlockCollector collector, DeadlockSnapshot previous)
|
private static async Task<DeadlockSnapshot> WaitForDeadlockAsync(AgentSession session, DeadlockCollector collector, DeadlockSnapshot previous)
|
||||||
{
|
{
|
||||||
// system_health dispatches events to its targets with up to 120 seconds latency.
|
// system_health dispatches events to its targets with up to 120 seconds latency.
|
||||||
|
|
|
||||||
196
tests/SQLInsight.Rules.Tests/IndexRuleTests.cs
Normal file
196
tests/SQLInsight.Rules.Tests/IndexRuleTests.cs
Normal file
|
|
@ -0,0 +1,196 @@
|
||||||
|
using SQLInsight.Core.Data;
|
||||||
|
using SQLInsight.Core.Findings;
|
||||||
|
using SQLInsight.Rules.Engine;
|
||||||
|
using SQLInsight.Rules.Indexes;
|
||||||
|
using static SQLInsight.Rules.Tests.TestInstances;
|
||||||
|
|
||||||
|
namespace SQLInsight.Rules.Tests;
|
||||||
|
|
||||||
|
public class IndexRuleTests
|
||||||
|
{
|
||||||
|
private static RuleResult Run(IRule rule, RuleContext context) => Assert.Single(new RuleEngine([rule]).Evaluate(context));
|
||||||
|
|
||||||
|
private static IndexInfo Index(int id, string? name, string type, string[] keys, string[]? include = null, bool unique = false, bool pk = false,
|
||||||
|
long reads = 100, long updates = 100, long pages = 1000, long rows = 100_000, long forwarded = 0, string table = "Orders") => new()
|
||||||
|
{
|
||||||
|
Schema = "dbo", Table = table, IndexId = id, Name = name, Type = type, KeyColumns = keys, IncludedColumns = include ?? [],
|
||||||
|
IsUnique = unique || pk, IsPrimaryKey = pk, UserSeeks = reads, UserUpdates = updates, UsedPages = pages, Rows = rows, ForwardedFetches = forwarded,
|
||||||
|
};
|
||||||
|
|
||||||
|
private static RuleContext Indexes(TimeSpan uptime, IndexDatabase db) => With(new IndexSnapshot
|
||||||
|
{
|
||||||
|
CapturedAt = Now,
|
||||||
|
SqlServerStartTime = Now.UtcDateTime - uptime,
|
||||||
|
Databases = [db],
|
||||||
|
});
|
||||||
|
|
||||||
|
private static readonly TimeSpan Month = TimeSpan.FromDays(30);
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Missing_index_suggests_create_with_equality_columns_first()
|
||||||
|
{
|
||||||
|
var db = new IndexDatabase
|
||||||
|
{
|
||||||
|
Name = "Sales",
|
||||||
|
Indexes = [Index(1, "PK_Orders", "CLUSTERED", ["Id"], pk: true)],
|
||||||
|
MissingIndexes = [new MissingIndex { Table = "[Sales].[dbo].[Orders]", EqualityColumns = "[CustomerId]", InequalityColumns = "[OrderDate]", IncludedColumns = "[Total]", Seeks = 50_000, Scans = 0, AverageTotalUserCost = 12, AverageUserImpact = 95 }],
|
||||||
|
};
|
||||||
|
|
||||||
|
var finding = Assert.Single(Run(new MissingIndexRule(), Indexes(Month, db)).Findings);
|
||||||
|
|
||||||
|
Assert.Equal(Severity.Low, finding.Severity);
|
||||||
|
Assert.Contains("CREATE INDEX [IX_Orders_CustomerId_OrderDate] ON [dbo].[Orders] ([CustomerId], [OrderDate]) INCLUDE ([Total]);", finding.FixScript, StringComparison.Ordinal);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Missing_index_extends_an_existing_index_with_the_same_keys()
|
||||||
|
{
|
||||||
|
var db = new IndexDatabase
|
||||||
|
{
|
||||||
|
Name = "Sales",
|
||||||
|
Indexes = [Index(1, "PK_Orders", "CLUSTERED", ["Id"], pk: true), Index(2, "IX_Orders_CustomerId", "NONCLUSTERED", ["CustomerId"], ["Status"])],
|
||||||
|
MissingIndexes = [new MissingIndex { Table = "[Sales].[dbo].[Orders]", EqualityColumns = "[CustomerId]", IncludedColumns = "[Total]", Seeks = 100_000, Scans = 0, AverageTotalUserCost = 20, AverageUserImpact = 90 }],
|
||||||
|
};
|
||||||
|
|
||||||
|
var finding = Assert.Single(Run(new MissingIndexRule(), Indexes(Month, db)).Findings);
|
||||||
|
|
||||||
|
Assert.Contains("Udvid det eksisterende index IX_Orders_CustomerId", finding.Recommendation, StringComparison.Ordinal);
|
||||||
|
Assert.Contains("INCLUDE ([Status], [Total]) WITH (DROP_EXISTING = ON)", finding.FixScript, StringComparison.Ordinal);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Missing_index_warns_about_write_heavy_table()
|
||||||
|
{
|
||||||
|
var db = new IndexDatabase
|
||||||
|
{
|
||||||
|
Name = "Sales",
|
||||||
|
Indexes = [Index(1, "PK_Orders", "CLUSTERED", ["Id"], pk: true, reads: 10, updates: 1_000_000)],
|
||||||
|
MissingIndexes = [new MissingIndex { Table = "[Sales].[dbo].[Orders]", EqualityColumns = "[CustomerId]", Seeks = 50_000, Scans = 0, AverageTotalUserCost = 12, AverageUserImpact = 95 }],
|
||||||
|
};
|
||||||
|
|
||||||
|
var finding = Assert.Single(Run(new MissingIndexRule(), Indexes(Month, db)).Findings);
|
||||||
|
|
||||||
|
Assert.Contains(finding.Evidence, e => e.Value.Contains("skrives langt oftere", StringComparison.Ordinal));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Small_missing_index_suggestion_is_ignored()
|
||||||
|
{
|
||||||
|
var db = new IndexDatabase
|
||||||
|
{
|
||||||
|
Name = "Sales",
|
||||||
|
MissingIndexes = [new MissingIndex { Table = "[Sales].[dbo].[Orders]", EqualityColumns = "[CustomerId]", Seeks = 50, Scans = 0, AverageTotalUserCost = 0.5, AverageUserImpact = 40 }],
|
||||||
|
};
|
||||||
|
|
||||||
|
Assert.Equal(RuleOutcome.Passed, Run(new MissingIndexRule(), Indexes(Month, db)).Outcome);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Unused_index_needs_seven_days_of_uptime()
|
||||||
|
{
|
||||||
|
var db = new IndexDatabase { Name = "Sales", Indexes = [Index(2, "IX_Unused", "NONCLUSTERED", ["Status"], reads: 0, updates: 50_000)] };
|
||||||
|
|
||||||
|
Assert.Equal(RuleOutcome.NotMeasurable, Run(new UnusedIndexRule(), Indexes(TimeSpan.FromDays(2), db)).Outcome);
|
||||||
|
var finding = Assert.Single(Run(new UnusedIndexRule(), Indexes(Month, db)).Findings);
|
||||||
|
Assert.Contains("DISABLE", finding.FixScript, StringComparison.Ordinal);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Unique_and_primary_key_indexes_are_never_unused_candidates()
|
||||||
|
{
|
||||||
|
var db = new IndexDatabase
|
||||||
|
{
|
||||||
|
Name = "Sales",
|
||||||
|
Indexes = [Index(2, "UX_Code", "NONCLUSTERED", ["Code"], unique: true, reads: 0, updates: 50_000), Index(1, "PK", "CLUSTERED", ["Id"], pk: true, reads: 0, updates: 50_000)],
|
||||||
|
};
|
||||||
|
|
||||||
|
Assert.Equal(RuleOutcome.Passed, Run(new UnusedIndexRule(), Indexes(Month, db)).Outcome);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Theory]
|
||||||
|
[InlineData(new[] { "A" }, new string[0], new[] { "A" }, new string[0], true)] // exact duplicate
|
||||||
|
[InlineData(new[] { "A", "B" }, new string[0], new[] { "A" }, new string[0], true)] // left prefix
|
||||||
|
[InlineData(new[] { "A", "B" }, new[] { "C" }, new[] { "A" }, new[] { "C" }, true)] // include covered by include
|
||||||
|
[InlineData(new[] { "A", "B" }, new string[0], new[] { "A" }, new[] { "B" }, true)] // include covered by key
|
||||||
|
[InlineData(new[] { "A", "B" }, new string[0], new[] { "A" }, new[] { "C" }, false)] // include not covered
|
||||||
|
[InlineData(new[] { "B", "A" }, new string[0], new[] { "A" }, new string[0], false)] // not a leading prefix
|
||||||
|
public void Covers(string[] coverKeys, string[] coverInclude, string[] candidateKeys, string[] candidateInclude, bool expected)
|
||||||
|
{
|
||||||
|
var cover = Index(2, "IX_Cover", "NONCLUSTERED", coverKeys, coverInclude);
|
||||||
|
var candidate = Index(3, "IX_Candidate", "NONCLUSTERED", candidateKeys, candidateInclude);
|
||||||
|
|
||||||
|
Assert.Equal(expected, DuplicateIndexRule.Covers(cover, candidate));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Unique_index_is_not_covered_by_a_wider_index()
|
||||||
|
{
|
||||||
|
Assert.False(DuplicateIndexRule.Covers(Index(2, "IX_Wide", "NONCLUSTERED", ["A", "B"]), Index(3, "UX_A", "NONCLUSTERED", ["A"], unique: true)));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Filtered_indexes_only_cover_the_same_filter()
|
||||||
|
{
|
||||||
|
var filtered = Index(2, "IX_Active", "NONCLUSTERED", ["A"]) with { Filter = "([Active]=(1))" };
|
||||||
|
|
||||||
|
Assert.False(DuplicateIndexRule.Covers(filtered, Index(3, "IX_A", "NONCLUSTERED", ["A"])));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Duplicate_rule_reports_each_pair_once()
|
||||||
|
{
|
||||||
|
var db = new IndexDatabase
|
||||||
|
{
|
||||||
|
Name = "Sales",
|
||||||
|
Indexes =
|
||||||
|
[
|
||||||
|
Index(1, "PK_Orders", "CLUSTERED", ["Id"], pk: true),
|
||||||
|
Index(2, "IX_A", "NONCLUSTERED", ["CustomerId"]),
|
||||||
|
Index(3, "IX_B", "NONCLUSTERED", ["CustomerId"]),
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
var finding = Assert.Single(Run(new DuplicateIndexRule(), Indexes(Month, db)).Findings);
|
||||||
|
|
||||||
|
Assert.Equal(Severity.Medium, finding.Severity);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Heap_with_many_forwarded_fetches()
|
||||||
|
{
|
||||||
|
var db = new IndexDatabase { Name = "Sales", Indexes = [Index(0, null, "HEAP", [], forwarded: 250_000)] };
|
||||||
|
|
||||||
|
var finding = Assert.Single(Run(new ForwardedRecordsRule(), Indexes(Month, db)).Findings);
|
||||||
|
|
||||||
|
Assert.Contains("ALTER TABLE [dbo].[Orders] REBUILD;", finding.FixScript, StringComparison.Ordinal);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Stale_statistics_on_large_tables_only()
|
||||||
|
{
|
||||||
|
var db = new IndexDatabase
|
||||||
|
{
|
||||||
|
Name = "Sales",
|
||||||
|
Statistics =
|
||||||
|
[
|
||||||
|
new StatisticInfo { Schema = "dbo", Table = "Orders", Name = "IX_Orders_Date", Rows = 1_000_000, Modifications = 400_000 },
|
||||||
|
new StatisticInfo { Schema = "dbo", Table = "Small", Name = "PK_Small", Rows = 500, Modifications = 500 },
|
||||||
|
new StatisticInfo { Schema = "dbo", Table = "Stable", Name = "PK_Stable", Rows = 1_000_000, Modifications = 1_000 },
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
var finding = Assert.Single(Run(new StaleStatisticsRule(), Indexes(Month, db)).Findings);
|
||||||
|
|
||||||
|
Assert.Equal("statistic: Sales.dbo.Orders.IX_Orders_Date", finding.Subject);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Auto_statistics_off()
|
||||||
|
{
|
||||||
|
var off = Database(change: d => d with { IsAutoUpdateStatistics = false });
|
||||||
|
|
||||||
|
var finding = Assert.Single(Run(new AutoStatisticsRule(), With(DatabaseList(off))).Findings);
|
||||||
|
|
||||||
|
Assert.Equal("ALTER DATABASE [Sales] SET AUTO_UPDATE_STATISTICS ON;", finding.FixScript);
|
||||||
|
}
|
||||||
|
}
|
||||||
Loading…
Add table
Reference in a new issue