你的SQL,可能比你想象的更慢——几个让我怀疑人生的性能翻车现场

2026-06-19 9 0

你的SQL,可能比你想象的更慢——几个让我怀疑人生的性能翻车现场

江湖上有个流传很广的"最佳实践":SQL要简单,越简单越好。能一条查询搞定的,就别用JOIN;能用子查询的,就别拆成两步;表结构能冗余就冗余,避免多表关联。听起来很有道理对吧?

我曾经也信过。直到有一天,线上告警响彻整个工位,我们DBA淡淡地发来一句:"你这查询跑了8秒。"那一刻,我人生观是崩塌的。

本文不讲理论,不背八股,就聊几个我真实踩过的坑。看完你可能会跟我一样,开始怀疑自己以前写的那些"简单SQL"到底有多少是真正的性能杀手。

翻车现场一:分页 OFFSET 1000000

需求很简单:拉取用户列表,分页展示,每页20条。用户量大了之后,产品同学说要支持跳页——用户可以直接输入页码跳到第500页。

你的SQL大概率长这样:

SELECT * FROM users ORDER BY created_at DESC LIMIT 20 OFFSET 50000;

看起来很正常,OFFSET 50000,LIMIT 20,数据库跳过前50000行返回后面20条,对吧?

错。大部分数据库实现里,这条SQL的执行过程是:先读取前50020行,然后丢弃前50000行,返回最后20行。也就是说,你让它干的事,和它实际干的事,完全不是一回事!

OFFSET本质上是一种"跳过"操作,不是"定位"操作。数据库必须老老实实扫描并丢弃那些行,然后才能给你结果。OFFSET越大,扫描浪费越多。

更可怕的是,如果这条SQL没有合适的索引,数据库还要做全表扫描 + filesort。1000万条数据的表,OFFSET 50000能跑几秒,OFFSET 500000就能跑几十秒。

正确的做法是什么?游标分页(Keyset Pagination)。用上一页最后一条记录的ID或时间戳作为锚点:

SELECT * FROM users
WHERE created_at < '2024-01-01 00:00:00'
ORDER BY created_at DESC
LIMIT 20;

这条查询是范围扫描,无论翻到第几页,耗时都是稳定的毫秒级。没有OFFSET,没有全表扫描,只有一次B+树的简单查找。

代价是什么?你没法直接跳到任意页了。这是取舍,但比起让用户等8秒,我选择让他们按顺序翻页。

翻车现场二:那条"简单"的UPDATE

第二个坑,来自一次历史包袱代码的优化。有一张订单表orders,一张商品表products,需求是:给指定商品ID列表的所有商品价格打8折。

接手的时候代码是这样的(PHP伪代码):

$productIds = [101, 202, 303, 404, 505];
foreach ($productIds as $id) {
    $db->query("UPDATE products SET price = price * 0.8 WHERE id = $id");
}

5条记录,5次UPDATE,看起来也没多慢对吧?然后产品说:现在支持批量选择了,商品ID最多可以传500个。

500次数据库往返,每次还有网络延迟和连接开销。如果连接池不够大,还要排队等连接。这哪里是更新数据,这是在折磨数据库。

改成批量UPDATE:

$ids = implode(',', array_map('intval', $productIds));
$db->query("UPDATE products SET price = price * 0.8 WHERE id IN ($ids)");

一次SQL,一次执行,500个ID瞬间搞定。但如果IN列表很大,更优雅的方案是用临时表 + JOIN:

CREATE TEMPORARY TABLE tmp_ids (id INT PRIMARY KEY);
INSERT INTO tmp_ids VALUES (101), (202), (303), ...;

UPDATE products p
INNER JOIN tmp_ids t ON p.id = t.id
SET p.price = p.price * 0.8;

MySQL对临时表JOIN有个优化策略叫Materialization,会把IN列表物化为临时表然后走索引JOIN,性能比直接IN好得多。

翻车现场三:ORM给了你简洁,却偷走了你的灵魂

第三个坑,是ORM的温柔陷阱。

某天线上发现一个接口特别慢,排查后发现SQL是这样的(由某个ORM框架自动生成):

SELECT * FROM orders WHERE user_id = 123;

这条SQL本身没问题,加了user_id索引,毫秒级返回。但问题在于是N+1查询。用户有100个订单,每个订单还要查一次商品信息:

SELECT * FROM products WHERE id = 456;
SELECT * FROM products WHERE id = 789;
... 重复100次

一条简单查询,触发101次数据库往返。这就是经典的N+1问题。

你写的代码很清晰,循环里单独查,逻辑分明。但ORM把这个循环隐藏起来了,你看到的只有第一条简洁的查询。N+1问题之所以臭名昭著,就是因为它不显眼——单测数据量小根本测不出来,上了生产用户一多,数据库直接爆。

解决方案很简单:JOIN,或者用ORM提供的预加载(eager loading)功能,一次性把所有数据拉回来。数据量如果真的很大,再考虑分批处理。但无论如何,不要让ORM在循环里单独查。

ORM是很好的工具,但用ORM的人不能不懂SQL。你不需要成为DBA,但你需要知道ORM背后在干什么。看不到的地方,不代表不存在。

翻车现场四:你以为加了索引就万事大吉了?

最后说个最容易被误解的概念:索引。

大部分教程会告诉你:慢?加索引啊。这话没错,但不全对。索引是有代价的——每次INSERT/UPDATE/DELETE,索引要同步更新。索引越多,写入越慢。

更重要的是:索引不是万能的,有最优前缀列顺序问题,有联合索引最左前缀原则,有索引失效的各种奇葩场景

举个真实的例子。有张日志表,大概1亿条记录,执行计划会根据数据分布决定用哪个索引,列顺序不同效果天差地别。具体用哪个,没有任何一本书或教程能告诉你答案,只有看实际的执行计划和真实数据分布才能判断。

总结一下

写了这么多,不是为了吓唬大家,而是想说:SQL性能优化这件事,最忌讳的就是拿着几条"原则"去套所有场景。简单不等于快,复杂不等于慢。JOIN可以比子查询快,批量UPDATE永远比循环单条UPDATE优雅,分页要慎用OFFSET,ORM用户要警惕N+1,索引不是加了就行还要考虑列顺序和数据分布。

最重要的优化原则只有一条:测量,不要猜测。

上线前看执行计划,上线后跑慢查询日志,用EXPLAIN分析,用PROFILING计时,用真实的业务数据量去测试。你以为的瓶颈,往往不是真正的瓶颈;你以为没问题的查询,往往是深夜告警的元凶。

好了,就吐槽到这儿。希望大家少踩坑,多测SQL,少加班。保重。

相关文章

OpenClaw 使用经验分享:我用这只“虾”做了什么骚操作
你写的API接口,为什么总被人嫌弃?
写API这事儿:那些年我踩过的坑,你们就别踩了
AI探索丨当AI开始整活:新闻、工具与那些让人忍不住吐槽的事儿
AI探索丨当AI开始整活:新闻、工具与那些让人忍不住吐槽的事儿
你以为HTTP连接很简单?踩完这些坑你才知道什么叫网络编程

发布评论