2023 年我们接手一个 SaaS CRM 系统 用 PostgreSQL 14 业务起来后客户数据库从 10GB 涨到 500GB 一些核心查询从 50ms 慢慢恶化到 30 秒 大客户列表页要等半分钟才出来 客户投诉一通接一通。我们老板拍板说"加索引"我们也就加索引 这一加发现完全不是 CREATE INDEX 一句话的事 加完该慢的还慢 不该慢的反而慢了 业务直接更崩。然后我们陆续踩了一堆坑。第一种最让我傻眼 我们看到 user_id 查询慢 直接 CREATE INDEX 加单列索引 结果 EXPLAIN 还是 Seq Scan 索引没用上 后来才知道 PostgreSQL 优化器判断走索引不如全表扫 因为 user_id 选择性太差(1000 个用户每个均有 10 万条数据)单列索引基本没用。第二种最难缠 我们给一个状态列加索引 status 只有 active inactive 两个值 索引建出来 500MB EXPLAIN 死活不走 这是低基数列加索引的典型反模式 应该用部分索引。第三种最离谱 我们一次性给一张 1 亿行的大表加 14 个索引 表插入性能从 5 万 TPS 跌到 800 TPS 每条 INSERT 要更新 14 个索引整张表写性能崩。第四种最致命 我们给一张表加联合索引 (user_id, created_at, status) 然后业务查询 WHERE status='active' AND created_at > '...' 索引死活用不上 后来才发现联合索引必须遵循最左前缀 status 不是最左列优化器不走索引。第五种最莫名其妙 索引明明在 EXPLAIN 显示 Index Scan 但查询还是 10 秒 后来才发现是 bloat 表与索引膨胀 VACUUM 没跑 索引实际只有 30% 有效数据其他都是死元组 这是 PostgreSQL MVCC 的著名坑。我盯着这一连串问题想了很久才彻底想明白第一版错在一个根本的认知上我以为索引就是加上就快 慢查询都能靠索引解决 可这个认知是错的真正能扛业务的 PostgreSQL 索引调优是一个 索引类型选择 加 选择性与基数分析 加 联合索引最左前缀 加 部分索引与表达式索引 加 写性能权衡 加 索引维护与膨胀治理 的整套工程方法论 任何一环没做都可能让你的索引变成查询杀手或者写性能毒药本文从头梳理 PostgreSQL 索引调优的工程要点 索引类型怎么选 联合索引怎么排 部分索引怎么用 EXPLAIN 怎么读 膨胀怎么治 以及一些把 PG 索引用扎实要避开的工程坑
问题背景:为什么"加索引"远不止 CREATE INDEX
很多团队的索引策略停留在"哪里慢加哪里"看到慢 SQL 就 CREATE INDEX 一发了之 但 PostgreSQL 索引体系远比 MySQL 复杂 选错类型 排错顺序 或者忽视维护 都会让索引从加速器变成减速器:
- 索引类型多达 6 种:B-tree Hash GIN GiST BRIN SP-GiST 每种适用场景不同 选错就是浪费。
- 选择性决定是否走索引:优化器估计走索引代价比全表扫高就不用 低选择性列(性别 状态)加单列索引基本无效。
- 联合索引最左前缀:(a, b, c) 索引 WHERE b=x 不走 WHERE a=x AND c=y 部分走 这是新人最容易踩的坑。
- 部分索引与表达式索引:WHERE status='active' 的查询用部分索引 体积减 90% 还更快。
- 每个索引都是写代价:14 个索引的表 INSERT 比单索引慢 14 倍 索引数量必须节制。
- 索引膨胀与 VACUUM:PG MVCC 设计下 UPDATE 与 DELETE 会留死元组 索引也会膨胀 必须定期维护。
一 索引类型选择:B-tree 不是万能
PostgreSQL 默认建的索引是 B-tree 适合等值与范围查询 但 5 种其他索引类型在特定场景能让性能数量级提升。下面是每种索引的适用场景与建索引语法。
-- 1 B-tree 默认索引 适合等值 范围 排序
-- 主键 外键 高频 WHERE 列
CREATE INDEX idx_users_email ON users(email);
CREATE INDEX idx_orders_created_at ON orders(created_at);
-- 2 Hash 索引 PG 10+ 支持 WAL
-- 仅支持 = 比 B-tree 略快但用场景少
-- 大多数情况下 B-tree 完全够用
CREATE INDEX idx_users_phone_hash ON users USING HASH (phone);
-- 3 GIN 倒排索引 适合数组 JSONB 全文搜索
-- 关键场景 实测比 B-tree 快 100 倍
CREATE INDEX idx_articles_tags ON articles USING GIN (tags);
-- tags 是 text[] 数组
-- 查询: SELECT * FROM articles WHERE tags @> ARRAY['postgres']
-- JSONB 查询
CREATE INDEX idx_user_data ON users USING GIN (metadata);
-- 查询: SELECT * FROM users WHERE metadata @> '{"role": "admin"}'
-- JSONB 路径索引 更精确
CREATE INDEX idx_user_role ON users USING GIN ((metadata->'role'));
-- 全文搜索
CREATE INDEX idx_articles_fts ON articles USING GIN (to_tsvector('english', content));
-- 查询: WHERE to_tsvector('english', content) @@ to_tsquery('postgres & tuning')
-- 4 GiST 通用搜索索引 适合几何 全文 范围类型
CREATE INDEX idx_locations_geo ON locations USING GIST (coordinates);
-- 几何邻近查询: ORDER BY coordinates <-> point(116.4, 39.9) LIMIT 10
-- 5 BRIN 块范围索引 适合超大表的物理有序数据
-- 一个 1TB 表 BRIN 索引只要 MB 级别
CREATE INDEX idx_logs_created_at_brin ON logs USING BRIN (created_at);
-- 适合按时间顺序插入的日志表 节省 99% 索引空间
-- 6 SP-GiST 适合非平衡数据(电话号码 IP 段 路径数据)
CREATE INDEX idx_ip_addresses ON ip_logs USING SPGIST (ip_address inet_ops);
实战经验:90% 场景用 B-tree;JSONB 与数组必须 GIN;时序大表用 BRIN 省空间;几何数据用 GiST;Hash 索引基本不用 B-tree 已经够好。选错索引类型轻则浪费空间 重则查询慢 10 倍以上。
二 选择性与基数:决定索引是否被使用
PostgreSQL 优化器基于代价决定是否走索引 关键指标是选择性(selectivity) 即"用这个条件过滤后剩多少数据"。选择性高(剩很少)走索引;选择性低(剩很多)直接全表扫更快。低选择性列加 B-tree 索引基本是白加。
-- 1 查看列的选择性
SELECT
attname,
n_distinct, -- 不同值的数量 - 表示比例 + 表示绝对数
null_frac, -- NULL 比例
correlation -- 物理排序相关性 接近 1 适合 BRIN
FROM pg_stats
WHERE tablename = 'users';
-- attname | n_distinct | null_frac
-- id | -1 | 0
-- email | -0.99 | 0 高选择性 适合索引
-- status | 3 | 0 低选择性 单列索引无效
-- gender | 2 | 0.1 低选择性 加索引浪费
-- 2 计算具体查询的选择性
EXPLAIN ANALYZE
SELECT * FROM users WHERE status = 'active';
-- 假设返回 90 万行 总 100 万 选择性 = 0.9
-- 优化器看到选择性 0.9 不走索引 直接 Seq Scan
EXPLAIN ANALYZE
SELECT * FROM users WHERE email = 'alice@example.com';
-- 返回 1 行 选择性 = 0.000001
-- 优化器看到选择性极高 走 Index Scan
-- 3 低选择性列的索引策略 部分索引
-- 假设 status 中 99% 是 active 1% 是 inactive
-- 业务只查 inactive 的
CREATE INDEX idx_users_inactive ON users(id) WHERE status = 'inactive';
-- 索引只包含 1% 的数据 体积小 速度快
-- 查询自动用上(只要条件包含 WHERE status='inactive')
SELECT * FROM users WHERE status = 'inactive' AND created_at > '2024-01-01';
选择性是优化器决策的核心 但人很难一眼看出。下面是用 pg_stats 系统视图扫全表 找出真正适合加索引的高基数列 + 强制走索引验证决策对错的诊断手法 这是生产 DBA 必备技能。
-- 4 用 pg_stats 找索引候选列
SELECT
schemaname, tablename, attname,
n_distinct,
most_common_vals,
most_common_freqs
FROM pg_stats
WHERE schemaname = 'public'
AND n_distinct > 100 -- 选择性较高
AND null_frac < 0.5
ORDER BY n_distinct DESC;
-- 5 强制走索引测试(仅诊断 不要生产用)
SET enable_seqscan = OFF;
EXPLAIN ANALYZE SELECT * FROM users WHERE status = 'active';
SET enable_seqscan = ON;
-- 如果强制走索引比 seq scan 慢 那优化器决策是对的
实战经验:n_distinct < 100 的列基本不要加单列 B-tree 索引;低选择性列要么不加索引 要么用部分索引只针对少数派值。我们曾经清理过一张表 删了 8 个低基数无效索引 表占用从 200GB 降到 80GB 写性能提升 3 倍。
三 联合索引:最左前缀与列序设计
联合索引是性能调优最强武器 但也是最容易写错的。一个联合索引 (a, b, c) 的列顺序错了 可能比没加索引还差。下面是列序设计的核心法则。
-- 1 联合索引基础 最左前缀原则
CREATE INDEX idx_orders_user_status_date
ON orders(user_id, status, created_at);
-- 能用上索引的查询:
SELECT * FROM orders WHERE user_id = 100; -- 用上(最左)
SELECT * FROM orders WHERE user_id = 100 AND status = 'paid'; -- 用上(最左 2)
SELECT * FROM orders WHERE user_id = 100 AND status = 'paid' AND created_at > '2024-01-01'; -- 全用
-- 用不上索引的查询(关键陷阱):
SELECT * FROM orders WHERE status = 'paid'; -- 不走(跳过最左)
SELECT * FROM orders WHERE created_at > '2024-01-01'; -- 不走
SELECT * FROM orders WHERE status = 'paid' AND created_at > '2024-01-01'; -- 不走
-- 部分用上(范围后断):
SELECT * FROM orders WHERE user_id = 100 AND created_at > '2024-01-01';
-- 用 user_id 走索引 但 created_at 因为跳过 status 走不了
-- 后面只能 Filter
-- 2 列序设计原则
-- a. 等值条件在前 范围条件在后
-- 错: WHERE created_at > X AND user_id = Y 索引 (created_at, user_id) 范围导致后面失效
-- 对: WHERE user_id = Y AND created_at > X 索引 (user_id, created_at) 等值精确定位
-- b. 高选择性在前
-- 但等值条件比高选择性更优先(等值能精确定位)
-- c. 不要把唯一列放前面
-- 错: CREATE INDEX (id, status, created_at) id 唯一后面都白搭
-- 3 ORDER BY 利用索引避免排序
-- 索引天然有序 可以省去 sort
CREATE INDEX idx_orders_user_created
ON orders(user_id, created_at DESC);
-- 这个查询走索引 不需要额外 sort
SELECT * FROM orders WHERE user_id = 100 ORDER BY created_at DESC LIMIT 10;
-- 4 覆盖索引 INCLUDE 子句 (PG 11+)
-- 把查询要的列放进索引 避免回表
CREATE INDEX idx_orders_user_created_amt
ON orders(user_id, created_at) INCLUDE (amount, status);
-- 这个查询完全走索引 不回表
SELECT user_id, created_at, amount, status FROM orders WHERE user_id = 100;
-- 5 索引列重复 不要造
-- 错: CREATE INDEX (user_id) 与 CREATE INDEX (user_id, created_at)
-- 第一个完全被第二个覆盖 删掉第一个
实战经验:联合索引列序设计是最考验功力的。我们的最佳实践是 等值在前 范围在后 高选择性在前;ORDER BY 列加进索引避免 sort;INCLUDE 列做覆盖索引减少回表。这三招用好 90% 的慢 SQL 都能优化掉。
四 部分索引与表达式索引:索引中的高级武器
普通索引覆盖全表数据 但很多业务场景只需要索引一小部分。部分索引(Partial Index)与表达式索引(Expression Index)能让索引体积小 90% 速度还更快。
-- 1 部分索引 适合稀疏热数据
-- 场景:订单表 99% 是 completed 1% 是 pending
-- 业务关心的 pending 订单(payment 处理中)
CREATE INDEX idx_orders_pending
ON orders(created_at)
WHERE status = 'pending';
-- 索引体积只有完整索引的 1%
-- 查询自动用上
SELECT * FROM orders WHERE status = 'pending' AND created_at > NOW() - INTERVAL '1 hour';
-- 2 部分索引 软删除场景
-- deleted_at IS NULL 的活跃数据通常 < 10%
CREATE INDEX idx_users_active_email
ON users(email)
WHERE deleted_at IS NULL;
-- 3 表达式索引 函数结果索引
-- 大小写不敏感搜索
CREATE INDEX idx_users_email_lower ON users(LOWER(email));
-- 查询必须用相同表达式
SELECT * FROM users WHERE LOWER(email) = LOWER('Alice@Example.COM');
-- 4 表达式索引 时间分组
CREATE INDEX idx_logs_date ON logs(DATE(created_at));
SELECT COUNT(*) FROM logs WHERE DATE(created_at) = '2024-01-15';
-- 5 表达式索引 JSON 字段
CREATE INDEX idx_users_age ON users(((metadata->'age')::int));
SELECT * FROM users WHERE (metadata->'age')::int > 18;
部分索引与表达式索引可以单独用 也可以组合用 实现"对小子集 用函数索引"。多租户系统这套组合特别好用 每个 tenant 一份小索引 维护灵活性能更好。
-- 6 部分 + 表达式组合
-- 只对未删除用户的小写邮箱建索引
CREATE INDEX idx_users_active_email_lower
ON users(LOWER(email))
WHERE deleted_at IS NULL;
-- 7 INSERT/UPDATE 触发条件检测
-- 实战:多租户系统按 tenant_id 分别建部分索引
CREATE INDEX idx_orders_tenant_1 ON orders(user_id, created_at) WHERE tenant_id = 1;
CREATE INDEX idx_orders_tenant_2 ON orders(user_id, created_at) WHERE tenant_id = 2;
-- 比一个大的联合索引性能更好 维护也更灵活
实战经验:稀疏数据(状态枚举 软删除)必用部分索引;函数查询必用表达式索引。我们曾经把一个 50GB 的完整索引换成 500MB 的部分索引 查询从 300ms 降到 5ms 维护成本还降低了。
五 EXPLAIN 解读与慢查询诊断
不会读 EXPLAIN 等于看不懂数据库在干什么。下面是 EXPLAIN ANALYZE 的关键输出与诊断技巧。
-- 1 EXPLAIN ANALYZE 标准格式
EXPLAIN (ANALYZE, BUFFERS, VERBOSE)
SELECT * FROM orders
WHERE user_id = 100 AND status = 'paid'
ORDER BY created_at DESC LIMIT 10;
-- 关键输出:
-- Index Scan using idx_orders_user_status on orders
-- (cost=0.42..8.45 rows=10 width=128)
-- (actual time=0.025..0.128 rows=10 loops=1)
-- Index Cond: (user_id = 100 AND status = 'paid')
-- Buffers: shared hit=12
-- Planning Time: 0.245 ms
-- Execution Time: 0.156 ms
-- 关键指标:
-- cost: 优化器估计代价 第一个是启动 第二个是总
-- actual time: 实际执行时间
-- rows: 估计 vs 实际行数 差距 > 10 倍说明统计不准
-- Buffers: shared hit 是缓存命中 read 是从磁盘读
-- 2 常见 scan 类型 与含义
-- Seq Scan 全表扫描 大表上是性能杀手
-- Index Scan 索引扫描 走索引但要回表
-- Index Only Scan 覆盖索引 完全不回表 最快
-- Bitmap Index Scan 多条件索引扫描后取交集
-- Nested Loop 嵌套循环 join 适合小表
-- Hash Join 哈希 join 适合大表
-- Merge Join 归并 join 适合有序数据
-- 3 慢查询排查 步骤
-- a. 找最慢 SQL pg_stat_statements
CREATE EXTENSION IF NOT EXISTS pg_stat_statements;
SELECT
query,
calls,
total_exec_time,
mean_exec_time,
rows
FROM pg_stat_statements
ORDER BY total_exec_time DESC
LIMIT 20;
-- b. EXPLAIN ANALYZE 看执行计划
-- c. 看 actual rows 与 estimated 偏差
-- 偏差大说明 ANALYZE 统计过期 跑 ANALYZE 重新收集
ANALYZE orders;
-- 4 强制提示 临时绕过优化器
/*+ IndexScan(orders idx_orders_user_status) */
SELECT * FROM orders WHERE user_id = 100;
-- 需要 pg_hint_plan 扩展 慎用
-- 5 慢查询日志
-- postgresql.conf
-- log_min_duration_statement = 1000 超过 1s 记录
-- log_statement = 'mod' 记录所有修改
实战经验:看到 Seq Scan 大表立刻警惕 是不是漏建索引;Index Scan 但 actual time 长 是不是 bloat;Nested Loop join 大表是不是该改 Hash Join;estimated rows 与 actual rows 差距大就 ANALYZE 重新收集统计。这四个套路解决 80% 的慢查询问题。
[mermaid]
flowchart TD
A[慢 SQL 出现] --> B[pg_stat_statements 找 TOP 20]
B --> C[EXPLAIN ANALYZE 看执行计划]
C --> D{扫描类型}
D -->|Seq Scan 大表| E[考虑加索引]
D -->|Index Scan 慢| F[检查 bloat]
D -->|Nested Loop 大表| G[调整 join 方式]
E --> H{选择性高}
H -->|是| I[加单列或联合索引]
H -->|否| J[考虑部分索引]
F --> K[VACUUM ANALYZE]
K --> L{仍慢}
L -->|是| M[REINDEX]
L -->|否| N[完成]
I --> N
J --> N
G --> N
六 索引维护:膨胀治理与 VACUUM
PostgreSQL 的 MVCC 机制下 UPDATE 与 DELETE 不会立即删除旧版本 会留下死元组 索引也会膨胀。生产数据库不做索引维护 半年后所有索引性能都会显著下降。下面是必须做的维护操作。
-- 1 检测索引膨胀
-- 用 pgstattuple 扩展
CREATE EXTENSION IF NOT EXISTS pgstattuple;
SELECT
schemaname, tablename, indexname,
pg_size_pretty(pg_relation_size(indexrelid)) AS index_size,
idx_scan, idx_tup_read, idx_tup_fetch
FROM pg_stat_user_indexes
JOIN pg_indexes USING (schemaname, tablename, indexname)
ORDER BY pg_relation_size(indexrelid) DESC;
-- 查具体膨胀比例
SELECT * FROM pgstatindex('idx_orders_user_id');
-- avg_leaf_density 应该 > 70% 否则需要 reindex
-- leaf_fragmentation 应该 < 30%
-- 2 VACUUM 与 ANALYZE
-- VACUUM 清理死元组 ANALYZE 更新统计信息
-- 自动 vacuum 配置(默认开启 但参数可能不够激进)
-- postgresql.conf
-- autovacuum = on
-- autovacuum_vacuum_threshold = 50
-- autovacuum_vacuum_scale_factor = 0.1 表 10% 变化触发
-- autovacuum_analyze_threshold = 50
-- autovacuum_analyze_scale_factor = 0.05
-- 手动 VACUUM 维护窗口跑
VACUUM (VERBOSE, ANALYZE) orders;
-- 紧急情况 VACUUM FULL 锁表
-- VACUUM FULL orders; 生产慎用 会锁表
-- 推荐用 pg_repack 在线重建表(不锁)
-- pg_repack -d mydb -t orders
-- 3 REINDEX 重建索引 治膨胀
-- 在线重建 PG 12+
REINDEX INDEX CONCURRENTLY idx_orders_user_id;
-- 重建整张表的所有索引
REINDEX TABLE CONCURRENTLY orders;
-- 4 找未使用的索引 删掉
SELECT
s.schemaname,
s.relname AS table,
s.indexrelname AS index,
pg_size_pretty(pg_relation_size(s.indexrelid)) AS size,
s.idx_scan AS scans
FROM pg_stat_user_indexes s
WHERE s.idx_scan = 0 -- 从未使用
AND NOT EXISTS ( -- 排除主键和唯一索引
SELECT 1 FROM pg_constraint
WHERE conindid = s.indexrelid
)
ORDER BY pg_relation_size(s.indexrelid) DESC;
-- 这些索引可以删 节省空间 提升写性能
DROP INDEX CONCURRENTLY idx_unused;
-- 5 索引重复检测 删冗余
-- 索引列前缀相同的可以删掉较短的
-- 例如有 (user_id) 和 (user_id, created_at) 删掉 (user_id)
实战经验:autovacuum 默认参数对大表不够 必须调整 scale_factor 到 0.05 或者更小;每周维护窗口 REINDEX CONCURRENTLY 治膨胀;每月找未使用索引清理。我们一个客户做完一轮治理 数据库大小从 800GB 降到 350GB 查询整体快 50%。
关键概念速查
| 概念 | 说明 | 推荐 | 备注 |
|---|---|---|---|
| B-tree | 默认索引 | 90% 场景 | 等值范围排序 |
| GIN | 倒排索引 | JSONB 数组 | 全文搜索必备 |
| BRIN | 块范围索引 | 时序大表 | 省 99% 空间 |
| 选择性 | 过滤剩余比例 | > 1% 不走索引 | 低选择性废索引 |
| 最左前缀 | 联合索引规则 | 等值在前 | 跳列废索引 |
| 部分索引 | 带 WHERE 的索引 | 稀疏数据必用 | 体积 -90% |
| 表达式索引 | 函数结果索引 | LOWER JSONB | 必须查询同表达式 |
| 覆盖索引 | INCLUDE 子句 | 避免回表 | PG 11+ |
| EXPLAIN | 执行计划 | 必看 | ANALYZE + BUFFERS |
| 膨胀 | 死元组累积 | 定期 REINDEX | CONCURRENTLY 不锁 |
避坑清单
- 不要看到慢就加索引 必须先看选择性 n_distinct < 100 加单列 B-tree 基本无效。
- 不要给一张表加 10+ 索引 写性能崩 5 个左右是甜区。
- 不要无脑用 B-tree JSONB 必 GIN 时序大表必 BRIN 几何数据必 GiST。
- 不要忽视联合索引最左前缀 列序错索引完全废 等值在前范围在后。
- 不要给稀疏数据加完整索引 必须用部分索引 体积小 10 倍速度还快。
- 不要 WHERE LOWER(email) 用 email 索引 必须 LOWER(email) 表达式索引。
- 不要忽视 ANALYZE 统计信息 estimated rows 与 actual rows 差距大优化器决策必错。
- 不要从不 VACUUM 半年后膨胀 50% 索引完全失效 设置 autovacuum 激进点。
- 不要 REINDEX 不加 CONCURRENTLY 生产会锁表导致业务停摆。
- 不要留未使用的索引 pg_stat_user_indexes 找 idx_scan=0 的 DROP 掉。
总结
把 PostgreSQL 索引调优这套从我们踩过的所有坑里反过来看 你会发现真正影响数据库性能的不是 CPU 不是磁盘 而是索引设计与维护的全栈能力。同样一张 1 亿行表 加 10 个 B-tree 索引写性能崩 加 3 个精心设计的联合 + 部分索引读快 100 倍写性能只降 10%;同样一个 SQL 没分析选择性瞎加索引优化器还是 Seq Scan 分析选择性 + 改部分索引 30 秒变 30ms。索引调优不是 CREATE INDEX 一句话的玩具 它是一个 类型选择 + 选择性分析 + 联合索引设计 + 部分与表达式索引 + EXPLAIN 解读 + 膨胀维护 的完整系统工程。
另一个常见的认知误区是把索引当成只读优化 觉得加索引只会让查询变快 不会带来副作用。但事实是每个索引都是写代价 INSERT UPDATE DELETE 都要同步更新所有相关索引 一张表 10 个索引意味着写性能除以 10。索引的金科玉律是宁缺毋滥 不是越多越好。
打个比方 PostgreSQL 索引像图书馆的检索系统。B-tree 是按书名字母排序的卡片柜(等值范围查询都快) GIN 是按主题词的倒排卡片(找含某词的所有书) BRIN 是按上架时间的区间索引(节省空间快速定位时间段) 部分索引是只对热门新书建的快速检索(稀疏数据) 表达式索引是按作者姓首字母的索引(函数查询) 最左前缀是必须按主索引顺序找(规则约束) VACUUM 是定期清理废弃卡片(死元组治理) REINDEX 是重排整柜卡片(膨胀治理)。哪一环没做 这个图书馆要么找书慢 要么柜子塞太满 要么有书但卡片是过期版本 你借了书才发现书在别处。
所以下一次再有人跟你说"加索引就快" 你可以反问他 选择性分析了吗 联合索引列序对吗 部分索引用了吗 表达式索引用了吗 EXPLAIN 看了吗 ANALYZE 跑了吗 VACUUM 设了吗 未用索引清了吗 这些工作没做完 PostgreSQL 索引只是一个让你跑通 demo 的工具 不是一个能扛业务的高性能引擎。从踩坑到投产 中间隔着一整套工程方法论 这条路没有捷径 但走完之后 你的数据库会从客户投诉变成性能担当 从凌晨被告警叫醒变成稳如老狗。
—— 别看了 · 2026