为什么这么慢:索引和 EXPLAIN

SQL 只描述想要的结果,如何查找由数据库决定。用 EXPLAIN 查看执行计划,可以判断它是在扫描全表还是使用索引。

全表扫描

在任何查询前面加上 EXPLAIN QUERY PLAN,它就不会被执行,回来的是计划。

1

EXPLAIN QUERY PLAN
SELECT * FROM products WHERE name = 'Quill Helmet';
在 Planche 中运行

结果 — 1 行

idparentnotuseddetail
20216SCAN products

浏览器会另开一个标签页打开 Planche,示例数据库已经载入,这条查询也已经放好。本页其他示例都会复用同一个标签页;在你按下 F9 之前,什么都不会执行。

SCAN products

SCAN 表示数据库把表里每一行都读了一遍并逐行判断。products.name 上没有索引,所以也没有更好的办法。400 行谁也感觉不到。要在意它,是因为这个代价和表的大小成正比:同样的计划放到一千万行上,每跑一次就读一千万行。

索引查找

price 上是有索引的。

SEARCH products USING INDEX idx_products_price (price>?)

SEARCH 才是你想看到的词。数据库走了索引,直接跳到价格超过 2000 的起点,只从那里往后读——答案之外的行一行都没碰。别的引擎用不同的词表达同一个区别:PostgreSQL 说 Seq Scan 和 Index Scan,MySQL 的 EXPLAIN 显示 type: ALL 和 type: range,SQL Server 画出来的是 Table Scan 和 Index Seek。

连接的计划

3

EXPLAIN QUERY PLAN
SELECT o.order_number, c.last_name
FROM orders o
JOIN customers c ON c.id = o.customer_id
WHERE o.status = 'delivered';
在 Planche 中运行

结果 — 2 行

idparentnotuseddetail
4061SEARCH o USING INDEX idx_orders_status (status=?)
9045SEARCH c USING INTEGER PRIMARY KEY (rowid=?)
SEARCH o USING INDEX idx_orders_status (status=?)
SEARCH c USING INTEGER PRIMARY KEY (rowid=?)

把它当成一个循环来读:通过状态索引找出已送达的订单,再对其中每一张按主键查它的客户。两步都是查找,健康的连接就长这样。要警惕的计划是内侧出现 SCAN——那意味着对外侧的每一行都完整读一遍表,也是开发库上很快的连接到了生产库上要跑几分钟的常见原因。外键列需要索引;包括 SQLite 和 MySQL 在内的大多数数据库都不会替你建。

怎样把索引弄丢

orders.ordered_at 上有索引。下面这条查询按它筛选,却仍然读了整张表。

4

EXPLAIN QUERY PLAN
SELECT * FROM orders WHERE substr(ordered_at, 1, 4) = '2025';
在 Planche 中运行

结果 — 1 行

idparentnotuseddetail
20216SCAN orders
SCAN orders

索引里存的是 ordered_at,不是 substr(ordered_at, 1, 4)。列一旦被包进函数,那份存好的顺序就什么也说明不了了,数据库只能对每一行都把表达式算一遍才知道结果。WHERE YEAR(created_at) = 2025、WHERE UPPER(email) = ... 和 WHERE price * 1.1 > 100 都是同一回事。

改写成让裸列直接面对比较:

5

EXPLAIN QUERY PLAN
SELECT * FROM orders
WHERE ordered_at >= '2025-01-01'
  AND ordered_at <  '2026-01-01';
在 Planche 中运行

结果 — 1 行

idparentnotuseddetail
30163SEARCH orders USING INDEX idx_orders_ordered_at (ordered_at…
SEARCH orders USING INDEX idx_orders_ordered_at (ordered_at>? AND ordered_at<?)

行还是那些行,现在索引在干活了。一般规则是:让索引列单独待在比较的一侧,把运算挪到另一侧。把日期上的函数换成半开区间,是这条规则最常见的应用。

ORDER BY 可以不花钱

6

EXPLAIN QUERY PLAN
SELECT * FROM products ORDER BY price DESC LIMIT 20;
在 Planche 中运行

结果 — 1 行

idparentnotuseddetail
50222SCAN products USING INDEX idx_products_price
SCAN products USING INDEX idx_products_price

第三种形状:不是查找,但也不是无序的扫描。查询要的是按价格排好的行,而索引本来就按价格有序,于是数据库沿着索引走,取到 20 行就停——完全没有排序。没有那个索引的话,它得读完 400 行、排好序、再扔掉 380 行。

这就是为什么索引对 ORDER BY ... LIMIT 的帮助不亚于对 WHERE 的帮助,也是为什么"按 X 取前 20"这类查询值得检查一下:有合适的索引就只碰 20 行,没有就得碰整张表。

一份简短的检查清单

  • 给你用来筛选和连接的列建索引——尤其是外键,它们很少会自动有。
  • 别在 WHERE 里把索引列套进函数调用。
  • 能用 LIKE 'abc%' 就别用 LIKE '%abc%';开头的通配符用不上索引。
  • 索引不是免费的——每次写入都要更新它们,所以只给你真的会查询的列建,别每个列都建。
  • 要测,别猜。改动前后各跑一次 EXPLAIN,把两份计划对着看。

接下来去哪儿

基础路线到此结束。方言页把三件事——分页、upsert、字符串与日期处理——在 MySQL、PostgreSQL、SQL Server、Oracle 和 SQLite 里并排放好,因为正是这三处的语法差得足够多,一条能跑的查询换个引擎就得先改写。

自己动手写

  1. 按邮箱查客户会用上索引吗?别猜,去要执行计划。

    查看答案

    查看答案

    EXPLAIN QUERY PLAN
    SELECT * FROM customers WHERE email = 'aisha.ahmed@example.com';
    在 Planche 中运行

    结果 — 1 行

    idparentnotuseddetail
    3039SEARCH customers USING INDEX sqlite_autoindex_customers_1 (…

    SEARCH customers USING INDEX sqlite_autoindex_customers_1 (email=?) — email 上的 UNIQUE 约束建出了这个索引,这也是为什么一个你从没显式建过索引的列照样很快。

  2. 换成按 first_name 查。先预测一下计划,再运行。

    查看答案

    查看答案

    EXPLAIN QUERY PLAN
    SELECT * FROM customers WHERE first_name = 'Aisha';
    在 Planche 中运行

    结果 — 1 行

    idparentnotuseddetail
    20216SCAN customers

    SCAN customers。first_name 单独并没有索引——idx_customers_name 覆盖的是 (last_name, first_name),而索引只能从它的首列开始用,所以按姓能找到,按名找不到。