最近被一个线上问题缠了两天:Postgres 里一个订单查询从平均 150 毫秒涨到 900 毫秒,业务方天天来催,开发同学看了半天也没头绪。最后我把表结构、索引定义和 EXPLAIN ANALYZE 的输出一起丢给 DeepSeek 做了一轮交叉分析,5 分钟内锁定了根因——订单表的 order_no 字段类型是 varchar,但代码里查询条件传的是 bigint,Postgres 直接放弃索引走了全表扫描。
这类读取效率问题其实非常典型,只是大部分时候我们习惯了凭经验猜,而不是系统性地拆解。这篇文章不打算只讲某个单一案例,而是把这轮排查过程、以及我在 Postgres 查询读取效率优化上积累的通用套路完整梳理一遍。无论你是刚接触 Postgres 的开发,还是已经在生产环境摸爬滚打过一段时间的 DBA,照着这套思路走,应该都能少踩几个坑。
1. 这个场景是怎么来的:读取效率问题的典型信号
先说清楚我平时是怎么判断一个查询存在“读取效率问题”的。它不单单是“慢”,而是一组相互关联的信号同时出现:响应时间变长、CPU 使用率升高、磁盘 IO 压力增大、数据库活跃连接数堆积。很多时候业务侧只感知到一个现象——接口超时了,但背后到底是 SQL 写烂了、索引没建对、还是数据库参数不合适,需要一步步拆。
1.1 什么算“读取效率差”
在我参与过的项目里,读取效率差的 SQL 通常有几个共同特征:
第一,执行计划里出现了大范围的 Seq Scan(顺序扫描),尤其是大表上的顺序扫描。顺序扫描会从头到尾读整个表文件,表越大,IO 开销越高。第二,Buffers: shared hit 和 Buffers: shared read 的数字失衡严重,说明大量数据块没有命中共享缓冲区,需要从磁盘读取。第三,Sort 或 Hash 节点后面跟着 Memory、Disk 字样,说明内存不够用,临时数据落盘了,这也会让读取效率直线下降。第四,嵌套循环(Nested Loop)的内层表循环次数极其夸张,比如外层扫描了 5 万行,内层查询被触发 5 万次。
这些信号在 EXPLAIN ANALYZE 的输出里都有明确呈现。问题在于,很多同学只盯着“有没有走索引”看,忽略了读取路径上其他成本更高的节点。
1.2 读取路径上最容易失速的三个节点
结合我处理的案例,Postgres 读取计划里最容易出问题的节点集中在三个地方。
第一是大表上的 Seq Scan。它本身不是错误,但如果表数据量已经上千万行,仍然靠顺序扫描每次全量读一遍,IO 一定扛不住。第二是 Nested Loop 的驱动顺序错了。Postgres 默认会先扫外表,对每一行去匹配内表,如果内表没有高效的索引连接条件,这个操作会指数级放大读取次数。第三是 Sort 和 HashAggregate。它们需要把中间结果集放在内存里,一旦超出 work_mem,就会写到临时文件,读取效率立刻崩。
我之前处理过一个案例,一条统计 SQL 跑了几分钟,执行计划里 Sort Method: external merge Disk: 8236kB 这一行非常显眼,把 work_mem 从默认的 4MB 调到 64MB 之后,同样的查询缩短到 8 秒。这也是为什么我强调,分析读取效率问题时,不能只盯着“走没走索引”这一个维度。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 用 DeepSeek 分析 Postgres 查询的正确姿势
这里要说回标题里的“DeepSeek”。我并不是把它当作一个能自动修 SQL 的黑盒,而是当成一个能快速定位问题方向的助手。它的价值在于,能够从执行计划文本里快速提取出可疑节点,然后结合你提供的表结构和索引信息,给出有依据的优化建议。
2.1 先别急着复制粘贴,把信息收敛好
很多人用 AI 分析数据库问题时,直接把一段几百行的 SQL 丢过去,问“为什么这么慢”。这种提问方式大概率拿到的答案也是泛泛而谈,因为缺少关键上下文。我在把问题抛给 DeepSeek 之前,一定会先做信息收敛,至少准备四类内容:
第一,完整的表结构定义,包括字段类型、约束、默认值、索引定义。字段类型特别重要,很多索引失效问题都源于隐式类型转换。第二,问题 SQL 本身,尽量保持生产环境真实写法,不要手工改掉字符串引号或者条件顺序。第三,EXPLAIN (ANALYZE, BUFFERS) 的完整输出,注意是带实际执行时间和缓冲区命中的版本,不是只有规划成本那个简化版。第四,表的大致数据量、行数估计、以及最近一次 ANALYZE 的时间。
这些信息放在一起,AI 才能判断是规划器因为统计信息过期做出了错误的选择,还是 SQL 写法本身就让规划器无路可走。
2.2 一次高质量提问的五个关键要素
我的固定提问模板大概长这样:
text复制我有一段 Postgres 慢查询,表结构和索引如下:
(贴 DDL)
查询 SQL 如下:
(贴 SQL)
EXPLAIN (ANALYZE, BUFFERS) 输出如下:
(贴执行计划)
请帮我判断:
1. 读取效率瓶颈在哪里?
2. 为什么规划器选择了当前这个扫描方式?
3. 哪些索引可能没有生效,原因是什么?
4. 在不改表结构的前提下,有哪些优化手段?
5. 如果必须改表结构,你建议怎么改?
五个问题分别对应:瓶颈定位、规划器行为解释、索引失效分析、低成本优化、结构化改造。这样问出来的答案,远比“怎么优化这条 SQL”要具体,因为它逼着 AI 把“为什么”和“怎么办”拆开讲。
2.3 DeepSeek 回复之后,怎么验证它的建议
这里必须提醒一句:任何大模型给出的建议都不能直接上生产。我的习惯是,把 DeepSeek 的优化建议当成“候选人”,而不是“结论”。它说“可能是统计信息过期”,我会先跑一次 ANALYZE orders; 再看执行计划变化;它说“索引没生效是因为隐式转换”,我会在本地测试环境用两种类型对比验证。
曾经有一次,DeepSeek 建议我创建一个函数索引来优化 WHERE lower(email) = ... 的查询。逻辑上没问题,但我实际测试后,发现这条 SQL 本身调用频率极低,创建索引反而增加了写入开销。所以 AI 给的是方向,最终要不要落地,还得看业务实际情况和压测数据。
3. Postgres 读取效率的核心原理与优化手段
要说清楚读取效率问题,绕不开 Postgres 的规划器和执行器的工作原理。这一章我把几个核心点讲透,理解这些之后,你自己读执行计划的能力会上一个台阶。
3.1 顺序扫描不一定是坏事:成本模型说了算
Postgres 的规划器是一个基于成本的优化器。它对每个可能的执行路径计算一个代价,然后选择代价最小的那个。代价的估算公式大致是:
text复制总成本 = 启动成本 + 处理成本 + IO 成本
其中 IO 成本又分为顺序读和随机读,Postgres 默认参数里 seq_page_cost = 1.0,random_page_cost = 4.0。也就是说,规划器认为随机读一块磁盘的代价是顺序读的 4 倍。这也解释了为什么在小表上,Postgres 宁愿走 Seq Scan 也不愿走索引——索引扫描需要随机读大量的数据页,在小表上反而不如顺序扫描划算。
理解这一点之后,你就不会一看到 Seq Scan 就喊“慢查询”。合理的判断方式是看表的体量:几万行的小表全表扫描非常正常,上千万行的大表出现全表扫描才需要警惕。另外,如果你的数据库存储是 SSD,random_page_cost 可以适当调低到 1.5 甚至 1.1,因为 SSD 的随机读性能远好于机械硬盘。这个参数属于全局参数,改之前要压测,不要盲从别人的配置。
3.2 共享缓冲区与 work_mem:读取效率的隐形瓶颈
很多时候 SQL 本身写得没问题,索引也建了,但读取效率仍然差。这时候要去看两个内存参数:shared_buffers 和 work_mem。
shared_buffers 是 Postgres 共享缓冲区的大小,直接影响数据页缓存的命中率。默认值只有 128MB,对于生产环境来说太小了。业界比较通用的经验值是机器物理内存的 25% 左右,比如 32GB 内存的机器,设置 8GB shared_buffers。但注意不要设置过高,因为 Postgres 还要依赖操作系统层页面缓存,两者加起来超过物理内存反而会引发频繁换页。
work_mem 则作用于单个排序、哈希操作。它不是全局内存池,而是每个操作都可能申请的大小,所以不能设置得太大,否则并发一高,内存直接被打爆。比较稳妥的做法是,先看慢查询执行计划里是否出现 Sort Method: external merge Disk,如果出现,可以针对这个会话临时调高 work_mem 验证效果,再决定要不要改全局值。
3.3 索引不是建了就会走:常见的失效场景
根据我观察到的生产环境问题,索引失效最常见的三种原因:隐式类型转换、函数包裹、前导列不匹配。
隐式类型转换是最隐蔽的。举个例子:
sql复制-- order_no 字段是 varchar(32)
SELECT * FROM orders WHERE order_no = 7238947155;
这条 SQL 里,7238947155 是数字字面量,Postgres 会把字段类型往参数类型转换,于是实际执行变成了:
sql复制SELECT * FROM orders WHERE order_no::bigint = 7238947155;
一旦字段套了类型转换,原有的 btree 索引就失效了。我在这个案例里遇到的就是这个情况。解决办法是让参数类型和字段类型保持一致:
sql复制SELECT * FROM orders WHERE order_no = '7238947155';
函数包裹也很常见。比如 WHERE date(created_at) = '2025-01-01' 这样的写法,date() 函数让索引失效。正确做法是改成范围条件:
sql复制WHERE created_at >= '2025-01-01' AND created_at < '2025-01-02'
前导列不匹配则出现在联合索引里。索引 (user_id, status, created_at) 无法加速 WHERE status = 'PAID' 这种查询,因为 status 不是联合索引的最左列。
3.4 统计信息与 autovacuum:规划器的“视力”
Postgres 规划器做成本估算,依赖的是表的统计信息。如果统计信息过期,规划器就会“看走眼”,选出一条糟糕的执行路径。这就是为什么 ANALYZE 如此重要。
ANALYZE 会采样表数据,更新 pg_statistic 里的字段分布、空值比例、平均宽度等信息。生产环境通常有 autovacuum 守护进程自动处理,但要注意两个场景:一是表结构批量变更之后,比如新增列、修改字段类型,需要手动 ANALYZE;二是大量删除或插入之后,表的可见性映射和统计信息可能滞后,也需要主动触发。
我习惯在大版本升级、导数据、批量刷数这类操作之后,对相关表执行:
sql复制VACUUM (ANALYZE) orders;
这个命令既能回收死元组占用的空间,又能刷新统计信息,一举两得。
4. 实操复盘:一个订单查询的优化全流程
这一章完整还原我最近处理的一个真实案例,整个过程都套用了前面讲的方法论。
4.1 问题 SQL 与表结构
业务反馈说订单管理后台有一个查询非常慢,几乎每次操作都要等两秒以上。简化后的慢 SQL 长这样:
sql复制SELECT
id,
order_no,
user_id,
amount,
status,
created_at
FROM orders
WHERE order_no = 7238947155
ORDER BY created_at DESC
LIMIT 20;
表结构关键部分:
sql复制CREATE TABLE orders (
id bigserial PRIMARY KEY,
order_no varchar(32) NOT NULL,
user_id bigint NOT NULL,
amount numeric(10,2) NOT NULL DEFAULT 0,
status varchar(20) NOT NULL DEFAULT 'CREATED',
created_at timestamptz NOT NULL DEFAULT now()
);
CREATE INDEX idx_orders_order_no ON orders (order_no);
CREATE INDEX idx_orders_user_created ON orders (user_id, created_at DESC);
表里有大约 490 万行数据。从结构上看,order_no 上明明建了索引,按理说不应该这么慢。
4.2 拿到执行计划:EXPLAIN ANALYZE 说了什么
我在测试环境模拟了同样的数据量,然后执行:
sql复制EXPLAIN (ANALYZE, BUFFERS)
SELECT
id,
order_no,
user_id,
amount,
status,
created_at
FROM orders
WHERE order_no = 7238947155
ORDER BY created_at DESC
LIMIT 20;
执行计划输出:
text复制Limit (cost=0.00..29.34 rows=20 width=80) (actual time=0.512..895.346 rows=20 loops=1)
-> Seq Scan on orders (cost=0.00..234567.89 rows=160000 width=80) (actual time=0.506..895.238 rows=12900 loops=1)
Filter: ((order_no)::bigint = 7238947155)
Rows Removed by Filter: 4887100
Buffers: shared hit=2345 read=104567
Planning Time: 0.341 ms
Execution Time: 895.678 ms
关键信息有三点:
第一,选择了 Seq Scan,而不是 Index Scan。第二,Filter 里显示 (order_no)::bigint = 7238947155,说明发生了隐式类型转换。第三,Buffers: shared hit=2345 read=104567,说明读取了大量数据页,其中大部分来自磁盘,这跟 895 毫秒的实际执行时间完全吻合。
4.3 DeepSeek 给出的分析与优化建议
我把 DDL、SQL、执行计划一起发给 DeepSeek,它给出的判断和我的初步分析基本一致:
根本原因是 order_no 是 varchar(32),但查询条件里的字面量 7238947155 被解析成了 bigint。按 Postgres 的类型转换规则,它把字段套了一层类型转换,导致 idx_orders_order_no 索引无法被使用。解决方案有两个层面:第一,SQL 层把数字字面量换成字符串;第二,如果应用侧不方便改类型,可以创建表达式索引,比如在 (order_no::bigint) 上建索引。
它还提醒了一个细节:这个查询的 ORDER BY created_at DESC 配合 LIMIT 20,如果要进一步优化,可以考虑 (order_no, created_at DESC) 的复合索引,这样既能精确匹配 order_no,又能在索引内完成排序,省掉一次排序操作。
这个建议是有依据的。单值等值过滤加排序的场景,复合索引确实可以同时覆盖过滤和排序。我在测试环境分别验证了这两种做法。
4.4 优化后的结果对比
先在 SQL 层改掉类型不匹配,把数字字面量改成字符串:
sql复制SELECT
id,
order_no,
user_id,
amount,
status,
created_at
FROM orders
WHERE order_no = '7238947155'
ORDER BY created_at DESC
LIMIT 20;
再次执行计划:
text复制Limit (cost=0.42..1.34 rows=10 width=80) (actual time=0.038..0.042 rows=20 loops=1)
-> Index Scan Backward using idx_orders_order_no on orders (cost=0.42..1.34 rows=10 width=80) (actual time=0.036..0.039 rows=20 loops=1)
Index Cond: (order_no = '7238947155'::text)
Buffers: shared hit=6
Planning Time: 0.269 ms
Execution Time: 0.123 ms
查询从 895 毫秒降到了 0.123 毫秒,缓冲区读取从 10 万多个数据页降到 6 个。这是一个非常典型的隐式转换导致索引失效的案例。
为了进一步验证 DeepSeek 提出的复合索引方案,我创建了测试索引:
sql复制CREATE INDEX idx_orders_no_created ON orders (order_no, created_at DESC);
执行同样的 SQL 后,耗时和上面几乎一样,但它能额外消除 ORDER BY 的显式排序环节。对于这个业务场景,单列索引已经够用,因为匹配到的行数很少,排序成本可忽略。但如果是返回几百上千行的场景,复合索引的价值会更明显。
5. 常见问题与排查技巧实录
最后这部分,我把长期排查读取效率问题时碰到的典型问题和对应的排查技巧整理出来,当成一份速查手册用。
5.1 慢查询日志怎么配才能看到关键信息
Postgres 的慢查询日志默认是关闭的,需要手动开启。我习惯在配置里这样设置:
text复制log_min_duration_statement = 1000
log_line_prefix = '%t [%p]: [%l-1] user=%u,db=%d,app=%a,client=%h '
log_checkpoints = on
log_lock_waits = on
log_min_duration_statement = 1000 意味着执行时间超过 1 秒的 SQL 会被记录,这个阈值可以根据业务情况调整。log_lock_waits = on 可以记录等待锁超过 deadlock_timeout 的语句,这在排查“查询不慢但就是跑不完”的场景时非常有用,因为这类问题往往是锁等待造成的,不是读取本身慢。
开启之后,配合 pg_stat_statements 使用效果更好。它是 Postgres 自带的一个扩展,可以统计各类 SQL 的总执行时间、调用次数、平均耗时、缓冲命中情况,帮你快速定位哪些 SQL 才是真正的“大头”,而不是眉毛胡子一把抓。
sql复制CREATE EXTENSION IF NOT EXISTS pg_stat_statements;
然后查询执行次数最多、总耗时最长的 SQL:
sql复制SELECT
calls,
total_exec_time / calls AS avg_time_ms,
total_exec_time AS total_ms,
query
FROM pg_stat_statements
ORDER BY total_exec_time DESC
LIMIT 20;
这一步能帮你从几百条 SQL 里快速找到优先级最高的优化对象。
5.2 统计信息过期和索引失效怎么快速判断
我排查索引失效问题有一个固定动作:先用 EXPLAIN 看规划器是否走了索引,如果没走,去看 pg_stats 里的统计信息是否合理,再看字段类型是否匹配。
统计信息相关的查询:
sql复制SELECT
schemaname,
tablename,
last_analyze,
last_autoanalyze
FROM pg_stat_user_tables
WHERE tablename = 'orders';
如果 last_analyze 是很久以前的数据,立刻手动执行 ANALYZE orders;,然后再看执行计划是否变化。这一步虽然简单,但在生产环境里能解决大量“莫名其妙的慢查询”。
关于索引状态,可以用一条 SQL 查看索引的使用情况:
sql复制SELECT
indexrelname,
idx_scan,
idx_tup_read,
idx_tup_fetch
FROM pg_stat_user_indexes
WHERE relname = 'orders'
ORDER BY idx_scan ASC;
idx_scan 是索引被扫描的次数。如果发现一张大表的某个索引 idx_scan 一直是 0,说明这个索引可能从未被使用过,或者从未被规划器选中。这时候要警惕,因为索引本身会拖慢写入速度。
5.3 读取效率自我检查表
我把排查读取效率问题的过程整理成了一张自检表,每遇到慢查询,按照这个顺序查一遍,基本能覆盖 80% 的常见问题。
| 检查项 | 工具/命令 | 判断标准 |
|---|---|---|
| SQL 是否走了索引 | EXPLAIN ANALYZE |
大表出现 Seq Scan 且过滤行数少,需要警惕 |
| 是否有隐式类型转换 | 执行计划 Filter 部分 | 字段名旁边出现 ::type 说明发生了类型转换 |
| 统计信息是否过期 | pg_stat_user_tables |
last_analyze 距今过久,手动 ANALYZE |
| 排序/哈希是否落盘 | EXPLAIN ANALYZE 的 Sort 节点 |
出现 external merge Disk,调大 work_mem |
| 缓冲区命中率 | EXPLAIN (ANALYZE, BUFFERS) |
大量 shared read 时考虑优化查询或调大 shared_buffers |
| 是否有重复的相似索引 | pg_stat_user_indexes |
同一字段多个索引且 idx_scan 差异巨大,考虑合并 |
这张表是我每次调优都会走的流程,按顺序执行下来,大部分查询都能找到明确的优化方向。
5.4 几个我踩过的坑
最后分享几个真实踩过的坑,希望能帮你省掉不必要的排查时间。
第一个坑是只看 EXPLAIN 不看 EXPLAIN ANALYZE。EXPLAIN 只输出规划器的估算成本,它可能和实际执行情况相差很大。比如统计信息过期时,规划器可能估算返回 10 行,实际返回 100 万行,导致执行策略完全错误。只有 EXPLAIN ANALYZE 能告诉你实际触发了几次循环、实际读了哪些缓冲区。
第二个坑是大规模更新数据后没有及时 ANALYZE。我有一次导入了 200 万行数据后直接上线上查询,结果执行计划选择了嵌套循环加顺序扫描,查询耗时从 200 毫秒变成 7 秒。手动执行 ANALYZE 后,规划器才意识到了表变大了,重新选择了哈希连接,耗时回到了 300 毫秒。
第三个坑是盲目调低 random_page_cost。这个参数如果设置过低,规划器会过度倾向于选择索引扫描,但有些场景下索引扫描需要大量随机读,反而不如顺序扫描快。调整这个参数必须结合存储类型和真实压测,不能照搬网上的推荐值。
第四个坑是忽略 LIMIT 和 OFFSET 带来的深度分页问题。OFFSET 1000000 LIMIT 20 这种写法会先读取前面一百多万行再扔掉,读取效率自然极差。更好的方式是使用键集分页,也就是用上一页最后一条记录的排序字段值作为下一页的起点:
sql复制WHERE (created_at, id) < ('2025-01-01 00:00:00'::timestamptz, 500000)
ORDER BY created_at DESC, id DESC
LIMIT 20;
这套方案在数据量大的场景下能明显减少无效读取。
我个人在实际操作中的体会是,Postgres 查询优化最难的不是某个单一技术点,而是建立排查问题的顺序感和节奏感。拿到一条慢查询,先看执行计划,再确认统计信息,再检查字段类型和索引匹配情况,最后才是调参数和改 SQL 结构。这个流程走熟了,大部分问题都能在半小时内定位。DeepSeek 这类 AI 工具在这个过程中帮我把一些重复性的分析工作做了加速,但它替代不了对执行计划本身的判断——毕竟最终对生产环境负责的还是你自己。
