Index Tuning Report – RockyPC / SQLStorm – 2026-05-13

Tuning Goal: Index Tuning

Server: RockyPC
Database: SQLStorm
Version: Microsoft SQL Server 2022 (RTM-GDR) 16.0.1175.1, Developer Edition (64-bit)
Engine Edition: Enterprise
Automatic Tuning: FORCE_LAST_GOOD_PLAN desired ON, actual ON

Executive summary

  1. Create a targeted nonclustered index on dbo.PostHistory for PostId + PostHistoryTypeId + CreationDate. This is the strongest missing-index candidate and aligns with expensive plan-cache and Query Store queries joining posts to history and close events.
  2. Create a nonclustered index on dbo.Comments(PostId, CreationDate). Comments is scanned heavily, appears in many aggregation queries, and currently has only the clustered PK on Id.
  3. Create a composite index on dbo.Badges(UserId, Class, Date DESC). This consolidates the duplicate missing-index requests on UserId, supports badge counting by class, and helps “latest badge per user” access patterns.
  4. Create a filtered index on dbo.Votes(PostId, VoteTypeId) for vote types (2,3,8,9). This supports the dominant query pattern of counting upvotes/downvotes and bounty-related votes by post, with lower footprint than an unfiltered index.
  5. Apply focused query rewrites for large CTE workloads that currently produce extreme reads, memory grants, and spills. Several top-cost statements are structurally expensive and will not be solved by indexing alone.
  6. Refresh statistics on large active tables with FULLSCAN after index changes. The workload shows several very large grants and spills, suggesting cardinality quality matters.
  • No forced plan failures were detected.
  • No automatic tuning correction is required; FORCE_LAST_GOOD_PLAN is already enabled and aligned.
  • No low page-density issue was found. All reported indexes are above 90% page density; no rebuild recommendation is justified from the provided density data.
  • No index drops are recommended from the provided evidence.
  • Columnstore is not recommended: no table exceeds 1,000,000 rows in the supplied data, and the workload is mixed analytical/reporting but not large enough here to justify additional columnstore maintenance overhead.

Detailed prioritized recommendations

Priority Recommendation Why Confidence
1 Create IX_PostHistory_PostId_PostHistoryTypeId_CreationDate High missing-index cost/impact, large table (847,593 rows), 133 clustered scans, high-cardinality PostId, repeated joins from Posts to PostHistory, and history-type filtering in cached queries. High (93%)
2 Create IX_Comments_PostId_CreationDate Comments has 351,440 rows and 215 clustered scans with no supporting nonclustered index. Many expensive queries aggregate comment counts per post and sometimes use recency logic. High (90%)
3 Create IX_Badges_UserId_Class_Date Consolidates duplicate missing-index requests for UserId; supports badge counts by user/class and latest badge lookups. UserId has strong cardinality; Class alone is low-cardinality and belongs after UserId. High (88%)
4 Create filtered IX_Votes_PostId_VoteTypeId_Filtered Votes has 926,084 rows and 348 clustered scans. Most expensive workload patterns count vote types 2/3 and bounty-related 8/9 by post. Filter reduces size and write cost versus a broad index. High (86%)
5 Create optional filtered IX_Votes_UserId_VoteTypeId_Filtered Useful for user-centered vote aggregations seen in Query Store and aligns with missing index output, but selectivity on UserId appears weaker than expected in supplied stats, so this is secondary. Medium (68%)
6 Refresh statistics on major tables after new indexes Query Store shows large grants and spills; better stats can improve join choice and grant sizing. High (85%)

Indexes to create

1. dbo.PostHistory

  • Recommendation: Create (PostId, PostHistoryTypeId, CreationDate DESC) including (UserId, Comment, UserDisplayName).
  • Why this shape: PostId is highly selective (~246,673 distinct), PostHistoryTypeId is low-cardinality and should be second, and CreationDate supports latest-history access.
  • Workload evidence:
    • Missing index request: Equality Columns: [PostId], include [PostHistoryTypeId], average impact 99.60.
    • Plan cache / Query Store examples:
      • LEFT JOIN PostHistory ph ON p.Id = ph.PostId
      • LEFT JOIN PostHistory PH ON P.Id = PH.PostId with PH.PostHistoryTypeId IN (10, 11)
      • SUM(CASE WHEN ph.PostHistoryTypeId = 10 THEN 1 ELSE 0 END)
  • Overhead assessment: Acceptable. Observed user_updates=0 in supplied usage stats and the table is read-heavy in the captured workload. Even allowing for DMV reset limitations, read benefit outweighs maintenance cost.

2. dbo.Comments

  • Recommendation: Create (PostId, CreationDate DESC) including (UserId, Score).
  • Why: PostId has strong cardinality (~122,541 distinct), and comment-count queries repeatedly join by post. CreationDate helps recent-comment ranking patterns.
  • Workload evidence:
    • Index usage shows only clustered PK activity with 215 scans.
    • Examples:
      • LEFT JOIN Comments c ON p.Id = c.PostId
      • (SELECT COUNT(*) FROM Comments c WHERE c.PostId = p.Id)
      • COUNT(c.Id) AS CommentCount
  • Overhead assessment: Low to moderate. Narrow key, moderate row count, and strong recurring join pattern justify it.

3. dbo.Badges

  • Recommendation: Create (UserId, Class, Date DESC) including (Name).
  • Why: Two missing-index recommendations both start with UserId. The composite cardinality of UserId, Class is strong; Class alone is not. Date DESC supports latest-badge lookups.
  • Workload evidence:
    • Missing index request: [UserId] include [Class], impact 98.94.
    • Duplicate missing request on [UserId] alone indicates a reusable access path is needed.
    • Examples:
      • LEFT JOIN Badges b ON u.Id = b.UserId
      • SUM(CASE WHEN b.Class = 1 THEN 1 ELSE 0 END)
      • b.Date = ( SELECT MAX(Date) FROM Badges WHERE UserId = u.Id )
  • Overhead assessment: Acceptable. Table is medium-sized (439,352 rows), scan-heavy (180 scans), and observed writes are zero in captured usage.

4. dbo.Votes filtered post-centric index

  • Recommendation: Create filtered (PostId, VoteTypeId) including (UserId, BountyAmount, CreationDate) with filter VoteTypeId IN (2,3,8,9).
  • Why: Most heavy queries count upvotes/downvotes and bounty amounts per post. PostId is highly selective (~242,634 distinct), and PostId, VoteTypeId has strong combined selectivity.
  • Workload evidence:
    • Votes clustered PK has 348 scans and no nonclustered support.
    • Examples:
      • LEFT JOIN Votes v ON p.Id = v.PostId
      • WHERE v.VoteTypeId IN (2, 3)
      • AND v.VoteTypeId = 8
      • COUNT(*) FROM Votes v WHERE v.PostId = p.Id AND v.VoteTypeId = 2
  • Overhead assessment: Better than a broad votes index because the filter limits storage and maintenance. This is preferred over a single unfiltered wide index.

5. dbo.Votes optional user-centric filtered index

  • Recommendation: Create filtered (UserId, VoteTypeId) including (PostId, BountyAmount, CreationDate) where UserId IS NOT NULL AND VoteTypeId IN (2,3,8,9).
  • Why: Several Query Store statements aggregate votes by user. This index is secondary because the provided density for UserId appears unexpectedly low-selectivity, so benefit may vary.
  • Workload evidence:
    • Missing index request on dbo.Votes: [UserId] include [VoteTypeId], [BountyAmount].
    • Examples:
      • LEFT JOIN Votes v ON u.Id = v.UserId
      • SUM(CASE WHEN v.VoteTypeId = 2 THEN 1 ELSE 0 END)
  • Overhead assessment: Medium. Add only if user-centric reports are recurring and important.

Query/code changes

  1. Replace repeated correlated subqueries with pre-aggregation.
    • High-cost examples repeatedly use patterns like (SELECT COUNT(*) FROM Comments c WHERE c.PostId = p.Id) and (SELECT COUNT(*) FROM Votes v WHERE v.PostId = p.Id AND v.VoteTypeId = 2).
    • Rewrite these into grouped derived tables or temp tables keyed by PostId, then join once.
    • Expected effect: lower repeated scans, lower reads, lower spill risk.
  2. Remove unnecessary joins to lookup tables for fixed vote-type predicates.
    • Example: JOIN VoteTypes vt ON v.VoteTypeId = vt.Id followed by checks for vt.Id = 2 or vt.Id = 3.
    • Use v.VoteTypeId directly unless lookup attributes are required.
    • Expected effect: simpler plans and lower join cost.
  3. Materialize large intermediate rowsets when multiple CTE layers multiply rows.
    • Several Query Store statements chain many CTEs over Users, Posts, Votes, Comments, and Badges with ranking and aggregation.
    • Use temp tables after major aggregation points such as per-post vote totals or per-user post totals.
    • Expected effect: lower memory grants, fewer spills, better cardinality stability.
  4. Avoid tag searches using LIKE '%' + TagName + '%' against Posts.Tags.
    • This pattern is non-SARGable and cannot be fixed effectively with a normal B-tree index.
    • Normalize tags into a bridge table such as PostTags(PostId, TagId), or use a search-specific design.
    • Expected effect: this is one of the biggest potential gains for the tag-related reporting queries.
  5. Correct logically suspicious badge aggregations.
    • One plan-cache statement shows COUNT(b.Id) AS GoldBadges, COUNT(b.Id) AS SilverBadges, COUNT(b.Id) AS BronzeBadges while joining b.Class = 1. That returns identical values for all three columns.
    • Use separate conditional sums by class, or separate pre-aggregations.

Statistics, maintenance, and tuning settings

  • Statistics: Recommend targeted FULLSCAN updates on Posts, Votes, Badges, PostHistory, and Comments after index creation. This is justified by multiple large memory grants and spills in Query Store.
  • Auto stats settings: AUTO_CREATE_STATISTICS = ON and AUTO_UPDATE_STATISTICS = ON are appropriate. No change recommended.
  • Async stats: AUTO_UPDATE_STATISTICS_ASYNC = OFF is acceptable here; no change recommended from supplied evidence.
  • Page density / fragmentation: No rebuild or reorganize action recommended. Reported page density is healthy:
    • IX_Posts_OwnerUserId_Covering: 90.23%
    • All other listed indexes: 98%+
  • Forced plan failures: None detected. No unforce action needed.
  • Automatic tuning: FORCE_LAST_GOOD_PLAN is already ON and matches desired state; keep enabled.

Scripts

Create nonclustered index for PostHistory joins and close-event analysis (Recommendation 1)

USE [SQLStorm];
GO
CREATE NONCLUSTERED INDEX [IX_PostHistory_PostId_PostHistoryTypeId_CreationDate]
ON [dbo].[PostHistory]
(
    [PostId] ASC,
    [PostHistoryTypeId] ASC,
    [CreationDate] DESC
)
INCLUDE
(
    [UserId],
    [Comment],
    [UserDisplayName]
)
WITH
(
    ONLINE = ON,
    SORT_IN_TEMPDB = ON,
    DATA_COMPRESSION = PAGE,
    FILLFACTOR = 95
);
GO

Create nonclustered index for Comments by PostId and recency (Recommendation 2)

USE [SQLStorm];
GO
CREATE NONCLUSTERED INDEX [IX_Comments_PostId_CreationDate]
ON [dbo].[Comments]
(
    [PostId] ASC,
    [CreationDate] DESC
)
INCLUDE
(
    [UserId],
    [Score]
)
WITH
(
    ONLINE = ON,
    SORT_IN_TEMPDB = ON,
    DATA_COMPRESSION = PAGE,
    FILLFACTOR = 95
);
GO

Create composite Badges index for UserId, Class, and latest badge date (Recommendation 3)

USE [SQLStorm];
GO
CREATE NONCLUSTERED INDEX [IX_Badges_UserId_Class_Date]
ON [dbo].[Badges]
(
    [UserId] ASC,
    [Class] ASC,
    [Date] DESC
)
INCLUDE
(
    [Name]
)
WITH
(
    ONLINE = ON,
    SORT_IN_TEMPDB = ON,
    DATA_COMPRESSION = PAGE,
    FILLFACTOR = 95
);
GO

Create filtered Votes index for post-centric vote and bounty aggregations (Recommendation 4)

USE [SQLStorm];
GO
CREATE NONCLUSTERED INDEX [IX_Votes_PostId_VoteTypeId_Filtered]
ON [dbo].[Votes]
(
    [PostId] ASC,
    [VoteTypeId] ASC
)
INCLUDE
(
    [UserId],
    [BountyAmount],
    [CreationDate]
)
WHERE [VoteTypeId] IN (2, 3, 8, 9)
WITH
(
    ONLINE = ON,
    SORT_IN_TEMPDB = ON,
    DATA_COMPRESSION = PAGE,
    FILLFACTOR = 95
);
GO

Create optional filtered Votes index for user-centric vote aggregations (Recommendation 5)

USE [SQLStorm];
GO
CREATE NONCLUSTERED INDEX [IX_Votes_UserId_VoteTypeId_Filtered]
ON [dbo].[Votes]
(
    [UserId] ASC,
    [VoteTypeId] ASC
)
INCLUDE
(
    [PostId],
    [BountyAmount],
    [CreationDate]
)
WHERE [UserId] IS NOT NULL
  AND [VoteTypeId] IN (2, 3, 8, 9)
WITH
(
    ONLINE = ON,
    SORT_IN_TEMPDB = ON,
    DATA_COMPRESSION = PAGE,
    FILLFACTOR = 95
);
GO

Refresh statistics on major workload tables after index creation (Recommendation 6)

USE [SQLStorm];
GO
UPDATE STATISTICS [dbo].[Posts] WITH FULLSCAN;
UPDATE STATISTICS [dbo].[Votes] WITH FULLSCAN;
UPDATE STATISTICS [dbo].[Badges] WITH FULLSCAN;
UPDATE STATISTICS [dbo].[PostHistory] WITH FULLSCAN;
UPDATE STATISTICS [dbo].[Comments] WITH FULLSCAN;
GO