事情的起点很普通:运营同事说"后台任务列表点不开了"。
我去点了一下,确实要等七八秒才出内容。第一反应是"数据量大了嘛",但当我看到表里只有 380 万行的时候,就知道不对——300 万行对 PostgreSQL 来说根本不算什么,8 秒一定是哪里写错了。
接下来的两天,我把这个页面从里到外翻了一遍。最后发现不是一个问题,是四个问题叠在一起:缺索引、N+1 查询、大偏移分页、以及一个每次保存都全字段写入的模型。每个单独看都不致命,叠起来就把一个本来 200 毫秒的查询拖到了 8 秒。
这篇文章完整记录这次排查:用什么工具定位、EXPLAIN 结果怎么读、每一处改了什么、改完的效果。中间也会写一些我当时不知道、查了才明白的东西。
TL;DR:先把慢查询抓出来(
pg_stat_statements+log_min_duration_statement),再用EXPLAIN (ANALYZE, BUFFERS)看它到底在干什么。这次的四个病因依次是:缺复合索引(全表扫)、select_related/prefetch_related没用(N+1)、LIMIT/OFFSET翻到深页(扫了 300 万行扔掉)、save()全字段写入(写了不该写的字段 + 索引全量更新)。改完 8.2 秒 → 180 毫秒。别凭感觉优化,一定先看 EXPLAIN。
目录
- 一、先把慢查询抓出来,别猜
- 二、EXPLAIN ANALYZE 怎么读
- 三、病因一:缺索引(以及索引不是越多越好)
- 四、病因二:N+1 查询
- 五、病因三:深分页,OFFSET 越大越慢
- 六、病因四:批量写入与全字段 save
- 七、连接数与连接池
- 八、表膨胀:为什么优化完过几个月又慢了
- 九、优化前后对比
- 十、日常怎么不让它再变慢
- 十一、坑清单
一、先把慢查询抓出来,别猜
我见过太多"优化"是这么做的:觉得这里可能慢,加个缓存;觉得那里可能慢,加个索引。改完测一下——好像快了?也不知道是不是心理作用。
正确顺序是:先知道慢在哪,再动手。
1. 打开慢查询日志
# postgresql.conf
log_min_duration_statement = 500 # 记录超过 500ms 的语句
log_statement = 'none'
log_line_prefix = '%m [%p] %q%u@%d '
改完 SELECT pg_reload_conf(); 就能生效,不用重启。日志里会看到:
2026-03-11 14:22:31.102 [12345] viddown@viddown LOG: duration: 8231.442 ms statement:
SELECT "downloader_downloadtask"."id", ... FROM "downloader_downloadtask"
LEFT OUTER JOIN ... ORDER BY "downloader_downloadtask"."created_at" DESC LIMIT 20 OFFSET 140020
8.2 秒,一眼就锁定了。
2. 装 pg_stat_statements(强烈推荐)
这个扩展会按"SQL 模板"聚合统计,能直接告诉你哪一类查询最耗时。它比看日志强的地方在于有总量视角。
shared_preload_libraries = 'pg_stat_statements'
pg_stat_statements.max = 10000
pg_stat_statements.track = all # 也统计嵌套/函数内的语句
这个需要重启数据库才能加载(因为是预加载库)。然后:
CREATE EXTENSION IF NOT EXISTS pg_stat_statements;
查最耗时的 10 条:
SELECT
substring(query, 1, 80) AS query,
calls,
round(total_exec_time::numeric, 1) AS total_ms,
round(mean_exec_time::numeric, 2) AS mean_ms,
rows
FROM pg_stat_statements
ORDER BY total_exec_time DESC
LIMIT 10;
我第一次跑出来的第一行就是任务列表那个查询:一天被调用 1.2 万次,平均 7.9 秒,总耗时占了全库的一半以上。
3. Django 侧看 SQL
开发时用 django-debug-toolbar,能看到每个页面发了多少条 SQL、每条多久、有没有重复。没有 toolbar 的话:
from django.db import connection
from django.conf import settings
# 开发环境打印所有 SQL
settings.DEBUG = True
qs = DownloadTask.objects.filter(status='SUCCESS')[:20]
list(qs)
for q in connection.queries[-5:]:
print(q['time'], q['sql'][:200])
还有个更狠的办法,直接把 ORM 生成的 SQL 打出来看:
print(DownloadTask.objects.filter(status='SUCCESS').order_by('-created_at').query)
str(qs.query) 打印的是近似 SQL(参数不一定转义正确),但看结构足够了。
二、EXPLAIN ANALYZE 怎么读
拿到慢查询之后,第一件事是看数据库实际怎么执行它:
EXPLAIN (ANALYZE, BUFFERS)
SELECT * FROM downloader_downloadtask
WHERE status = 'SUCCESS'
ORDER BY created_at DESC
LIMIT 20 OFFSET 140000;
实际输出(我简化了下):
Limit (cost=152341.22..152341.27 rows=20 width=248) (actual time=8211.442..8211.451 rows=20 loops=1)
Buffers: shared hit=128 read=412033
-> Sort (cost=152341.22..159842.10 rows=3000352 width=248) (actual time=7902.113..8155.902 rows=140020 loops=1)
Sort Key: created_at DESC
Sort Method: external merge Disk: 741288kB
Buffers: shared hit=128 read=412033
-> Seq Scan on downloader_downloadtask (cost=0.00..98234.00 rows=3000352 width=248)
(actual time=0.021..4821.330 rows=3000352 loops=1)
Filter: (status = 'SUCCESS'::text)
Rows Removed by Filter: 802114
Buffers: shared hit=128 read=412033
Planning Time: 0.214 ms
Execution Time: 8231.442 ms
怎么看(从里往外、从下往上):
| 位置 | 内容 | 含义 |
|---|---|---|
| 最内层 | Seq Scan on downloader_downloadtask |
全表扫描,读了 380 万行,过滤掉 80 万 |
| 内层 cost/actual | (cost=0.00..98234.00) (actual ...4821.330) |
光扫表就 4.8 秒 |
| 上一层 | Sort Method: external merge Disk: 741288kB |
排序没在内存里做,落磁盘了,724MB 临时文件 |
| 最外层 | Limit ... rows=20 |
折腾半天只返回 20 行 |
| Buffers | read=412033 |
读了 41 万个数据块(约 3.2GB),命中缓存的只有 128 个 |
三个致命信号,看到就该警觉:
Seq Scan出现在大表上(几百万行还全表扫);Sort Method: external merge Disk:—— 排序溢出到磁盘,说明work_mem不够或者排序的行数太多;- 返回行数远小于扫描行数(扫 380 万,返回 20)。
理想的执行计划应该长得像:
Limit (cost=0.43..12.88 rows=20 width=248) (actual time=0.031..0.142 rows=20 loops=1)
-> Index Scan using idx_task_status_created on downloader_downloadtask
(cost=0.43..12.88 rows=20 width=248) (actual time=0.029..0.135 rows=20 loops=1)
Index Cond: (status = 'SUCCESS'::text)
Planning Time: 0.180 ms
Execution Time: 0.168 ms
cost 从 152341 降到 12.88,actual time 从 8211ms 降到 0.142ms。数量级的差异,只能靠索引,别的优化手段都得往后排。
顺便说一句:EXPLAIN 只做计划不执行;EXPLAIN ANALYZE 会真的执行一遍。所以在生产上对 UPDATE/DELETE 用 EXPLAIN ANALYZE 要小心——它会真的改数据。我一般用 BEGIN; EXPLAIN ANALYZE ...; ROLLBACK;。
三、病因一:缺索引(以及索引不是越多越好)
加什么索引
这个查询的模式是 WHERE status = ? ORDER BY created_at DESC,标准的复合索引场景:
CREATE INDEX CONCURRENTLY idx_task_status_created
ON downloader_downloadtask (status, created_at DESC);
CONCURRENTLY 在生产上是必须的。不加的话,建索引会锁表(写操作全阻塞),几百万行的表要锁几十秒到几分钟,线上直接事故。CONCURRENTLY 不锁写,代价是更慢、且失败会留下 invalid 索引(要手动 DROP INDEX 重建)。
复合索引的字段顺序有讲究,我的判断规则:
- 等值条件(WHERE a = ?)的字段放前面;
- 排序/范围(ORDER BY b、b > ?)的字段放后面;
- 顺序错了索引就用不上或者只能用一半。
所以 (status, created_at) 是对的,(created_at, status) 对这个查询基本没用。
部分索引:只索引你真正查的
我们的场景里,status 分布极不均匀:
| status | 行数 | 占比 |
|---|---|---|
| SUCCESS | 2,980,000 | 78% |
| FAILED | 680,000 | 18% |
| DOWNLOADING | 1,200 | 0.03% |
| PENDING | 800 | 0.02% |
有意思的地方在于:列表页最常查的其实是"进行中"的任务(用户想看正在跑的),而它只占 0.05%。为它建一个部分索引,体积小到可以忽略:
CREATE INDEX CONCURRENTLY idx_task_active
ON downloader_downloadtask (created_at DESC)
WHERE status IN ('PENDING', 'DOWNLOADING');
这个索引只有 2000 行,几 KB。查询"进行中的任务"时扫描它,快到没感觉。
Django 里对应:
class DownloadTask(models.Model):
...
class Meta:
indexes = [
models.Index(fields=['status', '-created_at'], name='idx_status_created'),
models.Index(
fields=['-created_at'],
condition=Q(status__in=['PENDING', 'DOWNLOADING']),
name='idx_task_active',
),
]
另一个角度:高选择性的查询才需要索引。WHERE status='SUCCESS' 命中 78% 的行,这种情况下 PostgreSQL 的优化器可能会选择全表扫描,即使有索引——因为反正要读大部分数据页,走索引反而多一次 IO。这不是 bug,是正确的成本判断。我一开始不理解这点,加了索引发现没变快,还以为索引没生效。
索引的代价
每加一个索引:
- 写入(INSERT/UPDATE/DELETE)都要更新它,写放大;
- 占用磁盘;
- 给 autovacuum 增加负担。
我们的表里曾经有 9 个索引,其中 3 个从来没被用过。查未使用索引:
SELECT schemaname, relname, indexrelname, idx_scan, pg_size_pretty(pg_relation_size(indexrelid))
FROM pg_stat_user_indexes
WHERE idx_scan = 0
ORDER BY pg_relation_size(indexrelid) DESC;
idx_scan = 0 说明从统计重置以来没人用过。注意:统计是会重置的(重启、手动 pg_stat_reset()),所以别看了一天就删,观察一两周再说。我删掉那 3 个之后,写入快了约 20%。
四、病因二:N+1 查询
列表页每一行要显示"提交用户"和"标签",模型大致是这样:
class DownloadTask(models.Model):
user = models.ForeignKey(User, on_delete=models.CASCADE, related_name='tasks')
tags = models.ManyToManyField(Tag, blank=True)
...
模板里:
{% for task in tasks %}
<tr>
<td>{{ task.user.username }}</td>
<td>{% for t in task.tags.all %}{{ t.name }}{% endfor %}</td>
</tr>
{% endfor %}
20 行 = 1 次查列表 + 20 次查 user + 20 次查 tags = 41 条 SQL。 每条哪怕只有 2ms,加起来也 80ms,而且这还是本地、数据量小的情况。
select_related:外键/一对一,用 JOIN 一次查出来
tasks = DownloadTask.objects.select_related('user').filter(...) # 1 条 SQL
select_related 走 SQL JOIN,一次把关联表的数据取回来。只适用于 ForeignKey 和 OneToOneField(正向"多对一"、反向一对一)。
prefetch_related:多对多 / 反向外键,分两次查再拼
tasks = DownloadTask.objects.prefetch_related('tags').filter(...) # 2 条 SQL
prefetch_related 是两条独立的 SQL(先查主表,再用 WHERE id IN (...) 查关联表),然后在 Python 里拼起来。适用于 ManyToMany 和反向 FK。
两个一起用:
tasks = (DownloadTask.objects
.select_related('user')
.prefetch_related('tags')
.filter(status='SUCCESS')
.order_by('-created_at')[:20])
41 条 SQL → 3 条。
只想拿几个字段:only / values
列表页其实只需要 6 个字段,但 SELECT * 把 20 多个字段(包括一个 TEXT 的错误日志)全取回来了——那个 TEXT 字段平均 2KB,20 行就是 40KB 的无效传输。
# 只取需要的字段,其他字段延迟加载
tasks = DownloadTask.objects.only('id', 'status', 'created_at', 'filename', 'size')
# 或者干脆要字典,连模型实例化都省了
rows = (DownloadTask.objects
.filter(...)
.values('id', 'status', 'created_at', 'filename', 'size')[:20])
values() 返回字典,不构造模型实例,开销更小。代价是失去模型方法(比如 get_absolute_url()),纯展示的列表用它很合适。
反向的 defer() 是"排除某些字段",用在"我就要那个大 TEXT 之外的所有字段"的场景。
聚合别在 Python 里数
# 错:每个对象发一条 COUNT
for task in tasks:
print(task.chunks.count()) # N+1
# 对:annotate 一次性算出来
from django.db.models import Count
tasks = DownloadTask.objects.annotate(chunk_count=Count('chunks'))
五、病因三:深分页,OFFSET 越大越慢
看这个:
... ORDER BY created_at DESC LIMIT 20 OFFSET 140000;
OFFSET 140000 的意思是:先取出 140020 行,扔掉前 140000 行,返回剩下 20 行。数据库真的会去读那 14 万行。翻到第 7000 页,就是读 14 万行扔掉——这也解释了为什么"第一页很快,翻到后面越来越慢"。
方案 1:游标分页(keyset pagination)
不用 OFFSET,改成"从上一页最后一条之后继续":
# 第一页
tasks = DownloadTask.objects.order_by('-id')[:20]
# 后续页:带上上一页最后的 id
last_id = request.GET.get('last_id')
tasks = (DownloadTask.objects
.filter(id__lt=last_id) # 关键:用索引直接定位
.order_by('-id')[:20])
因为 id 上有主键索引,WHERE id < ? 可以直接定位,不需要扫描跳过任何行。翻到第一万页和第一页耗时一样。
代价:不能直接跳页("去第 500 页"做不到),只能"上一页/下一页"。对后台列表来说完全够用;如果产品一定要跳页,那就得接受深页慢,或者限制最大页数。
如果排序字段不是 id(比如按 created_at),要建 (created_at, id) 的复合索引,并且用元组比较:
tasks = (DownloadTask.objects
.filter(Q(created_at__lt=last_time) |
Q(created_at=last_time, id__lt=last_id))
.order_by('-created_at', '-id')[:20])
方案 2:总数不要每次都精确算
分页组件要显示"共 380 万条",于是每次都 COUNT(*)。在大表上 COUNT(*) 是全表扫描,我实测 1.8 秒。
三个办法:
a. 用近似值(PostgreSQL 的统计信息里有估算行数):
SELECT reltuples::bigint AS estimate FROM pg_class WHERE relname = 'downloader_downloadtask';
-- 3801234
快到可以忽略,误差通常在 5% 以内(取决于上次 ANALYZE 的时间)。后台列表显示"约 380 万条"完全够用。
b. 缓存总数,5 分钟更新一次。
c. 干脆不显示总数,只显示"下一页"按钮有没有。
我们选了 a + b:列表页用近似值,导出功能用缓存的精确值。
六、病因四:批量写入与全字段 save
save() 的隐藏代价
Django 的 save() 默认会把所有字段都写一遍(生成一条包含全部字段的 UPDATE)。两个问题:
- 并发更新时,A 进程改了
status,B 进程改了percent,如果 B 持有的对象是旧的,save 之后会把 A 的status覆盖回去; - 字段多的时候,UPDATE 语句很大,而且所有索引都要更新(哪怕你只改了一个没索引的字段)。
# 只更新变化的字段
task.status = 'DOWNLOADING'
task.save(update_fields=['status', 'updated_at'])
update_fields 是我现在的默认习惯。更激进的做法是用 QuerySet.update(),它不经过模型层,直接生成 UPDATE 语句,也不触发信号:
DownloadTask.objects.filter(id=task_id).update(status='DOWNLOADING', updated_at=timezone.now())
注意:update() 不会触发 post_save 信号。我们的 SSE 推送依赖这个信号,所以状态更新走 save(update_fields=...),只有纯数据修正的批量操作才用 update()。这个区别踩过一次:批量改完状态,前端一个都没刷新。
批量创建
# 错:800 次 INSERT,800 次往返
for item in items:
DownloadTask.objects.create(**item)
# 对:一次或几次批量 INSERT
DownloadTask.objects.bulk_create(
[DownloadTask(**item) for item in items],
batch_size=500,
ignore_conflicts=True, # 遇到唯一键冲突就跳过,不报错
)
实测:800 条从 3.2 秒降到 0.14 秒。
batch_size 不是越大越好。我测过 100 / 500 / 2000 / 10000,500~1000 是甜点区,再大 SQL 语句太长,解析开销反而上来,而且单个语句太大容易撞上 max_allowed_packet 之类的限制。
批量更新
tasks = list(DownloadTask.objects.filter(status='PENDING')[:1000])
for t in tasks:
t.status = 'CANCELED'
DownloadTask.objects.bulk_update(tasks, ['status', 'updated_at'], batch_size=500)
bulk_update 生成的是 UPDATE ... WHERE id IN (...) 或者 CASE WHEN,比 1000 条独立 UPDATE 快一个数量级。
事务边界
默认 Django 是 autocommit,每条 SQL 一个事务,每条都要刷 WAL。批量操作包在事务里能显著提速:
from django.db import transaction
with transaction.atomic():
DownloadTask.objects.bulk_create(objs, batch_size=500)
但要注意别开太长的事务:一个事务如果跑几分钟,会阻塞 autovacuum(后面的第八节),而且持有锁会让别的查询排队。我的原则是:一个事务只做一批事,不要包住整个循环 + 网络请求。尤其不要在 transaction.atomic() 里面发 HTTP 请求或者跑 ffmpeg——这种写法我见过,一个事务持锁 20 分钟,全站写入阻塞。
死锁
批量更新多行时,如果不同任务更新行的顺序不一致,会死锁:
事务 A: UPDATE id=1; UPDATE id=2;
事务 B: UPDATE id=2; UPDATE id=1; ← 互相等锁
解决办法:批量更新前按主键排序,保证所有事务的加锁顺序一致。
tasks.sort(key=lambda t: t.id) # 排序后再 bulk_update
PostgreSQL 会检测死锁并杀掉其中一个事务(报错 deadlock detected),不会永久挂住,但用户会看到失败。我们加了按主键排序之后,死锁从每天几次降到 0。
七、连接数与连接池
优化做完之后跑了一阵,开始偶发报:
FATAL: sorry, too many clients already
算一下就明白了:
gunicorn worker 数 × 每 worker 连接 + celery worker 数 × 每 worker 连接 + 管理连接
= 9 × 2 + 4 × 2 + 5 = 31
看着不多,但 PostgreSQL 默认 max_connections = 100,而且每个连接是一个独立进程,内存开销不小。更麻烦的是连接创建成本:每次新建连接要走 TCP + 认证 + 初始化,几毫秒到几十毫秒。
三件事:
1. 开启连接复用
DATABASES = {
'default': {
...
'CONN_MAX_AGE': 60, # 复用 60 秒
'CONN_HEALTH_CHECKS': True, # 每次用之前检查连接是否还活着(Django 4.1+)
}
}
CONN_MAX_AGE 让 Django 在一个请求结束后不关闭连接,下一个请求复用。默认是 0(每个请求新建连接)。
2. 算清楚并发,别让 worker 数失控
gunicorn 同步 worker 模式下,每个 worker 同时只处理一个请求,所以连接数 ≤ worker 数。但如果用了 gevent worker,并发请求数可以远大于 worker 数——这时候必须上连接池(pgbouncer),否则连接数会爆。
3. 上 pgbouncer
[databases]
viddown = host=127.0.0.1 port=5432 dbname=viddown
[pgbouncer]
pool_mode = transaction
max_client_conn = 1000
default_pool_size = 20
transaction 模式下连接只在事务期间持有,复用率最高。代价是不支持预处理语句(prepared statement),Django 里要关掉:
DATABASES = {
'default': {
...
'DISABLE_SERVER_SIDE_CURSORS': True,
}
}
pgbouncer 一定在测试环境先跑通再上生产。我第一次直接上,结果一批查询报 prepared statement "..." already exists,紧急回滚。
八、表膨胀:为什么优化完过几个月又慢了
优化完三个月,同一个页面又开始变慢。EXPLAIN 一看,索引还在、计划也没变,但 Buffers: read 变多了。
原因是 表膨胀(bloat)。
PostgreSQL 的 MVCC 机制:UPDATE 不是原地修改,而是插入一行新版本,把旧版本标记为 dead tuple;DELETE 也只是标记。这些死元组要靠 VACUUM 清理,清理出来的空间可以被复用,但不会自动还给操作系统(除非 VACUUM FULL,那会锁表)。
我们的任务表更新极其频繁(每条任务的状态变更多次),所以:
- 表里 dead tuple 很多;
- 物理文件越来越大;
- 即使只查 20 行,也要在更大的文件里扫。
autovacuum 一般是够用的,但对"高频更新 + 大表"要调参:
-- 让这张表更激进地触发 vacuum(默认 20% 变化才触发,大表很难达到)
ALTER TABLE downloader_downloadtask SET (
autovacuum_vacuum_scale_factor = 0.02, -- 2% 变化就触发
autovacuum_vacuum_cost_delay = 10, -- 别让它太"温柔"
autovacuum_analyze_scale_factor = 0.01
);
监控膨胀:
SELECT
schemaname, relname,
n_live_tup, n_dead_tup,
round(n_dead_tup::numeric / GREATEST(n_live_tup, 1) * 100, 2) AS dead_pct,
last_autovacuum
FROM pg_stat_user_tables
WHERE n_dead_tup > 10000
ORDER BY n_dead_tup DESC;
dead_pct 长期超过 20% 就要关注了。
另一个杀手:长事务。只要有任何一个老事务还开着,它之后产生的所有 dead tuple 都不能被清理(因为那个事务可能还需要看到它们)。我见过一个跑了 6 小时的 celery 任务持着事务,整张表的 dead tuple 涨到 300 万,磁盘从 8G 涨到 40G。所以:任务里不要开长事务,尽量短。
查长事务:
SELECT pid, now() - xact_start AS duration, state, left(query, 60)
FROM pg_stat_activity
WHERE xact_start IS NOT NULL AND now() - xact_start > interval '5 minutes'
ORDER BY duration DESC;
这条我加进了日常巡检脚本,超过 10 分钟就告警。
顺带一句:索引也会膨胀,REINDEX CONCURRENTLY(PG 12+)可以在线重建:
REINDEX INDEX CONCURRENTLY idx_task_status_created;
九、优化前后对比
把改动和效果列出来(同一个查询:列表页第 7000 页,20 行):
| 步骤 | 改动 | 耗时 | 累计 |
|---|---|---|---|
| 0 | 原始状态 | 8231 ms | — |
| 1 | 加 (status, created_at DESC) 复合索引 |
2100 ms | -74% |
| 2 | select_related('user') + prefetch_related('tags') |
1450 ms | -82% |
| 3 | only() 只取 6 个字段,去掉 TEXT |
620 ms | -92% |
| 4 | 改游标分页(id__lt 替代 OFFSET) |
190 ms | -97.7% |
| 5 | 总数用近似值(去掉 COUNT(*)) | 180 ms | -97.8% |
索引单独就砍掉了 74%,这是最划算的一步。而游标分页把"翻到深页"这个场景从"越翻越慢"变成了"恒定快"。
写入侧:
| 操作 | 改前 | 改后 |
|---|---|---|
| 导入 800 条记录 | 3.2 s | 0.14 s |
| 批量改 1000 条状态 | 4.8 s | 0.35 s |
| 单条状态更新 | 12 ms | 3 ms(update_fields) |
还有一个不在表里的收益:CPU 和 IO 降下来之后,整站的响应都变快了。之前那个查询一天跑 1.2 万次、每次 8 秒,等于一天有 26 小时(!)的 CPU 时间在干这一件事——是它把整个库拖慢的,不只是它自己慢。
十、日常怎么不让它再变慢
优化完不是终点。我给自己定了三件事:
1. 每周看一次 pg_stat_statements top 10
SELECT substring(query, 1, 70), calls,
round(total_exec_time::numeric/1000, 1) AS total_s,
round(mean_exec_time::numeric, 1) AS mean_ms
FROM pg_stat_statements
WHERE query NOT LIKE '%pg_stat%'
ORDER BY total_exec_time DESC LIMIT 10;
五分钟的事,能提前发现"某个新接口在偷偷全表扫"。
2. 新接口上线前跑一遍 EXPLAIN
凡是涉及大表的新查询,上线前在开发库(灌了生产量级数据的库)上跑一次 EXPLAIN ANALYZE,确认没有 Seq Scan 和 external merge Disk。这件事写进了我们的上线清单。
3. 慢查询日志告警
log_min_duration_statement = 500,日志里出现 duration: 超过 1 秒的就发到群里。不是要立刻处理,是保持感知——慢查询最怕的是"悄悄变慢,半年后才发现"。
顺带说个习惯:我在 Django 里加了个中间件,开发环境把超过 100ms 的查询直接打到终端上,红色的。写新功能的时候一眼就能看到"这条查询有问题",比事后优化省事太多。
十一、坑清单
- 凭感觉优化 → 改了一圈不知道有没有效。一定先看 EXPLAIN。
- 生产建索引没加
CONCURRENTLY→ 锁表,写入全阻塞。 - 复合索引顺序写反 →
WHERE a=? ORDER BY b要用(a, b),写成(b, a)基本没用。 - 给低选择性字段建索引期待它生效 → 命中 78% 行的条件,优化器会选全表扫,这是对的。改用部分索引。
select_related用在 ManyToMany 上 → 报错。多对多要用prefetch_related。prefetch_related之后又加了filter→ 预取失效,重新查库。要过滤就用Prefetch('tags', queryset=Tag.objects.filter(...))。values()之后还想调模型方法 → 拿的是字典,没有方法。- 深分页用 OFFSET → 越翻越慢。改游标分页。
COUNT(*)每页都算 → 大表上 1.8 秒。用近似值或缓存。save()全字段写入 → 并发覆盖 + 无谓的索引更新。用update_fields。- 用
queryset.update()以为会触发信号 → 不会,前端不刷新。 - 事务里跑 ffmpeg / 发 HTTP 请求 → 长事务阻塞 vacuum,全站写入排队。
- 批量更新不按主键排序 → 死锁。
- 上 pgbouncer 没关 server-side cursor →
prepared statement already exists。 - 忽略了长事务 → dead tuple 清不掉,磁盘暴涨。
- 删了没用的索引却没观察够 →
idx_scan=0可能是统计刚重置过,观察两周再删。
这次复盘给我最大的教训是:性能问题很少是"一个"问题。8 秒不是由某个错误导致的,是四个中等规模的问题叠乘出来的。如果我只修了索引,页面会从 8 秒变成 2 秒,我可能会觉得"差不多了"就收工——而剩下那 1.8 秒里的 N+1 和深分页,会在数据量再翻一倍的时候重新冒出来。
所以现在我处理这类问题的顺序是固定的:先量化(EXPLAIN + pg_stat_statements)→ 按影响排序 → 一个一个改 → 每改一次测一次 → 全部改完再复测一遍总量。中间不做"我觉得应该也差不多"的跳跃。
最后一条建议:把这次的 EXPLAIN 输出和优化前后的数字存进项目的文档里。半年后有人问"这个索引为什么这么建",有据可查;下次再出现慢查询,也有一份基线可以对比。我们现在的 docs/perf/ 目录下就躺着这么几份,每一份都是当时两天的活,但省下来的是后面无数次的重复排查。