ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

SQL经典181题:自连接与JOIN搞懂员工薪资比较

SQL经典181题:自连接与JOIN搞懂员工薪资比较 1. 这道经典SQL题到底在考什么很多人学SQL时遇到的第一道坎往往就是“SQL 181超过经理收入的员工”。这道题表面上看就是一个简单的查询实际上它把SQL里最核心的几个概念全揉在了一起自连接、JOIN语法、别名机制、还有对“行与行关系”的理解。我带了这么多年的新人几乎每次都要用这道题来检验对方是不是真的理解了SQL的思维而不只是会背几条SELECT语句。题面的需求很简单有一张员工表Employee包含id、name、salary和managerId四个字段managerId指向该员工的直属经理的id。要求找出所有收入超过自己经理的员工姓名。听起来就是一句话的事但真正动起手来你会发现一个很有意思的问题你要比较的数据不在不同行之间而是在同一张表的“上下级”两行之间。很多人第一反应是那是不是要复制两张表其实方向对了但不知道具体怎么操作。这道题最核心的考点就是你能不能想到用自连接或者退一步用子查询把一张表当成两张表来用。而自连接这个操作恰恰是SQL新手最容易卡住的地方。因为平时写JOIN都是连接两张不同的表很少遇到“自己连自己”的场景。从实际应用的角度来说这道题解决的是一种非常普遍的需求模式同一张业务表内部存在层级或关联关系需要跨行进行比较或计算。比如组织架构里对比员工和经理的薪资、订单表里对比同一客户不同时间下的订单金额、商品表里比较同品牌下不同型号的价格。只要做过几年数据开发或分析这种需求几乎每周都能碰上。所以这道题适合谁我觉得不只是面试前临时抱佛脚的求职者任何想把SQL写明白的人都值得花点时间把它吃透。因为一旦理解了自连接背后的逻辑后面再遇到更复杂的表间关联、树形结构查询、甚至某些窗口函数的使用场景都会轻松很多。2. 拆解题目本质单表内部如何实现上下级比较2.1 数据模型和表结构分析先看题目给的表结构。Employee表通常长这样字段名类型说明idint员工唯一标识主键namevarchar员工姓名salaryint员工薪资managerIdint经理的id关联本表的id字段需要注意的是managerId是自引用外键它指向的是同一张表里的id。也就是说这张表既存了员工信息也存了经理信息经理本身也是员工。只有CEO这种级别的员工managerId可能为NULL。我实际操作的时候习惯先把表建好插入几行有代表性的数据再开始写查询。比如我常用的测试数据是这样CREATE TABLE Employee ( id INT PRIMARY KEY, name VARCHAR(100), salary INT, managerId INT ); INSERT INTO Employee (id, name, salary, managerId) VALUES (1, Joe, 70000, 3), (2, Henry, 80000, 4), (3, Sam, 60000, NULL), (4, Max, 90000, NULL);这里Joe的经理是id3的SamJoe的薪资70000比经理Sam的60000高所以Joe应该出现在结果中。Henry的经理是MaxHenry薪资80000低于Max的90000所以不出现在结果中。这是这道题的标准测试数据能把逻辑测清楚。2.2 为什么需要自连接跨行比较的SQL思维SQL是一个基于集合的操作语言它的难点在于你习惯用excel思维去看待数据一行一行地去比对但SQL不是这么工作的。SQL里要比较两行数据就必须把两行数据“放到同一行”里然后用WHERE条件去做筛选。这道题里员工和经理的信息都在同一张表里但它们是不同的行。而我们要做的比较恰恰是员工行和经理行之间的薪资比较。这就逼着你必须创造一种方式让一张表以两种身份同时出现在FROM子句里。于是就有了自连接。自连接的写法其实就是给同一张表起不同的别名然后把它当成两张独立的表来JOIN。这个“当成两张表”的理解特别重要。我每次给新人讲的时候都会强调一个类比你可以把自连接理解为把一张表复制了一份一份叫员工表A一份叫经理表B然后通过A.managerId B.id这个条件把每个员工对应的经理行“挂”到同一行上。虽然物理上只有一张表但在SQL的执行逻辑里它完全等价于两张表做关联。这种“A表的某个字段等于B表的主键”的连接方式恰恰反映出managerId作为外键存在的基本逻辑。而外键关联在SQL里的实现手段就是JOIN。2.3 从执行逻辑看JOIN的底层行为JOIN的底层逻辑其实是一个笛卡尔积然后按ON条件筛选。换句话说MySQL先内存里把Employee表当作A、B两个集合做一次交叉组合比如表里有4条记录交叉后就有4×416条组合然后通过A.managerId B.id这个条件过滤掉那些没有意义的组合剩下的每一行就是“员工-经理”的配对。理解了这一步你就会明白为什么写自连接时ON条件这么关键。如果漏写了ON条件或写错了字段结果可能就是16行乱配或者干脆什么都没匹配出来。在实操中我还经常看到有人把ON条件写反写成A.id B.managerId导致结果完全反了。所以这道题看上去只是返回一个name字段但它能帮你把JOIN的执行原理、ON条件的含义、别名的必要性全部串起来。这也是它成为经典SQL题的原因。它不是一个偏题怪题而是一个高度浓缩了SQL核心概念的入门必刷题。3. 三种主流的SQL解法不只是写出来要理解为什么3.1 解法一自连接两表JOIN最直接的思路第一种解法是最容易理解的直接让员工表和经理表做一次自连接SELECT a.name AS Employee FROM Employee a JOIN Employee b ON a.managerId b.id WHERE a.salary b.salary;这段SQL的执行逻辑是这样的a是我们关注的员工b是该员工对应的经理JOIN条件a.managerId b.id把每个员工挂到他的经理行上WHERE子句再过滤掉那些工资不超过经理的员工。这里有一个新手很容易踩的坑漏掉WHERE条件。如果你只写了JOIN没有加薪资比较那么返回的是所有有经理的员工不管他工资比经理高还是低。虽然这道题的输出结果不同但逻辑上就错了。我见过不少人在OJ上提交时答案不对排查到最后发现WHERE条件没写或者写错成了a.salary b.salary结果把条件挂在ON后面。ON和WHERE虽然有时结果一样但语义不一样JOIN的ON负责连接逻辑WHERE负责结果过滤。另外一个细节就是SELECT后面的a.name AS Employee。这里有个小坑题目要求返回的列名是Employee。而Employee又是表名容易造成混淆。不过SQL标准允许列名和表名重名所以写法没问题。但为了可读性我建议用别名来区分比如把返回列命名为Employee表示“符合条件的员工姓名”。3.2 解法二子查询方式比较适合刚接触SQL的人第二种方式是子查询。它的核心思路是先查出每个员工的经理薪资然后再做比较。可以用相关子查询也可以先把经理工资算成一张子表再关联。写法一相关子查询直接在SELECT里查经理的工资SELECT a.name AS Employee FROM Employee a WHERE a.salary ( SELECT salary FROM Employee b WHERE b.id a.managerId );这里每次处理一行a的时候都要去执行一遍子查询找出a的经理工资。相关的意思是子查询里引用了外层查询的字段a.managerId两者产生了关联。写法二非相关的子查询先构建一个经理工资映射表SELECT a.name AS Employee FROM Employee a JOIN ( SELECT id, salary FROM Employee ) b ON a.managerId b.id WHERE a.salary b.salary;从执行效率上说写法二往往更好因为子查询只执行一次生成一张临时表然后像普通表一样参与JOIN。而写法一每条员工记录都要执行一次子查询对于大表来说性能压力会很大。我在实际生产环境里几乎不会大规模使用相关子查询来做这种关联比较除非表特别小。但话又说回来写子查询比写自连接更贴近自然语言。很多刚接触SQL的人反而更容易理解子查询方式因为它就像先“查出经理的工资”再去“比较员工的工资”逻辑是递进式的。所以如果你面试时紧张一下子忘了JOIN怎么写先写出子查询方式稳住局面也是OK的。3.3 解法三窗口函数写法开拓视野窗口函数在MySQL 8.0及以上版本、SQL Server、Oracle里都支持。虽然181这道题用窗口函数有些“杀鸡用牛刀”但它能帮你打开思路。用窗口函数怎么解呢关键是把经理的工资“拉”到员工那一行上这个操作叫平移。思路其实很清晰SELECT name AS Employee FROM ( SELECT name, salary, managerId, FIRST_VALUE(salary) OVER (PARTITION BY managerId) AS manager_salary FROM Employee ) t WHERE manager_salary IS NOT NULL AND salary manager_salary;这里PARTITION BY managerId意思是对每个经理管辖的员工分组然后取该组内第一个salary值也就是经理的工资。因为每个组内所有员工的managerId都一样所以FIRST_VALUE取到的就是经理的工资。最后再用WHERE条件过滤。这种方法虽然也能得到正确结果但它本质上是在变花样地做“按组取数”逻辑上比自连接绕一些。不过如果你在学窗口函数拿这道题练练手还是挺有意思的。尤其当你要处理的场景变成了“取每个部门里工资最高的员工”这种问题时窗口函数就成了正解。三种写法对比一下写法核心思想优点缺点适用场景自连接JOIN同一张表用两个别名关联逻辑清晰、执行高效JOIN条件需理解新手易错面试、生产环境首选子查询先查经理工资再比较思路直观贴近自然语言相关子查询性能较差小表或学习理解窗口函数组内平移经理工资可扩展性强适合更复杂需求语法较复杂有版本要求MySQL 8、数据分析场景4. 实操过程从建表到验证的完整演示4.1 建表和初始化数据的完整脚本我建议你动手实践时不要直接在OJ的编辑器里写而是自己本地装一个MySQL或者SQL Server建库建表把整个流程走一遍。这样你对数据的感知会更清晰遇到问题也更容易排查。我用的初始化脚本如下-- 创建数据库如果不存在 CREATE DATABASE IF NOT EXISTS sql_practice; USE sql_practice; -- 创建员工表 CREATE TABLE Employee ( id INT NOT NULL, name VARCHAR(100) NOT NULL, salary INT NOT NULL, managerId INT NULL, PRIMARY KEY (id) ); -- 插入测试数据 INSERT INTO Employee (id, name, salary, managerId) VALUES (1, Joe, 70000, 3), (2, Henry, 80000, 4), (3, Sam, 60000, NULL), (4, Max, 90000, NULL);注意managerId字段我设成了NULL允许因为经理可能没有上级比如Sam和Max他们的managerId是NULL。这在真实业务里非常常见一个公司的顶层领导没有上级。插入完数据可以先用SELECT * FROM Employee看一下全貌确认数据无误idnamesalarymanagerId1Joe7000032Henry8000043Sam60000NULL4Max90000NULL4.2 执行自连接查询并解释结果现在执行自连接查询SELECT a.name AS Employee FROM Employee a JOIN Employee b ON a.managerId b.id WHERE a.salary b.salary;先看JOIN部分a表是员工b表是经理。执行连接后中间结果是这样的a.namea.salarya.managerIdb.idb.nameb.salaryJoe7000033Sam60000Henry8000044Max90000Sam和Max因为managerId是NULL无法匹配到任何经理所以被JOIN自然排除在外了。WHERE条件a.salary b.salary把Henry排除因为80000不大于90000最终只留下Joe。符合条件的员工是Joe。4.3 数据扩展与边界情况验证光有标准测试数据还不够我建议你再加上一些边界情况来验证自己的查询是否健壮。比如INSERT INTO Employee (id, name, salary, managerId) VALUES (5, Alice, 85000, 1), (6, Bob, 75000, 1), (7, Tom, 120000, 2);这里Alice和Bob的经理都是JoeTom的经理是Henry。加上这些数据后结果会怎么样Alice的工资85000大于经理Joe的70000所以Alice应该出现在结果里Bob的工资75000也大于70000所以也要出现Tom工资120000大于Henry的80000所以也要出现。最终结果变成了Joe、Alice、Bob、Tom四个人。换句话说只要员工的工资高于其直接经理就满足条件不区分员工和经理的职级差异。这个逻辑在真实业务中很常见比如销售提成超过上级的案例比比皆是。我还喜欢测试一种情况如果两个人的工资一样查询不应该返回任何人。数据上就是不要插入工资相同的记录或者把某条记录的工资改掉再测一遍。这些边界测试能让你确信WHERE条件是严格的大于而不是大于等于。5. 常见问题与排查技巧实录5.1 新手最容易犯的五个错误这道题我在面试和带新人的时候见过太多共性错误。整理成排查表供你对照错误现象可能原因解决办法查询结果为空JOIN条件写反把a.id b.managerId写成了a.managerId b.id的反向检查ON条件的逻辑方向返回所有员工WHERE条件没写或写成了ON里确认薪资比较写在WHERE里报错Unknown Column没加表别名直接写了name、salary等字段导致歧义所有字段都加前缀a.或b.结果多出重复行表里有重复记录或JOIN时一对多匹配检查数据唯一性必要时用DISTINCT执行速度极慢相关子查询在循环执行改成JOIN或非相关子查询这五个错误里最常见的还是别名问题。尤其是在SELECT和WHERE里如果不带别名前缀多表JOIN时数据库分不清字段属于哪张表直接抛错。记住一个原则只要SQL里出现了两个表哪怕出自同一张物理表所有字段尽量都带上别名前缀这个习惯能帮你规避掉一大半低级报错。5.2 关于NULL值的处理managerId为NULL的情况必须考虑。在自连接里managerId为NULL的员工无法和任何经理行匹配JOIN直接排除。但如果有人用了LEFT JOIN情况就不一样了SELECT a.name AS Employee FROM Employee a LEFT JOIN Employee b ON a.managerId b.id WHERE a.salary b.salary;LEFT JOIN会把a表所有记录都保留下来,包括managerId为NULL的员工。但这些员工的b.salary是NULL而NULL 任何值都不成立所以这些记录依然不会出现在最终结果里。结果和INNER JOIN一致。但你如果换一种写法把WHERE条件改为WHERE a.salary COALESCE(b.salary, 0)那么没有经理的员工也会因为工资大于0而全部出现在结果里这显然不符合题目要求。所以这道题用INNER JOIN最稳妥别搞LEFT JOIN否则你还要额外处理NULL逻辑。5.3 面试官除了看答案还在看什么这道题是SQL 181属于一个入门难度题。但面试官绝不只是想看你能写出来他们更看重这几点第一能不能主动说出多表关联时为什么要用别名第二知不知道ON条件和WHERE条件的区别第三面试官追问“如果数据量到百万级哪种写法更快”时能不能接住。所以你在准备时不光要背答案还要把每种写法背后的执行逻辑想清楚。我自己面试时如果候选人能主动提到“自连接的本质是一张表以两种角色参与JOIN”或者能说出“相关子查询在大数据量下有性能隐患”基本上这道题就算过了。6. 从181到实战同类型业务场景的举一反三6.1 五类常见业务的同构需求这道题的价值不在于题目本身而在于它提炼出了一种需求模式同一张表内的跨行比较。这种模式在真实业务里到处都是。比如电商订单表比较每个客户最近两笔订单金额的变化看是涨了还是跌了。订单表里同一个用户有多行订单你要把同一用户的不同订单行放在一行进行比较。这就是自连接加分组排序的操作。再比如组织架构表查询所有“下属工资高于上级”的异常数据。这和181题几乎一模一样只是把员工表换成了组织架构表。我实际帮一家零售公司排查薪酬倒挂问题时用的就是这个思路。还有产品配置表里同一类产品的不同型号参数做比较数据库日志表里同一台服务器前后两条记录的CPU使用率变化甚至日活报表里同一天不同渠道的数据做对比。这些场景的核心逻辑都是“同表跨行比较”掌握了181这道题的基础这些需求都能套用同一套思路。6.2 自连接的性能优化建议和替代方案在实际生产环境里如果表数据量很大自连接可能会导致严重的性能瓶颈。因为自连接就是一次全表关联如果两个关联列上没有索引代价会很大。优化思路主要有三条第一确保关联字段有索引。在Employee表上managerId字段最好建索引因为JOIN条件就是A.managerId B.idB.id是主键已经有索引了但A.managerId往往没有。建立索引的SQL如下CREATE INDEX idx_manager_id ON Employee(managerId);第二从写法上避免笛卡尔积。自连接本身就需要笛卡尔积但ON条件能尽快过滤所以ON条件里的字段选择非常关键。第三考虑用窗口函数替代。窗口函数不需要显式JOIN在部分数据库引擎里的执行路径比自连接更优化。比如MySQL 8.0以上版本在处理“同组内比较”这类需求时窗口函数的性能往往不输自连接而且写法上也更符合分析场景的直觉。6.3 扩展思考如何从“会做题”到“会建模”最后说点个人体会。这道题的价值不仅在于SQL语法它还在训练你的数据建模意识。当你在建表时设计了自引用外键managerId就已经默认了表内存在层级关系。而后续所有的查询都要围绕这种关系的本质展开——要么JOIN要么子查询要么窗口函数。很多人在学SQL时喜欢刷题刷了好几页却感觉没进步。我自己的经验是每做完一道题要多问自己几个问题这个查询解决的是什么业务问题还有没有别的写法如果数据量扩大100倍当前写法还可行吗把一个普通题目剥开揉碎比走马观花过十道题更有效。SQL 181这道题虽然简单但如果能把自连接、别名、JOIN顺序、子查询性能这些点都理清楚你的SQL基础就算是真的站稳了。以后碰到再复杂的表关联需求你回头看这道题会觉得它是一个特别好的起点。
返回列表