OLTP 索引设计中如何解读 SELECT 与 INSERT/UPDATE/DELETE 的比例?
这些比例能说明捕获到的语句构成,却无法据此确定索引的数量和覆盖范围。对于这类约 50/50 的 OLTP 工作负载,可以先围绕主要查询建立少量键列较窄的索引,再根据实际收益决定是否增加覆盖列或专用索引。
没有通用规则能把“SELECT 占 53%”换算成“每张表应该有几个索引”。选择索引时,需要分析具体的表、查询和业务延迟目标,同时检查受影响的写入路径。
先确认这些比例表示什么
53% + 20% + 25% + 5% 约为 103%。这可能是取近似值造成的,也可能与统计窗口、分类方式或汇总口径有关。先确认四类语句使用相同的时间范围和分母,且没有重复归类。
即使比例准确,它反映的也只是执行次数:
- 一次主键查询和一次扫描大量数据的查询,都算一次 SELECT。
- 插入一行和批量插入大量行,都算一次 INSERT。
- UPDATE 的成本取决于查找范围、修改行数,以及修改的列涉及哪些索引。
因此,读写执行次数接近,不代表两者消耗的 CPU、I/O 或时间也接近。
Query Store 的数据还受捕获模式、保留策略和运行状态影响,不能直接当作全部业务请求的审计记录。按语句文本分类时,注释、CTE 等会影响简单的首词判断。一个业务事务也可能执行多条 SQL,因此语句次数不等于事务次数。
UPDATE 和 DELETE 也需要合适的索引
如果只把 SELECT 看作索引的受益方,把 INSERT、UPDATE、DELETE 全部看作成本,就会漏掉索引对数据修改操作的帮助。
例如:
UPDATE dbo.Tasks
SET Status = @NewStatus
WHERE ProjectId = @ProjectId
AND AssigneeId = @AssigneeId
AND Status = @OldStatus;
合适的索引可以帮助 SQL Server 定位待修改的行,减少扫描,并可能缩短锁持有时间。修改索引条目又会产生维护成本,评估时需要同时考虑这两部分。
对于常规行存储非聚集索引,各类操作主要涉及以下工作:
| 操作 | 主要考虑 |
|---|---|
| INSERT | 为符合条件的新行增加索引条目;筛选索引只收录满足筛选条件的行 |
| DELETE | 定位待删除行,并删除这些行对应的索引条目 |
| UPDATE | 定位待修改行;维护受修改列影响的索引,筛选条件变化也可能导致条目加入或移出 |
| SELECT | 是否减少读取页数、排序、回表或其他执行工作 |
UPDATE 对表上各个非聚集索引的维护成本不一定相同。如果一个经常修改的列出现在许多索引的键列或 INCLUDE 中,就需要重点检查写入成本。
如何判断读取密集型和写入密集型
在索引设计中,read-heavy、write-heavy 更适合用来描述工作负载的背景。即使 90% 的语句是 SELECT,剩余的批量写入也可能占据大部分资源消耗。
分类时需要同时说明:
- 按什么计算:执行次数、CPU、逻辑读取量、日志生成量,还是业务响应时间。
- 在什么范围内计算:整个数据库、某张热点表,还是某个业务流程。
- 覆盖什么时段:日常高峰、夜间批处理,还是月末任务。
全库读写比例约为 50/50,其中仍可能同时存在几乎只读的配置表和持续写入的任务事件表。这两类表应采用不同的索引策略。
做决策时,应先找出未达到延迟目标的业务操作、资源消耗最多的查询,以及写入最集中的表。全局比例很难提供这些信息。
用 Query Store 找到值得优化的查询
先按同一时间窗口内的总 CPU 和总逻辑读取量筛选查询,再结合执行次数、单次耗时和业务重要性确定优化顺序。
下面的示例汇总最近七天内完整落入窗口的统计区间,只统计正常完成的执行,并将同一 query_id 的多个计划合并:
DECLARE @EndTime datetimeoffset = SYSDATETIMEOFFSET();
DECLARE @StartTime datetimeoffset = DATEADD(day, -7, @EndTime);
WITH QueryTotals AS
(
SELECT
p.query_id,
SUM(rs.count_executions) AS execution_count,
SUM(rs.avg_cpu_time * rs.count_executions)
/ 1000.0 AS total_cpu_ms,
SUM(rs.avg_logical_io_reads * rs.count_executions)
AS total_logical_reads
FROM sys.query_store_runtime_stats AS rs
JOIN sys.query_store_plan AS p
ON p.plan_id = rs.plan_id
JOIN sys.query_store_runtime_stats_interval AS rsi
ON rsi.runtime_stats_interval_id =
rs.runtime_stats_interval_id
WHERE rsi.start_time >= @StartTime
AND rsi.end_time <= @EndTime
AND rs.execution_type = 0
GROUP BY p.query_id
)
SELECT TOP (20)
t.query_id,
t.execution_count,
t.total_cpu_ms,
t.total_logical_reads,
qt.query_sql_text
FROM QueryTotals AS t
JOIN sys.query_store_query AS q
ON q.query_id = t.query_id
JOIN sys.query_store_query_text AS qt
ON qt.query_text_id = q.query_text_id
ORDER BY t.total_cpu_ms DESC;
使用这些结果时,需要注意统计口径:
- 总成本通过“区间平均值 × 区间执行次数”累加,不能直接相加平均值。
avg_cpu_time的单位是微秒,示例将其转换为毫秒;逻辑读取量按页计数。- 示例排除了窗口边界处未完整落入的区间,因此没有覆盖截至当前时刻最近七天的全部执行。
- 失败、取消等执行未包含在内,需要另行分析。
可以将排序改为 total_logical_reads DESC,找出读取成本集中的查询。业务关键但执行频率低的操作也应单独检查,避免只看总量排名。
随后查看执行计划,必要时在测试环境采集实际执行计划,确认问题来自扫描、过多回表、排序,还是行数估计偏差。隐式转换、不利于索引查找的表达式等问题,可能需要先通过修改 SQL 解决。扫描本身并不意味着缺少索引。
约 50/50 时,如何选择索引策略
先支持主要访问路径,尽量让查询共用合适的索引;有明确的额外收益时,再增加专用索引。
按主要查询设计键列
假设任务列表经常执行以下查询:
SELECT TOP (50)
TaskId,
Title,
DueDate
FROM dbo.Tasks
WHERE ProjectId = @ProjectId
AND Status = @Status
ORDER BY DueDate;
如果 TaskId 是聚集索引键,可以验证以下候选索引:
CREATE INDEX IX_Tasks_ProjectId_Status_DueDate
ON dbo.Tasks (ProjectId, Status, DueDate)
INCLUDE (Title);
这个索引用前两列匹配等值条件,再按 DueDate 提供查询所需的顺序。非聚集索引中已有聚集键作为行定位器,因此在这个假设下,通常无需再次包含 TaskId。
是否采用还要看维护成本。若 Status 经常变化,就需要相应维护索引条目;若 Title 很宽或经常修改,覆盖它的代价也可能超过收益。
根据回表成本决定覆盖范围
不要为了消除所有 Key Lookup,就把查询返回的每一列都放进 INCLUDE。
只返回少量行时,回表的成本可能完全可以接受。宽索引会增加存储和缓存占用,也可能增加日志及写入成本。实际回表工作成为瓶颈时,才更有理由扩大覆盖范围。
按访问路径合并重复索引
多个查询若共享合适的前导键,可以考虑共用一个复合索引。但 (ProjectId, Status) 和 (Status, ProjectId) 支持的访问方式不同,不能仅因列集合相同就视为重复。
合并后仍需验证效果,因为一个很宽的“通用索引”未必比几个较窄的索引更经济。
对稳定的小范围条件考虑筛选索引
如果主要查询只访问占比较小的未完成任务,可以评估筛选索引。它能缩小索引体积,并减少不满足条件的行所需的维护。
评估时,需要检查查询谓词能否匹配筛选条件、参数化是否影响索引使用,以及状态变化是否会频繁导致条目进出索引。
如何验证索引值得保留
每次增加或调整一个索引,至少比较两组结果:
| 范围 | 需要比较的结果 |
|---|---|
| 目标查询 | 执行计划、逻辑读取量、CPU、耗时是否改善 |
| 受影响的写入 | 吞吐、事务延迟、日志生成量,以及锁等待是否恶化 |
目标查询可以结合实际执行计划与 SET STATISTICS IO, TIME ON 验证;写入影响则需要用具有代表性的并发负载测试。单次 SELECT 测试无法说明索引的整体收益。
测试数据应尽量接近预期规模和分布,涵盖大项目、数据倾斜、状态分布及批量更新。当前数据量较小时表现良好的方案,在数据增长后可能采用不同的执行计划。
sys.dm_db_index_usage_stats 可以帮助找出需要复查的索引。例如,在目标数据库中查看行存储非聚集索引:
SELECT
OBJECT_SCHEMA_NAME(i.object_id) AS schema_name,
OBJECT_NAME(i.object_id) AS table_name,
i.name AS index_name,
COALESCE(s.user_seeks, 0) AS user_seeks,
COALESCE(s.user_scans, 0) AS user_scans,
COALESCE(s.user_updates, 0) AS user_updates
FROM sys.indexes AS i
LEFT JOIN sys.dm_db_index_usage_stats AS s
ON s.database_id = DB_ID()
AND s.object_id = i.object_id
AND s.index_id = i.index_id
WHERE i.type = 2
AND i.is_hypothetical = 0;
这些数字不能直接换算成收益成本比。user_updates 是操作计数,不代表受影响行数、写入页数或耗时;一次 seek 的收益也可能远大于许多次其他访问。
DMV 计数会受实例重启等事件影响,因此应记录采集时间,并比较同一观察窗口内的增量。某个索引短期内没有读取记录,只能说明它需要复查。删除前还要检查主键或唯一约束、低频关键查询,以及定期作业。
通过具体查询分析确认索引收益,再用全局指标检查整体代价。对于约 50/50 的工作负载,可以保留共享索引,也可以为关键查询增加专用索引。是否接受额外的写入成本,应取决于查询是否有可测量的改善,以及事务表现是否仍可接受。
备注:内容仅供参考。
