Fase 2: Query Store analysis (plan regressions, failed forced plans, parameter sensitivity)
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 reads the 100 heaviest queries per Query Store database, split into the last 24 hours and the week before. Rules: - SQLI-QS-001 plan regression: new plan >= 2x slower than the best plan from the week before and >= 60 s extra per day; scripts the force - SQLI-QS-002 forced plan that fails (e.g. NO_INDEX); scripts the unforce - SQLI-QS-003 parameter sensitivity: very different plans or volatile durations; suggests PSP optimization / Query Store hints on 2022+ Also: backups taken in the same second a database was created count. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01E3EExwndy8Z22EK2Qpt6BK
This commit is contained in:
parent
3c35f92785
commit
e4910aeba5
8 changed files with 564 additions and 7 deletions
|
|
@ -21,5 +21,6 @@ public static class BuiltInCollectors
|
||||||
new CpuCollector(),
|
new CpuCollector(),
|
||||||
new BlockingCollector(),
|
new BlockingCollector(),
|
||||||
new DeadlockCollector(),
|
new DeadlockCollector(),
|
||||||
|
new QueryStoreCollector(),
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -10,7 +10,8 @@ namespace SQLInsight.Collectors.Databases;
|
||||||
public sealed class DatabaseCollector : Collector<DatabaseSnapshot>
|
public sealed class DatabaseCollector : Collector<DatabaseSnapshot>
|
||||||
{
|
{
|
||||||
// Backups of an earlier database with the same name (dropped and recreated) must not count, hence the
|
// Backups of an earlier database with the same name (dropped and recreated) must not count, hence the
|
||||||
// create_date comparison.
|
// create_date comparison. backupset stores whole seconds, so allow one second: a backup taken in the same
|
||||||
|
// second the database was created must count.
|
||||||
private const string BackupJoin = """
|
private const string BackupJoin = """
|
||||||
LEFT JOIN (
|
LEFT JOIN (
|
||||||
SELECT database_name,
|
SELECT database_name,
|
||||||
|
|
@ -103,9 +104,9 @@ public sealed class DatabaseCollector : Collector<DatabaseSnapshot>
|
||||||
{agSecondary}
|
{agSecondary}
|
||||||
THEN 1 ELSE 0 END,
|
THEN 1 ELSE 0 END,
|
||||||
{inAg},
|
{inAg},
|
||||||
CASE WHEN b.last_full >= 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 >= 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 >= 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}
|
||||||
FROM sys.databases d
|
FROM sys.databases d
|
||||||
{BackupJoin}
|
{BackupJoin}
|
||||||
|
|
@ -126,9 +127,9 @@ public sealed class DatabaseCollector : Collector<DatabaseSnapshot>
|
||||||
d.crdate,
|
d.crdate,
|
||||||
0,
|
0,
|
||||||
0,
|
0,
|
||||||
CASE WHEN b.last_full >= 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 >= 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 >= 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)
|
||||||
FROM master.dbo.sysdatabases d
|
FROM master.dbo.sysdatabases d
|
||||||
{BackupJoin}
|
{BackupJoin}
|
||||||
|
|
|
||||||
121
src/SQLInsight.Collectors/Performance/QueryStoreCollector.cs
Normal file
121
src/SQLInsight.Collectors/Performance/QueryStoreCollector.cs
Normal file
|
|
@ -0,0 +1,121 @@
|
||||||
|
using System.Data.Common;
|
||||||
|
using SQLInsight.Core.Data;
|
||||||
|
using SQLInsight.Core.Instances;
|
||||||
|
|
||||||
|
namespace SQLInsight.Collectors.Performance;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Reads Query Store from every user database that has it on. Only the 100 heaviest queries (by total duration over
|
||||||
|
/// eight days) are returned per database, so the cost stays bounded even with a large Query Store.
|
||||||
|
/// </summary>
|
||||||
|
public sealed class QueryStoreCollector : Collector<QueryStoreSnapshot>
|
||||||
|
{
|
||||||
|
public const int TopQueries = 100;
|
||||||
|
|
||||||
|
internal const string DatabasesSql = """
|
||||||
|
SELECT name FROM sys.databases
|
||||||
|
WHERE is_query_store_on = 1 AND state = 0 AND database_id > 4
|
||||||
|
""";
|
||||||
|
|
||||||
|
public override string Name => "query-store";
|
||||||
|
public override TimeSpan DefaultInterval => TimeSpan.FromMinutes(30);
|
||||||
|
public override IReadOnlyCollection<Capability> RequiredCapabilities => [Capability.QueryStore];
|
||||||
|
|
||||||
|
protected override async Task<QueryStoreSnapshot> CollectAsync(CollectorContext context, QueryStoreSnapshot? previous)
|
||||||
|
{
|
||||||
|
var ct = context.CancellationToken;
|
||||||
|
var names = await Db.QueryAsync(context.Connection, DatabasesSql, r => r.String(0), ct).ConfigureAwait(false);
|
||||||
|
var databases = new List<QueryStoreDatabase>(names.Count);
|
||||||
|
foreach (var name in names)
|
||||||
|
{
|
||||||
|
databases.Add(await ReadAsync(context.Connection, name, ct).ConfigureAwait(false));
|
||||||
|
}
|
||||||
|
|
||||||
|
return new QueryStoreSnapshot { CapturedAt = context.Now, Databases = databases };
|
||||||
|
}
|
||||||
|
|
||||||
|
private static async Task<QueryStoreDatabase> ReadAsync(DbConnection connection, string database, CancellationToken ct)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var plans = await Db.QueryAsync(connection, BuildStatsSql(database), r => new QueryPlanStats
|
||||||
|
{
|
||||||
|
QueryId = r.Long(0),
|
||||||
|
PlanId = r.Long(1),
|
||||||
|
IsRecent = r.Bool(2),
|
||||||
|
Executions = r.Long(3),
|
||||||
|
TotalDurationUs = r.Double(4),
|
||||||
|
TotalCpuUs = r.Double(5),
|
||||||
|
TotalLogicalReads = r.Double(6),
|
||||||
|
MaxStdevDurationUs = r.Double(7),
|
||||||
|
MaxDurationUs = r.Double(8),
|
||||||
|
MinDurationUs = r.Double(9),
|
||||||
|
LastExecution = r.IsNull(10) ? null : new DateTimeOffset(DateTime.SpecifyKind(r.DateTime(10), DateTimeKind.Utc)),
|
||||||
|
IsForced = r.Bool(11),
|
||||||
|
QueryText = r.NullableString(12),
|
||||||
|
ObjectName = r.NullableString(13),
|
||||||
|
}, ct).ConfigureAwait(false);
|
||||||
|
|
||||||
|
var failed = await Db.QueryAsync(connection, BuildFailedForcedSql(database),
|
||||||
|
r => new FailedForcedPlan(r.Long(0), r.Long(1), r.Long(2), r.NullableString(3), r.NullableString(4)), ct).ConfigureAwait(false);
|
||||||
|
|
||||||
|
return new QueryStoreDatabase { Name = database, Plans = plans, FailedForcedPlans = failed };
|
||||||
|
}
|
||||||
|
catch (DbException ex)
|
||||||
|
{
|
||||||
|
return new QueryStoreDatabase { Name = database, Plans = [], Error = ex.Message };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
internal static string BuildStatsSql(string database)
|
||||||
|
{
|
||||||
|
var db = Db.Identifier(database);
|
||||||
|
return $"""
|
||||||
|
DECLARE @now datetimeoffset = SYSDATETIMEOFFSET();
|
||||||
|
DECLARE @recent datetimeoffset = DATEADD(hour, -24, @now);
|
||||||
|
DECLARE @history datetimeoffset = DATEADD(day, -8, @now);
|
||||||
|
|
||||||
|
WITH stats AS (
|
||||||
|
SELECT p.query_id, p.plan_id,
|
||||||
|
CASE WHEN i.start_time >= @recent THEN 1 ELSE 0 END AS is_recent,
|
||||||
|
SUM(rs.count_executions) AS execs,
|
||||||
|
SUM(rs.avg_duration * rs.count_executions) AS total_duration,
|
||||||
|
SUM(rs.avg_cpu_time * rs.count_executions) AS total_cpu,
|
||||||
|
SUM(rs.avg_logical_io_reads * rs.count_executions) AS total_reads,
|
||||||
|
MAX(rs.stdev_duration) AS max_stdev_duration,
|
||||||
|
MAX(rs.max_duration) AS max_duration,
|
||||||
|
MIN(rs.min_duration) AS min_duration,
|
||||||
|
MAX(rs.last_execution_time) AS last_execution
|
||||||
|
FROM {db}.sys.query_store_runtime_stats rs
|
||||||
|
JOIN {db}.sys.query_store_runtime_stats_interval i ON i.runtime_stats_interval_id = rs.runtime_stats_interval_id
|
||||||
|
JOIN {db}.sys.query_store_plan p ON p.plan_id = rs.plan_id
|
||||||
|
WHERE i.start_time >= @history
|
||||||
|
GROUP BY p.query_id, p.plan_id, CASE WHEN i.start_time >= @recent THEN 1 ELSE 0 END
|
||||||
|
), top_queries AS (
|
||||||
|
SELECT TOP ({TopQueries}) query_id FROM stats GROUP BY query_id ORDER BY SUM(total_duration) DESC
|
||||||
|
)
|
||||||
|
SELECT s.query_id, s.plan_id, s.is_recent, s.execs, s.total_duration, s.total_cpu, s.total_reads,
|
||||||
|
s.max_stdev_duration, s.max_duration, s.min_duration,
|
||||||
|
CAST(SWITCHOFFSET(s.last_execution, '+00:00') AS datetime2(0)),
|
||||||
|
p.is_forced_plan, LEFT(qt.query_sql_text, 2000),
|
||||||
|
OBJECT_SCHEMA_NAME(q.object_id, DB_ID({Db.Literal(database)})) + N'.' + OBJECT_NAME(q.object_id, DB_ID({Db.Literal(database)}))
|
||||||
|
FROM stats s
|
||||||
|
JOIN top_queries t ON t.query_id = s.query_id
|
||||||
|
JOIN {db}.sys.query_store_plan p ON p.plan_id = s.plan_id
|
||||||
|
JOIN {db}.sys.query_store_query q ON q.query_id = s.query_id
|
||||||
|
JOIN {db}.sys.query_store_query_text qt ON qt.query_text_id = q.query_text_id;
|
||||||
|
""";
|
||||||
|
}
|
||||||
|
|
||||||
|
internal static string BuildFailedForcedSql(string database)
|
||||||
|
{
|
||||||
|
var db = Db.Identifier(database);
|
||||||
|
return $"""
|
||||||
|
SELECT p.query_id, p.plan_id, p.force_failure_count, p.last_force_failure_reason_desc, LEFT(qt.query_sql_text, 2000)
|
||||||
|
FROM {db}.sys.query_store_plan p
|
||||||
|
JOIN {db}.sys.query_store_query q ON q.query_id = p.query_id
|
||||||
|
JOIN {db}.sys.query_store_query_text qt ON qt.query_text_id = q.query_text_id
|
||||||
|
WHERE p.is_forced_plan = 1 AND p.force_failure_count > 0;
|
||||||
|
""";
|
||||||
|
}
|
||||||
|
}
|
||||||
55
src/SQLInsight.Core/Data/QueryStore.cs
Normal file
55
src/SQLInsight.Core/Data/QueryStore.cs
Normal file
|
|
@ -0,0 +1,55 @@
|
||||||
|
namespace SQLInsight.Core.Data;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Query Store statistics per database (2016+): the heaviest queries of the last eight days, split into the last
|
||||||
|
/// 24 hours and the week before, so plan changes and regressions can be seen.
|
||||||
|
/// </summary>
|
||||||
|
public sealed record QueryStoreSnapshot
|
||||||
|
{
|
||||||
|
public required DateTimeOffset CapturedAt { get; init; }
|
||||||
|
public required IReadOnlyList<QueryStoreDatabase> Databases { get; init; }
|
||||||
|
}
|
||||||
|
|
||||||
|
public sealed record QueryStoreDatabase
|
||||||
|
{
|
||||||
|
public required string Name { get; init; }
|
||||||
|
public required IReadOnlyList<QueryPlanStats> Plans { get; init; }
|
||||||
|
public IReadOnlyList<FailedForcedPlan> FailedForcedPlans { get; init; } = [];
|
||||||
|
|
||||||
|
/// <summary>Set when this database's Query Store could not be read.</summary>
|
||||||
|
public string? Error { get; init; }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// One plan of one query in one window. Durations and CPU are in microseconds, as Query Store stores them.
|
||||||
|
/// </summary>
|
||||||
|
public sealed record QueryPlanStats
|
||||||
|
{
|
||||||
|
public required long QueryId { get; init; }
|
||||||
|
public required long PlanId { get; init; }
|
||||||
|
|
||||||
|
/// <summary>True for the last 24 hours, false for the seven days before.</summary>
|
||||||
|
public required bool IsRecent { get; init; }
|
||||||
|
|
||||||
|
public required long Executions { get; init; }
|
||||||
|
public required double TotalDurationUs { get; init; }
|
||||||
|
public required double TotalCpuUs { get; init; }
|
||||||
|
public required double TotalLogicalReads { get; init; }
|
||||||
|
|
||||||
|
/// <summary>The largest per-interval standard deviation of duration (an approximation across intervals).</summary>
|
||||||
|
public required double MaxStdevDurationUs { get; init; }
|
||||||
|
|
||||||
|
public required double MaxDurationUs { get; init; }
|
||||||
|
public required double MinDurationUs { get; init; }
|
||||||
|
public DateTimeOffset? LastExecution { get; init; }
|
||||||
|
public bool IsForced { get; init; }
|
||||||
|
public string? QueryText { get; init; }
|
||||||
|
public string? ObjectName { get; init; }
|
||||||
|
|
||||||
|
public double AverageDurationUs => Executions == 0 ? 0 : TotalDurationUs / Executions;
|
||||||
|
public double AverageCpuUs => Executions == 0 ? 0 : TotalCpuUs / Executions;
|
||||||
|
public double AverageLogicalReads => Executions == 0 ? 0 : TotalLogicalReads / Executions;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>A plan forced with sp_query_store_force_plan that SQL Server could not use.</summary>
|
||||||
|
public sealed record FailedForcedPlan(long QueryId, long PlanId, long FailureCount, string? Reason, string? QueryText);
|
||||||
|
|
@ -45,6 +45,9 @@ public static class BuiltInRules
|
||||||
new LogFileWriteLatencyRule(),
|
new LogFileWriteLatencyRule(),
|
||||||
new CpuRule(),
|
new CpuRule(),
|
||||||
new MemoryGrantsPendingRule(),
|
new MemoryGrantsPendingRule(),
|
||||||
|
new PlanRegressionRule(),
|
||||||
|
new FailedForcedPlanRule(),
|
||||||
|
new ParameterSensitivityRule(),
|
||||||
|
|
||||||
// Concurrency
|
// Concurrency
|
||||||
new BlockingRule(),
|
new BlockingRule(),
|
||||||
|
|
|
||||||
205
src/SQLInsight.Rules/Performance/QueryStoreRules.cs
Normal file
205
src/SQLInsight.Rules/Performance/QueryStoreRules.cs
Normal file
|
|
@ -0,0 +1,205 @@
|
||||||
|
using System.Globalization;
|
||||||
|
using SQLInsight.Core.Data;
|
||||||
|
using SQLInsight.Core.Findings;
|
||||||
|
using SQLInsight.Core.Instances;
|
||||||
|
using SQLInsight.Rules.Engine;
|
||||||
|
|
||||||
|
namespace SQLInsight.Rules.Performance;
|
||||||
|
|
||||||
|
internal static class QueryStoreText
|
||||||
|
{
|
||||||
|
public const int MaxFindingsPerRule = 10;
|
||||||
|
|
||||||
|
public static string Ms(double microseconds) => $"{Format.Number(microseconds / 1000, microseconds < 10_000 ? 1 : 0)} ms";
|
||||||
|
|
||||||
|
public static string Seconds(double microseconds) => $"{Format.Number(microseconds / 1_000_000)} s";
|
||||||
|
|
||||||
|
public static string Describe(QueryPlanStats plan) =>
|
||||||
|
plan.ObjectName is { Length: > 0 } name ? $"{name}: {Shorten(plan.QueryText)}" : Shorten(plan.QueryText);
|
||||||
|
|
||||||
|
public static string Shorten(string? text)
|
||||||
|
{
|
||||||
|
var flat = string.Join(' ', (text ?? string.Empty).Split((char[]?)null, StringSplitOptions.RemoveEmptyEntries));
|
||||||
|
return flat.Length <= 300 ? flat : flat[..300] + " …";
|
||||||
|
}
|
||||||
|
|
||||||
|
public static string Use(string database) => $"USE [{database.Replace("]", "]]", StringComparison.Ordinal)}];";
|
||||||
|
|
||||||
|
public static Reference Docs => Engine.Docs.Learn("Query Store usage scenarios", "relational-databases/performance/query-store-usage-scenarios");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// A query got a new plan in the last 24 hours that is much slower than its best plan from the week before.
|
||||||
|
/// The classic case for forcing the old plan.
|
||||||
|
/// </summary>
|
||||||
|
public sealed class PlanRegressionRule : Rule
|
||||||
|
{
|
||||||
|
public const int MinExecutions = 10;
|
||||||
|
public const double MinSlowdown = 2.0;
|
||||||
|
|
||||||
|
/// <summary>Extra time per day the regression must cost before it is worth reporting.</summary>
|
||||||
|
public const double MinExtraDurationUs = 60_000_000;
|
||||||
|
|
||||||
|
public override string Id => "SQLI-QS-001";
|
||||||
|
public override string Title => "Planregression: forespørgsel har fået en langsommere plan";
|
||||||
|
public override IReadOnlyCollection<Capability> RequiredCapabilities => [Capability.QueryStore];
|
||||||
|
|
||||||
|
public override RuleEvaluation Evaluate(RuleContext context)
|
||||||
|
{
|
||||||
|
ArgumentNullException.ThrowIfNull(context);
|
||||||
|
var findings = new List<(double Extra, Finding Finding)>();
|
||||||
|
foreach (var database in context.Data.Get<QueryStoreSnapshot>().Databases)
|
||||||
|
{
|
||||||
|
foreach (var query in database.Plans.GroupBy(p => p.QueryId))
|
||||||
|
{
|
||||||
|
var best = query.Where(p => !p.IsRecent && p.Executions >= MinExecutions).MinBy(p => p.AverageDurationUs);
|
||||||
|
if (best is null)
|
||||||
|
{
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// A list, not MaxBy on the sequence: MaxBy throws on an empty sequence of value tuples.
|
||||||
|
var candidates = query
|
||||||
|
.Where(p => p.IsRecent && p.PlanId != best.PlanId && p.Executions >= MinExecutions && p.AverageDurationUs >= best.AverageDurationUs * MinSlowdown)
|
||||||
|
.Select(p => (Plan: p, Extra: (p.AverageDurationUs - best.AverageDurationUs) * p.Executions))
|
||||||
|
.Where(x => x.Extra >= MinExtraDurationUs)
|
||||||
|
.ToList();
|
||||||
|
if (candidates.Count == 0)
|
||||||
|
{
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
var worst = candidates.MaxBy(x => x.Extra);
|
||||||
|
var slow = worst.Plan;
|
||||||
|
|
||||||
|
var factor = slow.AverageDurationUs / best.AverageDurationUs;
|
||||||
|
findings.Add((worst.Extra, CreateFinding(
|
||||||
|
worst.Extra >= 10 * MinExtraDurationUs ? Severity.High : Severity.Medium,
|
||||||
|
$"{database.Name}: query {query.Key} er blevet {Format.Number(factor, 1)}× langsommere med plan {slow.PlanId}",
|
||||||
|
"Forespørgslen har inden for det seneste døgn brugt en ny plan, som er markant langsommere end den bedste plan "
|
||||||
|
+ "fra ugen før. Det skyldes typisk, at planen er genkompileret med andre parameterværdier (parameter sniffing), "
|
||||||
|
+ "efter opdaterede statistikker eller efter en ændring af indexes. Den ekstra tid rammer hver eneste udførelse.",
|
||||||
|
$"Sammenlign planerne i SSMS (Query Store → Regressed Queries). Er plan {best.PlanId} god i alle tilfælde, kan den "
|
||||||
|
+ "forceres som akut løsning, og årsagen findes bagefter. Følg op på, om forceringen lykkes (se SQLI-QS-002). "
|
||||||
|
+ "På Enterprise Edition kan automatic tuning (FORCE_LAST_GOOD_PLAN) gøre dette automatisk.",
|
||||||
|
[
|
||||||
|
new Evidence("Forespørgsel", QueryStoreText.Describe(slow.QueryText is null ? best : slow)),
|
||||||
|
new Evidence($"Ny plan {slow.PlanId} (seneste døgn)", $"{Format.Number(slow.Executions)} udførelser, gns. {QueryStoreText.Ms(slow.AverageDurationUs)}, {Format.Number(slow.AverageLogicalReads)} læsninger"),
|
||||||
|
new Evidence($"Bedste plan {best.PlanId} (ugen før)", $"{Format.Number(best.Executions)} udførelser, gns. {QueryStoreText.Ms(best.AverageDurationUs)}, {Format.Number(best.AverageLogicalReads)} læsninger"),
|
||||||
|
new Evidence("Ekstra tid seneste døgn", QueryStoreText.Seconds(worst.Extra)),
|
||||||
|
],
|
||||||
|
subject: $"query: {database.Name}/{query.Key}",
|
||||||
|
fixScript: $"{QueryStoreText.Use(database.Name)}\nEXEC sys.sp_query_store_force_plan @query_id = {query.Key}, @plan_id = {best.PlanId};",
|
||||||
|
references: QueryStoreText.Docs)));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return Result(findings.OrderByDescending(f => f.Extra).Take(QueryStoreText.MaxFindingsPerRule).Select(f => f.Finding));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// A plan forced in Query Store that SQL Server can no longer use (e.g. an index it needs was dropped).
|
||||||
|
/// </summary>
|
||||||
|
public sealed class FailedForcedPlanRule : Rule
|
||||||
|
{
|
||||||
|
public override string Id => "SQLI-QS-002";
|
||||||
|
public override string Title => "Forceret plan kan ikke bruges";
|
||||||
|
public override IReadOnlyCollection<Capability> RequiredCapabilities => [Capability.QueryStore];
|
||||||
|
|
||||||
|
public override RuleEvaluation Evaluate(RuleContext context)
|
||||||
|
{
|
||||||
|
ArgumentNullException.ThrowIfNull(context);
|
||||||
|
return Result(context.Data.Get<QueryStoreSnapshot>().Databases.SelectMany(db => db.FailedForcedPlans.Select(f => CreateFinding(
|
||||||
|
Severity.High,
|
||||||
|
$"{db.Name}: forceret plan {f.PlanId} for query {f.QueryId} fejler ({f.Reason ?? "ukendt årsag"})",
|
||||||
|
"Planen er forceret i Query Store, men SQL Server kan ikke genskabe den, typisk fordi et index, den bruger, er slettet "
|
||||||
|
+ "eller omdøbt (NO_INDEX), eller fordi skemaet er ændret. Forespørgslen kører så med en ny plan, måske netop den "
|
||||||
|
+ "dårlige, forceringen skulle forhindre, og hver kompilering bruger ekstra tid på det mislykkede forsøg.",
|
||||||
|
f.Reason == "NO_INDEX"
|
||||||
|
? "Genopret det index, planen bruger (se planens XML), eller fjern forceringen og find en ny god plan."
|
||||||
|
: "Fjern forceringen, og vurdér forespørgslen igen. Se last_force_failure_reason_desc i sys.query_store_plan.",
|
||||||
|
[
|
||||||
|
new Evidence("Forespørgsel", QueryStoreText.Shorten(f.QueryText)),
|
||||||
|
new Evidence("Fejl", $"{Format.Number(f.FailureCount)} gange, senest {f.Reason}"),
|
||||||
|
],
|
||||||
|
subject: $"query: {db.Name}/{f.QueryId}",
|
||||||
|
fixScript: $"{QueryStoreText.Use(db.Name)}\nEXEC sys.sp_query_store_unforce_plan @query_id = {f.QueryId}, @plan_id = {f.PlanId};",
|
||||||
|
references: QueryStoreText.Docs))).Take(QueryStoreText.MaxFindingsPerRule));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// A query whose duration swings wildly between executions, or that alternates between very different plans:
|
||||||
|
/// the signature of parameter sensitivity (parameter sniffing).
|
||||||
|
/// </summary>
|
||||||
|
public sealed class ParameterSensitivityRule : Rule
|
||||||
|
{
|
||||||
|
public const int MinExecutions = 100;
|
||||||
|
public const double MinMaxDurationUs = 1_000_000;
|
||||||
|
|
||||||
|
public override string Id => "SQLI-QS-003";
|
||||||
|
public override string Title => "Parameterfølsom forespørgsel (parameter sniffing)";
|
||||||
|
public override IReadOnlyCollection<Capability> RequiredCapabilities => [Capability.QueryStore];
|
||||||
|
|
||||||
|
public override RuleEvaluation Evaluate(RuleContext context)
|
||||||
|
{
|
||||||
|
ArgumentNullException.ThrowIfNull(context);
|
||||||
|
var version = context.Instance.Version;
|
||||||
|
var findings = new List<(double Weight, Finding Finding)>();
|
||||||
|
foreach (var database in context.Data.Get<QueryStoreSnapshot>().Databases)
|
||||||
|
{
|
||||||
|
foreach (var query in database.Plans.Where(p => p.IsRecent).GroupBy(p => p.QueryId))
|
||||||
|
{
|
||||||
|
var plans = query.Where(p => p.Executions >= 10).ToList();
|
||||||
|
var busiest = plans.MaxBy(p => p.Executions);
|
||||||
|
if (busiest is null)
|
||||||
|
{
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
var fastest = plans.Min(p => p.AverageDurationUs);
|
||||||
|
var slowest = plans.Max(p => p.AverageDurationUs);
|
||||||
|
var multiplePlans = plans.Count >= 2 && fastest > 0 && slowest / fastest >= 10 && slowest >= 100_000;
|
||||||
|
var volatileDuration = busiest.Executions >= MinExecutions && busiest.MaxDurationUs >= MinMaxDurationUs
|
||||||
|
&& busiest.AverageDurationUs > 0
|
||||||
|
&& busiest.MaxDurationUs / busiest.AverageDurationUs >= 20
|
||||||
|
&& busiest.MaxStdevDurationUs / busiest.AverageDurationUs >= 2;
|
||||||
|
if (!multiplePlans && !volatileDuration)
|
||||||
|
{
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
var evidence = new List<Evidence> { new("Forespørgsel", QueryStoreText.Describe(busiest)) };
|
||||||
|
evidence.AddRange(plans.OrderBy(p => p.AverageDurationUs).Select(p => new Evidence(
|
||||||
|
$"Plan {p.PlanId}",
|
||||||
|
$"{Format.Number(p.Executions)} udførelser, gns. {QueryStoreText.Ms(p.AverageDurationUs)}, min. {QueryStoreText.Ms(p.MinDurationUs)}, maks. {QueryStoreText.Ms(p.MaxDurationUs)}")));
|
||||||
|
|
||||||
|
var fix = version.Major >= 16
|
||||||
|
? $"{QueryStoreText.Use(database.Name)}\n-- Midlertidigt, uden kodeændring (SQL Server 2022+):\nEXEC sys.sp_query_store_set_hints @query_id = {query.Key}, @query_hints = N'OPTION (RECOMPILE)';"
|
||||||
|
: null;
|
||||||
|
|
||||||
|
findings.Add((plans.Sum(p => p.TotalDurationUs), CreateFinding(
|
||||||
|
Severity.Medium,
|
||||||
|
$"{database.Name}: query {query.Key} har meget svingende svartider",
|
||||||
|
multiplePlans
|
||||||
|
? $"Forespørgslen har det seneste døgn brugt {plans.Count} planer, hvor den langsomste i gennemsnit er {Format.Number(slowest / fastest)}× langsommere end den hurtigste. "
|
||||||
|
+ "Planen vælges ud fra parameterværdierne ved kompilering; passer den ikke til de næste værdier, bliver det langsomt."
|
||||||
|
: $"Den samme plan tager i gennemsnit {QueryStoreText.Ms(busiest.AverageDurationUs)}, men op til {QueryStoreText.Ms(busiest.MaxDurationUs)}. "
|
||||||
|
+ "Så store udsving skyldes typisk skævt fordelte data, hvor én plan ikke passer til alle parameterværdier.",
|
||||||
|
(version.Major >= 16
|
||||||
|
? "På SQL Server 2022 med compatibility level 160 kan Parameter Sensitive Plan optimization løse det automatisk. "
|
||||||
|
: string.Empty)
|
||||||
|
+ "Ellers: vælg ud fra, hvor ofte forespørgslen kører: OPTION (RECOMPILE) til sjældne, tunge forespørgsler; "
|
||||||
|
+ "OPTIMIZE FOR til en repræsentativ værdi; eller del forespørgslen op efter datafordelingen. "
|
||||||
|
+ "Query Store hints (2022+) kan afprøve et hint uden kodeændring.",
|
||||||
|
evidence,
|
||||||
|
subject: $"query: {database.Name}/{query.Key}",
|
||||||
|
fixScript: fix,
|
||||||
|
references: Docs.Learn("Parameter Sensitive Plan optimization", "relational-databases/performance/parameter-sensitive-plan-optimization"))));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return Result(findings.OrderByDescending(f => f.Weight).Take(QueryStoreText.MaxFindingsPerRule).Select(f => f.Finding));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -185,6 +185,47 @@ public class CollectorTests(SqlServerFixture sql)
|
||||||
Assert.Empty(again.NewDeadlocks);
|
Assert.Empty(again.NewDeadlocks);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Query_store_reports_plans_and_failed_forced_plans()
|
||||||
|
{
|
||||||
|
// Force a plan that uses an index, then drop the index: forcing fails with NO_INDEX.
|
||||||
|
// The statement lives in a procedure so it is not auto-parameterized and can be found by object. The data is
|
||||||
|
// selective enough that the optimizer seeks on the index by itself.
|
||||||
|
await sql.ExecuteAsSaAsync($"""
|
||||||
|
USE [{SqlServerFixture.TestDatabase}];
|
||||||
|
IF OBJECT_ID(N'dbo.QsProbe') IS NULL
|
||||||
|
BEGIN
|
||||||
|
CREATE TABLE dbo.QsProbe (Id int IDENTITY PRIMARY KEY, Code int NOT NULL, Filler char(200) NOT NULL DEFAULT 'x');
|
||||||
|
INSERT dbo.QsProbe (Code) SELECT TOP (50000) ABS(CHECKSUM(NEWID())) % 20000 FROM sys.all_objects a CROSS JOIN sys.all_objects b;
|
||||||
|
END;
|
||||||
|
IF INDEXPROPERTY(OBJECT_ID(N'dbo.QsProbe'), N'IX_QsProbe_Code', 'IndexID') IS NULL
|
||||||
|
CREATE INDEX IX_QsProbe_Code ON dbo.QsProbe (Code);
|
||||||
|
""");
|
||||||
|
await sql.ExecuteAsSaAsync($"USE [{SqlServerFixture.TestDatabase}]; EXEC (N'CREATE OR ALTER PROCEDURE dbo.QsProbeProc @code int AS SELECT Id, Filler FROM dbo.QsProbe WHERE Code = @code;');");
|
||||||
|
await sql.ExecuteAsSaAsync($"USE [{SqlServerFixture.TestDatabase}]; EXEC dbo.QsProbeProc 1; EXEC dbo.QsProbeProc 2; EXEC dbo.QsProbeProc 3; EXEC dbo.QsProbeProc 4; EXEC dbo.QsProbeProc 5; EXEC sys.sp_query_store_flush_db;");
|
||||||
|
|
||||||
|
await sql.ExecuteAsSaAsync($"""
|
||||||
|
USE [{SqlServerFixture.TestDatabase}];
|
||||||
|
DECLARE @q bigint, @p bigint;
|
||||||
|
SELECT TOP (1) @q = q.query_id, @p = p.plan_id
|
||||||
|
FROM sys.query_store_query q JOIN sys.query_store_plan p ON p.query_id = q.query_id
|
||||||
|
WHERE q.object_id = OBJECT_ID(N'dbo.QsProbeProc');
|
||||||
|
EXEC sys.sp_query_store_force_plan @q, @p;
|
||||||
|
DROP INDEX IX_QsProbe_Code ON dbo.QsProbe;
|
||||||
|
""");
|
||||||
|
await sql.ExecuteAsSaAsync($"USE [{SqlServerFixture.TestDatabase}]; EXEC dbo.QsProbeProc 42; EXEC sys.sp_query_store_flush_db;");
|
||||||
|
|
||||||
|
await using var session = await AgentSession.OpenAsync(sql);
|
||||||
|
var snapshot = await session.CollectAsync<QueryStoreSnapshot>(new QueryStoreCollector());
|
||||||
|
|
||||||
|
var db = Assert.Single(snapshot.Databases, d => d.Name == SqlServerFixture.TestDatabase);
|
||||||
|
Assert.Null(db.Error);
|
||||||
|
var probe = Assert.Single(db.Plans, p => p.IsRecent && p.ObjectName == "dbo.QsProbeProc" && p.Executions >= 5);
|
||||||
|
Assert.True(probe.IsForced);
|
||||||
|
var failed = Assert.Single(db.FailedForcedPlans);
|
||||||
|
Assert.Equal("NO_INDEX", failed.Reason);
|
||||||
|
}
|
||||||
|
|
||||||
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.
|
||||||
|
|
|
||||||
130
tests/SQLInsight.Rules.Tests/QueryStoreRuleTests.cs
Normal file
130
tests/SQLInsight.Rules.Tests/QueryStoreRuleTests.cs
Normal file
|
|
@ -0,0 +1,130 @@
|
||||||
|
using SQLInsight.Core.Data;
|
||||||
|
using SQLInsight.Core.Findings;
|
||||||
|
using SQLInsight.Rules.Engine;
|
||||||
|
using SQLInsight.Rules.Performance;
|
||||||
|
using static SQLInsight.Rules.Tests.TestInstances;
|
||||||
|
|
||||||
|
namespace SQLInsight.Rules.Tests;
|
||||||
|
|
||||||
|
public class QueryStoreRuleTests
|
||||||
|
{
|
||||||
|
private static RuleResult Run(IRule rule, RuleContext context) => Assert.Single(new RuleEngine([rule]).Evaluate(context));
|
||||||
|
|
||||||
|
private static QueryPlanStats Plan(long query, long plan, bool recent, long execs, double avgMs, double maxMs = 0, double stdevMs = 0) => new()
|
||||||
|
{
|
||||||
|
QueryId = query,
|
||||||
|
PlanId = plan,
|
||||||
|
IsRecent = recent,
|
||||||
|
Executions = execs,
|
||||||
|
TotalDurationUs = execs * avgMs * 1000,
|
||||||
|
TotalCpuUs = execs * avgMs * 800,
|
||||||
|
TotalLogicalReads = execs * 1000,
|
||||||
|
MaxStdevDurationUs = stdevMs * 1000,
|
||||||
|
MaxDurationUs = (maxMs == 0 ? avgMs : maxMs) * 1000,
|
||||||
|
MinDurationUs = avgMs * 500,
|
||||||
|
QueryText = "SELECT * FROM dbo.Orders WHERE CustomerId = @p0",
|
||||||
|
ObjectName = "dbo.GetOrders",
|
||||||
|
};
|
||||||
|
|
||||||
|
private static RuleContext Store(params QueryPlanStats[] plans) =>
|
||||||
|
With(new QueryStoreSnapshot { CapturedAt = Now, Databases = [new QueryStoreDatabase { Name = "Sales", Plans = plans }] });
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void New_slow_plan_is_a_regression_with_force_script()
|
||||||
|
{
|
||||||
|
var result = Run(new PlanRegressionRule(), Store(
|
||||||
|
Plan(7, 1, recent: false, execs: 5000, avgMs: 20),
|
||||||
|
Plan(7, 1, recent: true, execs: 200, avgMs: 20),
|
||||||
|
Plan(7, 2, recent: true, execs: 3000, avgMs: 400)));
|
||||||
|
|
||||||
|
var finding = Assert.Single(result.Findings);
|
||||||
|
Assert.Equal(Severity.High, finding.Severity); // 3000 × 380 ms = 1140 s extra
|
||||||
|
Assert.Equal("query: Sales/7", finding.Subject);
|
||||||
|
Assert.Contains("@query_id = 7, @plan_id = 1", finding.FixScript, StringComparison.Ordinal);
|
||||||
|
Assert.StartsWith("USE [Sales];", finding.FixScript, StringComparison.Ordinal);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Slowdown_too_small_to_matter_is_ignored()
|
||||||
|
{
|
||||||
|
// 3× slower, but only 20 executions × 40 ms = 0.8 s extra per day.
|
||||||
|
var result = Run(new PlanRegressionRule(), Store(
|
||||||
|
Plan(7, 1, recent: false, execs: 100, avgMs: 20),
|
||||||
|
Plan(7, 2, recent: true, execs: 20, avgMs: 60)));
|
||||||
|
|
||||||
|
Assert.Equal(RuleOutcome.Passed, result.Outcome);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Same_plan_getting_slower_is_not_a_plan_regression()
|
||||||
|
{
|
||||||
|
var result = Run(new PlanRegressionRule(), Store(
|
||||||
|
Plan(7, 1, recent: false, execs: 5000, avgMs: 20),
|
||||||
|
Plan(7, 1, recent: true, execs: 5000, avgMs: 200)));
|
||||||
|
|
||||||
|
Assert.Equal(RuleOutcome.Passed, result.Outcome);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Failed_forced_plan_is_high_with_unforce_script()
|
||||||
|
{
|
||||||
|
var context = With(new QueryStoreSnapshot
|
||||||
|
{
|
||||||
|
CapturedAt = Now,
|
||||||
|
Databases = [new QueryStoreDatabase { Name = "Sales", Plans = [], FailedForcedPlans = [new FailedForcedPlan(7, 3, 12, "NO_INDEX", "SELECT 1")] }],
|
||||||
|
});
|
||||||
|
|
||||||
|
var finding = Assert.Single(Run(new FailedForcedPlanRule(), context).Findings);
|
||||||
|
|
||||||
|
Assert.Equal(Severity.High, finding.Severity);
|
||||||
|
Assert.Contains("Genopret det index", finding.Recommendation, StringComparison.Ordinal);
|
||||||
|
Assert.Contains("sp_query_store_unforce_plan @query_id = 7, @plan_id = 3", finding.FixScript, StringComparison.Ordinal);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Plans_with_very_different_durations_indicate_parameter_sensitivity()
|
||||||
|
{
|
||||||
|
var finding = Assert.Single(Run(new ParameterSensitivityRule(), Store(
|
||||||
|
Plan(9, 1, recent: true, execs: 900, avgMs: 5),
|
||||||
|
Plan(9, 2, recent: true, execs: 100, avgMs: 2000))).Findings);
|
||||||
|
|
||||||
|
Assert.Contains("2 planer", finding.Explanation, StringComparison.Ordinal);
|
||||||
|
Assert.Contains("sp_query_store_set_hints", finding.FixScript, StringComparison.Ordinal); // 2022 test instance
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Volatile_single_plan_indicates_parameter_sensitivity()
|
||||||
|
{
|
||||||
|
var result = Run(new ParameterSensitivityRule(), Store(Plan(9, 1, recent: true, execs: 5000, avgMs: 50, maxMs: 8000, stdevMs: 300)));
|
||||||
|
|
||||||
|
Assert.Equal(RuleOutcome.Findings, result.Outcome);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Stable_query_is_not_parameter_sensitive()
|
||||||
|
{
|
||||||
|
var result = Run(new ParameterSensitivityRule(), Store(Plan(9, 1, recent: true, execs: 5000, avgMs: 50, maxMs: 120, stdevMs: 10)));
|
||||||
|
|
||||||
|
Assert.Equal(RuleOutcome.Passed, result.Outcome);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void No_hints_script_before_2022()
|
||||||
|
{
|
||||||
|
var context = WithVersion("15.0.4490.9", new QueryStoreSnapshot
|
||||||
|
{
|
||||||
|
CapturedAt = Now,
|
||||||
|
Databases = [new QueryStoreDatabase { Name = "Sales", Plans = [Plan(9, 1, true, 900, 5), Plan(9, 2, true, 100, 2000)] }],
|
||||||
|
});
|
||||||
|
|
||||||
|
Assert.Null(Assert.Single(Run(new ParameterSensitivityRule(), context).Findings).FixScript);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Query_store_rules_are_not_measurable_before_2016()
|
||||||
|
{
|
||||||
|
var result = Run(new PlanRegressionRule(), WithVersion("12.0.6024.0"));
|
||||||
|
|
||||||
|
Assert.Equal(RuleOutcome.NotMeasurable, result.Outcome);
|
||||||
|
}
|
||||||
|
}
|
||||||
Loading…
Add table
Reference in a new issue