
1. 这不是SQL语法课而是帮你真正“看见”数据关系的六把钥匙你有没有过这种体验写了一条SELECT语句加了WHERE、GROUP BY、JOIN结果跑出来一堆数据但心里其实并不完全清楚——数据库底层到底做了什么动作为什么加个ON条件就快了十倍为什么LEFT JOIN和INNER JOIN结果差那么多为什么两个表没关联字段却硬要查结果行数爆炸到百万级这些困惑根源不在SQL写得对不对而在于你还没真正“看见”数据库在执行查询时脑子里的那套逻辑动作。今天说的选择、投影、并、差、笛卡尔积、连接不是教科书里的抽象符号而是数据库引擎每秒都在做的六种基础“手部动作”。它们不依赖任何具体数据库MySQL/PostgreSQL/SQL Server都一样是关系代数这门语言的原子操作——就像加减乘除之于算术不是编程语言发明的而是数据世界本来的样子。我带过几十个从零起步转行做数据分析和后端开发的学员90%的人卡在SQL调优上不是因为不会写而是因为不知道自己写的SQL最终被数据库翻译成了哪几个基础动作的组合。比如你写SELECT name FROM users WHERE age 25数据库实际执行的是先做选择筛选age25的行再做投影只取name列。再比如你写SELECT u.name, o.amount FROM users u LEFT JOIN orders o ON u.id o.user_id它背后是先做笛卡尔积u × o再做选择ON条件匹配NULL补全逻辑最后做投影只取name和amount。这六个动作就是所有复杂查询的“乐高积木”。掌握它们你不再靠死记硬背记SQL语法而是能像看乐谱一样读懂任意一条查询背后的执行意图。无论你是刚学SQL的新手、常被慢查询折磨的后端工程师还是需要优化报表性能的数据分析师只要你想真正理解“数据怎么动起来”这篇就是为你写的。它不讲命令怎么敲只讲动作怎么想不堆砌定义只还原真实场景中每个动作的物理意义和代价。2. 六大核心动作深度拆解从纸面定义到内存现场2.1 选择Selection不是“挑数据”而是“切片过滤器”选择操作的符号是σsigma比如σage25(users)意思是“从users表中选出所有age大于25的行”。但光看这个符号你很难建立体感。我带你进数据库内存现场看看它到底干了什么。想象一张Excel表格有100万行用户数据每行包含id、name、age、city等10列。当你执行SELECT * FROM users WHERE age 25数据库不会把整张表加载进内存再一行行比对。它首先检查age列是否有索引。如果有B树索引它就直接跳到age26的叶子节点然后向右遍历所有age25的索引项拿到对应的主键id列表比如id1001,1002,…,999999。接着它用这批id去聚簇索引主键索引里精准定位每一行的物理存储位置把整行数据读出来。这个过程本质就是按条件切割原始数据集保留满足条件的行子集丢弃其余行。关键点在于选择操作只影响行数不影响列数。无论你WHERE后面写多复杂的条件结果集的列结构字段名、类型、顺序和原表完全一致。我实测过一个真实案例某电商用户表320万行无索引时WHERE last_login_time 2024-01-01耗时8.2秒建了last_login_time的B树索引后降到0.013秒。为什么因为没索引时数据库必须启动“全表扫描”——像人眼一排排扫Excel每行都读age字段值再比大小IO开销巨大有索引后它直接跳到时间线上的某个点像翻字典一样快速定位。这就是选择操作的物理代价真相索引是它的加速器全表扫描是它的默认模式。新手常犯的错是以为加WHERE就一定快其实没索引的WHERE就是最慢的全表扫描。另一个坑是WHERE status ! deleted表面看是排除但数据库内部仍需扫描所有行判断status值不如改成WHERE status IN (active,pending)让优化器更容易利用索引。提示SQL中的WHERE子句几乎100%对应关系代数的选择操作。但注意WHERE里不能出现聚合函数如COUNT、SUM因为选择发生在聚合之前。想筛聚合结果必须用HAVING——那是另一层动作了。2.2 投影Projection不是“选字段”而是“列压缩机”投影操作的符号是πpi比如πname, city(users)意思是“从users表中只取出name和city这两列”。很多人以为这只是“少选几列”但它的底层动作远不止于此。继续用那张100万行的Excel表举例。原表每行10列平均一行占200字节整表内存占用约200MB。当你执行SELECT name, city FROM users数据库引擎会做三件事第一确定name和city列在磁盘页中的偏移量比如name从第0字节开始占50字节city从第100字节开始占30字节第二为结果集预分配一块新内存区域每行只预留80字节5030第三逐行读取原数据页把指定偏移量的字节块拷贝到新内存区。这个过程本质是按列名切割原始数据集保留指定列丢弃其余列。关键点在于投影只影响列数不影响行数。无论你SELECT多少列结果行数和原表或前序操作结果完全一致。但投影的代价常被低估。我做过对比测试同样查100万行SELECT *耗时1.8秒SELECT id, name耗时0.9秒。快了一倍不完全是。因为SELECT *要把所有10列数据从磁盘读入内存再网络传输而SELECT id, name只需读2列IO量减少80%网络传输包也小得多。更隐蔽的是内存压力——如果应用层用ORM如Hibernate把整行映射成Java对象SELECT *会创建100万个含10个属性的对象而SELECT id, name只创建含2个属性的对象GC压力天壤之别。所以永远不要写SELECT *除非你真的需要所有列。我在review代码时发现70%的慢接口根源就是前端无关紧要的列表页后端却写了SELECT * FROM products结果把图片二进制字段、长文本描述全拖过来白白吃掉带宽和内存。注意投影会自动去重吗不会πcity(users)的结果可能有重复城市名。要去重必须显式加DISTINCT这是投影之后的额外操作不是投影本身。2.3 并Union不是“合并表”而是“行集合去重拼接”并操作的符号是∪比如R ∪ S意思是“把关系R和关系S的所有行合并去掉重复行”。这里有两个致命误区第一认为并就是简单拼接第二认为两张表结构不同也能并。真实场景是这样的你有users表id, name, email和admins表id, name, email想查所有用户管理员。执行SELECT id,name,email FROM users UNION SELECT id,name,email FROM admins。数据库怎么做它先分别执行两个SELECT得到两个结果集R和S然后把R和S的所有行放进一个哈希表key是整行内容哈希冲突时用链表解决最后遍历哈希表把每个key即唯一行输出。这个过程本质是将两个行集合合并为一个新集合要求两个集合具有相同属性名和兼容数据类型且结果自动去重。关键点有三第一并操作要求两个关系的列数、列名、数据类型必须严格一致MySQL允许列名不同但类型兼容PostgreSQL要求列名也一致第二结果自动去重代价是O(nm)时间和O(nm)空间第三并操作不保证行序如需排序必须加ORDER BY。我踩过一个典型坑某次导出报表需要“已支付订单”和“已发货订单”的并集。我写了SELECT order_id FROM paid_orders UNION SELECT order_id FROM shipped_orders。结果发现有些订单既付了款又发了货但在报表里只出现一次——这本是并操作的正确行为但业务方想要的是“所有发生过的事件”即不去重。这时就必须用UNION ALL它不做去重只是物理拼接速度比UNION快3-5倍。后来我们改用UNION ALL再用外层GROUP BY去重如果真需要性能提升显著。所以记住UNION 去重拼接UNION ALL 快速拼接选哪个取决于业务是否允许重复。2.4 差Difference不是“减法”而是“存在性否定过滤器”差操作的符号是−比如R − S意思是“属于R但不属于S的所有行”。这听起来像数学减法但数据库里它是个高成本操作因为要判断“不存在”。典型场景查“注册了但从未下单的用户”。users表有所有用户orders表有所有订单。执行SELECT id,name FROM users WHERE id NOT IN (SELECT user_id FROM orders)。数据库如何执行它先执行子查询得到所有user_id列表假设10万个然后对users表每行检查其id是否不在这个10万元素的列表里。最坏情况要对users表每行做10万次比较O(n×m)复杂度。现代数据库会优化把子查询结果构建成哈希表然后users表每行id做一次哈希查找降到O(nm)。但即便如此差操作仍是六种动作里最耗资源的之一。更高效的做法是用LEFT JOIN IS NULLSELECT u.id,u.name FROM users u LEFT JOIN orders o ON u.id o.user_id WHERE o.user_id IS NULL。数据库先做连接后面详述再筛出o表为NULL的行。为什么更快因为连接可以利用索引orders.user_id建索引而NOT IN在子查询为空时会返回空结果这是SQL标准陷阱。我优化过一个千万级用户表的差查询用NOT IN耗时23秒改用LEFT JOIN后降到0.8秒。所以差操作在SQL中应尽量避免用NOT IN/NOT EXISTS优先用外连接NULL判断。另外差操作要求两个关系结构完全相同否则无法比较“行是否相等”。2.5 笛卡尔积Cartesian Product不是“交叉连接”而是“无条件爆炸生成器”笛卡尔积的符号是×比如R × S意思是“R的每一行与S的每一行配对生成所有可能组合”。这是六种动作里最危险的一个因为它不加任何条件结果行数是|R|×|S|。举个血泪案例某次写报表SQL需要关联users和products表但我忘了写ON条件写了SELECT u.name,p.title FROM users u, products p。users表10万行products表5万行结果生成50亿行10万×5万数据库内存瞬间打满查询被OOM Killer杀掉。这就是笛卡尔积的恐怖之处——它不关心逻辑关联只机械地做排列组合。真实执行时数据库会取R的第一行然后遍历S的所有行生成R1-S1, R1-S2,…,R1-Sm再取R的第二行重复遍历S……直到R的最后一行。IO和CPU消耗呈指数级增长。所以笛卡尔积在生产环境几乎从不单独使用它总是作为连接操作的前置步骤。比如INNER JOIN数据库内部就是先做笛卡尔积再用ON条件做选择σcondition(R × S)。但优化器绝不会真生成50亿行再筛选它会用嵌套循环Nested Loop、哈希连接Hash Join或排序合并Sort-Merge Join等算法在生成过程中就过滤。比如哈希连接先把S表按连接键如product_id构建哈希表然后R表每行计算哈希值只匹配哈希桶里的S行跳过所有不匹配的组合。这避免了物理生成笛卡尔积。因此看到执行计划里有“Cartesian Product”基本等于告警灯亮起——说明你漏写了JOIN条件或者ON条件写错了导致无法走索引。2.6 连接Join不是“连表”而是“带条件的笛卡尔积筛选器”连接操作没有单一符号它分多种类型但核心都是在笛卡尔积基础上按连接条件ON做选择。最常见的INNER JOIN数学表达就是σR.aS.b(R × S)。但实际执行远比这复杂。以SELECT u.name,o.amount FROM users u INNER JOIN orders o ON u.id o.user_id为例。数据库不会真做笛卡尔积而是选最优算法嵌套循环连接Nested Loop适合小表驱动大表。比如users表1000行orders表100万行数据库会遍历users每行外层对每个u.id在orders表的user_id索引上快速查找匹配行内层。总IO≈1000次索引查找。哈希连接Hash Join适合两表都较大。数据库选较小表如users构建哈希表keyu.id, valueu.name然后扫描大表orders对每行o.user_id计算哈希查哈希表取匹配的u.name。内存消耗大但速度极快。排序合并连接Sort-Merge Join当两表连接键都有索引且已排序时。数据库分别扫描两表天然有序用两个指针同步移动匹配相等的键值。类似归并排序的合并步骤。我调优过一个报表原SQL用LEFT JOIN执行计划显示用嵌套循环耗时12秒。我加了/* USE_HASH(u o) */提示Oracle语法强制用哈希连接降到1.3秒。为什么因为users表10万行orders表80万行哈希连接避免了80万次索引查找。所以连接类型的选择本质是算法选择而算法选择取决于表大小、索引、内存等物理因素不是SQL写法决定的。这也是为什么EXPLAIN执行计划比SQL本身更重要——它告诉你数据库实际用了哪种“手部动作”。3. 六大动作的组合实战从单表查询到多表关联的完整推演3.1 单表查询选择投影的黄金搭档最简单的查询SELECT name, city FROM users WHERE age 25 AND status active背后是纯粹的选择投影组合。我们一步步拆解数据库执行流第一步选择σ。数据库解析WHERE条件生成谓词树(age 25) AND (status active)。它检查age和status列是否有索引。假设有复合索引(status, age)那么它就能用索引快速定位statusactive的索引段再在该段内二分查找age25的起始位置拿到匹配行的主键id列表。如果没有索引就全表扫描逐行判断条件。第二步投影π。拿到id列表后数据库用这些id去聚簇索引读取完整行但只提取name和city列的字节块组装成新行放入结果集缓冲区。注意即使原表有text大字段只要没在SELECT里就不会读取节省IO。实操中我见过一个反例某APP用户列表页前端只要显示头像URL和昵称后端却写了SELECT * FROM users WHERE ...。结果每次请求都把用户密码哈希、个人简介长文本、头像二进制全拖过来API响应时间从80ms飙到1200ms。改成SELECT avatar_url, nickname FROM users WHERE ...后立竿见影。所以单表查询的优化核心就是让选择尽可能走索引让投影尽可能精简列。索引设计要覆盖WHERE条件最左前缀原则SELECT列表要按需索取。3.2 两表内连接笛卡尔积→选择→投影的三段式SELECT u.name, o.amount, o.created_at FROM users u INNER JOIN orders o ON u.id o.user_id WHERE u.city Beijing AND o.amount 100。这个查询看似简单但动作链条更长第一步选择σ对users表。先用u.city Beijing筛选users表。假设有city索引快速得到北京用户的id列表比如1000个。第二步笛卡尔积×的隐式触发。数据库不会真生成1000×orders行而是用嵌套循环对每个北京用户的id去orders表查o.user_id ?。这里orders.user_id必须有索引否则每次都要全表扫描。第三步选择σ对orders表。在查到的每个用户的订单中再用o.amount 100过滤。如果orders表有(user_id, amount)复合索引就能在索引内完成过滤不用回表。第四步投影π。把匹配的u.name、o.amount、o.created_at三列组装成结果行。关键洞察连接条件ON和过滤条件WHERE的执行顺序直接影响性能。上面例子中u.city Beijing在连接前就筛掉了90%的用户让嵌套循环的外层只有1000次而不是10万次。如果把条件写成WHERE o.amount 100没筛users外层仍是10万次性能暴跌。所以永远把能大幅减少行数的过滤条件放在连接前的WHERE里而不是连接后的WHERE。这是SQL调优的铁律。3.3 多表左连接并差连接的混合体SELECT u.name, o.amount, p.title FROM users u LEFT JOIN orders o ON u.id o.user_id LEFT JOIN products p ON o.product_id p.id WHERE u.register_date 2023-01-01。LEFT JOIN的语义是保留左表所有行右表匹配不到则补NULL。这背后是三个动作的组合对u和o先做INNER JOINσu.ido.user_id(u × o)再做差u − πu.*(INNER JOIN)得到u中无订单的行最后把两部分UNION ALL。对结果和p同理对有订单的行做INNER JOIN对NULL订单行跳过连接。但数据库不会真这么分步执行。它用“嵌套循环外连接”先按u.register_date 2023-01-01筛users假设1万行对每行u查orders表匹配o.user_idu.id如果找到再对每个o查products表匹配p.ido.product_id如果没找到o直接输出u行p列为NULL。我优化过一个类似查询原SQL在orders表上没建user_id索引LEFT JOIN时每次都要全表扫描orders1万次×100万行100亿行扫描耗时4分钟。加了索引后降到0.6秒。所以LEFT JOIN的性能瓶颈90%在右表连接键的索引缺失。永远检查LEFT JOIN的右表ON条件里的字段是否建了索引3.4 复杂报表六大动作的全栈编排某电商后台要出“各城市销售额Top10含用户数、订单数、平均客单价”报表。SQL如下SELECT u.city, COUNT(DISTINCT u.id) AS user_cnt, COUNT(o.id) AS order_cnt, AVG(o.amount) AS avg_amount FROM users u INNER JOIN orders o ON u.id o.user_id WHERE u.city IS NOT NULL GROUP BY u.city HAVING COUNT(o.id) 100 ORDER BY avg_amount DESC LIMIT 10;这个查询的动作链是选择σu.city IS NOT NULL筛users表减少后续计算量连接⋈u和o做INNER JOIN生成用户-订单对投影π只取u.city、u.id、o.id、o.amount四列GROUP BY和聚合需要的最小列集分组Grouping按u.city分组这是额外动作但可看作对投影结果的再组织聚合AggregationCOUNT、AVG等对每组计算选择σHAVINGCOUNT(o.id) 100筛分组注意HAVING是对分组结果的选择WHERE是对行的选择排序SortingORDER BY avg_amount DESC限制LimitingLIMIT 10。其中HAVING和ORDER BY、LIMIT都不是六大基础动作但它们依赖前面动作的结果。关键优化点GROUP BY u.city要求u.city有索引否则分组要排序很慢HAVING COUNT(o.id) 100无法走索引但放在分组后执行代价可控。我实测给users.city建索引后这个报表从32秒降到2.1秒。4. 避坑指南那些年我们踩过的六大动作深坑4.1 选择操作的三大幻觉幻觉一“WHERE条件越多越快”真相每个WHERE条件都增加CPU计算且可能破坏索引使用。比如WHERE city Shanghai AND age 25 AND status active如果只有(city)单列索引后两个条件只能在索引扫描后做过滤效率不如只用city索引。更糟的是WHERE age 25 AND city Shanghai如果索引是(city, age)这个顺序就无法利用最左前缀变成全表扫描。所以WHERE条件顺序不重要索引列顺序才重要。幻觉二“LIKE %abc 也能走索引”真相只有LIKE abc%前缀匹配能用B树索引LIKE %abc后缀或LIKE %abc%中缀必须全表扫描。我见过一个搜索功能用WHERE name LIKE %keyword%查千万级商品表耗时47秒。改成全文索引MySQL的FULLTEXT或ES后降到0.05秒。所以模糊搜索别硬扛该上专用工具就上。幻觉三“NULL值比较用就行”真相WHERE column NULL永远返回空因为NULL不等于任何值包括它自己。必须用IS NULL或IS NOT NULL。更隐蔽的是WHERE column ! value会漏掉NULL行因为NULL ! value结果是UNKNOWN不满足WHERE条件。所以涉及NULL的查询务必用IS NULL/IS NOT NULL且考虑业务是否要包含NULL。4.2 投影操作的隐形杀手杀手一“SELECT COUNT(*) 比 SELECT COUNT(id) 快”真相在InnoDB中COUNT(*)是优化过的它不读行数据只统计聚簇索引的叶子节点数而COUNT(id)要读id列值如果id不是主键还要回表。所以COUNT(*)通常最快。但COUNT(1)和COUNT(*)性能一样因为1是常量不读数据。所以统计行数无脑用COUNT(*)。杀手二“视图View能提升性能”真相视图只是保存的SELECT语句查询视图时数据库会把视图定义展开再和外层查询合并优化。如果视图里有SELECT *而外层只用两列数据库仍要读所有列。我见过一个视图CREATE VIEW user_summary AS SELECT * FROM users JOIN profiles...被10个报表引用结果每个报表都拖全表字段。改成物化视图如PostgreSQL的MATERIALIZED VIEW或冗余字段表后性能飞跃。所以视图是逻辑封装不是性能优化要用物化手段解决性能问题。杀手三“JSON字段里存多值查询方便”真相比如user_tags JSON存[tag1,tag2]想查含tag1的用户得用WHERE JSON_CONTAINS(user_tags, tag1)。这无法走索引每次都要解析JSON字符串。正确做法是建关联表user_tags(user_id, tag_name)给tag_name建索引。所以JSON适合存查询无关的元数据不适合存需要高频查询的结构化数据。4.3 并、差、笛卡尔积的灾难现场灾难一“UNION代替OR以为更高效”真相SELECT * FROM t WHERE a1 OR b2和SELECT * FROM t WHERE a1 UNION SELECT * FROM t WHERE b2后者更慢。因为UNION要两次执行去重而OR可以在一次扫描中完成。除非a和b都有索引且OR条件能走索引合并Index Merge否则OR更优。所以简单OR条件别拆UNION。灾难二“用NOT IN实现差忽略NULL陷阱”真相SELECT id FROM users WHERE id NOT IN (SELECT user_id FROM orders WHERE user_id IS NOT NULL)。如果子查询结果有NULL比如orders表user_id允许NULL整个NOT IN返回空。因为id NOT IN (1,2,NULL)等价于id ! 1 AND id ! 2 AND id ! NULL而id ! NULL永远为FALSE。所以NOT IN子查询必须确保无NULL或改用LEFT JOIN。灾难三“笛卡尔积调试时没加LIMIT炸库”真相开发时写SELECT * FROM t1, t2查数据如果t1和t2都百万级结果5000亿行不仅查不出来还可能把数据库连接池占满。所以任何无ON条件的多表查询第一行必须加LIMIT 10确认结果合理后再删。我养成习惯写完JOIN先加LIMIT 1运行看执行计划和结果行数再决定是否去掉。4.4 连接操作的性能黑洞黑洞一“JOIN太多以为是SQL问题其实是数据模型问题”真相一个查询JOIN 8张表执行慢。优化师花一周调索引效果甚微。最后发现这是反范式设计本该在一张表里的数据被拆到8张表。比如订单详情本该有order_id, product_id, qty, price却拆成orders, order_items, products, categories… 正确做法是宽表设计用冗余换性能。所以JOIN数量是数据模型健康度的晴雨表超过4个JOIN就要反思模型。黑洞二“LEFT JOIN右表加WHERE变INNER JOIN”真相SELECT * FROM users u LEFT JOIN orders o ON u.id o.user_id WHERE o.status paid。这个WHERE把o.status paid放在外层会导致u中无订单的行被过滤掉因为o.status为NULLNULL ! paid。结果等价于INNER JOIN。要保留左表所有行必须把条件写进ONLEFT JOIN orders o ON u.id o.user_id AND o.status paid。所以LEFT JOIN的过滤条件必须放ON里不能放WHERE里。黑洞三“连接键类型不匹配索引失效”真相users.id是BIGINTorders.user_id是VARCHARON u.id o.user_id会导致隐式类型转换orders.user_id索引失效。执行计划显示typeALL全表扫描。解决方案统一类型或建函数索引如MySQL 8.0的INDEX (CAST(user_id AS UNSIGNED))。所以JOIN字段必须类型严格一致这是DBA建模时的红线。5. 实战诊断用EXPLAIN读懂数据库的“手部动作”5.1 EXPLAIN输出的核心字段解码EXPLAIN不是玄学它是数据库执行计划的“X光片”。以MySQL为例关键字段解读idSELECT标识符。相同id表示同一查询块数字越大越先执行。子查询id会递增。select_type查询类型。SIMPLE简单SELECT、PRIMARY最外层、SUBQUERY子查询、DERIVED派生表、UNIONUNION第二部分。table当前操作的表名。 表示派生表unionM,N表示UNION结果。type连接类型性能从好到坏system ≈ const eq_ref ref range index ALL。ALL是全表扫描必须消灭。possible_keys可能用到的索引。为空说明没索引可用。key实际使用的索引。NULL表示没用索引。key_len索引长度字节。越小越好说明索引选择性高。ref显示索引哪一列被使用。const表示常量字段名表示关联列。rows估算扫描行数。越小越好是优化的主要目标。Extra额外信息。Using where用WHERE过滤、Using index覆盖索引、Using temporary用临时表、Using filesort用文件排序——后两者是性能杀手。我教新人看EXPLAIN第一眼看type和rowstype不是ALLrows小于1000基本合格第二眼看key和key_lenkey不为NULLkey_len合理比如int列key_len4第三眼看Extra没有Using temporary或Using filesort。5.2 六大动作在EXPLAIN中的映射选择σ体现在WHERE条件的执行。typerange范围扫描或ref非唯一索引查找时就是选择在走索引typeALL时是全表扫描式选择。投影π体现在Using index。当Extra显示这个说明查询所需列都在索引里不用回表是投影的极致优化。并∪UNION查询中每个SELECT块有自己的EXPLAIN行type通常是ALL或rangeExtra可能有Using temporary去重需要临时表。差−NOT IN子查询会显示DEPENDENT SUBQUERYrows很大LEFT JOIN IS NULL会显示两个表的连接类型rows是左表行数。笛卡尔积×当EXPLAIN出现typeALL且没有ref即无连接条件就是笛卡尔积警告。rows 表1行数 × 表2行数。连接⋈typeeq_ref主键/唯一索引连接、ref非唯一索引连接、range范围连接都表示连接在走索引typeALL表示嵌套循环的内层全表扫描。5.3 一个慢查询的完整诊断案例某次线上报警一个用户中心接口超时。SQL是SELECT u.id, u.name, u.email, COUNT(o.id) as order_cnt FROM users u LEFT JOIN orders o ON u.id o.user_id WHERE u.status active GROUP BY u.id, u.name, u.email ORDER BY order_cnt DESC LIMIT 20;EXPLAIN显示id | select_type | table | type | possible_keys | key | key_len | rows | Extra 1 | SIMPLE | u | ref | idx_status | idx_status | 1 | 500000 | Using where; Using temporary; Using filesort 1 | SIMPLE | o | ALL | NULL | NULL | NULL | 2000000| Using join buffer (Block Nested Loop)问题定位u表typerefkeyidx_statusrows50万说明status索引有效但50万行太多o表typeALLkeyNULLrows200万说明orders.user_id没索引内层全表扫描Extra有Using temporary和Using filesort说明GROUP BY和ORDER BY没走索引要建临时表排序。优化步骤给orders.user_id建索引CREATE INDEX idx_user_id ON orders(user_id);优化GROUP BYu.id是主键GROUP BY u.id就够了去掉u.name,u.emailMySQL 5.7 ONLY_FULL_GROUP_BY模式下必须让ORDER BY走索引建联合索引(user_id, status)但这里order_cnt是聚合结果无法索引只能接受排序。优化后EXPLAINid | select_type | table | type | possible_keys | key | key_len | rows | Extra 1 | SIMPLE | u | ref | idx_status | idx_status | 1 | 500000 | Using where 1 | SIMPLE