应用程序向数据库发送一条 SELECT,片刻之后便拿到了结果。这个过程看起来像一次普通的函数调用,但数据库并不是收到 SQL 后就按照文字顺序逐句读取表。

它首先需要回答一系列问题:当前会话使用什么身份,能够访问哪些对象?SQL 的语法是否合法,表名和列名分别指向哪里?同一份结果可以通过哪些路径得到?应该扫描整张表,还是使用索引?是否需要排序,能够多早停止读取?目标数据页已经在内存中,还是必须通过存储层加载?

这些问题分别由连接管理、解析器、语义分析、查询改写、优化器、执行器与存储系统处理。一条查询会从连接和会话出发,经过理解、规划和数据访问,最后重新回到客户端。

一条 SQL 从连接到结果返回的完整执行链路

图中的前半段负责理解 SQL 并选择计划,后半段负责运行计划并取得数据。连接和会话为整条请求提供上下文,存储系统则把抽象的扫描算子落实为对具体页面和行版本的访问。

本文以一条 SELECT 为主线,介绍客户端—服务器型关系型数据库的通用执行流程。不同产品对模块的命名、存储组织和事务实现并不完全相同,文中的执行器示例更接近 PostgreSQL。事务隔离、并发控制、索引结构和崩溃恢复都有各自更深的实现,本文只解释它们在 SQL 执行链路中的位置。

1. 从一条查询开始

沿用数据库系列开篇中的订单示例。假设系统需要查询某位用户最近创建的 10 张订单:

SELECT
  id,
  status,
  created_at
FROM orders
WHERE user_id = :user_id
ORDER BY created_at DESC
LIMIT 10;

这里把开篇文章中的固定值 42 换成了客户端参数 :user_id。本文用 :user_id 统一表示应用层的具名参数;驱动或 ORM 可能在发送前将它改写成目标数据库支持的形式,例如 PostgreSQL 的 $1 或 MySQL 的 ?

这条 SQL 声明了需要什么结果,却没有规定数据库必须怎样得到结果。它没有要求扫描整张表还是使用索引,也没有规定怎样完成过滤和排序。SQL 的这种特征称为声明式:调用方描述目标,数据库负责选择实现路径。

后续章节会始终围绕这条查询推进。我们先看它如何进入数据库,再看数据库如何理解、优化和执行它。

2. SQL 到达数据库之前:连接、会话与请求协议

2.1 建立连接不一定发生在每条 SQL 之前

客户端—服务器型数据库通常通过 TCP 或 Unix Socket 与应用通信。TCP 通常用于通过网络连接数据库,Unix Socket 则用于同一主机上的进程间通信;TLS 可以为 TCP 连接提供传输加密。以一次全新的 TCP 连接为例,它可能经历:

建立网络连接
    ↓
协商 TLS(如果启用)
    ↓
身份认证
    ↓
创建数据库会话
    ↓
等待客户端命令

不过,这不代表每执行一条 SQL 都重新建立连接。Web 应用通常从连接池取得一个已经建立的连接,执行完成后再归还连接池。连接池省去了频繁握手和认证的成本,但数据库仍然需要为这个会话维护上下文。

常见的会话状态包括:

  • 当前用户、角色及对象权限;
  • 当前数据库与 Schema(组织表、视图等对象的命名空间)搜索路径;
  • 字符集、排序规则与时区;
  • 当前事务及其隔离级别;
  • 查询超时、内存限制等会话参数;
  • 临时表、预编译语句和游标。

因此,同一段 SQL 在不同会话中可能解析到不同 Schema,使用不同的时区转换,或者因为权限不同而得到错误。

2.2 SQL 文本与参数如何发送

客户端可以直接发送完整 SQL,也可以使用预编译或扩展查询协议,把 SQL 与参数分开。以 PostgreSQL 的扩展查询协议为例,发送到服务端的查询文本使用 $1 表示第一个参数:

SQL:   WHERE user_id = $1
参数:  $1 = 42

参数绑定让数据库能够按照目标类型处理参数,也避免应用把外部输入直接拼接进 SQL 结构。需要强调的是:参数只能替代值,不能直接替代表名、列名或任意 SQL 片段;动态标识符仍然需要由应用使用白名单等方式控制

预编译语句是否复用解析结果或执行计划,取决于数据库、驱动和参数情况。有些系统会复用解析、分析或改写后的编译阶段产物,有些系统还会在通用计划定制计划之间选择:前者不针对某一次参数值优化,后者会结合当前参数重新规划。因此,“使用预编译语句”不应简单等同于“永远只优化一次”,更不表示数据库会缓存上一次查询返回的数据行。

2.3 SQL 在什么事务中执行

即使应用没有显式执行 BEGIN,语句通常仍会进入一个事务上下文。在自动提交模式下,一条独立语句通常构成一个事务单元;在显式事务中,多条语句共享事务状态。

对于当前 SELECT,事务上下文会影响它能够看到哪些行版本。连接层把 SQL、参数和会话状态交给查询处理模块后,数据库才开始理解 SQL 文本本身。

3. 数据库如何读懂 SQL:从 Token 到查询树

3.1 词法分析识别 SQL 中的基本元素

数据库收到的 SQL 最初只是一串字符。词法分析器(Lexer)会把它拆分成 Token,也就是关键字、标识符、运算符、字面量和标点等基本单元。

例如,假设驱动已经把应用层的 :user_id 改写成 PostgreSQL 服务端能够识别的 $1,数据库收到的查询片段是:

SELECT id, status FROM orders WHERE user_id = $1;

会被识别成类似下面的序列:

SELECT | id | , | status | FROM | orders
WHERE  | user_id | = | $1 | ;

这一阶段只是在识别字符的角色,还没有读取 orders 表。

3.2 语法分析检查结构并生成语法树

语法分析器(Parser)根据 SQL 语法规则检查 Token 的组合,并生成语法树。简化后的结构可能是:

SelectStatement
├── SelectList
│   ├── id
│   ├── status
│   └── created_at
├── From
│   └── orders
├── Where
│   └── user_id = $1
├── OrderBy
│   └── created_at DESC
└── Limit
    └── 10

如果写成下面这样:

SELECT id, FROM orders;

Token 无法组成合法的查询结构,数据库会在语法分析阶段报告错误。

但是,语法正确不代表 SQL 已经具备完整含义:

SELECT not_exist FROM orders;

这条语句在结构上可能完全合法,只是引用了不存在的列。确认对象和类型属于下一步的工作。

3.3 语义分析把名字绑定到真实对象

语法树里的 ordersuser_idcreated_at 最初只是名称。语义分析阶段需要查询系统目录——数据库用来记录 Schema、表、列、类型和权限等元数据的内部结构——把这些名称绑定到具体对象,并补充类型等信息。

数据库通常会确认:

  • orders 对应当前搜索路径中的哪张表;
  • idstatususer_idcreated_at 分别对应哪一列;
  • user_id 与参数值的类型能否比较;
  • $1 应该转换成什么类型;
  • 查询需要访问哪些表、列和函数权限;
  • 函数、运算符和排序规则应该使用哪个实现。

对象绑定与权限校验不一定发生在同一时刻。数据库会在处理查询时记录所需的访问权限,并在产品规定的阶段完成检查;例如,PostgreSQL 会在查询树中保留权限信息,并由执行器完成相应检查。

如果查询连接了两张都含有 id 列的表,未限定表名的引用就可能产生歧义:

SELECT id
FROM users
JOIN orders ON users.id = orders.user_id;

语义分析完成后,数据库得到的不再只是 SQL 文字的结构,而是一棵已经关联到表、列、类型和运算符的查询树。数据库至此理解了查询“想做什么”,但还没有决定“怎样做”。

4. 查询改写与逻辑计划:先表达要完成哪些运算

4.1 查询树为什么还需要改写

当前查询只访问一张基础表,没有视图、子查询或复杂表达式,因此未必会发生显著的独立改写。但数据库仍可能规范化内部表达;在更复杂的查询中,还可能把视图展开为对基础表的访问、为行级安全规则加入额外条件,或者提前处理能够安全简化的常量表达式。

不同数据库把这些工作分别放在改写器、优化器或其他阶段,模块边界并不统一。重要的是:改写必须保持结果语义不变,不能因为执行更快而改变 NULL、外连接、聚合或函数调用的含义。

4.2 SQL 的逻辑处理顺序

这里需要区分 SQL 的书写顺序、逻辑处理顺序和物理执行顺序。对于当前查询,可以使用下面的链路理解结果在逻辑上如何形成:

FROM orders
    ↓
WHERE user_id = $1
    ↓
SELECT id, status, created_at
    ↓
ORDER BY created_at DESC
    ↓
LIMIT 10

更复杂的查询还可能包含 Join、GROUP BYHAVINGDISTINCT 等步骤。逻辑顺序用于解释 SQL 结果如何形成,不是数据库必须遵守的物理执行顺序。例如,只要不改变结果,优化器可以在扫描过程中直接判断 user_id,也可以利用有序索引避免单独排序。

当前查询可以抽象成下面的逻辑计划。其中 Project 表示投影,也就是只保留最终需要输出的列:

Limit(10)
└── Sort(created_at DESC)
    └── Project(id, status, created_at)
        └── Filter(user_id = $1)
            └── orders

逻辑计划描述需要完成扫描、过滤、投影、排序和限制行数,却没有指定采用全表扫描还是索引扫描。把这些逻辑运算转换成可运行的物理计划,是优化器的职责。

5. 优化器如何选择执行计划

5.1 同一条 SQL 存在许多候选路径

假设数据库中存在下面的索引:

CREATE INDEX idx_orders_user_created
ON orders(user_id, created_at DESC);

优化器至少可以考虑两类方案。第一类方案通过联合索引定位某位用户的订单,并直接按照创建时间倒序读取;第二类方案扫描订单表,过滤目标用户,再对候选订单排序并取前 10 条。

对于当前查询,这个联合索引同时支持 user_id 过滤、目标排序和 LIMIT 提前停止,通常具有很强的优势。但优化器仍不能仅凭“存在索引”就做决定:表非常小、统计估算失真、索引不可用,或者额外条件导致大量候选项被丢弃时,都可能改变候选成本。对于不具备排序和提前停止优势的一般查询,如果条件会返回表中大部分数据,顺序扫描也可能比大量索引访问更便宜。

5.2 统计信息帮助优化器估算行数

优化器通常不会为了生成计划而完整执行查询。它会读取提前收集的统计信息,例如:

  • 表和索引大约包含多少行与数据页;
  • 某列有多少个不同值;
  • NULL 和高频值各占多大比例;
  • 数值或时间值大致如何分布;
  • 某些列或物理顺序之间是否存在相关性。

基于这些信息,优化器会估算每个节点输出多少行。这个过程称为基数估算(Cardinality Estimation)

假设 orders 有 1 亿行,而一个用户通常只有几十笔订单,优化器可能估算 user_id = $1 只返回很小的结果集。此时,从联合索引的目标范围开始读取通常比扫描全部订单更便宜。

如果统计信息陈旧,或者不同用户的订单数量高度倾斜,估算行数就可能与真实行数相差很大。错误会沿计划树继续放大,并影响扫描方式和排序策略的选择。

5.3 成本不是预先测出的真实毫秒数

优化器会为候选路径估算成本,常见考虑因素包括:

  • 需要读取多少数据页;
  • 顺序读取与随机读取的相对代价;
  • 需要比较、计算或复制多少行;
  • Hash 表、排序和聚合需要多少内存;
  • 内存不足时是否可能写入临时文件;
  • 并行执行带来的收益能否覆盖启动、调度、数据传输和汇总等额外成本;
  • 查询只需要第一行、前几行,还是全部结果。

成本值通常是供同一个优化器比较方案的内部单位,不应直接当成预计毫秒数。优化器选择的也只是“根据现有信息估算成本较低”的计划,并不保证它一定是实际最快的计划。

连接表较多时,候选顺序会组合式增长。数据库通常不可能穷举所有方案,只能通过有限搜索和剪枝控制规划成本,在规划时间与计划质量之间权衡。

5.4 物理执行计划是什么样的

如果联合索引与数据分布都适合当前查询,计划的核心节点可能是 Index Scan → Limit:索引已经按照 user_idcreated_at DESC 组织,符合条件的订单可以按需要的顺序读出,得到 10 行后便有机会停止。

如果没有合适索引,核心节点可能变成 Sequential Scan → Top-N Sort → Limit。Top-N 排序不必保存完整有序结果,只需要持续维护当前最符合排序要求的 N 行,但它仍然需要消费下层提供的全部候选行。

同一条 SQL 的顺序扫描计划与有序索引计划对比

两棵计划都可能得到相同结果,但需要访问的数据量和中间操作完全不同。这些是说明算子关系的示意计划,不是某个真实数据库在未知数据集上必然生成的输出。

至此,数据库已经把“想得到什么”转换成了“准备怎样得到”。下一步,执行器会让这棵计划树真正开始产生数据。

6. 执行器如何运行计划树

优化器产出计划后,执行器才开始真正请求数据。许多数据库把执行计划组织成算子树,每个节点负责一种操作:

  • Scan 从表或索引取得候选行;
  • Filter 判断行是否满足条件;
  • Join 组合来自两侧的行;
  • Aggregate 维护分组和聚合状态;
  • Sort 建立指定顺序;
  • Project 只保留结果需要的列;
  • Limit 截断结果数量。

6.1 数据通常沿计划树逐层流动

PostgreSQL 的执行器采用类似需求拉取的方式。执行计划通常把最终输出节点画在上面,把扫描表或索引的节点画在下面。因此,“上层节点”表示更接近最终结果的节点,“下层节点”表示更接近数据来源的节点,并不表示它们按照从上到下的顺序一次性执行。

可以把每个计划节点理解成一个带有执行状态的迭代器:上层节点需要下一行时,会向自己的子节点发出请求;子节点如果还需要数据,就继续向更下层请求。请求逐层向下传递,找到的记录再逐层向上返回。

对于前面的索引计划,可以把过程简化为:

Limit 请求下一行
    ↓
orders Index Scan 请求下一条订单
    ↓
缓冲区与存储系统取得相关页面
    ↑
Index Scan 返回一条可见记录
    ↑
Limit 统计并返回这一行

这里的“请求下一行”是执行器内部的调用,不是客户端重新发送一次网络请求。每个节点也会保存自己的执行状态:Index Scan 会记住当前扫描到哪个索引项,Limit 会记住已经返回了多少行。下一次请求到来时,它们会从上一次的位置继续,而不是重新执行整条计划。

如果索引能够按照目标顺序持续返回订单,Limit 得到第 10 行后便不再请求第 11 行,下面的 Index Scan 也就有机会提前停止。不过,需求拉取并不表示数据库永远只读取最终返回的行数。像排序这样的节点,可能需要先取得大量甚至全部下层输入,才能返回自己的第一行;这种差异会在下一节继续说明。

不同数据库也可能一次处理一批行,采用向量化执行,或者在部分阶段使用不同的数据推动方式。无论具体模型如何,执行计划都不是一段从第一行顺序运行到最后一行的脚本,而是一组保存状态、相互请求并逐步产生结果的算子。

6.2 流水线算子与阻塞算子

部分算子可以边读取边输出,部分算子则必须先积累输入:

类型 常见算子 特征
流水线算子 Scan、Filter、Project、部分 Join 得到候选行后便可能向上返回
阻塞算子 Sort、部分 Aggregate、Hash Join 构建 Hash 表的阶段 通常需要先读取并保存一批输入

例如,没有有序索引时,Sort 通常必须消费全部候选订单后,才能确定时间最新的 10 条。它可能执行完整排序,也可能只维护当前最优的 Top-N;数据量超过可用内存时,某些排序或 Hash 操作还可能使用临时文件。

这也解释了为什么 LIMIT 10 不一定意味着数据库只读取 10 行。只有下层路径能够按目标顺序持续返回结果时,Limit 才可能在得到 10 行后提前停止;如果必须先处理全部候选行,LIMIT 可以降低排序内存和部分比较成本,却无法省掉下层输入扫描。

执行器知道应该运行哪个扫描算子,但 Scan 仍然只是逻辑入口。它要取得候选记录,还必须把表或索引访问转换成对具体数据页的请求。

7. 从索引到数据页:记录是怎样被读出来的

下面先用一张全景图串起这条路径。图中的 Heap、TID、Buffer Manager、shared buffers 和 MVCC 会在 7.1~7.4 逐项解释。

执行器通过索引、缓冲区和数据页取得可见记录

图 3 以 PostgreSQL 的普通 B-Tree 索引扫描为例。找到叶子索引项只是开始:页面能否在缓冲区命中、行版本是否对当前事务可见,也会影响实际访问成本。InnoDB 的具体路径与此不同,后文会单独对比。

7.1 数据库通常以页为单位管理数据

执行器请求的概念是一行,但存储系统通常以 数据页(Page) 为基本管理和 I/O 单位。一张表和一个索引都由许多页组成,页中再保存行、行位置或索引项。

不同产品的页结构并不相同。以 PostgreSQL 的普通表页为例,可以抽象为:

┌──────────────────────────────┐
│ Page Header                  │
├──────────────────────────────┤
│ Item Identifier / 行指针数组 │
├──────────────────────────────┤
│            空闲空间           │
├──────────────────────────────┤
│ 行数据                        │
├──────────────────────────────┤
│ 特殊区域(索引页可能使用)     │
└──────────────────────────────┘

PostgreSQL 通常使用 8 KB 页;InnoDB 默认使用 16 KB 页,并允许在初始化实例时选择其他支持的大小。这些数字是具体产品的默认值,不是关系型数据库的统一标准。

以页为单位读取能够摊薄 I/O 和管理成本,也意味着为了取得一行,数据库可能把包含多行的整个页读入内存。

7.2 B-Tree 索引如何缩小访问范围

数据库文档通常把常见的有序索引称为 B-Tree,其实现经常具有 B+Tree 风格的多层页结构。一次等值或范围查找可以概括为:

根页
  ↓ 根据键值选择分支
中间页
  ↓ 继续缩小范围
叶子页
  ↓ 找到索引项或起始位置
沿叶子页继续范围扫描

对于订单联合索引,数据库可以根据 user_id 定位对应范围的第一个叶子页,再按照 created_at DESC 的顺序继续读取。

但索引叶子最终如何定位完整行,取决于存储组织。下表中的 Heap 是 PostgreSQL 保存普通表行版本的堆式存储,TID(Tuple Identifier)则是由页号和页内位置组成的行版本标识:

实现 普通索引叶子保存什么 取得完整行的典型路径
PostgreSQL Heap + B-Tree 索引键与指向表中行版本的 TID 根据 TID 访问 Heap 页中的行版本
MySQL InnoDB 二级索引 二级索引键与主键值 根据主键值再次访问聚簇索引
MySQL InnoDB 主键聚簇索引 主键与完整行数据 叶子记录本身包含行数据

因此,“索引命中后再回表”不是所有数据库都使用同一种指针和存储路径。覆盖查询也只是说明查询所需字段可以从索引获得;数据库是否仍需访问表页确认可见性,取决于具体实现和页面状态。

7.3 访问数据页不等于每次访问磁盘

数据库通过缓冲区管理器(Buffer Manager)访问内存中的缓冲区。PostgreSQL 将主要数据库页缓存在 shared buffers 中,InnoDB 则使用 Buffer Pool 缓存表页和索引页。

一次页面访问可以简化为:

执行器请求某个页
    ↓
缓冲区中是否存在?
    ├── 是:从内存访问
    └── 否:通过操作系统与存储层加载页
              ↓
          放入缓冲区
              ↓
          返回给执行器

所以“使用索引”不等于“发生磁盘随机读取”,“顺序扫描”也不等于“一定很慢”。如果需要的页已经缓存,主要成本可能来自 CPU、内存访问和保护共享内存结构的内部同步;如果数据不在缓存中,I/O 才会成为更明显的成本。

同一条查询第一次较慢、随后变快,可能是相关索引页和数据页已经进入缓存。不过,操作系统页缓存、其他并发负载、查询计划变化和存储预读也会影响结果,不能仅凭两次耗时差异断定原因。

7.4 找到行以后还要检查可见性

索引或表扫描找到的是候选行或候选行版本。使用多版本并发控制(Multi-Version Concurrency Control,MVCC)的数据库还要根据当前事务的快照判断:这个版本是否已经提交,是否在快照建立前可见,是否已经被一个可见事务删除或替换。

因此,执行器大致会经历:

定位候选记录
    ↓
读取所在数据页
    ↓
检查行版本可见性
    ↓
计算剩余过滤条件
    ↓
把符合条件的行交给上层算子

普通 SELECT 往往可以借助 MVCC 读取适合当前快照的版本,而 SELECT ... FOR UPDATE 等锁定读取还需要获取相应锁。可见性规则由隔离级别和数据库实现共同决定,不能简化成“所有 SELECT 都不加锁”或“读取索引后一定得到最终结果”。

只有通过可见性和剩余条件检查的记录,才会继续沿计划树向上流动,最终成为返回给客户端的结果。

8. 结果如何返回客户端

计划顶层得到一行后,数据库还需要按照结果列类型对数据进行编码,并通过数据库协议发送给客户端。结果集较大时,服务端和驱动通常会分批传输,而不是把所有行一次性放进一个网络包。

完整链路还包括:

执行器生成结果行
    ↓
转换成协议中的文本或二进制格式
    ↓
写入网络缓冲区
    ↓
客户端驱动读取并解码
    ↓
转换成应用使用的数据结构

使用游标或设置抓取批次时,客户端可以逐批消费大型结果集,避免一次把全部结果保存在应用内存中。反过来,如果客户端读取速度低于服务端发送速度,网络缓冲区会逐渐饱和并迫使服务端放慢发送;这种反馈通常称为网络背压,它也可能延长连接和事务占用时间。

因此,应用观察到的总耗时可能同时包含:

  • 等待连接池连接;
  • 网络往返;
  • 数据库规划时间;
  • 数据库执行时间;
  • 锁等待与 I/O 等待;
  • 结果序列化、传输和客户端反序列化;
  • 应用消费结果的时间。

只看应用端总耗时无法直接断定优化器或存储层有问题,需要结合数据库侧的执行计划和等待信息继续定位。

到这里,一条 SELECT 已经完成了从应用到数据库、再从数据库返回应用的闭环。

9. 如何用 EXPLAIN 观察真实执行过程

观察执行计划,是把前面的抽象链路映射到真实查询的最直接方法。

9.1 EXPLAIN 与 EXPLAIN ANALYZE 的区别

PostgreSQL 可以只查看优化器生成的计划:

EXPLAIN
SELECT
  id,
  status,
  created_at
FROM orders
WHERE user_id = 42
ORDER BY created_at DESC
LIMIT 10;

需要比较估算与真实执行情况时,可以使用:

EXPLAIN (ANALYZE, BUFFERS)
SELECT
  id,
  status,
  created_at
FROM orders
WHERE user_id = 42
ORDER BY created_at DESC
LIMIT 10;

MySQL 8.4 可以使用:

EXPLAIN ANALYZE
SELECT
  id,
  status,
  created_at
FROM orders
WHERE user_id = 42
ORDER BY created_at DESC
LIMIT 10;

EXPLAIN 主要展示估算计划;EXPLAIN ANALYZE 会真实运行语句,并补充实际行数、循环次数和耗时等信息。各数据库允许分析的数据修改语句范围不同;在支持这类用法的数据库中,EXPLAIN ANALYZE 可能产生真实写入,应该只在测试环境或经过明确控制的事务中操作。即使事务最终回滚,也仍要考虑触发器、外部函数和锁等副作用。

9.2 阅读执行计划时先看什么

初次分析不必尝试一次理解所有字段,可以先按下面的顺序检查:

  1. 从计划底部开始,确认每张表采用了顺序扫描还是索引扫描。
  2. 查看过滤条件是在索引访问阶段缩小范围,还是读取之后才被 Filter 丢弃。
  3. 比较估算行数与实际行数,寻找差距最早出现的节点。
  4. 查看一个节点执行了多少次,避免忽略内层节点的大量重复工作。
  5. 检查排序、Hash 和聚合是否使用过多内存或临时存储。
  6. 在 PostgreSQL 中结合 BUFFERS 区分缓冲命中与页面读取,但不要把 Buffer 计数直接等同于物理磁盘次数。
  7. 最后再比较规划时间、执行时间与应用端总耗时,确认时间消耗位于哪一层。

执行计划最重要的价值,不是证明“数据库没有使用我预期的索引”,而是把优化器的估算和执行器的真实工作量放在一起比较。如果估算已经明显错误,添加索引未必是第一步;统计信息、数据分布、条件相关性和参数计划都可能需要继续检查。

EXPLAIN 让前面的 SELECT 主线从抽象机制变成了可以观察的执行证据。

10. 读路径之外:写语句如何进入修改流程

写语句同样要经过连接、解析、语义分析、改写、优化和执行计划。UPDATEDELETE 通常需要先通过扫描节点找出目标行,再把候选行交给修改节点。

INSERT 的入口不同:简单的 INSERT ... VALUES 通常先构造待插入记录,INSERT ... SELECT 则消费查询结果,然后再进入约束检查、索引维护和日志路径。它不需要像 UPDATEDELETE 那样先扫描目标行。

仍以订单系统为例,下面的语句只允许把待支付订单更新为已支付:

UPDATE orders
SET status = 'paid'
WHERE id = :order_id AND status = 'pending';

数据库还需要根据具体产品与事务状态完成以下工作:

  • 处理写冲突并取得必要的行锁或其他锁;
  • 在等待或并发更新后重新确认目标行和条件;
  • 检查数据类型、NOT NULLCHECK、唯一约束和外键;
  • 执行触发器并计算生成列等派生值;
  • 创建新行版本、保存 Undo(回滚或历史版本信息),或者采用其他可回滚表示;
  • 维护受影响的索引;
  • 生成 WAL(Write-Ahead Log,预写日志)或 redo log(重做日志)等恢复记录;
  • 把已经在内存中修改、但尚未写回持久存储的数据页标记为脏页。

提交成功通常不要求所有脏数据页已经写回磁盘,而是要求数据库在当前持久化配置下留下足够的提交与恢复信息。具体耐久边界还取决于同步刷盘、复制方式、存储系统和数据库配置。这些机制属于事务与恢复专题,放在这里的目的只是说明:写语句并不是绕过查询执行流程,而是在找到目标行之后继续进入并发控制与持久化路径。

读写两条路径至此都已明确:普通 SELECT 在返回结果后结束,写语句则继续进入并发控制与持久化。最后,我们集中纠正常见误解,并重新串起完整流程。

11. 常见误解与完整回顾

先集中纠正几个容易出现的误解:

常见说法 更准确的理解
SQL 按 SELECTFROMWHERE 的文字顺序执行 SQL 有逻辑处理顺序,物理执行顺序由优化器在保持语义的前提下决定
建立索引后数据库一定会使用 优化器会比较候选成本,返回大量数据时顺序扫描可能更便宜
使用索引就一定发生磁盘随机读取 相关索引页和数据页可能已经位于数据库缓冲区
LIMIT 10 表示数据库只读取 10 行 如果需要先扫描、连接或排序大量候选数据,底层读取量可能远大于 10 行
优化器选择的是绝对最快计划 它根据统计信息和成本模型选择估算较优计划,估算和搜索都有边界
EXPLAIN ANALYZE 只是显示计划 它会真实执行语句,并产生正常执行会发生的锁、读取或写入
COMMIT 成功表示所有数据页已经写入磁盘 提交通常表示必要日志满足当前耐久承诺,脏数据页可以稍后写回

现在可以把整条链路重新串起来:

  1. 应用建立或复用连接,数据库在会话中维护身份、权限和事务上下文。
  2. 客户端发送 SQL 与参数,数据库协议把请求交给查询处理模块。
  3. 词法与语法分析把字符转换成语法树,语义分析再绑定表、列、类型和函数。
  4. 查询树经过必要改写,形成表达关系运算的逻辑计划。
  5. 优化器根据索引、统计信息和成本模型比较扫描、连接、排序等候选路径。
  6. 选中的路径被展开为由多个算子组成的物理执行计划。
  7. 执行器沿计划树请求数据,存储系统通过索引或表扫描访问缓冲区中的数据页。
  8. 行版本经过可见性和过滤条件检查,再参与连接、聚合、排序或限制行数。
  9. 顶层结果被编码并分批返回客户端;写语句则继续进入锁、约束、日志和提交路径。

SQL 是声明式语言。应用负责准确描述目标,数据库则把这个目标转换成执行计划,并在事务、索引、缓冲区与存储机制的共同支持下得到结果。理解这条路径之后,数据库为什么可能不使用索引、LIMIT 为什么仍然很慢、估算行数为什么值得关注,也就不再是彼此孤立的性能经验。

参考资料