Skip to content

SQL Server 核心全过程原理

SQL Server 不能只背“聚集索引叶子是数据行、非聚集索引会 Key Lookup、事务日志用于恢复”。这些都是点,真正项目里要能串成过程:

  1. 一条查询怎么经过解析、优化、执行计划和 Buffer Pool。
  2. 聚集索引、非聚集索引和堆表分别怎么定位行。
  3. 一条更新怎么写数据页、写事务日志、提交和恢复。
  4. 默认锁机制为什么会阻塞,行版本隔离又如何减少读写阻塞。
  5. 为什么 Key Lookup、参数嗅探、TempDB、锁升级会让线上变慢。
  6. 商业系统应该怎样看实际执行计划和等待类型。

学习目标

问题学完后要能说清
SQL Server 数据怎么存MDF/NDF 数据文件,LDF 日志文件,数据以 8KB 页组织
查询怎么执行解析、优化、执行计划、访问方法、Buffer Pool、返回结果
聚集索引是什么叶子层就是数据行,一个表只能有一个
非聚集索引怎么回表叶子层保存索引键和行定位器,可能触发 Key Lookup
事务日志做什么回滚、崩溃恢复、日志备份、高可用复制
锁升级为什么发生细粒度锁过多时为了降低锁管理成本,可能升级为表锁
行版本怎么减少阻塞Snapshot/RCSI 让读从版本存储读取旧版本,降低读写冲突
慢 SQL 怎么排查实际执行计划、估算/实际行数、等待类型、TempDB、锁和统计信息

总体架构

mermaid
flowchart TD
    A["客户端连接"] --> B["SQL Server 引擎"]
    B --> C["查询处理器"]
    C --> D["解析 / 优化 / 执行"]
    D --> E["存储引擎"]
    E --> F["Buffer Pool"]
    F --> G["数据页 / 索引页"]
    E --> H["事务日志管理"]
    H --> I["LDF 日志文件"]
    G --> J["MDF / NDF 数据文件"]
    E --> K["锁管理器"]
    E --> L["TempDB"]
组件作用常见问题
查询处理器解析 SQL、生成执行计划统计信息不准、参数嗅探、计划不佳
存储引擎读写页、维护索引、管理事务IO、锁、日志、Buffer Pool 压力
Buffer Pool缓存数据页和索引页扫描太多导致缓存污染
事务日志记录修改过程日志写入慢、日志文件暴涨
锁管理器控制并发访问阻塞、死锁、锁升级
TempDB排序、Hash、版本存储、临时对象TempDB IO 或空间瓶颈

一条 SELECT 的全过程

示例:

sql
select top (20) Id, AssetNo
from dbo.Asset
where OwnerId = @OwnerId
order by CreatedAt desc;

执行流程:

mermaid
flowchart TD
    A["客户端提交 SQL"] --> B["解析和绑定对象"]
    B --> C["优化器生成候选计划"]
    C --> D["根据统计信息估算成本"]
    D --> E["选择执行计划"]
    E --> F["执行器运行算子"]
    F --> G{"访问方式"}
    G --> H["Index Seek"]
    G --> I["Index Scan"]
    G --> J["Table Scan"]
    H --> K["Buffer Pool 查页"]
    I --> K
    J --> K
    K --> L{"页在内存吗"}
    L -- "是" --> M["逻辑读"]
    L -- "否" --> N["物理读磁盘"]
    N --> M
    M --> O["排序 / Top / 返回结果"]

每一步要理解:

步骤做什么影响性能的因素
解析绑定确认表、列、权限、类型SQL 是否可编译
优化选择索引、Join 顺序、Join 算法统计信息、参数值、索引
执行按计划运行算子扫描行数、排序、Hash、回表
Buffer Pool缓存数据页和索引页页命中率、扫描量
返回结果网络发送给客户端返回行数、列宽

执行计划中的关键算子

算子含义关注点
Index Seek利用索引定位范围或点查通常较好,但范围大也会慢
Index Scan扫描索引大量页可能比表扫描轻,但仍可能慢
Table Scan扫描堆表大表高频查询要警惕
Clustered Index Scan扫描聚集索引叶子层本质接近扫全表数据
Key Lookup通过非聚集索引定位后回聚集索引取列少量可以,大量会很慢
RID Lookup堆表通过 RID 回表堆表常见
Nested Loops外层每行驱动内层查找外层行数大时会放大
Hash Match构建哈希表再匹配可能吃内存和 TempDB
Sort排序大排序会申请内存,不足溢写 TempDB

聚集索引、非聚集索引和堆表

SQL Server 最重要的是区分三种定位方式。

mermaid
flowchart TD
    A["表数据组织"] --> B["聚集索引表"]
    A --> C["堆表 Heap"]
    B --> D["聚集索引叶子层就是数据行"]
    B --> E["非聚集索引叶子保存聚集键"]
    C --> F["行没有聚集顺序"]
    C --> G["非聚集索引叶子保存 RID"]

聚集索引

聚集索引的叶子层就是数据行。

mermaid
flowchart TD
    A["Clustered Index Root"] --> B["Intermediate Level"]
    B --> C["Leaf Level"]
    C --> D["完整数据行"]

特点:

特点说明
一个表只能有一个因为数据行只能按一种聚集结构组织
叶子层是数据不需要再通过别的结构找完整行
聚集键会进入非聚集索引非聚集索引需要用它回到数据行

非聚集索引和 Key Lookup

非聚集索引叶子不是完整行,而是索引键加行定位器。

mermaid
flowchart TD
    A["非聚集索引 Seek"] --> B["找到索引键"]
    B --> C["拿到聚集键"]
    C --> D["Key Lookup 到聚集索引"]
    D --> E["取完整列"]

示例:

sql
create index IX_Asset_Owner_CreatedAt
on dbo.Asset(OwnerId, CreatedAt desc);

select Id, AssetNo, Status
from dbo.Asset
where OwnerId = @OwnerId
order by CreatedAt desc;

如果索引没有包含 Status,执行计划可能出现 Key Lookup。少量 Lookup 可以接受,如果返回几万行,每行都 Lookup,就会变成大量随机访问。

解决方式之一是 INCLUDE:

sql
create index IX_Asset_Owner_CreatedAt
on dbo.Asset(OwnerId, CreatedAt desc)
include (AssetNo, Status);

但 INCLUDE 不是越多越好。索引变宽会增加写入维护成本、占用更多页、降低缓存效率。

一条 UPDATE 的全过程

示例:

sql
update dbo.Asset
set Status = 'USED',
    UpdatedAt = sysdatetime()
where AssetNo = @AssetNo
  and Status = 'IDLE';

执行流程:

mermaid
flowchart TD
    A["定位目标行"] --> B["获取更新锁 / 排他锁"]
    B --> C["记录事务日志"]
    C --> D["修改 Buffer Pool 中的数据页"]
    D --> E["维护相关索引"]
    E --> F["事务提交"]
    F --> G["日志刷盘保证持久性"]
    G --> H["返回成功"]
    H --> I["脏页后续由后台写回数据文件"]

每一步为什么需要:

步骤作用
定位目标行使用索引可以减少扫描和锁范围
获取锁防止其他事务同时修改同一行
记录日志支持回滚和崩溃恢复
修改页先改内存中的数据页
维护索引被更新列涉及索引时需要同步修改
提交日志持久化后事务成功
后台刷页数据页不必每次提交立即刷盘

事务日志为什么重要

事务日志 LDF 不是普通审计日志,而是数据库恢复机制核心。

mermaid
flowchart TD
    A["事务修改数据"] --> B["生成日志记录"]
    B --> C["日志写入 LDF"]
    C --> D["commit 成功"]
    D --> E["数据页稍后刷盘"]
    E --> F["宕机恢复"]
    F --> G["重做已提交事务"]
    F --> H["撤销未提交事务"]

事务日志用于:

用途说明
回滚撤销未提交事务
崩溃恢复重做已提交、撤销未提交
日志备份Full 恢复模式下支持时间点恢复
Always On / 复制副本同步依赖日志传递和重放

为什么日志可能暴涨:

原因说明
大事务日志必须保留到事务完成
Full 模式不做日志备份日志不能截断
长时间复制/AG 延迟日志需要保留给副本
大量索引维护DML 同时修改多个索引

默认锁和阻塞

SQL Server 默认 Read Committed 下,读通常会加共享锁,写会加排他锁。共享锁和排他锁冲突。

mermaid
sequenceDiagram
    participant A as 事务A
    participant B as 事务B
    A->>A: update Asset set Status='USED' where Id=1
    A->>A: 持有排他锁未提交
    B->>B: select * from Asset where Id=1
    B-->>A: 可能等待事务A释放锁
    A->>A: commit
    B->>B: 继续读取

常见锁:

含义
Shared Lock共享锁,读数据时常见
Exclusive Lock排他锁,修改数据时使用
Update Lock更新锁,减少转换死锁
Intent Lock意向锁,表示下层对象有锁
Schema Lock架构锁,DDL 或编译时可能涉及

行版本隔离怎么减少阻塞

SQL Server 可以通过行版本减少读写阻塞,常见是 RCSI 和 Snapshot。

模式说明
Read Committed Snapshot Isolation让 Read Committed 读使用行版本
Snapshot Isolation事务级快照隔离

简化流程:

mermaid
flowchart TD
    A["更新数据行"] --> B["旧版本写入版本存储"]
    B --> C["新版本留在数据页"]
    D["读事务开始"] --> E["按时间点读取可见版本"]
    E --> F["必要时从版本存储取旧版本"]

版本存储通常在 TempDB 中,因此行版本能减少读写阻塞,但不是免费:

好处代价
普通读不容易被写阻塞TempDB 空间和 IO 增加
报表读更稳定长事务会保留更多版本
提升读写并发需要监控版本存储压力

锁升级为什么发生

SQL Server 维护大量行锁、页锁也有成本。如果一个事务持有大量细粒度锁,可能升级为表锁。

mermaid
flowchart TD
    A["事务扫描或修改大量行"] --> B["持有大量行锁 / 页锁"]
    B --> C["锁管理成本升高"]
    C --> D["SQL Server 尝试锁升级"]
    D --> E["升级为表锁"]
    E --> F["并发范围扩大,其他会话更容易阻塞"]

如何减少:

  1. 条件命中索引,减少扫描行数。
  2. 批量任务分批提交。
  3. 缩短事务时间。
  4. 避免在事务中等待外部接口。
  5. 必要时评估锁升级策略,但不要把禁用锁升级当万能药。

参数嗅探为什么导致忽快忽慢

SQL Server 编译执行计划时可能根据第一次执行的参数值估算行数,这叫参数嗅探。

mermaid
flowchart TD
    A["第一次执行 OwnerId=小客户"] --> B["优化器估算返回很少"]
    B --> C["生成 Nested Loops + Seek 计划"]
    C --> D["计划被缓存"]
    D --> E["第二次 OwnerId=大客户"]
    E --> F["复用小客户计划"]
    F --> G["实际返回很多,性能很差"]

排查方向:

方法说明
看实际执行计划估算行数和实际行数是否差很大
更新统计信息减少估算偏差
重新编译OPTION (RECOMPILE) 针对特殊 SQL
优化索引和查询让不同参数都能接受
Query Store观察计划变化和回退

TempDB 为什么重要

TempDB 不只是临时表,它还承载很多内部工作。

用途示例
排序和 Hash 溢出内存不够时写 TempDB
临时表和表变量#temp、内部临时对象
行版本存储RCSI、Snapshot 使用
DBCC 和部分维护任务内部工作空间

TempDB 瓶颈会表现为:

  1. 排序、Hash Join 很慢。
  2. 版本存储增长。
  3. 临时表高并发争用。
  4. 磁盘 IO 高。

商业场景:资产列表和状态更新

表结构:

sql
create table dbo.Asset (
  Id bigint identity(1,1) not null,
  AssetNo varchar(64) not null,
  OwnerId bigint not null,
  Status varchar(20) not null,
  CreatedAt datetime2 not null default sysdatetime(),
  UpdatedAt datetime2 not null default sysdatetime(),
  constraint PK_Asset primary key clustered (Id)
);

create unique index UX_Asset_AssetNo
on dbo.Asset(AssetNo);

create index IX_Asset_Owner_CreatedAt
on dbo.Asset(OwnerId, CreatedAt desc)
include (AssetNo, Status);

列表查询:

sql
select top (20) Id, AssetNo, Status
from dbo.Asset
where OwnerId = @OwnerId
order by CreatedAt desc;

状态更新:

sql
update dbo.Asset
set Status = 'USED',
    UpdatedAt = sysdatetime()
where AssetNo = @AssetNo
  and Status = 'IDLE';

为什么这样设计:

设计原因
聚集主键用 Id插入顺序稳定,行定位简单
资产编号唯一索引状态更新快速定位
OwnerId + CreatedAt 索引列表页按拥有者和时间排序
INCLUDE 查询列减少 Key Lookup
Status 放在更新条件防止重复状态流转

线上排查流程

慢 SQL

mermaid
flowchart TD
    A["SQL 慢"] --> B["看实际执行计划"]
    B --> C{"估算行数和实际行数差距大吗"}
    C -- "是" --> D["统计信息 / 参数嗅探"]
    C -- "否" --> E{"是否有 Key Lookup 很多"}
    E -- "是" --> F["INCLUDE 或减少返回列"]
    E -- "否" --> G{"是否 Sort/Hash 溢出"}
    G -- "是" --> H["看内存授予和 TempDB"]
    G -- "否" --> I["看等待类型、锁、IO"]

阻塞和死锁

mermaid
flowchart TD
    A["接口等待"] --> B["看阻塞链"]
    B --> C{"是否被未提交事务阻塞"}
    C -- "是" --> D["找到 blocker SQL"]
    D --> E["提交/回滚/修复事务边界"]
    C -- "否" --> F["看锁升级、死锁图、长事务"]

日志文件暴涨

检查项说明
恢复模式Full 模式是否做日志备份
大事务是否一次更新/删除太多
AG/复制延迟日志是否要保留给副本
索引数量DML 是否维护过多索引

常见坑

后果正确做法
只看有无索引Key Lookup 很多仍慢看实际执行计划和行数
INCLUDE 过多列索引膨胀,写入变慢只覆盖高频查询
大事务批量更新日志暴涨、锁升级、阻塞分批提交
忽略统计信息估算错误,计划不佳定期维护统计信息
忽略参数嗅探同一 SQL 忽快忽慢看计划缓存和实际行数
开启行版本后不管 TempDBTempDB 压力变大监控版本存储和 IO
在事务中调用外部接口锁持有时间不可控外部调用放事务外

面试标准回答

SQL Server 查询怎么执行

text
SQL Server 查询会先由查询处理器解析和绑定对象,再由优化器根据统计信息和成本选择执行计划。执行计划由 Index Seek、Index Scan、Key Lookup、Nested Loops、Hash Match、Sort 等算子组成。执行时存储引擎按计划访问数据页和索引页,优先从 Buffer Pool 读取,未命中才物理读磁盘。排查慢 SQL 要看实际执行计划、估算行数和实际行数差异、逻辑读、等待类型以及是否有大量 Key Lookup、Sort 或 Hash 溢出。

SQL Server update 和事务日志过程

text
SQL Server 更新数据时,会先定位目标行并获取相应锁,然后记录事务日志,修改 Buffer Pool 中的数据页,并维护相关索引。事务提交时日志保证修改可恢复,数据页本身可以稍后由后台写回数据文件。恢复时 SQL Server 会根据事务日志重做已提交事务并撤销未提交事务,所以事务日志不仅用于回滚,也用于崩溃恢复、日志备份和高可用同步。

聚集索引、非聚集索引和 Key Lookup

text
SQL Server 聚集索引的叶子层就是数据行,因此一个表只能有一个聚集索引。非聚集索引的叶子层保存索引键和行定位器,如果表有聚集索引,通常保存聚集键;如果是堆表,则保存 RID。当查询字段不在非聚集索引中时,需要通过聚集键或 RID 回到数据行取完整列,这就是 Key Lookup 或 RID Lookup。少量 Lookup 可以接受,大量 Lookup 会造成随机访问和性能下降,可以考虑 INCLUDE 覆盖字段或调整查询。

关联知识点

本章小结

SQL Server 的主线是:查询处理器选择计划,存储引擎按页读写数据,Buffer Pool 缓存页,聚集索引决定数据行组织,非聚集索引通过行定位器回表,事务日志保证恢复,锁和行版本共同处理并发。真正理解这些过程后,再遇到 Key Lookup、锁升级、参数嗅探、TempDB 压力、日志暴涨和慢 SQL,就能知道问题发生在哪一层,而不是只会说“加索引”。