Skip to content
··阅读时间4分钟

我的数据库“逻辑正确”,但也慢了 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_idresource_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_idresource_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

---

我还在学习:“能跑” 和 “跑得快” 是两个不同目标,而且只有一个能真正上线。

---

继续阅读

我的旅程
联系
语言
偏好设置