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,就能知道问题发生在哪一层,而不是只会说“加索引”。