SQL Server 核心全过程原理
SQL Server 不能只背“聚集索引叶子是数据行、非聚集索引会 Key Lookup、事务日志用于恢复”。这些都是点,真正项目里要能串成过程:
- 一条查询怎么经过解析、优化、执行计划和 Buffer Pool。
- 聚集索引、非聚集索引和堆表分别怎么定位行。
- 一条更新怎么写数据页、写事务日志、提交和恢复。
- 默认锁机制为什么会阻塞,行版本隔离又如何减少读写阻塞。
- 为什么 Key Lookup、参数嗅探、TempDB、锁升级会让线上变慢。
- 商业系统应该怎样看实际执行计划和等待类型。
学习目标
| 问题 | 学完后要能说清 |
|---|---|
| SQL Server 数据怎么存 | MDF/NDF 数据文件,LDF 日志文件,数据以 8KB 页组织 |
| 查询怎么执行 | 解析、优化、执行计划、访问方法、Buffer Pool、返回结果 |
| 聚集索引是什么 | 叶子层就是数据行,一个表只能有一个 |
| 非聚集索引怎么回表 | 叶子层保存索引键和行定位器,可能触发 Key Lookup |
| 事务日志做什么 | 回滚、崩溃恢复、日志备份、高可用复制 |
| 锁升级为什么发生 | 细粒度锁过多时为了降低锁管理成本,可能升级为表锁 |
| 行版本怎么减少阻塞 | Snapshot/RCSI 让读从版本存储读取旧版本,降低读写冲突 |
| 慢 SQL 怎么排查 | 实际执行计划、估算/实际行数、等待类型、TempDB、锁和统计信息 |
总体架构
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 的全过程
示例:
select top (20) Id, AssetNo
from dbo.Asset
where OwnerId = @OwnerId
order by CreatedAt desc;执行流程:
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 最重要的是区分三种定位方式。
flowchart TD
A["表数据组织"] --> B["聚集索引表"]
A --> C["堆表 Heap"]
B --> D["聚集索引叶子层就是数据行"]
B --> E["非聚集索引叶子保存聚集键"]
C --> F["行没有聚集顺序"]
C --> G["非聚集索引叶子保存 RID"]聚集索引
聚集索引的叶子层就是数据行。
flowchart TD
A["Clustered Index Root"] --> B["Intermediate Level"]
B --> C["Leaf Level"]
C --> D["完整数据行"]特点:
| 特点 | 说明 |
|---|---|
| 一个表只能有一个 | 因为数据行只能按一种聚集结构组织 |
| 叶子层是数据 | 不需要再通过别的结构找完整行 |
| 聚集键会进入非聚集索引 | 非聚集索引需要用它回到数据行 |
非聚集索引和 Key Lookup
非聚集索引叶子不是完整行,而是索引键加行定位器。
flowchart TD
A["非聚集索引 Seek"] --> B["找到索引键"]
B --> C["拿到聚集键"]
C --> D["Key Lookup 到聚集索引"]
D --> E["取完整列"]示例:
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:
create index IX_Asset_Owner_CreatedAt
on dbo.Asset(OwnerId, CreatedAt desc)
include (AssetNo, Status);但 INCLUDE 不是越多越好。索引变宽会增加写入维护成本、占用更多页、降低缓存效率。
一条 UPDATE 的全过程
示例:
update dbo.Asset
set Status = 'USED',
UpdatedAt = sysdatetime()
where AssetNo = @AssetNo
and Status = 'IDLE';执行流程:
flowchart TD
A["定位目标行"] --> B["获取更新锁 / 排他锁"]
B --> C["记录事务日志"]
C --> D["修改 Buffer Pool 中的数据页"]
D --> E["维护相关索引"]
E --> F["事务提交"]
F --> G["日志刷盘保证持久性"]
G --> H["返回成功"]
H --> I["脏页后续由后台写回数据文件"]每一步为什么需要:
| 步骤 | 作用 |
|---|---|
| 定位目标行 | 使用索引可以减少扫描和锁范围 |
| 获取锁 | 防止其他事务同时修改同一行 |
| 记录日志 | 支持回滚和崩溃恢复 |
| 修改页 | 先改内存中的数据页 |
| 维护索引 | 被更新列涉及索引时需要同步修改 |
| 提交 | 日志持久化后事务成功 |
| 后台刷页 | 数据页不必每次提交立即刷盘 |
事务日志为什么重要
事务日志 LDF 不是普通审计日志,而是数据库恢复机制核心。
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 下,读通常会加共享锁,写会加排他锁。共享锁和排他锁冲突。
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 | 事务级快照隔离 |
简化流程:
flowchart TD
A["更新数据行"] --> B["旧版本写入版本存储"]
B --> C["新版本留在数据页"]
D["读事务开始"] --> E["按时间点读取可见版本"]
E --> F["必要时从版本存储取旧版本"]版本存储通常在 TempDB 中,因此行版本能减少读写阻塞,但不是免费:
| 好处 | 代价 |
|---|---|
| 普通读不容易被写阻塞 | TempDB 空间和 IO 增加 |
| 报表读更稳定 | 长事务会保留更多版本 |
| 提升读写并发 | 需要监控版本存储压力 |
锁升级为什么发生
SQL Server 维护大量行锁、页锁也有成本。如果一个事务持有大量细粒度锁,可能升级为表锁。
flowchart TD
A["事务扫描或修改大量行"] --> B["持有大量行锁 / 页锁"]
B --> C["锁管理成本升高"]
C --> D["SQL Server 尝试锁升级"]
D --> E["升级为表锁"]
E --> F["并发范围扩大,其他会话更容易阻塞"]如何减少:
- 条件命中索引,减少扫描行数。
- 批量任务分批提交。
- 缩短事务时间。
- 避免在事务中等待外部接口。
- 必要时评估锁升级策略,但不要把禁用锁升级当万能药。
参数嗅探为什么导致忽快忽慢
SQL Server 编译执行计划时可能根据第一次执行的参数值估算行数,这叫参数嗅探。
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 瓶颈会表现为:
- 排序、Hash Join 很慢。
- 版本存储增长。
- 临时表高并发争用。
- 磁盘 IO 高。
商业场景:资产列表和状态更新
表结构:
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);列表查询:
select top (20) Id, AssetNo, Status
from dbo.Asset
where OwnerId = @OwnerId
order by CreatedAt desc;状态更新:
update dbo.Asset
set Status = 'USED',
UpdatedAt = sysdatetime()
where AssetNo = @AssetNo
and Status = 'IDLE';为什么这样设计:
| 设计 | 原因 |
|---|---|
| 聚集主键用 Id | 插入顺序稳定,行定位简单 |
| 资产编号唯一索引 | 状态更新快速定位 |
| OwnerId + CreatedAt 索引 | 列表页按拥有者和时间排序 |
| INCLUDE 查询列 | 减少 Key Lookup |
| Status 放在更新条件 | 防止重复状态流转 |
线上排查流程
慢 SQL
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"]阻塞和死锁
flowchart TD
A["接口等待"] --> B["看阻塞链"]
B --> C{"是否被未提交事务阻塞"}
C -- "是" --> D["找到 blocker SQL"]
D --> E["提交/回滚/修复事务边界"]
C -- "否" --> F["看锁升级、死锁图、长事务"]日志文件暴涨
| 检查项 | 说明 |
|---|---|
| 恢复模式 | Full 模式是否做日志备份 |
| 大事务 | 是否一次更新/删除太多 |
| AG/复制延迟 | 日志是否要保留给副本 |
| 索引数量 | DML 是否维护过多索引 |
常见坑
| 坑 | 后果 | 正确做法 |
|---|---|---|
| 只看有无索引 | Key Lookup 很多仍慢 | 看实际执行计划和行数 |
| INCLUDE 过多列 | 索引膨胀,写入变慢 | 只覆盖高频查询 |
| 大事务批量更新 | 日志暴涨、锁升级、阻塞 | 分批提交 |
| 忽略统计信息 | 估算错误,计划不佳 | 定期维护统计信息 |
| 忽略参数嗅探 | 同一 SQL 忽快忽慢 | 看计划缓存和实际行数 |
| 开启行版本后不管 TempDB | TempDB 压力变大 | 监控版本存储和 IO |
| 在事务中调用外部接口 | 锁持有时间不可控 | 外部调用放事务外 |
面试标准回答
SQL Server 查询怎么执行
SQL Server 查询会先由查询处理器解析和绑定对象,再由优化器根据统计信息和成本选择执行计划。执行计划由 Index Seek、Index Scan、Key Lookup、Nested Loops、Hash Match、Sort 等算子组成。执行时存储引擎按计划访问数据页和索引页,优先从 Buffer Pool 读取,未命中才物理读磁盘。排查慢 SQL 要看实际执行计划、估算行数和实际行数差异、逻辑读、等待类型以及是否有大量 Key Lookup、Sort 或 Hash 溢出。SQL Server update 和事务日志过程
SQL Server 更新数据时,会先定位目标行并获取相应锁,然后记录事务日志,修改 Buffer Pool 中的数据页,并维护相关索引。事务提交时日志保证修改可恢复,数据页本身可以稍后由后台写回数据文件。恢复时 SQL Server 会根据事务日志重做已提交事务并撤销未提交事务,所以事务日志不仅用于回滚,也用于崩溃恢复、日志备份和高可用同步。聚集索引、非聚集索引和 Key Lookup
SQL Server 聚集索引的叶子层就是数据行,因此一个表只能有一个聚集索引。非聚集索引的叶子层保存索引键和行定位器,如果表有聚集索引,通常保存聚集键;如果是堆表,则保存 RID。当查询字段不在非聚集索引中时,需要通过聚集键或 RID 回到数据行取完整列,这就是 Key Lookup 或 RID Lookup。少量 Lookup 可以接受,大量 Lookup 会造成随机访问和性能下降,可以考虑 INCLUDE 覆盖字段或调整查询。关联知识点
本章小结
SQL Server 的主线是:查询处理器选择计划,存储引擎按页读写数据,Buffer Pool 缓存页,聚集索引决定数据行组织,非聚集索引通过行定位器回表,事务日志保证恢复,锁和行版本共同处理并发。真正理解这些过程后,再遇到 Key Lookup、锁升级、参数嗅探、TempDB 压力、日志暴涨和慢 SQL,就能知道问题发生在哪一层,而不是只会说“加索引”。
