必须在JOIN前用ROW_NUMBER()对右表分组排序并筛选rn=1行,且过滤条件(如is_deleted=0)和排序字段(updated_at DESC, id DESC)需置于CTE内;错误地将rn=1放WHERE会破坏LEFT JOIN语义。

JOIN前用ROW_NUMBER()截断右表重复行
直接在JOIN条件里加WHERE或MAX()无法解决“一对多导致虚增”,必须在关联前就让右表每组只有一行。核心是把右表用ROW_NUMBER()打标,再在ON里筛出rn = 1的行。
常见错误是把rn = 1写在WHERE里——这会把左表没匹配上的行也过滤掉,破坏LEFT JOIN语义。
- 必须用CTE或子查询包装右表,并在
ON中写AND r.rn = 1 - 排序字段要包含时间+主键,例如
ORDER BY updated_at DESC, id DESC,避免时间相同时结果不确定 - 如果右表有逻辑删除字段(如
is_deleted = 0),过滤必须放在CTE内部,否则已删记录仍参与排序
WITH latest_order_item AS (
SELECT *,
ROW_NUMBER() OVER (
PARTITION BY order_id
ORDER BY updated_at DESC, id DESC
) AS rn
FROM order_items
WHERE is_deleted = 0 -- ✅ 这里过滤
)
SELECT o.*, i.item_name
FROM orders o
LEFT JOIN latest_order_item i
ON o.id = i.order_id AND i.rn = 1; -- ✅ AND 在 ON 里
MySQL 5.7 或不支持窗口函数时怎么处理
旧版MySQL不能用ROW_NUMBER(),只能靠自连接或相关子查询模拟“找不出更新记录”的逻辑。性能差,但语义准确。
容易漏掉的是时间相等时的二级比较——比如同秒插入多条,只比时间会漏判,必须加上id或created_at以外的唯一字段。
- 自连接写法中,
OR (t2.time = t1.time AND t2.id > t1.id)这个括号不能省,否则逻辑优先级错乱 -
WHERE t2.id IS NULL才是“最新”的本质定义,不是“时间最大” - 索引必须是
(order_id, updated_at, id),否则全表扫描
SELECT o.*, i1.item_name
FROM orders o
LEFT JOIN order_items i1 ON o.id = i1.order_id
LEFT JOIN order_items i2
ON i1.order_id = i2.order_id
AND (i2.updated_at > i1.updated_at
OR (i2.updated_at = i1.updated_at AND i2.id > i1.id))
WHERE i2.id IS NULL;
别用GROUP BY + MAX(time)去关联
这种写法看似简洁,但实际会丢字段、漏数据、错关联。它只返回时间,拿不到整行;且当多条记录时间相同时,JOIN可能匹配到任意一条,业务上不可控。
更隐蔽的问题是:如果时间字段含NULL,MAX(time)直接忽略这些行,导致本该保留的记录被跳过。
-
SELECT user_id, MAX(created_at) FROM orders GROUP BY user_id→ 只能拿到两列,其他字段全丢了 - 后续再
JOIN原表取完整字段?那又回到“多条时间相同怎么选”的老问题 - 想补救?加
DISTINCT或GROUP BY在JOIN后——但虚增已经发生,聚合结果已失真
索引和NULL值这两个点最容易被忽略
没索引时,ROW_NUMBER()分组排序可能触发文件排序,大表直接卡死;而NULL值在排序中的位置因数据库而异,MySQL默认排最后,PostgreSQL默认排最前,不显式控制就会拿错“最新”。
- 联合索引必须以
PARTITION BY字段开头,例如(user_id, updated_at DESC, id) - MySQL不支持
NULLS LAST,得改写成ORDER BY updated_at IS NULL, updated_at DESC - 跨时区系统务必统一时间基准,比如
created_at AT TIME ZONE 'UTC'再排序

















