深蓝黑背景封面:右侧大标题「SQL 不是按书写顺序跑的」,副标题「逻辑执行流水线与 join 的结果集差异」;左侧原创示意图——一条纵向流水线八级:from、join、where、group by、having、select、order by、limit,select 一级用高亮标出「倒数第三才轮到它」;左上标注「面试经典 · 数据库」,右上标注「Day 02 · 10-30」 图 1:本文封面,SELECT 写在最前,执行却排在倒数第三。

先说明属性:这是一篇历史笔试题复盘,题目出自本地历史笔试资料,与任何公司的当前招聘无关。这道题表面考两个知识点——SQL 逻辑执行顺序、left join 与 inner join 的差异——实际上考的是同一件事:你真的知道一条 SQL 是按什么流程算出结果的吗?

先看流水线:书写的顺序不是执行的顺序。

我们写 SQL 从 SELECT 起笔,但逻辑上它几乎是最后才执行的。一条查询的逻辑执行顺序是:

FROM 与 JOIN 先把参与查询的表拼成一张大表;WHERE 对大表逐行过滤;GROUP BY 把过滤后的行分组;HAVING 对分组后的组再过滤;SELECT 这时才登场,做列投影、算表达式、起别名;ORDER BY 排序;LIMIT 截断输出。

这个顺序直接解释了一批「玄学报错」。最经典的:WHERE 里引用 SELECT 起的别名会报「未知列」——WHERE 执行时 SELECT 还没跑,别名根本不存在。反过来 ORDER BY 里可以用别名,因为它排在 SELECT 之后。「这个子句看得见什么、看不见什么」,答案全在流水线的位置里。

再看 join:差在「配不上怎么办」。

INNER JOIN 只保留两张表能按条件配上的行,配不上的一律丢弃。LEFT JOIN 以左表为基准:左表每一行都保留,右表配不上就用 NULL 补齐右表的列。MySQL 官方文档的原话是:LEFT JOIN 中如果右表没有匹配行,结果里右表的列全部置 NULL——文档还顺势给了一个经典用法:LEFT JOIN 后加 WHERE 右表.id IS NULL,就能筛出「左表里有、右表里没有」的行。

行数关系也随之清晰:inner join 的结果行数不超过左表行数乘匹配度,而 left join 至少包含左表全部行。做数据校验时,left join 后的行数等于左表行数(右表无重复匹配时),是一条好用的不变量。

真正的陷阱:同一个条件,写在 ON 里还是 WHERE 里。

对 inner join 来说两者等价。但对 left join,位置决定语义:条件写在 ON 里,它只影响右表怎么匹配,左表的行照样全保留,配不上就补 NULL;条件写在 WHERE 里,它在 join 之后执行,会把那些补了 NULL 的行再过滤掉——效果上 left join 退化成 inner join。MySQL 优化器文档甚至明确说:WHERE 中出现对右表列的非空判断时,外连接会被优化为内连接。很多人 left join 写出「结果和 inner 一样」的困惑,根源就在这里。

用代码把整条流水线跑一遍。

下面用内存表实现一个迷你流水线,对照 join 类型、条件位置与分组过滤。用 Node.js 22.15.0 直接运行(node sql-pipeline.js):

'use strict';

// 迷你 SQL 流水线:FROM/JOIN -> WHERE -> SELECT,对照 join 类型与条件位置
const users = [
  { id: 1, name: '林一' },
  { id: 2, name: '王二' },
  { id: 3, name: '赵三' }, // 没有任何订单
];
const orders = [
  { oid: 101, uid: 1, amount: 200 },
  { oid: 102, uid: 1, amount: 50 },
  { oid: 103, uid: 2, amount: 999 },
];

function innerJoin(left, right, on) {
  const out = [];
  for (const l of left) for (const r of right) {
    if (on(l, r)) out.push({ ...l, ...r });
  }
  return out;
}
function leftJoin(left, right, on) {
  const out = [];
  for (const l of left) {
    const matched = right.filter((r) => on(l, r));
    if (matched.length === 0) out.push({ ...l, oid: null, uid: null, amount: null }); // 补 NULL 行
    else for (const r of matched) out.push({ ...l, ...r });
  }
  return out;
}
const eq = (l, r) => l.id === r.uid;

const inner = innerJoin(users, orders, eq);
const left = leftJoin(users, orders, eq);
console.log(`inner join 行数:${inner.length}(赵三消失)`);
console.log(`left join 行数:${left.length}(赵三保留,订单列补 NULL)`);

// 同一个条件 amount > 100:写在 ON 里 vs 写在 WHERE 里
const onBig = leftJoin(users, orders, (l, r) => l.id === r.uid && r.amount > 100);
console.log(`条件写在 ON:${onBig.length} 行 -> ${onBig.map((r) => `${r.name}:${r.amount}`).join(',')}`);
const whereBig = left.filter((r) => r.amount > 100);
console.log(`条件写在 WHERE:${whereBig.length} 行 -> ${whereBig.map((r) => `${r.name}:${r.amount}`).join(',')}`);

// 加上 GROUP BY + HAVING 的完整流水线:各用户大单总额,只留超过 150 的
const grouped = new Map();
for (const r of left.filter((r) => r.amount !== null)) {
  grouped.set(r.name, (grouped.get(r.name) ?? 0) + r.amount);
}
const having = [...grouped.entries()].filter(([, sum]) => sum > 150);
console.log(`group by + having(>150):${having.map(([n, s]) => `${n}=${s}`).join(',')}`);

实测输出:

inner join 行数:3(赵三消失)
left join 行数:4(赵三保留,订单列补 NULL)
条件写在 ON:3 行 -> 林一:200,王二:999,赵三:null
条件写在 WHERE:2 行 -> 林一:200,王二:999
group by + having(>150):林一=250,王二=999

四行输出各有看点:赵三没有订单,inner join 里直接消失,left join 里带着 NULL 留下;同一个「金额大于 100」的条件,写在 ON 里赵三还在(只是订单列是 NULL),写在 WHERE 里赵三被过滤——left join 当场退化成 inner join 的效果。这就是条件位置决定语义的完整证据链。

左侧流水线详图:FROM 与 JOIN 先拼出大表(left join 保留左表全部行、右表配不上补 NULL),WHERE 再按行过滤,GROUP BY 分组,HAVING 过滤组,SELECT 做投影与别名,ORDER BY 排序,LIMIT 截断;每级右侧标注该级「看得见什么、看不见什么」,特别标出 WHERE 阶段 SELECT 别名还不存在。右侧对照实验:同一条件写在 ON 里与写在 WHERE 里——ON 里只影响右表匹配、左表行保留补 NULL;WHERE 里会把 NULL 行再滤掉,left join 退化成 inner join 的效果。底部总结:记住别名可见性与 NULL 补行时机,是读对结果集的两把钥匙 图 2:流水线八级与 ON/WHERE 对照实验。

工程里的翻车现场。

数据分析、用户行为宽表查询里,「以为 WHERE 在 JOIN 之前过滤右表」是结果悄悄翻车的常见原因:想要「所有用户加满足条件的订单」,条件却写进了 WHERE,没有订单的用户整批消失,报表少了一块还没人发现。AI 应用的埋点宽表查询同样中招。记住两把钥匙:别名可见性看流水线位置,NULL 补行时机看 join 类型与条件位置。

回到笔试现场,建议的答题结构:先一口气报出八级流水线,再用「WHERE 看不见 SELECT 别名」证明你真的理解它,然后讲 left join 的 NULL 补行与行数不变量,最后把 ON 与 WHERE 的条件位置差异作为压轴。四步下来,这道题的分就拿满了。你被「left join 结果跟 inner 一样」坑过吗?欢迎说说当时的排查过程。