找出表中只出现一次的数据。
Find data that appears only once in the table.
考察要点
这道题是 SQL 面试中的 "Hello World",看似简单,但面试官想通过它快速筛选候选人的基本功是否扎实。主要考察以下三点:
- SQL 聚合与过滤:你是否熟练掌握
GROUP BY分组、COUNT()聚合函数,以及HAVING子句的正确用法。 - SQL 执行顺序:你是否清楚
WHERE和HAVING的本质区别,即WHERE在分组前过滤行,HAVING在分组后过滤组。 - 现代 SQL 特性:你是否了解并能运用窗口函数 (
Window Functions) 解决此类问题,这能体现你的知识广度和深度,是加分项。
核心概念
要解决此问题,必须理解以下几个核心 SQL 概念:
-
GROUP BY子句GROUP BY用于将结果集中的行按照一个或多个列进行分组,通常与聚合函数(COUNT,SUM,AVG等)一起使用,对每个分组进行计算。 -
COUNT()聚合函数COUNT()用于计算行数。COUNT(*): 计算组内所有行数,包括 NULL 值。COUNT(column): 计算组内指定列中非 NULL 值的行数。COUNT(DISTINCT column): 计算组内指定列中唯一且非 NULL 值的行数。 在本题中,COUNT(*)或COUNT(num)效果相同(假设num列不含 NULL)。
-
HAVING子句HAVING用于在GROUP BY分组之后,对聚合结果进行过滤。这是它与WHERE的根本区别。- 执行顺序:
FROM->WHERE->GROUP BY->HAVING->SELECT->ORDER BY - 作用对象:
WHERE作用于原始表中的每一行;HAVING作用于GROUP BY生成的中间结果(分组)。
特性 WHEREHAVING作用时机 分组聚合前 分组聚合后 作用对象 行数据 聚合结果(组) 能否使用聚合函数 否 是 (e.g., HAVING COUNT(*) > 1) - 执行顺序:
-
窗口函数
COUNT() OVER()(MySQL 8.0+) 窗口函数在保留原始行的情况下,为每一行计算一个与其相关窗口(一组行)的聚合值。COUNT(*) OVER (PARTITION BY column): 对column进行分区(类似分组),然后计算每个分区内的行数,并将该计数值作为新的一列附加到分区内的每一行上。它不合并行,保留了所有原始数据。
数据示例: 原始表
my_numbers:id num 1 8 2 8 3 3 4 1 5 4 6 5 7 6 使用
COUNT(*) OVER (PARTITION BY num)后的中间结果:id num count_per_num 3 3 1 4 1 1 5 4 1 6 5 1 7 6 1 1 8 2 2 8 2 可以看到,原始的 7 行数据都在,只是增加了一列
count_per_num。之后只需筛选count_per_num = 1的行即可。
多种解法
假设我们有如下的表 my_numbers:
1CREATE TABLE my_numbers (2 num INT3);4INSERT INTO my_numbers (num) VALUES (8), (8), (3), (1), (4), (5), (6);
目标:找出 3, 1, 4, 5, 6。
解法 1:GROUP BY + HAVING (最经典、通用)
这是最标准、最通用的解法,适用于所有主流数据库。
- 标注版本:✅ 所有 SQL 数据库 (MySQL, PostgreSQL, Oracle, SQL Server 等)
- SQL 代码:
sql1SELECT num2FROM my_numbers3GROUP BY num4HAVING COUNT(num) = 1;
- 时间/空间复杂度:
- 时间复杂度: 或 。如果使用哈希表进行分组,复杂度为 ;如果数据量大到内存不足需要排序,则为 。
- 空间复杂度:,其中 K 是
num列中唯一值的数量,用于存储分组和计数的哈希表。
- 索引依赖:
- 在
num列上建立索引 (CREATE INDEX idx_num ON my_numbers(num);) 可以极大地提升性能。数据库可以利用索引的有序性来优化GROUP BY操作(如 MySQL 中的 "Loose Index Scan"),避免全表扫描和额外的排序。
- 在
解法 2:窗口函数 (现代 SQL 推荐)
这种解法更具表现力,在需要保留原始行信息的复杂场景下尤为强大。
- 标注版本:✅ MySQL 8.0+, PostgreSQL, Oracle, SQL Server;❌ MySQL 5.7 及以下版本不支持。
- SQL 代码:
sql1-- 使用 CTE (Common Table Expression) 提高可读性2WITH NumberCounts AS (3 SELECT4 num,5 COUNT(*) OVER (PARTITION BY num) AS occurrence_count6 FROM7 my_numbers8)9SELECT num10FROM NumberCounts11WHERE occurrence_count = 1;
- 时间/空间复杂度:
- 时间复杂度:通常为 ,因为窗口函数需要对分区键进行排序。某些情况下数据库可以优化为 。
- 空间复杂度:,因为需要为原始表的每一行都保留一个窗口计算结果。
- 索引依赖:
- 在
num列上的索引同样可以帮助PARTITION BY子句,加速数据分区的过程。
- 在
何时使用:当你不希望 GROUP BY 将多行压缩为一行,而是想在原始行上附加聚合信息时,窗口函数是最佳选择。例如,"查询所有只出现一次的数字,并返回它们在原表中的所有信息"。
解法 3:子查询 (不推荐,但可作为思路)
通过子查询先计算出所有数字的出现次数,再与原表进行连接。
- 标注版本:✅ 所有 SQL 数据库
- SQL 代码:
注意:这个写法是冗余的,因为子查询sql1SELECT DISTINCT T1.num2FROM my_numbers AS T13JOIN (4 SELECT num5 FROM my_numbers6 GROUP BY num7 HAVING COUNT(num) = 18) AS T2 ON T1.num = T2.num;
T2本身已经得出了最终结果。这里仅作思路展示,实际面试中直接用解法 1 即可。更糟糕的写法是WHERE num IN (subquery),在某些老版本 MySQL 中性能较差。
常见低分回答
WHERE和HAVING混淆:这是最典型的错误,直接暴露了对 SQL 执行顺序的无知。sql1-- 错误! 聚合函数不能用在 WHERE 子句中2SELECT num, COUNT(num)3FROM my_numbers4WHERE COUNT(num) = 15GROUP BY num;SELECT非聚合列而没有GROUP BY:在严格模式下(如 PostgreSQL 或标准 SQL),这会直接报错。sql1-- 错误! 'num' 列必须出现在 GROUP BY 子句中或在聚合函数中使用2SELECT num, COUNT(*)3FROM my_numbers;- 误用
COUNT(列)vsCOUNT(*):虽然在本题中COUNT(num)和COUNT(*)结果一致(假设num非空),但候选人应清楚它们的细微差别。如果num列可能为NULL,COUNT(num)会忽略NULL行,可能导致逻辑错误。COUNT(*)是最稳妥的选择。 - 忽略
NULL的特殊语义:如果表中存在NULL值,GROUP BY会将所有NULL视为一组。COUNT(NULL)结果为 0,但COUNT(*)会计算NULL所在的行。候选人应能讨论NULL对结果的影响。
工程实践与优化
- 索引是关键:对于千万级以上的数据,
num列上必须有索引。没有索引的GROUP BY会导致全表扫描 (Full Table Scan) 和文件排序 (filesort),这是灾难性的。 - 查看执行计划:面试时可以说 "我会用
EXPLAIN来分析这条 SQL 的执行计划"。- 理想情况 (
GROUP BY):EXPLAIN结果中Extra字段显示Using index for group-by或Loose Index Scan。这表示 MySQL 直接利用索引结构完成了分组,无需读取数据行,性能极高。 - 次优情况:
Extra字段显示Using index,表示进行了覆盖索引扫描。 - 糟糕情况:
Extra字段显示Using temporary; Using filesort。这表示 MySQL 需要创建临时表,并在磁盘上进行排序来完成分组,性能很差。
- 理想情况 (
- 数据倾斜:如果某个
num值出现次数远超其他值,可能会导致GROUP BY或PARTITION BY的数据倾斜,单个线程/节点负载过高。在分布式数据库中,这需要特殊处理(如添加随机前缀salt)。 - 预聚合:如果 "查找只出现一次的数据" 是一个高频请求,但数据写入不频繁,可以考虑建立一个物化视图或汇总表,定时任务(如每晚)计算好每个数字的频次。这样,查询请求可以直接命中汇总表,实现毫秒级响应。
常见追问与延伸题目
-
追问:如果数据量增长 100 倍达到 10 亿级别,你的 SQL 还能跑吗?怎么优化?
- 回答要点 1 (单机 MySQL):首先,
GROUP BY方案在有合适索引的情况下依然是理论上最优的。但 10 亿级别数据,GROUP BY聚合过程会消耗大量内存和 CPU,甚至可能因为中间结果集太大而频繁刷盘,导致查询变得极慢或超时。优化方向是:- 硬件:增加服务器内存(
innodb_buffer_pool_size,tmp_table_size)。 - 分治:如果业务允许,可以按时间范围或其他分区键分批处理,例如
SELECT ... FROM my_numbers WHERE created_time BETWEEN ... AND ... GROUP BY ...,然后由应用层合并结果。
- 硬件:增加服务器内存(
- 回答要点 2 (架构升级):对于这个数据量级,更现实的方案是采用大数据技术栈。我会将数据导入到如 Hive, Spark, ClickHouse 等分布式计算平台。这个问题在 MapReduce 模型中非常经典:
- Map 阶段:以
num为 key,1为 value,输出(num, 1)。 - Reduce 阶段:对相同 key 的 value 进行求和,得到
(num, count)。 - 最后再过滤出
count = 1的结果。这个过程可以水平扩展,轻松处理 PB 级数据。
- Map 阶段:以
- 回答要点 1 (单机 MySQL):首先,
-
追问:如果要查找出现次数 Top K 的数字,SQL 怎么写?
- 回答要点:这是一个 Top-K 问题,非常经典。只需在解法 1 的基础上增加
ORDER BY和LIMIT。这考察了对sql1SELECT num, COUNT(num) AS occurrence_count2FROM my_numbers3GROUP BY num4ORDER BY occurrence_count DESC5LIMIT 10; -- 查找出现次数最多的前 10 个数字ORDER BY和LIMIT的熟练运用。
- 回答要点:这是一个 Top-K 问题,非常经典。只需在解法 1 的基础上增加
-
追问:如果要找出表中第一个只出现一次的数字呢?(按原表顺序)
- 回答要点:这个问题
GROUP BY就很难处理了,因为它破坏了原始的顺序信息。这正是窗口函数的优势所在。假设表有一个自增id列来表示顺序。这个问题能很好地区分候选人是否真正理解了不同解法的适用场景和局限性。sql1-- 必须使用窗口函数2SELECT num3FROM (4 SELECT5 num,6 COUNT(*) OVER (PARTITION BY num) as cnt,7 ROW_NUMBER() OVER (ORDER BY id) as rn -- 保留原始顺序8 FROM my_numbers9) AS temp10WHERE cnt = 111ORDER BY rn ASC -- 按原始顺序排序12LIMIT 1;
- 回答要点:这个问题