资讯动态

窗口函数实战:分组取TopN与最近三笔订单的SQL写法

发布时间:2026/10/26 14:13:40 来源:尧图企业网站定制
1. 从一道“老题”说起为什么最近三笔订单值得反复琢磨“查询出每个用户的最近三笔订单”——这道题在大数据SQL的练习清单里出现频率极高几乎成了窗口函数章节的“保留曲目”。我最早接触它是在做数仓离线报表的时候当时业务方提的需求原话是“给我拉一下每个客户最近三次的下单记录我要看复购节奏”。听起来简单写起来也不复杂但真正把它放到生产环境里跑才发现坑比想象中多得多数据量上亿、订单时间有重复、用户有退款单、分区表还带倾斜。所以这道题表面考的是ROW_NUMBER实际考的是你对窗口函数执行逻辑、排序稳定性、数据边界和性能代价的整体理解。这篇文章适合三类人看一是正在刷SQL题、准备数据岗面试的同学你需要的不只是答案而是能讲清楚“为什么这么写”二是刚进数仓团队、开始接真实取数需求的工程师你需要把练习题转化成能上生产的写法三是对窗口函数一直半懂不懂、想彻底捋清楚的分析师。我会从题目拆解讲到方案选型再讲到完整实操和踩坑记录尽量把这道题背后能延展的东西都摊开讲。核心关键词就三个窗口函数、分组取TopN、最近三笔订单。你把这三个词吃透这一类“每组取前N条”的问题基本就通杀了。先说清楚这道题到底在解决什么问题。有一张订单表里面存着用户ID、订单ID、下单时间、金额等字段一个用户可能有一笔订单也可能有几百笔。需求是对每个用户只保留按时间倒序排在最前面的三条记录。注意“每个用户”这四个字它意味着这不是全局取TopN而是分组取TopN——每个分组内部独立排名互不干扰。这个区别决定了你不可能用简单的ORDER BY ... LIMIT 3搞定因为LIMIT是全局的它只会给你整个表最新的三条而不是每人三条。理解到这一层窗口函数就成了最自然的解法。2. 方案选型为什么窗口函数是这道题的最优解2.1 三种常见写法的横向对比拿到“分组取TopN”的需求业内常见的写法大概有三种窗口函数、自关联计数、以及GROUP_CONCAT之类的聚合技巧。我把它们的核心逻辑和适用场景列出来你一看就明白为什么我最终锁定窗口函数。方案核心思路优点缺点适用场景窗口函数ROW_NUMBER分组内排名后筛rn3逻辑清晰、性能好、易扩展需要数据库支持窗口函数绝大多数现代数仓自关联计数对每条记录数比它新的有几条兼容老版本数据库复杂度O(n²)、大表基本跑不动小数据量、老引擎聚合函数拼接先拼再拆单行输出解析麻烦、无法保留多字段特殊报表需求自关联的写法我早年在一个老系统上被迫用过逻辑是“对订单表a的每一行去订单表b里数有多少条同用户且时间更晚的记录如果少于3条就保留”。这个思路本身没错但它是相关子查询数据量一上来执行计划就惨不忍睹几百万行能跑到你怀疑人生。窗口函数则是一次排序扫描就完成分组排名复杂度低了一个量级。所以在支持窗口函数的引擎上没有任何理由选自关联。2.2 ROW_NUMBER、RANK、DENSE_RANK到底选哪个这是这道题第一个真正的分水岭。三个排名函数长得很像但语义完全不同选错了结果就错。我用一个具体场景说明假设某用户有三笔订单下单时间分别是10:00、10:00、09:00前两笔时间完全相同。ROW_NUMBER()不管时间是否相同强行给1、2、3结果稳定但“并列”被打破。RANK()相同时间给相同名次结果是1、1、3出现跳号。DENSE_RANK()相同时间给相同名次但不跳号结果是1、1、2。那这道题该用哪个关键看业务对“最近三笔”的定义。如果业务说的是“最近三条记录”那用ROW_NUMBER最合适因为它保证每人恰好三条不会因为时间重复而多给或少给。如果业务说的是“最近三个时间点”那时间相同的订单应该一起算这时候DENSE_RANK更贴合语义。我在实际项目里默认用ROW_NUMBER因为它结果确定性最强下游做数据核对时不会因为并列导致条数飘忽。但我会在需求评审时明确问一句“同一时刻的多笔订单算一笔还是多笔”这个确认动作能省掉后面大量返工。提示ROW_NUMBER在时间字段有重复时具体哪条排前面是不确定的除非你在ORDER BY里再加一个唯一性字段比如订单ID做兜底。这一点后面实操部分会重点讲。2.3 排序字段的选择时间、ID还是金额题目说“最近”默认按下单时间倒序。但真实场景里下单时间字段经常有坑有的是字符串类型、有的是时间戳、有的还带时区。更麻烦的是有些系统里“下单时间”和“支付时间”是两回事业务说的“最近订单”到底按哪个算必须问清楚。我的经验是排序字段一定要选业务语义明确且单调性好的字段。下单时间通常够用但如果同一秒内有多笔就得补一个自增订单ID作为第二排序键保证排名稳定可复现。还有一种情况是业务想看“最近三笔已支付订单”那过滤条件就得先加上status paid再做排名顺序不能反。我见过有人先排名后过滤结果某些用户筛完只剩一笔因为排名时把未支付单也算进去了。这种错误在数据核对时特别隐蔽因为总数看着对细看全是错的。3. 核心细节拆解窗口函数到底怎么跑的3.1 PARTITION BY与ORDER BY的分工窗口函数的语法骨架是函数() OVER (PARTITION BY ... ORDER BY ...)。这两个子句分工非常明确PARTITION BY负责“分组”把数据切成一个个用户的小块ORDER BY负责“组内排序”决定谁排第一谁排第二。你可以把它想象成运动会PARTITION BY是把运动员按班级分开ORDER BY是在每个班级内部按成绩排名最后ROW_NUMBER发号码牌。号码牌在每个班级内部都是从1开始互不影响——这正是“每个用户最近三笔”需要的效果。这里有个容易忽略的点PARTITION BY的字段如果有NULLNULL会被当成一个独立分组。如果订单表里存在用户ID为空的脏数据这些记录会自成一组排名照样从1开始。所以生产环境里我通常会在WHERE里先过滤掉user_id IS NOT NULL避免脏数据混进结果。3.2 窗口函数的执行顺序与WHERE的位置这是新手最容易翻车的地方。SQL的逻辑执行顺序大致是FROM → WHERE → GROUP BY → HAVING → 窗口函数 → ORDER BY → LIMIT。注意窗口函数在WHERE之后执行这意味着你不能在WHERE里直接引用窗口函数产生的别名。比如下面这种写法是错的-- 错误写法WHERE在窗口函数之前执行此时rn还不存在 SELECT user_id, order_id, order_time, ROW_NUMBER() OVER (PARTITION BY user_id ORDER BY order_time DESC) AS rn FROM orders WHERE rn 3;正确做法是把窗口函数的结果包一层子查询或CTE在外层再筛rn 3。这个“包一层”的动作不是啰嗦而是SQL语义决定的。我刚开始写的时候也犯过这个错报错信息还特别不直观后来养成习惯只要用到窗口函数结果做过滤一律套子查询形成肌肉记忆。3.3 时间倒序与并列处理的细节ORDER BY order_time DESC看起来没问题但如果order_time精度只到秒同一用户同一秒下两单的情况在促销场景里并不罕见。这时候ROW_NUMBER给出的1、2顺序是不确定的今天跑和明天跑结果可能不一样。解决办法是在排序里追加一个唯一键ROW_NUMBER() OVER ( PARTITION BY user_id ORDER BY order_time DESC, order_id DESC ) AS rn加上order_id DESC之后排序就变成全序结果稳定可复现。这个细节在面试里是加分项在生产里是必须项——数据团队最怕的就是“同样的SQL跑两次结果不一样”那会让下游对数据失去信任。4. 完整实操从建表到出结果的每一步4.1 模拟数据准备为了把过程讲透我先造一张模拟订单表。字段包括用户ID、订单ID、下单时间、订单金额、订单状态。数据里我故意埋了几个坑有用户只有一笔订单、有用户时间完全重复、有未支付订单、有用户ID为空的脏数据。CREATE TABLE orders ( user_id VARCHAR(20), order_id VARCHAR(30), order_time TIMESTAMP, amount DECIMAL(10,2), status VARCHAR(10) ); INSERT INTO orders VALUES (u001, o1001, 2024-05-01 10:00:00, 120.00, paid), (u001, o1002, 2024-05-03 09:30:00, 88.00, paid), (u001, o1003, 2024-05-05 14:20:00, 200.00, paid), (u001, o1004, 2024-05-07 16:00:00, 66.00, paid), (u001, o1005, 2024-05-07 16:00:00, 45.00, unpaid), (u002, o2001, 2024-05-02 11:00:00, 300.00, paid), (u002, o2002, 2024-05-04 12:00:00, 150.00, paid), (u003, o3001, 2024-05-06 08:00:00, 99.00, paid), (NULL, o4001, 2024-05-06 09:00:00, 10.00, paid);这批数据里u001有四笔已支付加一笔未支付且有两笔时间完全相同u002只有两笔u003只有一笔还有一条user_id为空的脏数据。用它来验证各种边界情况刚好够用。4.2 基础版每人最近三笔含未支付先写最直接的版本不区分状态纯按时间取前三WITH ranked AS ( SELECT user_id, order_id, order_time, amount, status, ROW_NUMBER() OVER ( PARTITION BY user_id ORDER BY order_time DESC, order_id DESC ) AS rn FROM orders WHERE user_id IS NOT NULL ) SELECT user_id, order_id, order_time, amount, status FROM ranked WHERE rn 3 ORDER BY user_id, rn;跑出来的结果里u001会拿到o1004、o1005时间相同按订单ID倒序、o1003三条u002拿到o2002、o2001两条u003拿到o3001一条。注意u001的o1005是未支付单如果业务只要已支付就得在CTE里加过滤。4.3 进阶版只取已支付订单的最近三笔WITH ranked AS ( SELECT user_id, order_id, order_time, amount, ROW_NUMBER() OVER ( PARTITION BY user_id ORDER BY order_time DESC, order_id DESC ) AS rn FROM orders WHERE user_id IS NOT NULL AND status paid ) SELECT user_id, order_id, order_time, amount FROM ranked WHERE rn 3 ORDER BY user_id, rn;这个版本里过滤条件放在窗口函数之前保证排名只在已支付订单内部进行。这是正确顺序千万别反过来。我见过有人把status paid放到外层WHERE结果排名时未支付单占了名额筛完就凑不齐三笔了。4.4 参数化版本把“3”变成可配置真实项目里“最近N笔”的N经常变硬编码3不灵活。可以把N抽成变量不同引擎写法略有差异。以常见数仓为例可以用参数占位-- 假设参数 :top_n 由调度系统传入值为3 WITH ranked AS ( SELECT user_id, order_id, order_time, amount, ROW_NUMBER() OVER ( PARTITION BY user_id ORDER BY order_time DESC, order_id DESC ) AS rn FROM orders WHERE user_id IS NOT NULL AND status paid ) SELECT user_id, order_id, order_time, amount FROM ranked WHERE rn :top_n ORDER BY user_id, rn;这样一份SQL能服务多个需求方改参数就行不用改逻辑。我在团队里推这个做法主要是为了避免“每人复制一份SQL改数字”导致的版本混乱。4.5 结果验证怎么确认自己写对了写完不能只看总数要逐用户核对。我的验证清单是这样的先查每个用户的订单总数再查结果里每个用户的条数两者取小应该等于结果条数。用一条SQL就能自查-- 核对结果条数应等于 min(用户订单数, 3) SELECT t.user_id, t.total_cnt, r.result_cnt, CASE WHEN r.result_cnt LEAST(t.total_cnt, 3) THEN OK ELSE CHECK END AS flag FROM ( SELECT user_id, COUNT(*) AS total_cnt FROM orders WHERE user_id IS NOT NULL AND status paid GROUP BY user_id ) t LEFT JOIN ( SELECT user_id, COUNT(*) AS result_cnt FROM ( SELECT user_id, ROW_NUMBER() OVER (PARTITION BY user_id ORDER BY order_time DESC, order_id DESC) AS rn FROM orders WHERE user_id IS NOT NULL AND status paid ) x WHERE rn 3 GROUP BY user_id ) r ON t.user_id r.user_id;这个自查动作花不了两分钟但能挡住绝大多数低级错误。养成习惯之后交付质量会明显不一样。5. 常见问题与排查技巧实录5.1 问题速查表现象可能原因排查方向解决方式结果条数远少于预期WHERE里直接用了rn别名检查SQL执行顺序套子查询再过滤每人条数不稳定排序字段有重复值看时间字段精度追加唯一键排序某些用户缺失user_id为NULL被分组查脏数据WHERE过滤NULL结果里混入未支付单过滤条件位置错误看status过滤在哪层移到窗口函数之前大表跑得极慢全表排序无分区裁剪看执行计划加分区条件、减少扫描列同一SQL两次结果不同排序非全序检查ORDER BY补唯一键兜底5.2 三个我踩过的真实坑第一个坑是分区字段没加导致全表扫描。订单表通常是按日期分区的如果查询不带日期条件引擎会把所有分区都扫一遍。我早期写练习SQL从不加分区条件搬到生产表上直接超时。后来养成习惯只要表有分区WHERE里必带时间范围哪怕业务没要求也要跟需求方确认一个合理窗口。第二个坑是窗口函数和DISTINCT混用。有次我想去重后再排名写成了SELECT DISTINCT ... ROW_NUMBER() OVER(...)结果去重和窗口函数的执行顺序让我懵了半天。窗口函数是在DISTINCT之前还是之后执行不同引擎行为不完全一致最稳妥的做法是先子查询去重再在外层做窗口排名别把两件事塞进一层。第三个坑是数据倾斜。某个大客户订单量占了全表三成PARTITION BY user_id之后这个分区特别大单个task跑得特别慢。解决办法要么是加随机前缀打散再聚合要么是对大客户单独处理。这道题在小数据量下体现不出来但数据量上千万之后倾斜就是绕不开的话题。5.3 面试场景下的加分回答如果这道题出现在面试里光写出ROW_NUMBER只能算及格。我建议你主动补三句话第一说明为什么不用LIMIT全局vs分组第二说明ROW_NUMBER和RANK的区别以及选择理由第三提一句时间重复时追加唯一键排序保证稳定性。这三句一出口面试官基本能判断你是真写过而不是背过。我面过不少人能把第三点讲清楚的不到三成而这恰恰是生产环境最在意的细节。6. 性能优化与扩展场景6.1 大表下的写法取舍数据量到亿级之后窗口函数的排序代价会变得可观。几个实用的优化方向一是只SELECT需要的列别SELECT *窗口排序时携带的列越少内存压力越小二是先过滤再排名把时间范围、状态等条件下推到子查询减少参与排序的行数三是利用分区裁剪把分区字段条件写进WHERE四是考虑预聚合如果这个“最近三笔”是高频需求可以做成每日增量表用增量方式维护每个用户的最新三条避免每次全量重算。增量维护的思路值得展开说一句每天新订单进来后把新订单和昨天的结果表合并再重新按用户排名取前三。这样每次只处理增量加少量历史比全量扫订单表快得多。这个方案我在一个日增千万订单的场景里用过效果很明显代价是需要多维护一张结果表和一套调度逻辑。6.2 从“最近三笔”延伸到“每组TopN”这道题的本质是“分组取TopN”把N换成任意值、把分组字段换成任意维度套路完全一样。比如“每个品类销量前三的商品”“每个部门薪资前五的员工”“每个门店最近七天销售额最高的三个时段”写法都是PARTITION BY 分组字段 ORDER BY 排序字段加外层rn N。你把这一个模式练熟这一类需求就都有了模板。我个人的习惯是把它封装成一段注释清晰的SQL片段存在自己的模板库里遇到新需求改字段名就行效率提升非常明显。6.3 一个容易忽略的边界N大于实际条数当某个用户订单不足三笔时ROW_NUMBER只会给出1、2外层rn 3照样保留不会报错也不会补空行。这通常是符合预期的。但如果业务要求“不足三笔也要输出三行、缺的补空”那就得用左连接一张序号表来补齐写法会复杂不少。这种需求我在报表场景见过做法是先造一个1到N的序号表跟用户表交叉连接再左连接排名结果。要不要做到这一步取决于业务对“完整性”的要求别自己加戏问清楚再动手。7. 我个人在实际操作中的几点体会这道题我前前后后写过不下二十遍每次场景不同踩的坑也不同。最大的体会是练习题和生產题之间隔着的不是语法而是对数据和业务的敬畏。练习题给你一张干净的表生生产题给你一张有脏数据、有倾斜、有历史遗留字段的表还附赠一个“明天就要”的截止时间。所以我现在写任何取数SQL第一步永远是SELECT COUNT(*)看看数据量第二步是抽样看几条数据长什么样第三步才是动手写逻辑。这个习惯让我少返工了很多次。另外一个体会是关于“最近”这个词的歧义。业务说“最近三笔”可能是按下单时间、可能是按支付时间、可能是按更新时间甚至可能是按业务系统里的某个业务日期。我现在的做法是需求里出现任何时间相关的词一律让对方给出字段名和口径白纸黑字确认。这不是较真而是数据工作里最划算的沟通成本——问一句花三十秒猜错了返工花半天。最后分享一个小技巧写完这类分组取TopN的SQL别急着交付先拿一两个典型用户手工核对。挑一个订单多的、一个订单少的、一个时间有重复的把他们的原始订单和你的结果并排放一起看。这个动作能发现百分之九十以上的逻辑错误比任何自动化校验都直接。数据这行手比眼靠谱眼见不一定为实跑出来对得上才算数。

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑