不能直接用 GROUP BY + MAX(time) 获取完整记录,因为非聚合字段未包含在 GROUP BY 中时结果不可靠;应使用 ROW_NUMBER() 窗口函数按 user_id 分组、created_at 降序编号后取 rn=1 的行。

为什么不能直接用 GROUP BY + MAX(time) 拿到完整记录
很多人试过先 GROUP BY user_id,再用 MAX(created_at) 找出每个用户的最大时间戳,但发现拿不到对应那条记录的 action、ip 等字段——因为 SQL 标准里,非聚合字段和 GROUP BY 字段不一致时,结果不可靠(MySQL 5.7+ 默认报错,PostgreSQL 直接拒绝)。你真正要的是“每组里某条完整行”,不是“每组某个字段的最大值”。
推荐做法:用窗口函数 ROW_NUMBER() 排序后取 Top 1
这是最直观、可读性好、且兼容多数现代数据库(PostgreSQL / SQL Server / MySQL 8.0+ / Oracle)的解法。核心思路是:按 user_id 分组,按时间倒序编号,取编号为 1 的那条。
SELECT user_id, action, created_at, ip
FROM (
SELECT *,
ROW_NUMBER() OVER (PARTITION BY user_id ORDER BY created_at DESC) AS rn
FROM user_actions
) t
WHERE rn = 1;
- 如果同一用户有两条操作时间完全相同,
ROW_NUMBER()会 arbitrarily 选一条(无稳定排序时);需要确定性结果,加一个二级排序,比如ORDER BY created_at DESC, id DESC -
RANK()或DENSE_RANK()不适合这里——它们会对并列时间分配相同排名,导致返回多行 - 旧版 MySQL(
MySQL 5.7 及更早版本的替代方案:关联子查询
用子查询先找出每个用户的最大时间,再和原表联查匹配完整行。注意必须确保 (user_id, created_at) 组合唯一,否则仍可能拿到多条。
SELECT a1.user_id, a1.action, a1.created_at, a1.ip FROM user_actions a1 INNER JOIN ( SELECT user_id, MAX(created_at) AS max_time FROM user_actions GROUP BY user_id ) a2 ON a1.user_id = a2.user_id AND a1.created_at = a2.max_time;
- 如果存在时间相同但动作不同的多条记录,这个写法会全部返回——它本质是“最后一次操作的时间点”,不是“最后一次操作的单条记录”
- 加索引能显著提升性能:
CREATE INDEX idx_user_time ON user_actions(user_id, created_at); - 避免在子查询里用
ORDER BY ... LIMIT 1去“取最新一条”,因为 MySQL 5.7 不允许在子查询中对非相关字段使用 LIMIT(会报错或行为异常)
JOIN 写法本身不是问题,但容易误用成笛卡尔积
有人试图用 LEFT JOIN 自连接来排除“非最新”的记录,比如让 a1.created_at ,再查 <code>a2.id IS NULL 的行。这种写法逻辑正确但极易出错:
- 没加
WHERE a1.user_id = a2.user_id条件?立刻变成全表交叉,数据量大时直接卡死 - 时间字段为
NULL时,判断失效,这些记录会被漏掉 - 即使写对了,执行计划常走嵌套循环,比窗口函数或子查询慢得多
真要用自连接,务必加上 user_id 等值条件,并测试 EXPLAIN 看是否走了索引。
实际业务里,时间字段精度(秒 vs 毫秒)、是否允许为空、是否有唯一约束,都会影响最终选哪种方案——别只抄代码,先看自己表的结构和数据分布。

















