核心思路是用row_number()按user_id分组、created_at降序排序取每组首行;需用coalesce处理空值,加id等唯一字段防重复;必须建立(user_id, created_at desc, id desc)联合索引提升性能。

用 ROW_NUMBER() 按用户+时间倒序标序号
核心思路是:对每个用户的所有订单,按下单时间从新到旧排序,取序号为 1 的那条。关键不是“最后一条”,而是“最新时间那条”——数据库里没有隐含的“末笔”概念,得靠时间字段显式定义。
假设表叫 orders,有字段 user_id、order_id、created_at:
SELECT *
FROM (
SELECT *,
ROW_NUMBER() OVER (
PARTITION BY user_id
ORDER BY created_at DESC, order_id DESC
) AS rn
FROM orders
) t
WHERE rn = 1;
注意点:
-
PARTITION BY user_id确保分组只在同用户内生效 -
ORDER BY created_at DESC是主排序,但若存在同一秒多单,必须加次级排序(如order_id DESC)避免ROW_NUMBER()非确定性结果 - 别用
RANK()或DENSE_RANK()——它们会对相同时间的订单给相同序号,导致“末笔”查出多条
遇到 created_at 为空或重复怎么办
真实数据常有脏数据:部分订单没时间戳,或多个订单共用同一毫秒级时间。这时候直接 ORDER BY created_at DESC 会出问题。
稳妥做法是补一个确定性兜底排序:
- 先用
COALESCE(created_at, '1970-01-01')把空值统一归到最早,避免它们被误选为“末笔” - 再叠加
order_id DESC(前提是order_id是自增或时间相关递增) - 如果连
order_id都不可靠,考虑加个id(主键)作为最终排序依据,确保每行唯一可比
示例修正:
ROW_NUMBER() OVER ( PARTITION BY user_id ORDER BY COALESCE(created_at, '1970-01-01') DESC, id DESC ) AS rn
性能差?检查是否建了联合索引
窗口函数本身不慢,慢在没索引支撑的 PARTITION BY + ORDER BY 组合。执行计划里如果看到 Sort 节点占大头,基本就是缺索引。
针对上面查询,最有效的索引是:
CREATE INDEX idx_user_created_id ON orders (user_id, created_at DESC, id DESC);
注意三点:
- 顺序必须是
user_id在前(对应PARTITION BY) -
created_at DESC要和ORDER BY方向一致,否则无法跳过排序 - 最后加上
id(或order_id)是为了覆盖查询,避免回表
MySQL 8.0 之前没法用窗口函数?用 LEFT JOIN 自关联模拟
老版本 MySQL 或某些 OLAP 引擎不支持窗口函数时,得换思路:找“不存在更新订单”的订单。
逻辑是:对每个 o1 订单,查是否存在同用户、时间更晚的 o2 订单;找不到,o1 就是末笔。
SELECT o1.* FROM orders o1 LEFT JOIN orders o2 ON o1.user_id = o2.user_id AND o2.created_at > o1.created_at WHERE o2.order_id IS NULL;
这个写法看着简单,但实际容易翻车:
- 空值参与
>比较结果为UNKNOWN,导致漏数据 —— 必须提前过滤或用COALESCE - 没有索引时,复杂度是 O(n²),千万级订单基本跑不动
- 如果时间精度低(比如只到天),且同天多单,仍可能返回多条,得额外加
MAX(id)子查询收口
真要兼容老版本,优先升级;实在不行,就老老实实加索引 + 控制数据质量。
窗口函数不是银弹,但只要 PARTITION BY 字段区分度高、时间字段靠谱、索引到位,“末笔订单”这种需求其实非常干净。最容易被忽略的,其实是时间字段本身的可信度 —— 别急着写 SQL,先看几条脏数据长什么样。










