返回探索
指南VibeFix 编辑部更新于 2026年10月15日

AI 生成的查询把数据库拖死了:vibe 项目的索引与慢查询实战

项目上线后 API 的 p99 从 300ms 涨到 3 秒,问题八成在数据库。本文给你一套闭环打法:用 pg_stat_statements 揪出真凶、5 分钟读懂 EXPLAIN、按查询模式选 B-tree/GIN/BRIN/部分索引/覆盖索引、避开 AI 代码的 6 个经典慢查询坑,再用 CREATE INDEX CONCURRENTLY 安全上线。每周 15 分钟巡检清单附后。

慢查询与索引实战指南封面:发现、定位、修复、验证、巡检的慢查询定位闭环

站内已经有三篇数据库指南,分别管三件事:选型篇回答"用什么数据库",迁移篇回答"表结构怎么安全地变",备份篇回答"丢了怎么恢复"。本文是第四块拼图,回答的是上线之后最疼的那个问题:数据库已经在跑、数据在涨,但查询越来越慢,API 的 p99 从几百毫秒一路涨到几秒,该从哪下手?

先说一个残酷的真相:AI 生成的查询代码(Prisma、Drizzle 都一样),在数据量小的时候全是对的——逻辑正确、测试通过。但它几乎从不考虑数据量:该取 20 条的列表页它给你全表扫,该走索引的过滤它给你函数套列,分页永远是 OFFSET。表到 10 万行之前你感觉不到,过了这个坎,慢查询会一个接一个冒出来。本文不讲数据库理论,只给你一套能直接照着跑的闭环:

慢查询定位闭环:发现(p99/慢查询日志)→定位(EXPLAIN)→修复(建索引/改写 SQL)→验证(idx_scan/慢日志)→巡检(每周清单),循环往复图 1:慢查询定位闭环。每次只解决 Top 1 慢查询,跑完一整圈再进下一轮——贪多的人,最后哪个都没验证。

一、症状识别:怎么知道慢的是数据库

API 变慢了,先别急着加索引。第一步是确认"锅"到底在不在数据库身上,误判的代价是你在错误的方向上折腾一下午。

看 p99,不看平均值。平均响应时间是最会骗人的指标:1000 次 50ms 的请求里混进 10 次 5 秒的慢查询,平均值只涨到 100ms,看起来"还行",但用户体感已经崩了。去你的监控(Sentry、Vercel Analytics、自建 Prometheus 都行)里看 p95/p99 的曲线。如果 p99 从 300ms 涨到 2~3 秒,而 p50 纹丝不动——这就是典型的"少数慢查询拖死整体",八成和数据库有关。

用 pg_stat_statements 揪出真凶。这是 PostgreSQL 的慢查询"账本",按查询模板聚合,告诉你每个查询被调用了多少次、总共吃了多少时间。先确认扩展开了:

CREATE EXTENSION IF NOT EXISTS pg_stat_statements;

然后跑这条"总耗时 Top 10",它直接告诉你该先修谁:

SELECT query, calls,
       round(total_exec_time::numeric, 1) AS total_ms,
       round(mean_exec_time::numeric, 1) AS mean_ms,
       round((100 * total_exec_time / sum(total_exec_time) OVER ())::numeric, 1) AS pct
FROM pg_stat_statements
ORDER BY total_exec_time DESC
LIMIT 10;

读这张表的要领:先看 total_ms 和 pct(占总耗时的百分比),再看 calls 和 mean_ms。一个 mean 只有 5ms 但被调用了 200 万次的查询(N+1 的典型长相),总耗时可能超过一个 mean 2 秒但一天只跑几次的报表查询。修的顺序按 total_ms 排,Top 3 吃掉总耗时 50% 以上的情况非常常见——先把这三个干掉,p99 立竿见影。另外注意,query 列里的参数会被规范化成 $1、$2,同一个模板只出现一行,这正是你要的聚合效果。

打开慢查询日志,当"哨兵"。log_min_duration_statement 是 PostgreSQL 的配置项,超过这个阈值的 SQL 会被记进日志,格式里自带 duration: 2341.502 ms。建议值:刚上线设 1000(毫秒),稳定后收紧到 500。Supabase、Neon、Railway 这些托管平台都在控制台里直接给了这个开关,不用改配置文件。日志的价值在于"第一时间发现"——p99 曲线是滞后的,慢查询日志是实时的。

最后给一个三条件判定法,三个都满足再动手:① API 的 p99 在涨;② 数据库的 CPU 或 I/O 同期也在涨(托管平台控制台都能看到);③ 慢查询日志 / pg_stat_statements 里能找到反复出现的具体 SQL。如果 ① 成立但 ②③ 不成立,慢的可能在应用层(比如外部 API 调用、图片处理),先别碰数据库。

用 Supabase / Neon / Railway 这类托管平台的同学注意:这三样东西在控制台里都有现成入口,不用自己搭。Supabase 在 Database → Query Performance 里直接给了慢查询 Top 榜和索引建议;Neon 在 Monitoring 里看计算资源的 CPU 曲线;Railway 的 Postgres 插件页能看到慢查询日志开关。先把这些免费面板翻一遍,很多时候连 SQL 都不用手写,面板已经把真凶指出来了。自己搭 VPS 跑 Postgres 的,才需要手动装扩展、配日志参数——但原理完全一样。

二、EXPLAIN (ANALYZE, BUFFERS):五分钟读懂执行计划

锁定嫌疑 SQL 之后,下一步是看它的"执行计划"——数据库到底是怎么执行这条查询的。命令就一行,记住这个固定搭配:

EXPLAIN (ANALYZE, BUFFERS, TIMING OFF)
SELECT * FROM orders WHERE user_id = 'abc' ORDER BY created_at DESC LIMIT 20;

ANALYZE 表示真跑一遍(而不是只做计划),BUFFERS 显示缓存命中情况,TIMING OFF 关掉计时开销让数字更干净。注意:ANALYZE 会真实执行,UPDATE/DELETE 别直接套这个命令,先在事务里 BEGIN; EXPLAIN ANALYZE ...; ROLLBACK; 包起来。

输出里你只需要会读四行:

  • Seq Scan on orders —— 全表扫描。表只有几千行时无所谓;但一张 10 万行以上的热表出现 Seq Scan,基本就是红灯。它的反面是 Index Scan using idx_orders_user_id(走了索引)或 Bitmap Heap Scan(走了索引+回表)。
  • Rows Removed by Filter: 98200 —— 全文最有信息量的一行。意思是数据库扫了 10 万行,扔掉了 98200 行,只留下 1800 行。如果这个数字很大,说明过滤条件没有可用的索引,或者条件写法让索引失效了。看到它,第一反应应该是"缺索引",而不是"数据太多了"。
  • Buffers: shared hit=12 read=845 —— 缓存命中情况。hit 是从内存读的,read 是从磁盘读的。如果 read 占比很高,第一次查慢、第二次查快,多半是缓存还没热,不是索引问题——别急着建索引,先确认是不是冷启动。
  • 预估行数 vs 实际行数。计划里 rows=100 是优化器的估计,actual rows=50000 是真实跑出来的。如果两者差 10 倍以上,先别建索引,先跑 ANALYZE orders; 更新统计信息——优化器拿着过期的数据做计划,加什么索引都可能选错。

看一个真实的"前后对比"长什么样。假设 orders 表 50 万行,查某用户的最新 20 个订单:

-- 修之前:Seq Scan,Execution Time: 812.4 ms
Seq Scan on orders  (cost=0.00..12842.00 rows=1820 width=96)
  (actual time=0.031..798.112 rows=1820 loops=1)
  Filter: (user_id = 'abc')
  Rows Removed by Filter: 498180
  Buffers: shared hit=1240 read=6120

-- 加上 (user_id, created_at DESC) 复合索引之后:Execution Time: 0.8 ms
Limit  (cost=0.42..8.45 rows=20 width=96)
  (actual time=0.021..0.742 rows=20 loops=1)
  -> Index Scan using idx_orders_user_created on orders
       Index Cond: (user_id = 'abc')
       Buffers: shared hit=24

800ms 变 0.8ms,1000 倍的差距,改动只是一个索引。这就是为什么慢查询优化的投入产出比极高:你不需要重构,不需要换数据库,大多数时候只需要一个建对的索引。但"建对"二字,恰恰是下一节要讲的。

三、索引决策流程:五种索引用在哪

PostgreSQL 的索引不止一种,"缺索引"和"该建哪种索引"是两个问题。决策顺序永远是:先看查询长什么样,再选索引类型——反过来(先建了再说)是索引膨胀的万恶之源。

索引类型决策流程图:按查询模式选择 B-tree、部分索引、GIN、BRIN 或覆盖索引(INCLUDE)图 2:索引类型决策。90% 的查询用默认 B-tree 就够了;剩下四种是给特定查询模式准备的"专科医生",别拿来当感冒药乱吃。
  • B-tree(默认类型):等值、范围、排序,90% 的情况用它。你不写类型,CREATE INDEX 建的就是 B-tree。复合索引的列顺序有口诀:等值列在前,范围列在后。比如 WHERE user_id = ? AND created_at > ? ORDER BY created_at DESC,索引应该是 (user_id, created_at DESC)——user_id 是等值,放前面;created_at 既是范围又是排序,放后面。顺序反了,排序那部分就用不上索引。Prisma 里这样写:@@index([userId, createdAt(sort: Desc)])。
  • 部分索引(Partial Index):只查表中一小部分行。典型场景:WHERE status = 'active',而 active 只占 5%。CREATE INDEX idx ON orders (user_id) WHERE status = 'active';——索引只包含这 5% 的行,又小又快,写放大也低。注意查询的 WHERE 必须带上同样的条件,优化器才会用它。
  • GIN 索引:JSONB、数组、全文搜索。AI 特别爱把"以后可能用的字段"塞进 JSONB,然后某天写个 WHERE meta->>'plan' = 'pro',全表扫描跑到天荒地老。解法:CREATE INDEX idx ON users USING gin (meta jsonb_path_ops);。全文搜索(to_tsvector)同样走 GIN。
  • BRIN 索引:日志、时序类天然有序的大表。BRIN 不存每一行的位置,只存"这一块数据大概在什么范围",索引体积可以小到 B-tree 的百分之一。代价是只适合 created_at 这种插入顺序即物理顺序的列,乱序写入的表别用。
  • 覆盖索引(INCLUDE):查询只要几列,想避免"回表"。CREATE INDEX idx ON orders (user_id) INCLUDE (total, status);——INCLUDE 的列不参与过滤和排序,只是"顺路带着",让 SELECT user_id, total, status WHERE user_id = ? 全程只读索引,不回表查数据页。适合高频、列少、读多写少的查询。

还有两条"别折腾"原则:① 万行以下的小表先别建索引,全表扫几毫秒的事,索引的维护成本反而更高;② 一种查询配一种索引,不要把五个单列索引全加上指望优化器自己挑——它经常挑错,复合索引才是正解。

四、AI 生成代码的 6 个经典慢查询坑

这一节是全文最值得收藏的部分。AI 生成的查询代码,慢的原因高度集中,下面六个坑覆盖了九成以上的案例。每个坑给三样东西:长什么样、怎么发现、最小修复。

坑 1:N+1 查询。长相:先查出 100 个订单,再循环里每个订单查一次用户。Prisma 的 include 本质是发多条 SQL 实现的,循环里再套查询就是 N+1。发现信号:pg_stat_statements 里同一个查询的 calls 是别人的几十上百倍,但 mean_ms 很小——单次不慢,架不住次数多。修复:一次取完,Prisma 用 include 一次性关联,Drizzle 用 relational query 或 inArray 批量查。

// 坑:循环里查(100 个订单 = 101 次查询)
for (const o of orders) { o.user = await db.user.findUnique({ where: { id: o.userId } }); }

// 修:一次取完(2 次查询)
const orders = await db.order.findMany({ include: { user: true } });

坑 2:SELECT *。AI 默认把所有列都取出来,包括你根本不用的 content 大文本列。单行数据从几 KB 膨胀到几十 KB,带宽和内存一起遭殃,列表页尤其明显。修复:显式 select 只要用到的列。Prisma:select: { id: true, title: true, createdAt: true };Drizzle 同理只 pick 需要的列。

坑 3:OFFSET 深分页。OFFSET 100000 LIMIT 20 看起来只取 20 条,但数据库要先扫完前面 10 万行再扔掉,页码越深越慢。修复:游标分页(keyset pagination),用上一页最后一条的排序键当游标:

-- 坑:OFFSET 100000 LIMIT 20(越往后越慢)
-- 修:游标分页(每次都是索引定位,稳定几毫秒)
SELECT * FROM orders
WHERE (created_at, id) < ('2026-10-01 00:00:00', 'ord_999')
ORDER BY created_at DESC, id DESC
LIMIT 20;

注意 (created_at, id) 这种行值比较需要复合索引 (created_at DESC, id DESC) 配合,id 加进来是为了处理 created_at 重复时的排序稳定性。

坑 4:函数套列,索引直接失效。WHERE lower(email) = ?、WHERE DATE(created_at) = ?——列外面套了函数,B-tree 索引就用不上了(索引存的是原值)。修复二选一:① 建表达式索引 CREATE INDEX idx ON users (lower(email));;② 改写查询,比如 DATE(created_at) = '2026-10-15' 改写成 created_at >= '2026-10-15' AND created_at < '2026-10-16',后者还能走范围索引。

坑 5:隐式类型转换。列是 bigint,查询却传了字符串 '123',PostgreSQL 会把列转成文本再比较,索引失效。Prisma 的 queryRaw 传参、从 URL query 里直接拿字符串当条件,都是重灾区。修复:参数类型和列类型保持一致,BigInt(id) 该转就转,别偷懒。

坑 6:OR 条件。WHERE a = ? OR b = ?,优化器经常直接放弃索引走全表扫(因为它要同时满足两个分支的最优)。修复:拆成 UNION ALL 两条各自走索引的查询,或者确认两个分支都有索引让优化器走 BitmapOr——但 BitmapOr 的条件比较苛刻,拆查询更稳:

-- 坑:OR 导致全表扫
SELECT * FROM posts WHERE author_id = 'u1' OR reviewer_id = 'u1';
-- 修:UNION ALL,各走各的索引
SELECT * FROM posts WHERE author_id = 'u1'
UNION ALL
SELECT * FROM posts WHERE reviewer_id = 'u1';

自查方法:把这六个坑打印出来贴在显示器旁边,下次让 AI 写查询代码时,逐条过一遍——比事后救火便宜十倍。

五、生产环境加索引的安全动作

索引选对了,剩下的就是"怎么安全地加上去"。这里有一个新手最容易踩的雷:普通的 CREATE INDEX 会锁表——建索引期间,表上的写入会被阻塞。几千行的小表一眨眼就建完,你感觉不到;几十万行的生产表,锁个几分钟,用户端就是"提交按钮转圈"。

安全动作只有三步,顺序不能乱:

  1. 用 CREATE INDEX CONCURRENTLY 建。它不阻塞写入,代价是建得慢一点(通常慢 1~2 倍),以及不能在事务块里执行——Prisma migrate、Drizzle migrate 默认把迁移包在事务里,所以生产大表的索引别走常规迁移,手动连数据库执行。低峰期跑,盯着看完成。
  2. 跑 ANALYZE 更新统计信息。新索引建好后,优化器还不知道它有多好用,ANALYZE 表名; 让它重新采样,否则可能出现"索引建好了但计划还是全表扫"的灵异现象。
  3. 验证索引真的在干活。查 pg_stat_user_indexes 看新索引的 idx_scan 在涨;再去慢查询日志确认那条 SQL 不再出现。三天后 idx_scan 还是 0,说明建错了,回炉到第二节重读执行计划。
-- 生产环境建索引的标准姿势(psql 里手动执行,不在事务中)
CREATE INDEX CONCURRENTLY idx_orders_user_created
  ON orders (user_id, created_at DESC);
ANALYZE orders;

-- 检查 CONCURRENTLY 失败留下的坏索引(indisvalid = false 就是坏的,DROP 掉重来)
SELECT indexrelid::regclass AS index_name, indisvalid
FROM pg_index WHERE NOT indisvalid;

-- 验证新索引是否被使用
SELECT indexrelname, idx_scan FROM pg_stat_user_indexes
WHERE indexrelname = 'idx_orders_user_created';

特别提醒 Prisma/Drizzle 用户:schema 里加 @@index 生成的迁移是普通 CREATE INDEX。生产表已经很大时,正确姿势是:先手动 CONCURRENTLY 建好同名索引,再跑迁移(迁移会发现索引已存在而跳过,或用 --create-only 手工改掉建索引语句)。别让迁移工具在生产环境锁你的表。

如果 CONCURRENTLY 建到一半失败了(比如中途连接断了),它会留下一个 indisvalid 的坏索引:占着名字、占着空间,但优化器永远不会用它。这时候别直接重跑——先 DROP INDEX CONCURRENTLY 坏索引名; 删干净,再重新建。判断坏没坏就用上面那条查 pg_index 的 SQL。另外 CONCURRENTLY 建索引期间如果表上有写入高峰,它需要等待两次"事务静默点",大表上跑个把小时都正常,别因为看着慢就中途杀掉,杀掉反而留下坏索引更麻烦。

六、索引不是越多越好:写放大与僵尸索引

AI 有个坏习惯:每个外键、每个 where 条件都顺手加个索引。索引是"用读性能换写性能"的交易——每多一个索引,每次 INSERT/UPDATE/DELETE 就要多写一份,这叫写放大。一张有 8 个索引的表,写入一次等于写 9 份数据。对读多写少的配置表无所谓,对订单、日志这种写入频繁的表,索引太多会直接拖慢写入。

找出"僵尸索引"(建了但没人用的)只需要一条 SQL:

SELECT schemaname || '.' || relname AS table_name,
       indexrelname AS index_name,
       pg_size_pretty(pg_relation_size(indexrelid)) AS index_size,
       idx_scan
FROM pg_stat_user_indexes
WHERE idx_scan = 0
  AND indexrelid NOT IN (SELECT indexrelid FROM pg_index WHERE indisunique)
ORDER BY pg_relation_size(indexrelid) DESC;

注意三个细节:① 排除唯一索引(indisunique),唯一约束是正确性保障,不是性能索引,不能删;② 刚建的索引或统计刚被重置(pg_stat_reset())的别急着删,观察一个完整的业务周期(至少两周);③ 删之前先确认没有查询依赖它——把索引名和慢查询 Top 10 对一遍。

经验法则:单表索引超过 5 个,就值得审一次。审的时候问三个问题:这个索引服务哪个查询?那个查询还在跑吗?有没有复合索引可以合并掉两个单列索引?多数 vibe 项目的索引膨胀,都是"AI 顺手加的"和"早期查询留下的"两类,定期清理一次,写入性能和磁盘空间都会回来。

七、每周 15 分钟巡检清单

索引建好了不代表永远高枕无忧:数据分布会变、新功能会带来新查询、AI 会继续顺手加索引。每周花 15 分钟跑一遍这个清单,把问题掐灭在 p99 飙升之前:

  • 慢查询 Top 10 有没有新面孔。跑第一节那条 pg_stat_statements 查询,对比上周:新进榜的查询优先看,总耗时占比超过 50% 的 Top 3 必须处理。
  • 慢查询日志本周新增了几个不同的 SQL 模板。新功能上线后最容易冒出新的慢查询,日志是最早的哨兵。
  • seq_scan 占比有没有漂移。SELECT relname, seq_scan, idx_scan FROM pg_stat_user_tables ORDER BY seq_scan DESC;——某张表的 seq_scan 一周内暴涨,说明有查询没走索引,去第二节读它的执行计划。
  • 统计信息新不新鲜。last_analyze / last_autoanalyze 距今超过 7 天的热表,手动跑一次 ANALYZE。数据分布变化大的表(比如大促后的订单表),自动 analyze 经常跟不上。
  • 僵尸索引标记。跑第六节那条 SQL,idx_scan = 0 的非唯一索引先标记观察,两周后还是 0 就删。
  • 表膨胀抽查。SELECT relname, n_dead_tup FROM pg_stat_user_tables ORDER BY n_dead_tup DESC;——死元组堆积严重的表,VACUUM 可能没跟上,索引再好也救不了全表膨胀。
  • 告警阈值设好。p99 超过 1 秒持续 10 分钟就告警,直接链到慢查询日志。没有告警的巡检清单,等于没有清单。

最后总结成一句话:慢查询优化不是一次性工程,而是一个闭环习惯——发现、定位、修复、验证、巡检,每周转一圈。AI 帮你写代码的速度越快,你越需要这套"刹车系统":数据量上来之后,拼的不是谁功能多,而是谁的 p99 稳。从今天起,把 log_min_duration_statement 打开,把每周 15 分钟写进日历,三个月后你会感谢现在这个决定。

附:三条 SQL 存进你的工具箱,下次 p99 一涨直接掏出来用——慢查询 Top 10(第一节)、僵尸索引(第六节)、seq_scan 漂移(第七节)。工具箱里的 SQL 不用多,常用的三条比收藏夹里三十篇教程管用。

阅读 0评论 0

评论 (0)

ME
0/1000
评论加载中...
浏览项目广场发布你的项目

相关文章

数据生命周期状态机图:正常使用进入回收站开始倒计时,可一键恢复或到期彻底删除
指南
别让删除成为单行道:vibe 项目的软删除与回收站实战

备份是最后的防线,回收站是第一道。这篇实战指南讲应用层的删除语义:三种删除语义决策表、deleted_at 数据模型、partial 唯一索引、Prisma 查询层防线、回收站保留期分级、GDPR 与个保法删除权对账,以及 8 项上线 checklist。

PostgreSQL数据库后端工程