我的数据库“逻辑正确”,但也慢了 296 倍。
我发现我的 Postgres 数据库里有 89 个外键,但它们一个索引都没有——本该毫秒级的查询被拖到 843ms,差点毁掉我的 alpha 发布。
2025 年 10 月 25 日。我的 SaaS 应用终于做到了功能完整。九个核心功能、多租户架构、渐进式学习——全部能用。但它很慢,非常慢。
Dashboard 查询像在爬行。按理应秒开的列表要等好几秒。alpha 测试用户开始问:“这个站是不是坏了?”
我担心的已经不只是性能,而是 生死问题。
我花了两天优化查询、重写 RLS 策略、加缓存。几乎没有改观。距离计划中的 alpha 发布只剩 5 天,我越来越焦虑:一个技术上“正确”的数据库,为什么会慢成这样?
然后我跑了一条诊断查询,胃瞬间往下沉。
89 个外键。零索引。
这是一个没人告诉你的 Postgres“特性”的故事——它差点拖垮我 alpha 发布的 2 周,以及最终 4 分钟就救回来的修复。
风险有多高:慢,等于死
研究显示,每增加 100ms 延迟就会损失 1% 转化。加载时间到了 2-3 秒,我不只是提供了糟糕体验,我是在毁掉 alpha 成败关键的第一印象。
当时的直接风险:
- alpha 测试用户开始怀疑平台是否稳定、值不值得推荐
- 早期用户把信任给了我,我却在透支这份信任
- 如果问题带进正式发布:行业研究显示 3 秒加载时跳出率可达 40%
- “这个产品有潜力”和“这个产品像坏了”之间,只差这点性能
- 数据库 CPU 在 80-90%(贵,而且越来越糟)
- 距离计划发布 5 天,项目完全卡死
那 2 周的真实代价:
- 40+ 小时在慢查询、代码 profiling、RLS 策略上排障
- 发布延迟 1 周(错过内部截止时间)
- 本可用于做新功能的时间被全部吞掉
- 早期测试用户对我的信心受损
技术债正在变成 业务债。而我还不知道根因是什么。
---
排查过程:沿着慢查询一路追
到 10 月底,我的平台已经有真实复杂度:30+ 张表、80+ 条 RLS 策略、几百个外键关系。那些本该瞬间完成的操作变得异常迟缓——加载列表、读取 dashboard 数据、按 org_id 过滤。
我先用 pg_stat_statements 找“罪魁祸首”:
```sql
SELECT query, mean_exec_time, calls
FROM pg_stat_statements
ORDER BY mean_exec_time DESC
LIMIT 10;
```
每条慢查询都有同样模式——按外键过滤:
```sql
SELECT * FROM user_data
WHERE org_id = '...' AND resource_id = '...';
```
这本应是瞬时查询。我有这些列。我有 RLS 策略。为什么还这么慢?
我去看执行计划:
```sql
EXPLAIN ANALYZE
SELECT * FROM user_data
WHERE org_id = 'abc-123' AND resource_id = 'xyz-789';
```
结果让我心里一沉:
```
Seq Scan on user_data (cost=0.00..2847.23 rows=15 width=1024)
Filter: ((org_id = 'abc-123') AND (resource_id = 'xyz-789'))
Rows Removed by Filter: 12,834
Execution Time: 843.271 ms
```
顺序扫描(Sequential scan)。Postgres 在读完整张表后再过滤。一个本该毫秒级的查询,却走了最慢路径。
但 org_id 和 resource_id 明明是外键。外键不应该默认有索引吗?
---
真相时刻:外键不会自动建索引
那天我硬吃下去的一课是:
Postgres 会自动给 PRIMARY KEY 和 UNIQUE 约束创建索引。
Postgres 不会自动给 FOREIGN KEY 创建索引。
我再说一遍,因为这个误解让我白白损失了 2 周:
FOREIGN KEY ≠ INDEX
当你写下:
```sql
CREATE TABLE user_data (
id UUID PRIMARY KEY, -- ✅ Automatically indexed
org_id UUID REFERENCES organizations(id), -- ❌ NOT indexed!
resource_id UUID REFERENCES resources(id), -- ❌ NOT indexed!
created_at TIMESTAMPTZ
);
```
Postgres 会创建外键约束(保证引用完整性),但不会自动给 org_id 或 resource_id 建索引。
为什么?因为 Postgres 不能替你假设查询模式。也许你从不按外键过滤,也许你总是沿特定方向 join。所以它把决策权留给你。
问题在于: 我根本不知道这个决策是我来做。我一直以为“有外键”就等于“查询有索引”。并不是。
---
诊断结果:到底有多糟?
我写了一条查询,找出所有“有外键但没索引”的列:
```sql
SELECT
c.conrelid::regclass AS table_name,
a.attname AS column_name
FROM pg_constraint c
JOIN pg_attribute a ON a.attnum = ANY(c.conkey) AND a.attrelid = c.conrelid
WHERE c.contype = 'f'
AND NOT EXISTS (
SELECT 1 FROM pg_index i
WHERE i.indrelid = c.conrelid AND a.attnum = ANY(i.indkey)
);
```
结果像火车一样撞了过来:
89 行。
32 张表里共 89 个外键,索引为零。
每一次 join、每一次 filter、每一次 RLS 检查都在做全表扫描。难怪一切都慢。
---
修复:索引末日(The Index Apocalypse)
2025 年 10 月 25 日,上午 7:51。我知道只能这么做。
一个 migration,89 个索引。我把它叫做 “The Index Apocalypse”。
```sql
-- The Index Apocalypse Migration
-- Purpose: Fix performance by indexing all foreign key columns
-- Organization-scoped tables
CREATE INDEX idx_users_org_id ON users(org_id);
CREATE INDEX idx_resources_org_id ON resources(org_id);
CREATE INDEX idx_documents_org_id ON documents(org_id);
-- Resource-scoped tables
CREATE INDEX idx_user_data_resource_id ON user_data(resource_id);
CREATE INDEX idx_tasks_resource_id ON tasks(resource_id);
-- Multi-column indexes for common query patterns
CREATE INDEX idx_user_data_org_resource ON user_data(org_id, resource_id);
-- Soft-delete queries
CREATE INDEX idx_resources_org_archived ON resources(org_id, archived_at);
-- Total: 89 indexes across 32 tables
```
我跑下 migration。手指在 Enter 键上方真的停了一下,怕自己把情况搞得更糟。T.T
迁移耗时:4 分钟。
---
结果:从灾难到可发布
修复前(无索引)
```sql
Seq Scan on user_data
Execution Time: 843.271 ms
Rows Removed by Filter: 12,834
```
修复后(有索引)
```sql
Index Scan using idx_user_data_org_resource
Execution Time: 2.847 ms
```
843ms → 2.8ms
单条查询 快了 296 倍。
真实业务影响:
- Dashboard 加载:2-3 秒 → 120ms
- 资源列表:1+ 秒 → 45ms
- 数据查询:850ms → 12ms
平均提升:20-40 倍。一些涉及多个外键的复杂 join?100 倍。
前后差异是天壤之别。平台从“这个是不是坏了?”变成“哇,这很快。” :D
---
为什么这对多租户 SaaS 极其关键
如果你在做带 Row-Level Security 的多租户 SaaS,这件事是生死级别的。
RLS 策略会在 每一条查询 上执行:
```sql
CREATE POLICY resources_org_isolation ON resources
USING (org_id = get_user_org_id());
```
如果 org_id 没索引,这条策略会让每次查询都落到顺序扫描。哪怕是简单 SELECT,也会慢得痛苦。
代价: 没索引的多租户隔离 = 更贵的基础设施 + 更慢的用户体验 = 更高流失。
业务教训: 没做性能优化的安全功能,并不真正“安全”——因为用户会在体验到你安全性之前就离开。
---
可复用模式:哪些列必须建索引
付了这次昂贵学费后,我给每张新表都用这个清单:
这些必须建索引:
1. 外键列 - 每一个都要
2. RLS 策略里的列 - 尤其是多租户里的 org_id
3. WHERE 子句常用列 - 经常按它过滤就建索引
4. ORDER BY 列 - 没索引排序会拖垮性能
考虑组合索引:
```sql
-- For: WHERE org_id = X AND resource_id = Y
CREATE INDEX idx_table_org_resource ON table(org_id, resource_id);
```
也别过度建索引: 每个索引都有存储成本、写入成本和维护成本。经验法则是:经常出现在 WHERE/JOIN/ORDER BY 里的列就建,否则不要。
---
你现在就可以检查数据库
先跑这条诊断查询:
```sql
SELECT
c.conrelid::regclass AS table_name,
a.attname AS column_name
FROM pg_constraint c
JOIN pg_attribute a ON a.attnum = ANY(c.conkey) AND a.attrelid = c.conrelid
WHERE c.contype = 'f'
AND NOT EXISTS (
SELECT 1 FROM pg_index i
WHERE i.indrelid = c.conrelid AND a.attnum = ANY(i.indkey)
);
```
如果有结果,就说明你有缺失索引。
然后找慢查询:
```sql
SELECT query, mean_exec_time, calls
FROM pg_stat_statements
WHERE mean_exec_time > 100
ORDER BY mean_exec_time DESC;
```
再用 EXPLAIN ANALYZE 验证这些慢查询是不是在走顺序扫描。
---
经验总结
1. 对 Postgres 的“想当然”很危险
我以为外键会自动有索引。事实并非如此。务必验证。
2. RLS 离不开索引
没有正确索引时,Row-Level Security 不只是无效,甚至更糟——它会给每条查询加额外负担。
3. 多租户 = 到处给 org_id 建索引
在多租户架构中,org_id 几乎出现在每条查询里。每张表都要建索引。没有例外。
4. 性能是业务问题
如果页面要 3 秒加载,用户不会在乎你的 SQL 在技术上是否“正确”。快查询 = 更高转化 = 更高收入。
5. 索引越早加越好
空表上加索引几乎瞬间完成;百万行数据上加索引会花很久,甚至锁表。开发初期就做。
6. 所有优化都要测量
前后都用 EXPLAIN ANALYZE。用数据证明提升。
---
你的检查清单
每创建一张新表:
```sql
CREATE TABLE new_table (
id UUID PRIMARY KEY,
org_id UUID REFERENCES organizations(id),
resource_id UUID REFERENCES resources(id),
created_at TIMESTAMPTZ,
archived_at TIMESTAMPTZ
);
-- ✅ Index foreign keys
CREATE INDEX idx_new_table_org_id ON new_table(org_id);
CREATE INDEX idx_new_table_resource_id ON new_table(resource_id);
-- ✅ Index RLS columns + common filters
CREATE INDEX idx_new_table_org_archived ON new_table(org_id, archived_at);
-- ✅ Index sort columns
CREATE INDEX idx_new_table_created ON new_table(created_at DESC);
-- ✅ Create RLS policies
CREATE POLICY new_table_org_isolation ON new_table
FOR ALL TO authenticated
USING (org_id = get_user_org_id());
```
---
最后的想法
89 个缺失索引。2 周排障。一条诊断查询全部定位。4 分钟修复。
讽刺的是,Postgres 其实提供了完整的诊断工具。只是我之前不知道要看哪里。现在我每建一张表就立刻补齐索引——外键、RLS 列、排序字段,全部到位。
那次 4 分钟加上 89 个索引的 migration,帮我省掉了未来数周的性能优化,也避免了一场发布事故。
给创始人: 性能不只是工程问题,它是转化问题、留存问题、信誉问题。那个“技术上没错但慢到不能用”的数据库,正在让你流失客户。性能要早投。
给工程师: Postgres 不会自动给外键建索引。但你应该主动建。你的用户(还有数据库 CPU)都会感谢你。
你有没有遇到过那种“技术上正确、体感却慢得崩溃”的数据库问题?你最后怎么修的——又花了多久才找到答案?
致敬,
Chandler
STRAŦUM 架构系列: 这次性能危机,是一整套多租户拼图的最后一块。它始于 Day 2 就做多租户,经历了 Day 67 的完整 schema 重建,也包括修复 导航上下文丢失导致的 31 个空白屏。
---
正在用 Postgres 做多租户 SaaS? 我是踩坑后学到这一课的,你不必再踩。趁用户察觉之前,现在就检查你的数据库。
申请 alpha: https://stratum.chandlernguyen.com/request-invitation
---
我还在学习:“能跑” 和 “跑得快” 是两个不同目标,而且只有一个能真正上线。
---





