提示

返回博客列表

后台任务列表从 8 秒到 180 毫秒:一次 Django ORM + PostgreSQL 慢查询的完整复盘

事情的起点很普通:运营同事说"后台任务列表点不开了"。

我去点了一下,确实要等七八秒才出内容。第一反应是"数据量大了嘛",但当我看到表里只有 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。

目录

一、先把慢查询抓出来,别猜

我见过太多"优化"是这么做的:觉得这里可能慢,加个缓存;觉得那里可能慢,加个索引。改完测一下——好像快了?也不知道是不是心理作用。

正确顺序是:先知道慢在哪,再动手。

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 个

三个致命信号,看到就该警觉:

  1. Seq Scan 出现在大表上(几百万行还全表扫);
  2. Sort Method: external merge Disk: —— 排序溢出到磁盘,说明 work_mem 不够或者排序的行数太多;
  3. 返回行数远小于扫描行数(扫 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,而且这还是本地、数据量小的情况。

tasks = DownloadTask.objects.select_related('user').filter(...)   # 1 条 SQL

select_related 走 SQL JOIN,一次把关联表的数据取回来。只适用于 ForeignKey 和 OneToOneField(正向"多对一"、反向一对一)。

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)。两个问题:

  1. 并发更新时,A 进程改了 status,B 进程改了 percent,如果 B 持有的对象是旧的,save 之后会把 A 的 status 覆盖回去;
  2. 字段多的时候,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 的查询直接打到终端上,红色的。写新功能的时候一眼就能看到"这条查询有问题",比事后优化省事太多。

十一、坑清单

  1. 凭感觉优化 → 改了一圈不知道有没有效。一定先看 EXPLAIN。
  2. 生产建索引没加 CONCURRENTLY → 锁表,写入全阻塞。
  3. 复合索引顺序写反 → WHERE a=? ORDER BY b 要用 (a, b),写成 (b, a) 基本没用。
  4. 给低选择性字段建索引期待它生效 → 命中 78% 行的条件,优化器会选全表扫,这是对的。改用部分索引。
  5. select_related 用在 ManyToMany 上 → 报错。多对多要用 prefetch_related。
  6. prefetch_related 之后又加了 filter → 预取失效,重新查库。要过滤就用 Prefetch('tags', queryset=Tag.objects.filter(...))。
  7. values() 之后还想调模型方法 → 拿的是字典,没有方法。
  8. 深分页用 OFFSET → 越翻越慢。改游标分页。
  9. COUNT(*) 每页都算 → 大表上 1.8 秒。用近似值或缓存。
  10. save() 全字段写入 → 并发覆盖 + 无谓的索引更新。用 update_fields。
  11. 用 queryset.update() 以为会触发信号 → 不会,前端不刷新。
  12. 事务里跑 ffmpeg / 发 HTTP 请求 → 长事务阻塞 vacuum,全站写入排队。
  13. 批量更新不按主键排序 → 死锁。
  14. 上 pgbouncer 没关 server-side cursor → prepared statement already exists。
  15. 忽略了长事务 → dead tuple 清不掉,磁盘暴涨。
  16. 删了没用的索引却没观察够 → idx_scan=0 可能是统计刚重置过,观察两周再删。

这次复盘给我最大的教训是:性能问题很少是"一个"问题。8 秒不是由某个错误导致的,是四个中等规模的问题叠乘出来的。如果我只修了索引,页面会从 8 秒变成 2 秒,我可能会觉得"差不多了"就收工——而剩下那 1.8 秒里的 N+1 和深分页,会在数据量再翻一倍的时候重新冒出来。

所以现在我处理这类问题的顺序是固定的:先量化(EXPLAIN + pg_stat_statements)→ 按影响排序 → 一个一个改 → 每改一次测一次 → 全部改完再复测一遍总量。中间不做"我觉得应该也差不多"的跳跃。

最后一条建议:把这次的 EXPLAIN 输出和优化前后的数字存进项目的文档里。半年后有人问"这个索引为什么这么建",有据可查;下次再出现慢查询,也有一份基线可以对比。我们现在的 docs/perf/ 目录下就躺着这么几份,每一份都是当时两天的活,但省下来的是后面无数次的重复排查。

想亲手试试?用 VidDown 一键解析下载

粘贴视频链接即可解析,多平台支持、网页端即用;下载桌面客户端解锁海外平台本地解析,开通会员更享不限次下载。

顶部