MySQL 8.0+ 中必须显式指定窗口帧 ROWS BETWEEN UNBOUNDED PRECEDING AND UNBOUNDED FOLLOWING,否则 LAST_VALUE 默认仅作用于当前行;FIRST_VALUE 同理需明确帧范围,且 ORDER BY 必须唯一(如 created_at, order_id),才能正确取用户首单和末单。

MySQL 8.0+ 怎么用 FIRST_VALUE 和 LAST_VALUE 取首单末单
直接说结论:不能无脑套用 FIRST_VALUE/LAST_VALUE,默认窗口帧(ROWS BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW)会导致 LAST_VALUE 在每行都返回当前行,而不是组内真正的末单。必须显式指定窗口帧。
假设订单表 orders 有字段:user_id、order_id、created_at,按时间取首单和末单:
SELECT
user_id,
order_id,
created_at,
FIRST_VALUE(order_id) OVER (
PARTITION BY user_id
ORDER BY created_at, order_id
ROWS BETWEEN UNBOUNDED PRECEDING AND UNBOUNDED FOLLOWING
) AS first_order_id,
LAST_VALUE(order_id) OVER (
PARTITION BY user_id
ORDER BY created_at, order_id
ROWS BETWEEN UNBOUNDED PRECEDING AND UNBOUNDED FOLLOWING
) AS last_order_id
FROM orders;
-
ORDER BY必须明确——仅靠created_at可能有重复,补上order_id保证排序唯一性 -
ROWS BETWEEN UNBOUNDED PRECEDING AND UNBOUNDED FOLLOWING是关键,否则LAST_VALUE行为不符合直觉 - 该写法在 MySQL 8.0+、PostgreSQL、SQL Server、Oracle 均可用;SQLite 不支持
LAST_VALUE
兼容老版本 MySQL(5.7 及以下)怎么绕过 FIRST_VALUE/LAST_VALUE
没有窗口函数?用自关联或子查询模拟。核心思路是:对每个 user_id,找 MIN(created_at) 对应的单条记录(首单),再找 MAX(created_at) 对应的单条记录(末单)。
推荐用 JOIN + GROUP BY 配合 MIN/MAX 时间戳匹配(避免 IN 子查询在重复时间下误匹配多行):
SELECT o1.user_id, o1.order_id AS first_order_id, o1.created_at AS first_created_at, o2.order_id AS last_order_id, o2.created_at AS last_created_at FROM ( SELECT user_id, MIN(created_at) AS min_time, MAX(created_at) AS max_time FROM orders GROUP BY user_id ) t JOIN orders o1 ON o1.user_id = t.user_id AND o1.created_at = t.min_time JOIN orders o2 ON o2.user_id = t.user_id AND o2.created_at = t.max_time;
- 如果同一用户在相同
created_at有多单,此写法可能返回笛卡尔积(比如 2 个首单 × 3 个末单 = 6 行)。此时需加ORDER BY ... LIMIT 1子查询,或改用ROW_NUMBER()模拟(需变量或临时表) - 索引建议:在
(user_id, created_at)上建联合索引,加速分组与连接
FIRST_VALUE 和 LAST_VALUE 的常见翻车点
不是语法写错,而是语义理解偏差导致结果诡异:
- 忘记
PARTITION BY→ 全表当成一个组,所有用户共享同一个“首单” - 漏写
ORDER BY→ 窗口函数行为未定义,MySQL 可能报错,其他数据库可能随机返回 - 用
RANGE替代ROWS帧 → 若created_at有重复值,RANGE BETWEEN ...会把同时间所有行纳入当前帧,LAST_VALUE可能返回同时间的任意一单,而非逻辑上“最后插入”的那单 - 在聚合查询里混用窗口函数 → 如
SELECT user_id, COUNT(*), FIRST_VALUE(...) OVER (...) FROM ... GROUP BY user_id,会报错或结果不可控(窗口函数应在分组后计算)
要不要用 ROW_NUMBER() 替代 FIRST_VALUE/LAST_VALUE
可以,而且更可控。尤其当你需要首单/末单的完整行数据(不止一个字段),ROW_NUMBER() 配合条件过滤比多次调用 FIRST_VALUE 更清晰:
WITH ranked AS (
SELECT *,
ROW_NUMBER() OVER (PARTITION BY user_id ORDER BY created_at, order_id) AS rn_asc,
ROW_NUMBER() OVER (PARTITION BY user_id ORDER BY created_at DESC, order_id DESC) AS rn_desc
FROM orders
)
SELECT
user_id,
MAX(CASE WHEN rn_asc = 1 THEN order_id END) AS first_order_id,
MAX(CASE WHEN rn_desc = 1 THEN order_id END) AS last_order_id
FROM ranked
GROUP BY user_id;
- 好处:逻辑直观,容易扩展(比如取第 2 单、倒数第 2 单)
- 注意:两次
ORDER BY方向要一致(升序+降序),否则rn_desc = 1可能不对应最晚时间(例如时间相同但order_id排序方向不一致) - 性能:比纯
FIRST_VALUE多一次排序,但现代数据库优化器通常能复用排序结果
真正容易被忽略的是时间精度和时区。如果 created_at 是 DATETIME 且没设时区,跨系统同步或夏令时切换时,MIN/MAX 或排序可能出偏移——首单末单判断依赖的是业务上“谁先发生”,不是数据库存储字面值。











