昨天跑 50 毫秒的 SQL,今天突然飙到 5 秒,数据库 CPU 直接冲到 90%。这不是一个假设性问题,而是很多 DBA 和开发者在生产环境里真实踩过的坑。问题出现时,业务方在催,监控在报警,而你手头只有一堆零散的线索:一条 SQL、一个时间点、一个飙升的 CPU 指标。
很多人第一反应是“加索引”或者“优化 SQL”,但真正的问题往往藏在更深的地方。一次性能的断崖式下跌,很少是单一原因造成的,它更像是一个信号,告诉你系统里某个长期存在的隐患被触发了。今天,我们就来拆解这个经典面试题背后的完整排查链路,它不仅是面试技巧,更是一套能在关键时刻救场的实战方法。
1. 先别急着优化 SQL:确认问题边界和影响范围
当 CPU 飙到 90%,你的第一反应不应该是立刻打开 SQL 优化工具。在动手之前,必须先搞清楚三件事:问题是不是 SQL Server 引起的?影响面有多大?是持续性的还是间歇性的?盲目优化可能让你在错误的方向上浪费大量时间。
1.1 确认 CPU 高负载的“元凶”
在 Windows 环境下,打开任务管理器,查看sqlservr.exe进程的 CPU 占用率。如果它持续接近 100%,那基本可以确定问题出在数据库引擎内部。但如果sqlservr.exe的 CPU 并不高,而是系统整体的 CPU 很高,那可能是防病毒软件、其他驱动或操作系统组件的问题,需要联系系统管理员一起排查。
一个更精确的方法是使用性能计数器。你可以通过 PowerShell 脚本定期采集Process(sqlservr*)\% User Time和% Privileged Time的数据。如果% User Time持续高于 90%,基本可以锁定是 SQL Server 的用户态代码(也就是你的查询)导致了高 CPU。如果% Privileged Time很高,则可能是系统调用或驱动问题。
$serverName = $env:COMPUTERNAME $Counters = @( ("\\$serverName" + "\Process(sqlservr*)\% User Time"), ("\\$serverName" + "\Process(sqlservr*)\% Privileged Time") ) Get-Counter -Counter $Counters -MaxSamples 30 | ForEach { $_.CounterSamples | ForEach { [pscustomobject]@{ TimeStamp = $_.TimeStamp Path = $_.Path Value = ([Math]::Round($_.CookedValue, 3)) } Start-Sleep -s 2 } }在 SQL Server Management Studio (SSMS) 中,你也可以使用标准报表中的“性能仪表板”。深色部分代表 SQL Server 进程的 CPU 使用率,浅色部分代表整个系统的 CPU 使用率。这是一个快速可视化的方法。
1.2 量化 SQL 查询的 CPU 贡献度
确定了是 SQL Server 的问题后,下一步是量化:当前所有正在执行的查询,总共占用了多少 CPU 资源?这能帮你判断是少数几个“坏查询”作祟,还是大量并发查询的累积效应。
DECLARE @init_sum_cpu_time int, @utilizedCpuCount int -- 获取 SQL Server 使用的 CPU 核心数 SELECT @utilizedCpuCount = COUNT( * ) FROM sys.dm_os_schedulers WHERE status = 'VISIBLE ONLINE' -- 计算过去 5 秒内查询消耗的 CPU 占总容量的百分比 SELECT @init_sum_cpu_time = SUM(cpu_time) FROM sys.dm_exec_requests WAITFOR DELAY '00:00:05' SELECT CONVERT(DECIMAL(5,2), ((SUM(cpu_time) - @init_sum_cpu_time) / (@utilizedCpuCount * 5000.00)) * 100 ) AS [CPU from Queries as Percent of Total CPU Capacity] FROM sys.dm_exec_requests如果这个百分比很高(比如超过 70%),说明当前活跃查询就是罪魁祸首。如果百分比很低,但sqlservr.exe的 CPU 依然很高,那就要怀疑是不是后台任务(如统计信息更新、索引重建、锁等待)或 SQL Server 内部组件(如锁管理器、任务调度器)出现了问题。
1.3 建立问题的时间线:是突然发生还是缓慢恶化?
询问业务方或查看监控,确定性能下降是精确发生在某个时间点,还是一段时间内逐渐变慢。
- 突然发生:通常与数据/结构变更相关。例如:统计信息更新、索引被意外删除或禁用、数据量突变(如凌晨的批量导入)、应用程序发布了新版本并带来了新的低效查询。
- 缓慢恶化:通常与数据增长、资源竞争或计划缓存恶化相关。例如:表数据持续增长导致原有执行计划不再高效,参数嗅探(Parameter Sniffing)问题随着数据分布变化而凸显。
这个判断能极大缩小你的排查范围。如果是“昨天50ms,今天5s”这种突变,重点就应该放在变更上。
2. 定位罪魁祸首:找出正在消耗 CPU 的查询
确认了边界,接下来就是抓“现行犯”。我们的目标是找到那些正在执行或刚刚执行完的、消耗大量 CPU 的查询。
2.1 抓取当前正在运行的高 CPU 查询
使用sys.dm_exec_requests和sys.dm_exec_sessions这两个动态管理视图 (DMV),可以实时看到每个会话正在执行的请求及其资源消耗。
SELECT TOP 10 s.session_id, r.status, r.cpu_time, r.logical_reads, r.reads, r.writes, r.total_elapsed_time / (1000 * 60) AS 'Elaps M', SUBSTRING(st.TEXT, (r.statement_start_offset / 2) + 1, ((CASE r.statement_end_offset WHEN -1 THEN DATALENGTH(st.TEXT) ELSE r.statement_end_offset END - r.statement_start_offset) / 2) + 1) AS statement_text, COALESCE(QUOTENAME(DB_NAME(st.dbid)) + N'.' + QUOTENAME(OBJECT_SCHEMA_NAME(st.objectid, st.dbid)) + N'.' + QUOTENAME(OBJECT_NAME(st.objectid, st.dbid)), '') AS command_text, r.command, s.login_name, s.host_name, s.program_name, s.last_request_end_time, s.login_time, r.open_transaction_count FROM sys.dm_exec_sessions AS s JOIN sys.dm_exec_requests AS r ON r.session_id = s.session_id CROSS APPLY sys.dm_exec_sql_text(r.sql_handle) AS st WHERE r.session_id != @@SPID ORDER BY r.cpu_time DESC这个查询结果非常关键:
cpu_time: 该请求已消耗的 CPU 时间(毫秒)。这是排序的核心依据。statement_text: 当前正在执行的 SQL 语句片段。command_text: 所属的数据库和对象(如果可解析)。login_name,host_name,program_name: 谁、从哪里、用什么工具执行的。这能帮你判断是来自应用服务器、报表工具还是人为的即席查询。status: 查询状态(如running,suspended)。如果status是suspended但cpu_time很高,说明它之前已经消耗了大量 CPU,现在可能在等待资源(如 I/O)。
注意:如果当前没有高 CPU 的活跃查询,问题可能是间歇性的,或者罪魁祸首已经执行完毕。这时就需要查询历史执行记录。
2.2 查询历史高 CPU 查询
计划缓存(Plan Cache)中存储了之前执行过的查询及其统计信息。通过查询sys.dm_exec_query_stats,我们可以找到历史上消耗 CPU 最多的查询。
SELECT TOP 10 qs.last_execution_time, st.text AS batch_text, SUBSTRING(st.TEXT, (qs.statement_start_offset / 2) + 1, ((CASE qs.statement_end_offset WHEN -1 THEN DATALENGTH(st.TEXT) ELSE qs.statement_end_offset END - qs.statement_start_offset) / 2) + 1) AS statement_text, (qs.total_worker_time / 1000) / qs.execution_count AS avg_cpu_time_ms, (qs.total_elapsed_time / 1000) / qs.execution_count AS avg_elapsed_time_ms, qs.total_logical_reads / qs.execution_count AS avg_logical_reads, (qs.total_worker_time / 1000) AS cumulative_cpu_time_all_executions_ms, (qs.total_elapsed_time / 1000) AS cumulative_elapsed_time_all_executions_ms FROM sys.dm_exec_query_stats qs CROSS APPLY sys.dm_exec_sql_text(sql_handle) st ORDER BY (qs.total_worker_time / qs.execution_count) DESC这里重点关注avg_cpu_time_ms(平均每次执行消耗的 CPU 时间)和execution_count(执行次数)。一个平均 CPU 很高但执行次数少的查询,可能是今天新出现的“坏查询”。一个平均 CPU 不高但执行次数极高的查询,可能是由于并发量突增导致的累积效应。
找到目标 SQL 后,立即保存其完整的 SQL 文本、执行计划(如果可能)以及相关的sql_handle或plan_handle。这是后续分析的基石。
3. 深度分析:为什么同一条 SQL 今天变慢了?
找到了消耗 CPU 的 SQL,这只是第一步。核心问题是:为什么昨天快,今天慢?执行计划变了。99% 的此类性能突变,根源都在于执行计划(Execution Plan)的变更。我们需要像一个侦探一样,检查执行计划的“健康状态”。
3.1 首要检查:统计信息是否过时?
统计信息是查询优化器(Query Optimizer)为表数据构建的“数据画像”,包括行数、唯一值数量、数据分布等。如果这个画像过时了,优化器就会基于错误的信息制定一个低效的执行计划。
如何检查?
- 直接更新:对查询涉及的表,执行
UPDATE STATISTICS。最粗暴但有效的方法是更新整个数据库的统计信息:
注意:EXEC sp_updatestatssp_updatestats会对所有用户表运行UPDATE STATISTICS。在生产环境,这可能会消耗大量 I/O 和 CPU,并阻塞查询。建议在业务低峰期进行,或针对特定表更新。 - 查看统计信息最后更新时间:
如果SELECT OBJECT_NAME(s.object_id) AS TableName, s.name AS StatsName, STATS_DATE(s.object_id, s.stats_id) AS LastUpdated, s.auto_created, s.user_created FROM sys.stats s WHERE OBJECT_NAME(s.object_id) IN ('YourTableName') -- 替换为你的表名 ORDER BY LastUpdated;LastUpdated远早于数据发生重大变化的时间(例如大批量增删改),那么统计信息很可能已经过时。
为什么统计信息过时会导致计划变差?假设你的查询条件是WHERE Status = 'Active',昨天有 1000 行是Active,优化器可能选择索引查找。今天经过批量更新,有 100 万行变成了Active,但统计信息没更新,优化器仍然认为只有 1000 行,可能还是选择索引查找(实际需要回表 100 万次),而不是更高效的表扫描或批处理模式。
3.2 检查缺失索引
缺失索引是导致表/索引扫描(Scan)的常见原因,而扫描会消耗大量 CPU 和 I/O。SQL Server 会自动记录它认为可能有益的缺失索引建议。
SELECT CONVERT(VARCHAR(30), GETDATE(), 126) AS runtime, mig.index_group_handle, mid.index_handle, CONVERT(DECIMAL(28, 1), migs.avg_total_user_cost * migs.avg_user_impact * (migs.user_seeks + migs.user_scans) ) AS improvement_measure, 'CREATE INDEX missing_index_' + CONVERT(VARCHAR, mig.index_group_handle) + '_' + CONVERT(VARCHAR, mid.index_handle) + ' ON ' + mid.statement + ' (' + ISNULL(mid.equality_columns, '') + CASE WHEN mid.equality_columns IS NOT NULL AND mid.inequality_columns IS NOT NULL THEN ',' ELSE '' END + ISNULL(mid.inequality_columns, '') + ')' + ISNULL(' INCLUDE (' + mid.included_columns + ')', '') AS create_index_statement, migs.*, mid.database_id, mid.[object_id] FROM sys.dm_db_missing_index_groups mig INNER JOIN sys.dm_db_missing_index_group_stats migs ON migs.group_handle = mig.index_group_handle INNER JOIN sys.dm_db_missing_index_details mid ON mig.index_handle = mid.index_handle WHERE CONVERT (DECIMAL (28, 1), migs.avg_total_user_cost * migs.avg_user_impact * (migs.user_seeks + migs.user_scans)) > 10 -- 可根据实际情况调整阈值 ORDER BY improvement_measure DESC重点关注improvement_measure值最高的前几条建议。但请谨慎对待:
- 不要盲目创建:每个索引都有维护成本(写操作变慢)。评估索引的使用频率和收益。
- 合并索引建议:SQL Server 可能对同一个表给出多个相似的缺失索引建议,需要人工合并。
- 检查现有索引:有时问题不是没有索引,而是现有索引的字段顺序不对,或者需要重建(
ALTER INDEX ... REBUILD)。
3.3 参数嗅探(Parameter Sniffing)问题
这是导致“同一条 SQL,有时快有时慢”的经典元凶。当存储过程或参数化查询第一次编译时,SQL Server 会“嗅探”传入的参数值,并基于该值的数据分布生成一个“认为最优”的执行计划,然后将其缓存。如果后续传入的参数值数据分布差异巨大,这个缓存的计划就可能非常低效。
如何诊断?
清空计划缓存(临时验证):这是最直接的验证方法。找到问题查询的
plan_handle,然后只清除它的缓存:-- 查找特定查询的计划句柄 SELECT text, 'DBCC FREEPROCCACHE (0x' + CONVERT(VARCHAR (512), plan_handle, 2) + ')' AS dbcc_freeproc_command FROM sys.dm_exec_cached_plans CROSS APPLY sys.dm_exec_query_plan(plan_handle) CROSS APPLY sys.dm_exec_sql_text(plan_handle) WHERE text LIKE '%YourProblemQueryText%' -- 替换部分查询文本执行查询结果中生成的
DBCC FREEPROCCACHE命令。然后重新运行你的问题查询(用今天的参数)。如果速度恢复正常,那么参数嗅探的可能性就很大。警告:不要在生产环境直接运行不带参数的
DBCC FREEPROCCACHE,这会清空所有计划缓存,导致短时间内所有查询都需要重新编译,可能引发性能雪崩。对比执行计划:使用 SSMS,分别用“快”的参数和“慢”的参数执行同一条 SQL,并比较它们的实际执行计划。观察是否使用了不同的索引、连接方式(如 Hash Join 变成 Nested Loops)或预估行数与实际行数差异巨大。
如何解决参数嗅探?
- 使用
OPTION (RECOMPILE):在查询末尾添加此提示,强制每次执行都重新编译,生成针对当前参数的最优计划。适用于执行不频繁但要求高的查询。CREATE PROCEDURE MyProc @Param INT AS SELECT ... FROM ... WHERE ... = @Param OPTION (RECOMPILE) - 使用
OPTION (OPTIMIZE FOR UNKNOWN)或OPTIMIZE FOR (@variable = value):前者让优化器使用平均数据密度来生成计划,后者指定一个“典型”值来生成计划。 - 使用本地变量:在存储过程内部,先将输入参数赋值给一个本地变量,然后在查询中使用本地变量。这会阻止优化器嗅探到原始参数值。
CREATE PROCEDURE MyProc @Param INT AS BEGIN DECLARE @LocalParam INT = @Param; SELECT ... FROM ... WHERE ... = @LocalParam; END - 更新统计信息:有时过时的统计信息会加剧参数嗅探的问题,确保统计信息最新是基础。
3.4 非 SARGable 查询导致扫描
SARGable (Search Argument Able) 指的是查询条件能够有效地利用索引。非 SARGable 的写法会强制 SQL Server 进行全表或全索引扫描,消耗大量 CPU。
常见非 SARGable 写法:
- 在列上使用函数或计算:
-- 坏:无法使用 ProductNumber 上的索引 SELECT * FROM Production.Product WHERE SUBSTRING(ProductNumber, 0, 4) = 'HN-' -- 好:重写为 LIKE,如果前导字符固定 SELECT * FROM Production.Product WHERE ProductNumber LIKE 'HN-%' - 在列上进行运算:
-- 坏:无法使用 UnitPrice 上的索引 SELECT * FROM Sales.SalesOrderDetail WHERE UnitPrice * 0.10 > 300 -- 好:将运算移到条件另一侧 SELECT * FROM Sales.SalesOrderDetail WHERE UnitPrice > 300 / 0.10 - 隐式或显式类型转换:
-- 坏:T1.ProdID 是 VARCHAR,但被转换为 INT,无法使用索引 SELECT * FROM T1 JOIN T2 ON CONVERT(INT, T1.ProdID) = T2.ProductID -- 好:确保连接列数据类型一致。或者为 T1 创建计算列并索引。 ALTER TABLE dbo.T1 ADD IntProdID AS CONVERT(INT, ProdID); CREATE INDEX IndProdID_int ON dbo.T1 (IntProdID);
检查你找到的高 CPU SQL,是否存在这类写法。修改为 SARGable 形式往往是成本最低、效果最显著的优化。
4. 超越 SQL 本身:系统级和配置问题排查
如果上述针对 SQL 和索引的分析都未能找到根本原因,或者 CPU 高企但活跃查询不多,就需要将视线扩大到整个 SQL Server 实例和操作系统环境。
4.1 检查并禁用不必要的跟踪和 XEvent 会话
SQL Trace 和扩展事件 (XEvent) 会话如果配置不当,尤其是捕获了过多事件(如sql_statement_completed),会产生巨大的性能开销。
-- 检查活动的 Profiler 跟踪 PRINT '--Profiler trace summary--' SELECT traceid, property, CONVERT(VARCHAR(1024), value) AS value FROM ::fn_trace_getinfo(default) GO -- 检查活动的 XEvent 会话 PRINT '--XEvent Session Details--' SELECT sess.NAME 'session_name', event_name, xe_event_name, trace_event_id FROM sys.dm_xe_sessions sess JOIN sys.dm_xe_session_events evt ON sess.address = evt.event_session_address INNER JOIN sys.trace_xe_event_map xemap ON evt.event_name = xemap.xe_event_name GO如果发现非必要的、高开销的跟踪或会话,考虑在业务低峰期停止它们。
4.2 自旋锁(Spinlock)争用
在高并发、高性能的系统中,SQL Server 内部的自旋锁争用可能导致 CPU 利用率虚高。常见的可疑对象包括SOS_CACHESTORE、SOS_BLOCKALLOCPARTIALLIST、XVB_LIST等。
症状:CPU 使用率很高,但通过sys.dm_exec_requests查看到的活跃查询 CPU 并不高,或者大量查询状态为SIGNAL_WAIT类型且等待资源是SOS_SCHEDULER_YIELD。
诊断与缓解:
- 查询
sys.dm_os_spinlock_stats查看自旋锁的争用情况。 - 对于特定的自旋锁问题,微软可能会提供跟踪标志(Trace Flag)作为临时解决方案。例如,历史上
TF174用于缓解SOS_CACHESTORE争用,TF8102和TF8101用于缓解XVB_LIST争用。 - 重要:跟踪标志是高级功能,必须经过充分测试并在微软官方文档或知识库文章的建议下使用。错误使用可能导致不稳定。
4.3 操作系统电源计划
这是一个容易被忽略但影响巨大的配置。Windows 服务器的电源计划如果设置为“平衡”,操作系统可能会动态降低 CPU 频率以节省能耗。这会导致 SQL Server 需要更长的 CPU 时间来完成相同的工作,从而表现出更高的 CPU 使用率百分比。
解决方案:将电源计划设置为“高性能”或“卓越性能”。这可以确保 CPU 始终以最高额定频率运行,提供稳定可预测的性能。
4.4 虚拟机配置问题
如果 SQL Server 运行在虚拟化环境(如 VMware ESXi),需要确保:
- 不要过度分配 CPU:为虚拟机分配超过物理核心数的 vCPU 会导致严重的调度竞争。
- 正确配置 CPU 关联性和保留:咨询虚拟化管理员,确保 SQL Server VM 获得了有保障的 CPU 资源。
- 安装并更新 VMware Tools:确保使用了优化的虚拟硬件驱动。
4.5 纵向扩展:增加 CPU 资源
如果经过以上所有优化,单条查询的 CPU 时间已经降到最低,但整体工作负载的并发量就是那么大,导致总 CPU 持续高位,那么唯一的出路就是增加 CPU 资源(纵向扩展)。
在决定扩容前,可以用以下查询识别那些执行频繁、单次消耗 CPU 适中的“温和小查询”,它们可能是并发压力的主要来源:
-- 找出平均CPU时间超过200毫秒且执行超过1000次的查询 DECLARE @cputime_threshold_microsec INT = 200*1000 -- 200毫秒 DECLARE @execution_count INT = 1000 SELECT qs.total_worker_time/1000 AS total_cpu_time_ms, qs.max_worker_time/1000 AS max_cpu_time_ms, (qs.total_worker_time/1000)/qs.execution_count AS average_cpu_time_ms, qs.execution_count, q.[text] FROM sys.dm_exec_query_stats qs CROSS APPLY sys.dm_exec_sql_text(plan_handle) AS q WHERE (qs.total_worker_time/qs.execution_count > @cputime_threshold_microsec OR qs.max_worker_time > @cputime_threshold_microsec) AND qs.execution_count > @execution_count ORDER BY qs.total_worker_time DESC如果这类查询很多,且业务无法再优化,那么增加 CPU 核心数就是合理的硬件投资。
5. 构建你的排查清单:从现象到根因的决策树
面对突发的 SQL 性能问题,遵循一个清晰的排查路径能帮你节省大量时间。下面这个决策树可以作为一个快速参考:
- 现象:CPU 持续 > 90%。
- 第一步:定位源头
- 任务管理器/性能计数器确认是
sqlservr.exe进程导致。 - 使用
sys.dm_exec_requests和sys.dm_exec_query_stats定位高 CPU 查询。 - 保存问题 SQL 文本和执行计划。
- 任务管理器/性能计数器确认是
- 第二步:分析 SQL 与计划
- 检查统计信息:是否过时?尝试更新。
- 检查缺失索引:DMV 是否有高收益建议?
- 检查执行计划:对比快/慢时的计划。关注:
- 预估行数 vs 实际行数(巨大差异指向统计信息问题)。
- 扫描(Scan) vs 查找(Seek)。
- 连接类型(如出现意外的 Hash Join 或 Nested Loops)。
- 参数嗅探迹象(编译时间 vs 不同参数)。
- 检查查询写法:是否存在非 SARGable 写法(列上函数、运算、类型转换)?
- 第三步:检查系统与环境
- 是否有高开销的跟踪或 XEvent 会话?
- 检查自旋锁争用情况(
sys.dm_os_spinlock_stats)。 - 检查操作系统电源计划(是否为“高性能”)。
- 如果是虚拟机,检查 CPU 资源配置。
- 第四步:验证与解决
- 统计信息/索引问题:在测试环境验证后,于业务低峰期实施变更。
- 参数嗅探:根据查询特性选择
RECOMPILE、OPTIMIZE FOR或使用本地变量。 - 非 SARGable 查询:重写查询。
- 系统配置问题:调整电源计划、停止非必要跟踪、咨询虚拟化管理员。
- 资源瓶颈:论证并申请增加 CPU 资源。
最后,记住一个原则:一次只做一个变更,并观察效果。生产环境的优化最忌讳“乱拳打死老师傅”。每次变更后,清晰地记录下变更内容、时间、预期效果和实际结果。这样,当下次“昨天50ms,今天5s”的问题再次出现时,你不仅知道怎么排查,还能积累下属于你自己的、经过实战检验的故障知识库。