先看两组数字:同样的查询,无索引跑完要118毫秒,有索引只要0.06毫秒。不是快了一倍,也不是快了一个数量级——是差不多1900倍。而这才只是一张120万行的订单表,还没到千万级别。

很多Python开发者对SQL调优的直觉还停留在“别循环查数据库”上。用select_related、prefetch_related或者selectinload把N+1抹平,每个请求干的活确实少得像个正常人。可一旦单表上了百万行,每个查询本身的成本就开始吃人。这时候瓶颈不再是“查了多少次”,而是“每次查的时候数据库到底在干什么”——而答案十有八九都落在索引上。

索引这件事,抽象一点讲就是把“每行都看一眼”变成“直接翻到那一页”。没有索引,一条WHERE customer_id = 48291会让Postgres老老实实地整表扫描——O(n),数据翻倍,时间翻倍。而索引是一棵独立的、排好序的B树,查找一个值时沿着树往下走,O(log n)。1000万行的表,全扫是读1000万行,走索引大概只需要摸23个节点。

但这个加速不是白给的。每一次INSERT、UPDATE、DELETE,所有涉及索引的列都得同步更新这棵树;每多建一个索引,磁盘上就多存一份排好序的数据副本。所以本质上,索引是在用“写入变慢”和“多占硬盘”换“读取变快”。如果你几乎不会按某个字段筛选或排序,却还在上面盖了个索引,那就是纯粹在交写性能和存储的智商税,读性能一丁点都没赚回来。

怎么判断到底该在哪里建索引?光靠猜不行,得学会看查询计划。Postgres的EXPLAIN ANALYZE会老实交代查询优化器实际怎么跑的,尤其要注意几个信号:计划节点里写的是Seq Scan还是Index Scan/Index Only Scan——前者就是全表扫,几乎等于“这里缺索引”的报警灯。估算行数rows和actual ... rows之间的差距如果很大,说明统计信息太旧,跑一个ANALYZE往往就能让计划聪明回来。cost=某某..某某是规划器内部的成本单位,用来横向对比不同的执行方案,不用去纠结它和墙上钟表怎么换算。最后,如果Seq Scan下面的Rows Removed by Filter数字高得离谱,那就是最直白的求助信号:求一个索引救命。

再看一个更具体的例子。没加索引前,一句select * from orders where customer_id = 48291的执行计划长这样:Seq Scan on orders,filter一行一行筛,Rows Removed by Filter: 1199959,也就是说引擎读了差不多120万行,最后只留下41行有用的,实际耗时118毫秒。建一个单列索引CREATE INDEX idx_orders_customer_id ON orders (customer_id)之后,计划立刻变成Index Scan using idx_orders_customer_id,Index Cond明确标出查询条件,执行时间陡然跌到0.061毫秒。120万行里捞41行,从118毫秒缩到0.06毫秒,并且随着表越来越大,这种差距只会越拉越开。

单列索引玩明白了,就该看看复合索引的坑了——(customer_id, status)和(status, customer_id)是两棵完全不同的B树。复合索引按照左前缀规则生效,先按第一个字段排,在第一个字段值相同的小组里,再按第二个字段排。也就是说,查询条件带了customer_id时,(customer_id, status)这棵树能派上用场;但如果只想按status筛选,或者先查status再查customer_id,那么前缀对不上,这棵树就用不起来,查询还是会掉回全表扫描。所以在建复合索引的时候,排在前面的列一定要是你最常用来筛选或连接的那个,否则又是一笔空付工资的糊涂账。

说到底,索引调优没有银弹,就是一遍遍地看查询计划、辨认Seq Scan、对比估算行数和实际行数、然后有策略地补上那些真正值回写成本与硬盘代价的索引。对Python开发者来说,ORM藏掉了很多SQL,但藏不掉那些在百万、千万数据量下突然炸开的性能问题。学会盯着EXPLAIN ANALYZE看,往往比升级硬件更省钱,也更救命。