group by会丢失原始行粒度,因其本质是将多行折叠为一行聚合结果,导致订单时间、order_id等明细字段不可见;窗口函数如row_number() over(partition by user_id order by created_at desc)则在保留每行基础上编号,再筛选rn=1即可精准获取每组最新记录。

为什么GROUP BY会丢失原始行粒度
当你对表执行 GROUP BY 时,数据库必须把多行“折叠”成一行,原始的每条记录信息(比如订单时间、用户IP、操作ID)就没了。这不是bug,是语义决定的——GROUP BY 的目标就是聚合,不是保留明细。
典型场景:想查每个用户的最新一笔订单,同时还要显示这笔订单的 order_id、created_at、amount。用 GROUP BY user_id 配合 MAX(created_at) 只能拿到时间,拿不到对应那行的其他字段,除非嵌套子查询或 JOIN,写法绕且易错。
窗口函数不折叠行,它在保持原表每一行的基础上“旁加”计算结果,这才是保留粒度的关键。
用ROW_NUMBER()配合PARTITION BY取每组最新/最旧记录
这是替代 GROUP BY + MAX/MIN 最直接的方式。核心是用 ROW_NUMBER() OVER (PARTITION BY user_id ORDER BY created_at DESC) 给每个用户的订单按时间倒序编号,再过滤出 rn = 1 的行。
-
PARTITION BY user_id对应原GROUP BY user_id的分组逻辑 -
ORDER BY created_at DESC决定“最新”的定义;换成ASC就是“最早” - 必须用
ROW_NUMBER(),不能用RANK()或DENSE_RANK()——它们对相同值会并列编号,导致rn = 1可能返回多行 - 这个写法在 PostgreSQL、SQL Server、Oracle、MySQL 8.0+、Doris、StarRocks 中都可用;SQLite 3.25+ 也支持
SELECT order_id, user_id, created_at, amount
FROM (
SELECT *,
ROW_NUMBER() OVER (PARTITION BY user_id ORDER BY created_at DESC) AS rn
FROM orders
) t
WHERE rn = 1;
用聚合窗口函数(SUM/AVG/COUNT)避免自连接或子查询
当你要在明细行上“附带”聚合值(比如每笔订单显示该用户总消费额),传统做法是写子查询或关联聚合结果,性能差且可读性低。窗口版聚合函数直接解决这个问题。
-
COUNT(*) OVER (PARTITION BY user_id)返回该用户有多少笔订单(每行都一样) -
SUM(amount) OVER (PARTITION BY user_id)返回该用户累计金额 - 注意:
OVER ()不带PARTITION BY表示全表范围;OVER (ORDER BY ...)会变成累积计算(如SUM(amount) OVER (PARTITION BY user_id ORDER BY created_at)是用户时间序下的滚动和) - 如果只想要去重计数(如用户买了几个不同商品),
COUNT(DISTINCT product_id) OVER (...)在 PostgreSQL 和 BigQuery 支持,但 MySQL 8.0 和 SQL Server **不支持**,得换用ARRAY_AGG或临时表绕过
常见坑:ORDER BY缺失导致结果不稳定
几乎所有窗口函数(尤其是 ROW_NUMBER()、LAG()、LEAD())都依赖明确的 ORDER BY。漏掉它,数据库可能按任意物理顺序编号,两次查询结果不一致,线上排查极难。
- 即使业务上认为“同一用户的时间肯定不同”,也要显式写
ORDER BY created_at, order_id防止毫秒级重复 -
PARTITION BY后没写ORDER BY不报错,但行为未定义——PostgreSQL 会警告,MySQL 8.0 默认按无序处理,结果不可控 - 聚合类窗口函数(如
SUM() OVER (PARTITION BY ...))可以不写ORDER BY,但只要涉及“位置”(第几行、前一行、后一行),就必须有确定排序
粒度保留不是靠技巧,而是靠理解窗口函数的“行上下文”本质:它不改变行数,只扩展信息。真正容易被忽略的,是那个看似可选的 ORDER BY —— 它不是锦上添花,而是稳定输出的底线。










