
SQL 性能问题很少会以清晰的错误消息出现。它们通常是逐渐变慢的:一个原本 200ms 加载的页面现在需要 2 秒;一个夜间批处理作业从 10 分钟变成 1 小时。根本原因几乎从来不是“数据库太慢”——而是“SQL 做了远超必要的工作”。
本文涵盖了最常见的 SQL 性能陷阱,解释了它们为何有害,并展示了如何修复。我们将专注于实用的、数据库无关的建议(以 MySQL 和 PostgreSQL 为例),并逐步讲解一个完整的端到端优化。试试我们的 SQL 格式化工具,在优化时保持查询可读。
1. 致命错误:在生产中使用 SELECT *
SELECT * 对临时查询很方便,但在生产代码中几乎总是有害的。它强制数据库从磁盘读取每一列,即使只需要几个字段。这会增加 I/O、内存使用、网络传输,并且通常阻止索引覆盖扫描。
糟糕:
SELECT * FROM orders WHERE user_id = 1001;
良好:
SELECT order_id, order_amount, create_time
FROM orders
WHERE user_id = 1001;
如果表有 50 列而你只需要 3 列,你浪费了 94% 的数据传输。在数千次查询中,这会迅速累积。
2. 缺失或错放的索引
索引是影响最大的优化工具。最常见的错误是没有在 WHERE、JOIN 或 ORDER BY 中使用的列上建立索引。但索引并非越多越好——每个索引都会增加写入开销。
复合索引设计
对于如下查询:
SELECT *
FROM tasks
WHERE region_code = '330101'
AND biz_status = 'PENDING'
AND risk_level = 'HIGH'
AND insurance_type = 'PENSION'
AND cert_deadline >= '2026-06-01'
AND cert_deadline < '2026-06-08'
ORDER BY cert_deadline ASC, updated_at DESC
LIMIT 20;
一个覆盖过滤和排序列的复合索引可以完全消除表扫描:
CREATE INDEX idx_tasks_filter_sort
ON tasks (region_code, biz_status, risk_level, insurance_type, cert_deadline, updated_at DESC);
将等值条件放在前面,然后是范围条件,最后是排序列。如果需要包含额外列以避免回表,使用 INCLUDE(PostgreSQL)或覆盖索引(MySQL)。
3. IN vs EXISTS 陷阱
IN 对小的静态列表没问题:
SELECT * FROM orders WHERE status IN ('PAID', 'SHIPPED', 'FINISHED');
但对于返回多行的子查询,EXISTS 通常更好,因为它可以在找到第一个匹配后提前停止:
-- 大子查询时慢
SELECT *
FROM customers
WHERE id IN (SELECT customer_id FROM orders WHERE amount > 1000);
-- 使用 EXISTS 更快
SELECT c.*
FROM customers c
WHERE EXISTS (
SELECT 1
FROM orders o
WHERE o.customer_id = c.id
AND o.amount > 1000
);
NOT IN 的 NULL 陷阱
如果子查询结果包含任何 NULL,NOT IN 会静默返回零行:
-- 如果 orders.customer_id 有 NULL,可能返回零行
SELECT *
FROM customers
WHERE id NOT IN (SELECT customer_id FROM orders);
始终优先使用 NOT EXISTS 或 LEFT JOIN ... IS NULL:
SELECT c.*
FROM customers c
WHERE NOT EXISTS (
SELECT 1
FROM orders o
WHERE o.customer_id = c.id
);
-- 或者:
SELECT c.*
FROM customers c
LEFT JOIN orders o ON o.customer_id = c.id
WHERE o.customer_id IS NULL;
4. 在索引列上使用函数
对列进行函数包装通常会使索引失效:
-- 对 create_time 的索引在此无效
SELECT * FROM orders WHERE DATE(create_time) = '2026-06-28';
-- 更好:范围查询
SELECT * FROM orders
WHERE create_time >= '2026-06-28 00:00:00'
AND create_time < '2026-06-29 00:00:00';
5. 隐式类型转换
当列的类型与值的类型不同时,数据库可能会转换列,破坏索引使用:
-- phone 是 VARCHAR,但值是整数
SELECT * FROM users WHERE phone = 13800001111; -- 索引被忽略
-- 正确:匹配类型
SELECT * FROM users WHERE phone = '13800001111'; -- 使用索引
6. 过多的 JOIN 和 SELECT *
连接多张表本身并不慢——真正的成本来自处理过多的行。始终尽早使用 CTE 或子查询过滤,以减少中间结果集。
-- 不要先连接所有表再过滤:
SELECT o.*, c.name
FROM orders o
JOIN customers c ON o.customer_id = c.id
WHERE o.order_date >= '2026-01-01';
-- 先过滤,再连接:
WITH recent_orders AS (
SELECT * FROM orders WHERE order_date >= '2026-01-01'
)
SELECT r.*, c.name
FROM recent_orders r
JOIN customers c ON r.customer_id = c.id;
7. 使用 OFFSET 的深分页
随着偏移量的增加,OFFSET + LIMIT 会变慢,因为数据库仍然需要扫描并丢弃所有跳过的行。改用键集分页("seek method"):
-- 大偏移量时慢
SELECT * FROM orders ORDER BY id LIMIT 20 OFFSET 100000;
-- 快速:键集分页
SELECT * FROM orders
WHERE id > 100000
ORDER BY id
LIMIT 20;
实战示例:优化慢查询
让我们逐步讲解一个真实世界的优化。我们有一个 120 万行的任务表,需要查找某个地区的前 20 条高风险养老金待办。
原始查询:
SELECT *
FROM benefit_cert_task
WHERE region_code = '330101'
AND biz_status = 'PENDING'
AND risk_level = 'HIGH'
AND insurance_type = 'PENSION'
AND cert_deadline >= '2026-06-01'
AND cert_deadline < '2026-06-08'
ORDER BY cert_deadline ASC, updated_at DESC
LIMIT 20;
第 1 步:检查执行计划
EXPLAIN ANALYZE ...
计划显示对 benefit_cert_task 进行了并行顺序扫描,过滤了 400,000 行,执行时间为 93 ms。
第 2 步:识别缺失的索引
该表仅在 task_id 上有主键。没有索引覆盖过滤或排序列。
第 3 步:创建复合索引
CREATE INDEX idx_benefit_cert_task_filter_sort
ON benefit_cert_task (region_code, biz_status, risk_level, insurance_type, cert_deadline, updated_at DESC)
INCLUDE (task_id, person_id, agency_id);
第 4 步:验证改进
EXPLAIN ANALYZE ...
现在计划使用索引扫描,执行时间降至 8 ms——提升了 11 倍。I/O 也显著下降。
常见陷阱
- 过度索引:每个索引都会减慢写入速度。只添加查询实际使用的索引。
- 忽略统计信息:优化器依赖表统计信息。在批量加载或大量数据更改后运行
ANALYZE。 - 在不同索引列上使用
OR:OR可能会使索引失效。当分支互斥时,优先使用UNION ALL。 - 假设总是需要
LEFT JOIN:如果只需要左表中匹配的行,使用INNER JOIN或EXISTS。 - 不监控慢查询:启用慢查询日志(
slow_query_log=ON,long_query_time=0.5)并定期检查。
常见问题
为什么我的查询即使使用了索引也很慢?
索引可能不够有选择性——如果它返回表的大部分行,优化器可能更倾向于全表扫描。此外,如果索引不覆盖查询中的所有列,数据库需要额外的查找(MySQL 中的“书签查找”,PostgreSQL 中的“位图堆扫描”)。
我应该总是使用 NOT EXISTS 而不是 NOT IN 吗?
对于子查询,是的——NOT EXISTS 避免了 NULL 陷阱,并且可能更高效。对于没有 NULL 的静态列表,NOT IN 没问题。
多少个索引算太多?
没有硬性数字,但一个好的经验法则是:如果某个索引从未被任何查询使用,就删除它。使用 pg_stat_user_indexes(PostgreSQL)或 sys.schema_unused_indexes(MySQL)监控索引使用情况。
COUNT(*) 总是全表扫描吗?
在 MySQL 的 InnoDB 中,是的——除非你有一个比主键更小的二级索引。在 PostgreSQL 中,如果可见性映射是最新的,带有主键的表的 COUNT(*) 会使用仅索引扫描。
最快的分页方式是什么?
键集分页(使用 WHERE id > ? ORDER BY id LIMIT ?)每页是 O(1),而 OFFSET 是 O(n)。对于大数据集,使用键集分页。
结论
SQL 优化不是魔法——而是理解数据库如何处理你的查询,并为其提供正确的工具(索引、统计信息、清晰的语法)以高效执行。首先启用慢查询日志,检查执行计划,并先修复最严重的问题。一个精心设计的复合索引可以将 100ms 的查询变成 5ms。