这道题我有一个很深的印象——不是因为解法复杂,恰恰相反,它是不少人在LeetCode上接触的第一道数据库题:表结构简单、逻辑直白、最后只输出去重后的ID列表。“1148. 文章浏览 I”看起来简单到有点像是在送分,但它其实把SQL查询里最容易被忽略的三件事全揉在一起了:条件过滤、去重、排序。很多人第一次写完就报错,不是逻辑错了,而是漏了ORDER BY或者用了SELECT DISTINCT但没想清楚它到底干了什么。
这篇文章我就从题目本身出发,把这道SQL题的完整拆解、解题姿势、背后的业务含义,以及从它延伸出来的真实场景都讲一遍。适合刚入门SQL的读者,也适合准备面试想系统过一遍基础查询的人——因为这道题考的不是你会不会写SELECT,而是你会不会“严谨地写SELECT”。
1. 题目到底在问什么:先读懂"文章浏览"这个表结构
LeetCode 1148题给的是一张名叫Views的表,四个字段:article_id(文章ID)、author_id(作者ID)、viewer_id(浏览者ID)、view_date(浏览日期)。表里的每一行,代表某个人在某一天浏览了某一篇文章。
题目的要求是:找出所有浏览过自己文章的作者,按作者ID升序返回。判断标准就一条:author_id和viewer_id相等——也就是说,一篇文章的作者本人打开了这篇自己的文章,这行记录就符合条件。
我先说说这张表结构里值得注意的细节。它存储的是浏览行为,不是浏览统计。每一行是一次独立的浏览事件,同一个人一天看了同一篇文章三次,表里就可能有三行记录,日期、观看者、文章、作者四个维度缺一不可。这个表在真实业务里通常叫用户行为日志表、埋点明细表或者访问流水表,底下往往还带着时间戳精确到秒甚至毫秒。真正的生产场景里这种表极其庞大,动不动几亿行,做题的目的是在这个模型上用最标准的SQL写法拿到预期结果。
细看字段名:author_id出现在行里,意味着这张表在设计上容忍"文章作者信息被冗余在浏览记录里"这件事。你可能觉得这违反了范式,但真实场景里恰恰常见——数据分析师拿到手的明细表经常是已经关联好维度的"宽表",作者ID直接放在行为记录里,就是为了减少查询时的JOIN操作。所以这个表结构本身就不是随便设计的,它背后对应的是"宽表建模"的思路。
再看题目的输出要求:只要 author_id 这一个字段,而且要去重。一个作者可能浏览了自己的好几篇文章,也可能分好几天浏览自己的文章——但这些都不重要,题目只要这个人的ID出现一次就够了。所以"至少有一次"这个隐含语义,是整道题第一个容易被忽略的点。
2. 一条SELECT就能过,但里面藏着三个小坑
2.1 条件过滤:WHERE子句该怎么写
我们需要筛选出author_id = viewer_id的行。写成SQL就是:
SELECT author_id FROM Views WHERE author_id = viewer_id这一条语句几乎是条件过滤的标准写法了。但我要强调一个容易出错的地方:题目里有一个隐含要求——同一个作者如果有多条符合条件的行为记录,最终输出中不能出现重复。比如某个作者浏览了自己的三篇文章,按上面的写法,输出三行ID,这就不符合题目的"至少一次"的语义了。
2.2 去重的三种姿势:DISTINCT、GROUP BY、还是窗口函数?
既然要避免重复,最常见的做法是加DISTINCT:
SELECT DISTINCT author_id FROM Views WHERE author_id = viewer_id ORDER BY author_id这样去重、排序一步到位,提交就能过,这是最简单直接的写法。
除了DISTINCT,也完全可以用GROUP BY来做去重:
SELECT author_id FROM Views WHERE author_id = viewer_id GROUP BY author_id ORDER BY author_idGROUP BY的语义是"按作者分组",每组只返回一行,自然也就实现了去重。这两种写法在结果上完全等价,差别在底层的执行逻辑上——DISTINCT本质上是在做排序去重,或者哈希去重;GROUP BY则要先分组再聚合,在只取分组列的时候它追求的结果就是唯一值。在小数据量表上性能差异可以忽略不计,但在千万行的真实明细表上,两者的执行计划会走不同的算子,这个差别我后面专门展开讲。
说到这还得提一句窗口函数方案:
SELECT DISTINCT author_id FROM ( SELECT author_id, ROW_NUMBER() OVER (PARTITION BY author_id ORDER BY view_date) AS rn FROM Views WHERE author_id = viewer_id ) t WHERE rn = 1 ORDER BY author_id这种写法在本题里属于"杀鸡用牛刀",但它能给你一个新的理解维度——如果题目变成了"找出浏览过自己文章,且最近一次浏览日期是某某时间之后的作者",窗口函数的优势就体现出来了。就本题而言,我不推荐用它,但知道它的存在能帮你理解SQL解决重复问题的全景。
2.3 排序:为什么就不能省掉ORDER BY
很多人在DISTINCT版本里忘记ORDER BY,结果提交直接报错。MySQL的默认输出顺序可能是插入顺序、索引顺序、哈希分布顺序——没有任何一种可以保证是最终想要的ID升序。题目明确要求"按ID升序返回",得不到这个顺序就属于输出不满足要求,判题系统直接判定错误。
在没有索引的情况下,MySQL返回行的顺序通常是全表扫描的物理顺序,这在多版本数据库上都没法作为可靠预期。就算碰巧这一次升序了,换个数据分布顺序就乱掉——所以ORDER BY author_id是这道题的刚需,不是可选项。
3. 从"作者浏览自己的文章"说起:这道题背后的三种业务逻辑
这道题的WHERE条件,inner join到真实业务里其实对应着至少三类分析场景。先说最常见的作者创作积极性分析。在内容社区产品里,作者发布文章后反复查看自己的文章页面,可能是检查排版、数评论、盯数据。如果一位作者频繁浏览自己的文章,说明他对自己作品的投入度高,反过来也说明他刚发布不久、处于活跃期。拿这道题的SQL去扫描每天的文章浏览流水,就能圈出"今天就着自己的文章看了至少一次"的作者列表,再配上浏览次数的统计,就成了运营做作者召回的基础指标。
第二类是异常行为的初筛。在反作弊场景里,一个账号既是作者又是浏览者、且频繁浏览自己的高价展示内容——平台方会引入作者自浏览的行为特征来配合风控判断。这不是说作者看自己的文章就一定有恶意为难,而是"自浏览"这个行为在风控模型里需要单独记录。如果一张行为宽表里没有author_id和viewer_id两个字段,风控分析师就得通过文章ID反查作者表做JOIN,成本和复杂度都上去了——所以宽表设计在这里有它非常现实的意义。
第三类是内容推荐去重。推荐系统在做协同过滤时,如果拿用户浏览记录当训练数据,必须把"作者浏览自己文章"这类自交互动作剔除,否则模型容易学出一个荒谬的偏置——把文章推荐给作者本人。说到底,数据分析里面经常要做"排除自己看自己"的清洗,正是这道题的SQL帮我们筛选出来的是反向集合。做题时只是输出一个作者ID列表,放到真实场景里它就是数据管道上的一个清洗节点。
4. 为什么面试官爱问这道题:看上去在考SQL,其实在考严谨性
这道题被LeetCode放在数据库入门区,但它频繁出现在一线公司的SQL初面里,是有原因的。我见过不少候选人选了DISTINCT + ORDER BY提交通过,但一问"为什么要加DISTINCT"就答不上来——他们以为DISTINCT是"可选优化",甚至有人觉得"表里数据本来就该是唯一的"。第二个追问是:"如果这张Views表里恰好没有重复行,你的SQL还需要DISTINCT吗?"答案是如果业务语义要求唯一,不管数据现状怎样都得加,因为数据质量不是永远可靠的,今天没有重复不代表明天没有,schema里也没有对(author_id, article_id, viewer_id, view_date)建唯一约束。这道题就在这里:它考的是一条SQL里的业务语义是不是被你想透了,而不仅仅是语法上对不对。
第三个追问通常是关于GROUP BY和DISTINCT哪个更好。这问题在我当年的面试里也遇到过,没有绝对答案,得看数据分布和索引情况。在字段可空性不同的场合,两者的语义都会有细微差别。GROUP BY在部分数据库的优化器里可能走HashAggregate,DISTINCT在一些场景下会走Unique排序——如果查询带索引,GROUP BY可以利用索引的有序性去分组,DISTINCT同样可以利用索引。真到定义性能对比时,要在具体的执行计划上说话。
还有第四种追问:为什么不用*或者不用子查询?如果SELECT *会把所有字段输出,不符合题目要求;子查询用IN的话SQL一样能跑,但嵌套层数和可读性都不如直接WHERE等值比较。这道题的优秀解法恰恰是"最简单的写法"——数据库查询的最高境界,是能用一层SELECT解决的问题绝不套多余结构。
5. 避坑实测:我自己在跑通这道题时遇到的三个意外
5.1 空指针和NULL值陷阱
Views表里,如果author_id或者viewer_id存在NULL,WHERE author_id = viewer_id 这个条件会直接把这些行过滤掉。因为SQL里NULL和任何值做等值比较,结果都是UNKNOWN,既不是TRUE也不是FALSE。所以带NULL的行会被排除。这个行为在题目里不影响结果,但真实数据里NULL值很常见,你在清洗脚本里得明确知道它们是被静默过滤掉的。要做"包含NULL也视为相等"的判断,得写上WHERE author_id IS NOT DISTINCT FROM viewer_id或者用<=>操作符——不过这属于边界情况,绝大多数业务需求里NULL行本来就是脏数据。
5.2 DISTINCT加多列的结果陷阱
如果哪天题目改成"找出浏览过自己文章的所有作者,并同时列出他们浏览过的文章ID",写法就变成SELECT DISTINCT author_id, article_id——这时候去重是以(author_id, article_id)二元组为单位的。一个作者可能对应多行记录,因为文章ID不同。这是很多改动时容易踩的坑,要时刻注意DISTINCT到底作用在哪一列组合上。SQL是声明式语言,你在SELECT列表后加一个DISTINCT,它作用的是整个列表所有列的组合,而不只是后面的第一个字段。
5.3 MySQL的ONLY_FULL_GROUP_BY模式
在5.7以上版本的MySQL里,默认开启了sql_mode=ONLY_FULL_GROUP_BY。如果你写了类似GROUP BY author_id却非要在SELECT里取article_id,会直接报错。这对做题影响不大,但正好提醒你区分两种问题:"去重取唯一作者"和"按作者分组并聚合多行信息"是完全不同的两件事。前者用GROUP BY没问题,后者必须搭配聚合函数或者窗口函数。理解这个边界,比背下一条SQL语句重要得多。
6. 高频变体:把这道题改一改,能考出完全不同的知识点
这道题作为I,自然有II甚至III。但我说的不光是LC后面的题目编号,而是面试官可能临场出的变体。
变体一:输出有自我浏览行为的作者ID和浏览总次数。SQL写法:
SELECT author_id, COUNT(*) AS cnt FROM Views WHERE author_id = viewer_id GROUP BY author_id ORDER BY author_id这一改,DISTINCT就完全不够用了,必须分组+聚合。这里要注意COUNT(*)统计的是行数,也就是浏览事件数;如果想统计作者浏览了几篇不同文章,得用COUNT(DISTINCT article_id)。
变体二:找出三位及以上作者共同浏览过的文章ID。这要自连接Views表,本质上是多表关联+分组过滤,考察的难度上了一个台阶。
变体三:找出作者最后一篇浏览自己文章的时间。这就轮到MAX和GROUP BY登场:
SELECT author_id, MAX(view_date) AS last_view_date FROM Views WHERE author_id = viewer_id GROUP BY author_id这类变体万变不离其宗——先是条件过滤,再是分组聚合,最后是排序或HAVING条件过滤分组。把这三板斧吃透了,不管题目换成文章表、用户表、订单表,你都难不倒。
7. 真实执行计划的简单观察:DISTINCT与GROUP BY的性能差异
这道题的数据量只有不到十行,压根谈不上性能,但为了让你在面试里能说出个子丑寅卯,我建议你到本地MySQL里执行一下EXPLAIN,观察执行计划。在我自己常用的MySQL 8.0环境里,数据量构造到一百万多行、并且对(author_id, viewer_id)建了索引之后,DISTINCT语句的执行计划往往显示Using index for group-by,优化器会选择利用索引的有序性做去重;GROUP BY版本在同样索引下也可能走上类似的路径。两者最终殊途同归。
但如果你在查询里加上了额外的大字段,或者索引建得不合理,优化器可能改为Using temporary + Using filesort,这时候两版本的性能差异才真正拉开。Distinct天然自带排序去重,GROUP BY则需要内存临时表配合聚合。在小表上这些执行细节都是0和1毫秒的区别,可一旦上了真实的生产数据规模,执行计划的不同就是秒级和分钟级的差异了。
我推荐一个习惯:拿到这类"去重+排序"的需求,先写DISTINCT版本,这是最符合声明式思维的写法;遇到分组聚合字段、或者要配合HAVING过滤分组结果时,再换成GROUP BY。不要为了显得技术高深非得选GROUP BY,SQL的第一审美是可读性和正确的业务语义。
最后一个我自己常用来加深印象的做法,是把这道题的SQL改成数据质量校验脚本:每天跑一遍,看看有没有作者ID在浏览流水里异常高频地作为浏览者出现。工具没变,SQL也没变,但角度一变,它就从一道面试题变成了一个能用在实际业务里的数据巡检小脚本。这也是我特别推荐每一位刷题的同学做的事:把一道题还原成一个分析场景,你会发现它的价值远不是AC一个用例那么简单。