最近在折腾某个数据检索相关的内部项目,核心存储用的就是 PostgreSQL。说实话,早年写 SQL 基本停留在“能跑就行、返回结果就完事”的阶段。直到某一次线上慢查询把服务拖到告警,我才开始正视一个问题:一条 PostgreSQL 的 SQL 查询,从客户端发出到结果返回,中间到底经历了什么?为什么同样的表、同样的数据量,换个写法执行时间差了上百倍?
这篇想写的不是那种“从零安装数据库”的新手教程,而是一条更接近实战的查询之旅:我会把一次普通 SELECT 从连接到解析、从优化器决策到执行器运行、从索引选择到参数调优的全过程拆开讲。适合刚接触 PostgreSQL、想搞懂执行原理的开发者,也适合写了几年 SQL 但没系统看过优化器如何“拍板”的朋友。文章里会有不少可以直接抄走的排查思路和优化手段,也有我实际踩过的坑。
1. 查询之旅的起点:先把执行链路搞清楚
1.1 一条SQL从客户端到服务器:连接与解析
很多人以为“执行一条 SQL”是从数据库收到完整语句才开始的,其实在 PostgreSQL 里,第一步是连接管理。客户端通过 TCP 或本地 socket 连接到 PostgreSQL 的 postmaster 主进程,主进程验证完身份(包括 pg_hba.conf 的访问规则、用户名密码或证书认证)后,会 fork 出一个独立的 backend process 来服务这个连接。也就是说,每个连接对应一个独立进程,而不是一个线程。这也是 PostgreSQL 并发模型和 MySQL 线程模型最大的区别之一。
这个模型带来的直接后果是:连接数一旦飙高,进程数就会跟着涨,内存和 CPU 的开销会非常明显。所以很多线上环境会强制要求使用连接池(应用侧连接池或代理层连接池),比如把连接数控制在几十个以内,而不是让应用直连数据库。之前遇到过一个事故:某应用因为连接没有正确释放,数据库的 backend process 堆积到几百个,内存直接吃紧,整体性能雪崩。处理方式不复杂——把连接池加上、调小超时时间——但排查过程相当折磨。
连接建立之后,客户端发来的 SQL 文本会以消息形式传给 backend process。这里有一个容易忽略的小细节:PostgreSQL 的协议是明文文本协议,SQL 语句本身就是传输内容,不是像某些数据库那样走二进制协议。这也意味着中间任何一层代理(比如负载均衡器、网关)都可以读取和解析 SQL,方便做审计或路由,但也需要注意传输加密的配置(SSL)问题。
当服务端收到完整的 SQL 语句后,第一步是“词法分析”和“语法分析”。词法分析把字符串拆成一个个 token(关键字、标识符、数字、操作符),语法分析根据 PostgreSQL 的语法规则生成解析树。这一阶段如果报错,通常就是“语法错误”类的提示。正常情况下,接下来会进入语义分析,系统会检查表是否存在、字段是否存在、类型是否匹配、是否有权限等。这一步会产生一个查询树(Query Tree),后续所有优化和执行都基于这个结构。
1.2 查询分析:解析器、分析器、重写器
从解析树到查询树,中间有一层容易被忽略的环节:查询重写。PostgreSQL 有一个规则系统,视图(View)就是在这个阶段展开的。你写的“SELECT * FROM my_view”,在重写阶段会被替换成视图定义里的查询语句。还有规则(Rule),理论上可以在表上定义重写规则来改变查询行为,但实际工作中我几乎没见人用过规则系统,因为它太容易把执行计划搞乱。视图倒是天天用,所以理解“视图不是表,它只是查询宏”这件事很重要——视图不会提升查询性能,它只是帮你把 SQL 文本预先组装好。
重写完成后,查询树正式进入优化器。到这里,一条 SQL 的“旅程”才真正到了最关键的分岔路口。优化器要决定:这个查询是直接全表扫描,还是走某个索引?多张表关联时,是先查 A 再关联 B,还是先查 B 再关联 A?关联算法是用嵌套循环(Nested Loop)、哈希关联(Hash Join)还是排序合并(Merge Join)?这些决定直接决定了查询要花几十毫秒还是几十秒。
这里我最初也犯过一个认知错误:以为 SQL 写得好就万事大吉。其实优化器根本不看你 SQL 的“风格”,它只看查询树和统计信息。同一个查询,可能因为统计信息过期、参数值不同、甚至会话级配置差异,得到完全不同的执行计划。所以 SQL 查询优化的第一课不是“背优化技巧”,而是理解优化器在拿什么做决策。接下来这一节,就把优化器的工作机制拆开看。
2. 优化器是如何“拍板”的:统计信息与代价模型
2.1 统计信息:优化器的“食材”
优化器做决策的第一手依据,是表、列和索引的统计信息。PostgreSQL 在表上执行 ANALYZE(手动或 autovacuum 自动触发)后,会把采样数据写入 pg_statistic 系统表,同时通过 pg_stats 视图暴露给用户。统计信息里包括:列的空值比例(null_frac)、平均宽度(avg_width)、唯一值数量(n_distinct)、最常见的值及其频率(most_common_vals 和 most_common_freqs)、直方图边界(histogram_bounds)等。
这些数据看起来枯燥,但它们直接决定优化器对“返回行数”的估计。举个例子:一个订单表有 500 万行,其中 status 字段只有 3 个值(pending、paid、cancelled),分布比例是 80%、15%、5%。如果你查 status = 'cancelled',优化器根据统计信息估算出大约 25 万行;如果查 status = 'pending',估算约 400 万行。同样是一个等值查询,优化器可能为前者选择索引扫描,为后者选择全表扫描——因为回表 400 万行的代价远高于全表顺序扫描。
这里最常见的坑是:统计信息长期不更新,或者采样比例不够导致估算失真。比如一张表数据从 10 万涨到 1000 万,如果 autovacuum 没及时跑 ANALYZE,优化器还认为表只有 10 万行,就会选出一个完全离谱的执行计划。我之前排查过一个典型案例:某报表查询平时只需 200 毫秒,某天突然变成 30 秒。查 EXPLAIN 发现优化器估算的返回行数只有几百,实际返回却有几百万。就是因为大事务一直持有锁,autovacuum 的 ANALYZE 被阻塞,统计信息停留在几个月前。手动执行 ANALYZE 后,查询恢复如初。
所以优化器干活的第一条原则是:给什么食材,做什么菜。我们日常能用到的调试手段,就是通过 EXPLAIN 观察估算行数(rows)和实际行数(actual rows)是否匹配。如果两者差距巨大,第一怀疑对象就是统计信息过期,其次才是执行计划本身的问题。
2.2 代价模型与并行查询:优化器在权衡什么
有了统计信息,下一步就是计算代价。PostgreSQL 使用一种基于成本的模型,每种操作都被折算成“代价单位”。默认参数下,顺序扫描一条记录的代价是 1,随机访问一条记录(比如索引扫描中的堆表访问)的代价是 4(random_page_cost 默认值)。CPU 处理一条记录的成本大约是 0.01。这些参数本质上是在模拟磁盘 IO 和 CPU 计算的开销比例,优化器把整条路径的代价累加起来,选择总代价最低的方案。
理解这一点后,很多貌似“奇怪”的选择就说得通了。例如在普通机械硬盘上,random_page_cost 应该调高一些(比如 4.0 以上),因为随机 IO 比顺序 IO 慢得多;而在 NVMe SSD 上,随机 IO 和顺序 IO 的差距大幅缩小,把 random_page_cost 调到 1.0~1.5 反而更接近真实硬件,优化器也就更愿意走索引。我自己在云主机上做过对比:把 random_page_cost 从默认 4 调成 1.1 后,一批涉及大表点查的业务查询平均耗时下降了约 40%。但这个参数不能盲调,如果库里大量查询本来就是合理的全表扫描,调低它会诱导优化器在某些场景下选择更差的索引路径,所以改完一定要做回归测试。
现代 PostgreSQL 还有一个重要选项:并行查询。当单条 SQL 被判定为高代价操作(比如大表全表扫描、聚合、排序)时,优化器可以决定启用多个 worker process 并行执行。相关参数有 max_parallel_workers_per_gather、min_parallel_table_scan_size、parallel_setup_cost、parallel_tuple_cost 等。它们的本质是权衡“启动并行进程的开销”和“并行节省的时间”。并行不是免费的——每个 worker 都要消耗内存、要传递数据、要做最后的汇总合并,小查询开并行的收益几乎为负。
我见过一个真实场景:某数据仓库类查询,单次扫描 2 亿行做聚合,串行要 8 分钟,开启 4 个并行 worker 后缩短到 2 分半。但同样的并行配置,放到一个每分钟几千次的 OLTP 小查询上,反而让 CPU 使用率无谓升高、平均延迟变差。所以经验法则是:给并行查询设置门槛,让全表扫描小表时不启用并行,只在大表分析型查询里生效。具体操作就是设置 min_parallel_table_scan_size,例如 8MB,让低代价顺序扫描不触发并行。
3. 索引设计与查询优化:从EXPLAIN看真相
3.1 索引类型怎么选
优化器有了执行方向,具体怎么“走路”靠的是访问方法(Access Method),最常见的访问方法就是索引。PostgreSQL 的索引类型比很多数据库丰富,实际工作中最常见的几种必须搞清楚:
- B-Tree:通用默认,几乎所有操作符(=、>、<、BETWEEN、LIKE 'abc%')都能用。适合等值和范围查询,也支持排序。
- Hash:只支持等值比较(=),没有顺序性。在某些点查场景下比 B-Tree 快,但实际系统里用得不多,因为 B-Tree 也能应对等值查询。
- GIN:适合“一个字段包含多个值”的场景,典型的是数组、全文检索 tsvector、JSONB 的 @> 或 ? 操作符。
- BRIN:针对超大表的“块级索引”,记录的是每个数据块内某个列的最小值和最大值。如果数据物理顺序与该列顺序高度相关(比如自增主键、时间戳),BRIN 索引体积极小、扫描效率极高。
- GiST:适合几何类型、范围类型、全文检索(也有 GIN 方案),一般业务系统接触较少。
挑选索引类型的原则并不复杂:先看查询条件和数据分布,再选匹配的索引结构。大多数电商、内容类系统的常规查询,B-Tree 就够了;涉及数组包含判断、JSONB 字段查询,优先考虑 GIN;几百 GB 以上的大表,特别是日志表、流水表,BRIN 的性价比有时远超 B-Tree。
索引设计中有一个很难绕开的“回表”概念。PostgreSQL 的索引不直接存储行数据(少数情况除外),索引扫描会先定位到索引条目,再到堆表(Heap)里取整行数据,这个动作叫回表(Table Access)。如果查询只需要索引列本身,优化器可能选择 Index Only Scan,完全跳过回表。所以在设计复合索引时,把 SELECT 需要的列加入索引(或多列索引的尾部),有机会让查询变成 Index Only Scan,省掉大量随机读取。不过索引列越多,写入代价越大,这是必须权衡的。
3.2 实操案例:三层SELECT优化实例解读
直接用一个我处理过的模拟案例来说明 EXPLAIN 的读法。假设有一张用户订单表 user_orders,结构如下:
CREATE TABLE user_orders ( id BIGSERIAL PRIMARY KEY, user_id INT NOT NULL, status TEXT NOT NULL, created_at TIMESTAMPTZ NOT NULL DEFAULT now(), amount NUMERIC(10, 2) NOT NULL );表里有约 500 万行数据。业务上有一个高频查询:查某个用户的最近 20 笔订单。原始 SQL 长这样:
SELECT id, status, amount, created_at FROM user_orders WHERE user_id = 123456 ORDER BY created_at DESC LIMIT 20;执行后特别慢,通过 EXPLAIN ANALYZE 看到的计划核心部分如下:
Sort (cost=xxxx..xxxx rows=xxxx width=xx) Sort Key: created_at DESC -> Seq Scan on user_orders (cost=0.00..xxxxx rows=xxxx width=xx) Filter: (user_id = 123456)关键信息就两个:Seq Scan 意味着全表扫了 500 万行,Filter 逐行过滤;Sort 意味着把所有匹配的行都拿出来排了序再取前 20。这种写法就是把数据全部捞一遍,代价很大。
第一轮优化很简单:给 user_id 建普通 B-Tree 索引。执行后计划变成:
Index Scan using idx_user_orders_user_id on user_orders (cost=0.43..xxx rows=xxx width=xx) Index Cond: (user_id = 123456) -> Sort行数降下来了,但排序仍然存在。如果这个用户的订单量有几十万行,排序开销依然不容忽视。第二轮再优化:把索引改为复合索引 (user_id, created_at DESC)。
CREATE INDEX idx_user_orders_user_created ON user_orders(user_id, created_at DESC);执行计划直接变成了:
Limit -> Index Scan Backward using idx_user_orders_user_created on user_orders Index Cond: (user_id = 123456)这里没有 Sort 节点了,因为 B-Tree 索引本身维护了列的顺序,优化器按索引顺序读取前 20 条即可。整个查询从全表扫描 500 万行,变成了索引内定位 + 顺序读取 20 条,耗时有数量级的下降。
做这类优化时,EXPLAIN ANALYZE 的输出要看几个核心字段:
- actual time:每个节点的实际时间和总时间。如果某个节点 actual time 特别大,优先针对它下手。
- rows:实际返回行数,对比计划里的估算行数,差距过大说明统计信息或参数有问题。
- Buffers:读取的缓冲块数量,反映 IO 压力。
另外,EXPLAIN 的 cost 单位不是毫秒也不是秒,是抽象的代价单位。虽然它和实际执行时间大体正相关,但不建议直接用它换算出执行时间,还是要看 ANALYZE 给出的实际时间。
4. 查询优化的进阶技巧:不只是加索引
4.1 查询重写:换个写法,绕开执行计划的陷阱
索引优化做完之后,很多查询仍然不够快。这时候往往不是索引没建对,而是 SQL 本身的写法把优化器逼进了死角。PostgreSQL 优化器虽然强大,但对某些模式支持得并不完美,需要靠查询重写来“引导”它。
一个非常经典的案例是 NOT IN。假设要查“从未下单的用户”,有人会写成:
SELECT * FROM users WHERE id NOT IN (SELECT user_id FROM orders);这个写法的问题在于:如果 orders.user_id 存在 NULL,NOT IN 的语义会变成“结果为空”——这是 SQL 三值逻辑的经典陷阱。更隐蔽的是,优化器在处理 NOT IN 时往往无法有效使用索引或哈希关联,选择 NestLoop Anti Join,代价极高。换成 NOT EXISTS 通常更好:
SELECT * FROM users u WHERE NOT EXISTS (SELECT 1 FROM orders o WHERE o.user_id = u.id);NOT EXISTS 的执行路径通常是 Anti Join,优化器可以用 Hash Anti Join 或 NestLoop Anti Join,配合索引效率高得多。实际项目中,我曾把一条 NOT IN 子查询改写为 NOT EXISTS 后,执行时间从 12 秒降到 0.8 秒,纯粹是写法变化带来的收益。
另一个常见场景是 OR 条件的处理。如果查询里多个条件用 OR 连接,且每个条件各自有索引,优化器有时会选择 BitmapOr 或直接把多个索引扫描结果合并,但某些复杂 OR 会退化成全表扫描。一个相对稳妥的重写是把 OR 拆成 UNION ALL 分支,每个分支单独走索引,最后再合并。比如:
SELECT * FROM orders WHERE user_id = 1 OR status = 'urgent';可以改写成:
SELECT * FROM orders WHERE user_id = 1 UNION ALL SELECT * FROM orders WHERE status = 'urgent' AND user_id <> 1;注意第二个分支要加 user_id <> 1 避免重复数据,这就是 UNION ALL 去重的代价由自己控制。当然,这种改写不是银弹,如果两个条件过滤性一般,拆分后反而可能更快或更慢,必须结合 EXPLAIN 验证。
还有一种我非常推荐的重写思路是善用 CTE 和窗口函数。很多人在处理“取每个分组内最新一条记录”时,第一反应是子查询 + GROUP BY,写出来又绕又慢。用窗口函数 ROW_NUMBER() 不仅逻辑清晰,执行效率也常常更好:
SELECT * FROM ( SELECT *, ROW_NUMBER() OVER (PARTITION BY user_id ORDER BY created_at DESC) AS rn FROM orders ) t WHERE rn = 1;这条查询在 PostgreSQL 中会先按 user_id 分区、按 created_at 排序,再取每个分区的第一行。配合合适的索引,执行路径非常可控。
4.2 服务端原生功能:分区表、锁等待与参数调优
索引和重写之外,还有几项“服务端原生功能”值得在查询之旅中专门体验一下。
第一个是声明式分区(Declarative Partitioning)。当某张表数据量级达到数亿行、且查询条件总是落在某个时间范围或某个类型值上时,分区表能大幅缩小扫描范围。PostgreSQL 支持 RANGE、LIST、HASH 分区,使用方式上,创建主表和子表时指定分区键即可。底层实现上,查询优化器会做分区裁剪(Partition Pruning),比如按 created_at 做 RANGE 分区,查询 WHERE created_at >= '2024-01-01' 时,优化器直接从元数据里知道只需要扫描 2024 年之后的分区,其他分区根本不碰。
不过分区表有两个容易被低估的成本:一是分区数量不能太多,一两百个分区以内通常没问题,超过上千个会在元数据管理和计划生成阶段引入额外开销;二是某些操作(如跨分区去重、跨分区关联)反而可能比单表更慢,因为优化器需要做更多分支合并。所以要不要分区,一定先看查询是否稳定落在某个分区键上,而不是单纯因为表大。
第二个是锁等待问题。查询之旅里,SQL 慢有时候不是执行本身慢,而是被阻塞了。PostgreSQL 的行锁机制中,如果一个事务更新了某行但还没提交,另一个事务去更新同一行就会阻塞。还有一种更安静的杀手:idle in transaction。一个事务开始后执行了一些写操作,但连接一直没有提交或回滚,甚至客户端不再发命令,这个连接会一直持有锁,其他会话的查询就一直在等。排查方法我习惯用 pg_stat_activity 视图:
SELECT pid, state, wait_event_type, wait_event, now() - xact_start AS xact_age, query FROM pg_stat_activity WHERE state <> 'idle' ORDER BY xact_age DESC;如果发现大量 xact_age 很大的连接,基本可以锁定是应用层事务管理出了问题。配合 pg_locks 视图,能进一步确认锁冲突的对象。
第三个是内核参数的影响。PostgreSQL 有大量与查询执行相关的配置项,直接在 postgresql.conf 或会话级别可调。常见的有:
- work_mem:排序、哈希关联、聚合等操作在内存中能使用的上限。太小会让这些操作落盘(写到临时文件),性能断崖式下跌。但调太大会导致并发场景下内存耗尽,因为每个会话都可能分配这么多内存。比如 work_mem = 64MB,同时 100 个会话做排序,内存就可能多出几个 GB。
- shared_buffers:共享缓冲区大小,默认 128MB 偏小。按经验,通常可以设置到物理内存的 15%~25%,但要配合操作系统参数和文件系统缓存策略整体考虑。
- effective_cache_size:这是优化器用来估算“系统可用缓存”的参考值,不是真实分配的内存,调低会让优化器低估索引扫描的收益,倾向于选择全表扫描。
参数调优的通用建议是先通过 pg_stat_statements 找到高频次、高总耗时的 SQL,针对这些 SQL 做 EXPLAIN,再结合参数调整,而不是盲目把 shared_buffers 调大、work_mem 调高。我见过太多人把 work_mem 调到 1GB,结果并发一高内存直接打满,这是典型的“好心办坏事”。
5. 常见问题与排查技巧实录
5.1 慢查询排查台账:症状、原因与解法
查询之旅里最难的部分往往是定位问题。同一个“查询变慢”的症状,背后的原因差别可能很大。我整理了平时遇到频率最高的问题,做成一个可以直接对照的排查表。
| 症状 | 常见原因 | 排查思路 | 解决手段 |
|---|---|---|---|
| 同一条SQL忽快忽慢 | 统计信息过期或参数值偏离 | 对比 EXPLAIN 的估算rows和实际rows | 执行 ANALYZE,检查 autovacuum 是否被阻塞 |
| 查询走了索引还是慢 | 索引选择性差,回表次数多 | 查看索引扫描返回行数占总行数比例 | 改列顺序、增加覆盖列、考虑 BRIN/GIN |
| 多表关联极慢 | 关联列无索引或类型不匹配 | 查看 Hash Join 的 hashtable 大小、是否落盘 | 给关联列建索引、统一字段类型 |
| 并发升高后延迟暴涨 | work_mem 不足导致临时文件落盘 | 日志里出现 temporary file 提示 | 按工作负载合理调大 work_mem,或减少并发 |
| 查询卡住不返回 | 锁等待或 idle in transaction | 查询 pg_stat_activity 和 pg_locks | 修复应用事务、调大锁超时参数 |
| 返回行数不多但内存占用高 | 排序/聚合/哈希操作占内存 | 检查 Sort 或 HashAggregate 节点 | 调整 work_mem,优化排序字段索引 |
| 数据量增大后计划完全变化 | 统计信息陈旧或参数不合理 | 对比历史 EXPLAIN | 手动 ANALYZE,或关闭泛化计划(generic plan) |
排查工具方面,首推 pg_stat_statements,它会把 SQL 归一化后记录执行次数、总耗时、平均耗时、缓冲区命中情况,是定位慢查询的第一利器。其次是 auto_explain,它能在日志中自动记录超过指定耗时的 SQL 的完整执行计划,对于生产环境复现问题很有用,不用反复手动抓取 EXPLAIN。
5.2 避坑指南:计划不稳定与统计信息陷阱
在我经手的项目里,最隐蔽的问题之一就是执行计划不稳定。同样的 SQL,因为参数不同而走了不同的计划。PostgreSQL 对预编译语句有两种计划策略:自定义计划(Custom Plan)和通用计划(Generic Plan)。当某条 prepared statement 执行到第五次时,优化器会评估通用计划的代价,如果低于自定义计划,后续就用通用计划。问题在于,通用计划是基于“参数未知”的假设生成的,一旦数据分布极不均匀(比如某个 user_id 占了表中 60% 的数据),通用计划很可能选了全表扫描,而实际上用户传的恰好是只占 0.01% 的参数,这就导致偶发慢查询。
解法有几个:一是把这类查询从 prepared statement 改成普通文本查询,让优化器每次看到具体参数;二是调整 plan_cache_mode;三是通过改写 SQL(比如加条件提示)来约束计划。PostgreSQL 原生没有 MySQL 的 FORCE INDEX,但有扩展 pg_hint_plan 可以注入 hints,不过我不建议一上来就上这个扩展,因为它治标不治本,应该优先想办法让统计信息和数据分布保持一致。
另一个统计信息陷阱是关于 NULL 的估算。很多优化器会把“列不存在 NULL”作为默认假设之一,但如果某列虽然建了索引,却有大量 NULL,那么等值查询 NULL 时索引扫描可能还不如全表扫描。数据仓库类场景中,NULL 比例高的维度列格外明显。解决思路是:统计数据分布,必要时用 COALESCE 把 NULL 变成一个业务默认值(比如 'unknown'),让索引真正生效。
5.3 查询之旅的终点:从EXPLAIN到持续监控
如果让我给出一条优化路径的建议,我会说:不要等出故障才去看 EXPLAIN。把 EXPLAIN、pg_stat_statements、auto_explain、pg_stat_activity 这几个工具常态化投放到日常监控里,每次上线新功能、数据量翻倍、大促前,都跑一遍关键 SQL 的 EXPLAIN ANALYZE 并留存计划快照。这样一旦执行计划恶化,你可以对比“之前好的计划”和“现在差的计划”,找差异往往比从零分析快得多。
我之前在某项目里就建立过这个习惯。当时一个对账查询原本稳定在 300ms,某次数据量翻倍后突然变成 8 秒,靠留存计划快照比对,一眼就看到关联顺序从 Hash Join 变成了 NestLoop,问题定位到小表统计信息退化,重新 ANALYZE 后恢复。如果当时没有历史计划,肯定要花半天去猜。
排查之外,有一点我越来越认同:数据库查询性能的最终瓶颈通常不是某个单点,而是数据模型、访问模式、索引设计、参数配置这四者的匹配程度。索引加得再多,如果查询条件本身不满足最左前缀原则或数据分布极端,效果也会打折。而 PostgreSQL 的查询之旅,本质上就是不断在“SQL 文本、统计信息、代价模型、执行器能力”之间寻找最优解的循环过程。
最后再分享一个小技巧:文中写的 EXPLAIN 片段,建议你在自己环境里跑一遍,把 ANALYZE、BUFFERS、SETTINGS 三个选项都打开:
EXPLAIN (ANALYZE, BUFFERS, SETTINGS) SELECT ...;BUFFERS 会显示每个节点读取了多少缓冲块,SETTINGS 会显示当前会话和全局的参数设置差异。很多执行计划的差异,恰恰是会话级参数和全局参数不同导致的。把这个习惯固化下来,你再看执行计划的感觉会完全不同。毕竟,查询之旅最有价值的部分,从来不是某一个索引或某一次改写,而是你慢慢建立起的那套“看到问题就能猜到原因”的直觉。