必须在order by末尾添加唯一列(如id)确保排序确定性,否则created_at等重复字段会导致分页漂移;row_number()需套子查询并用between过滤rn别名,且须建对应联合索引。

ORDER BY 不唯一是漂移的根源
分页重复或漏数据,99%不是 LIMIT 写错了,而是 ORDER BY 字段存在重复值。比如只按 created_at 排序,但多条记录时间完全相同,数据库不保证它们的相对顺序——翻页时,这批“同时间”的行可能被前后页反复切分。
验证方法很简单:SELECT created_at, COUNT(*) FROM orders GROUP BY created_at ORDER BY COUNT(*) DESC LIMIT 5,如果某时间点出现几百上千条,就踩中雷区了。
- 唯一性补救必须加在
ORDER BY最末:如ORDER BY created_at DESC, id DESC - 不能用
updated_at替代id,因为两个字段都可能重复,仍不稳定 - 联合索引
(created_at DESC, id DESC)要同步建立,否则排序走全表扫描
ROW_NUMBER() 必须套子查询才能过滤
ROW_NUMBER() 生成的行号(比如别名 rn)属于投影阶段,在 SQL 执行顺序中晚于 WHERE,所以直接写 WHERE rn BETWEEN 101 AND 120 会报错 Invalid column name 'rn'。
- 正确写法只能是两层嵌套:
SELECT * FROM (SELECT *, ROW_NUMBER() OVER (ORDER BY created_at DESC, id DESC) AS rn FROM orders) t WHERE t.rn BETWEEN 101 AND 120 - 不能用
WHERE rn > 100 LIMIT 20,优化器可能提前截断,导致漏数据 - CTE 写法也合法,但子查询更直观、兼容性更好(MySQL 8.0+/PostgreSQL/SQL Server 都支持)
窗口函数分页不是万能,高偏移量下仍有隐患
用 ROW_NUMBER() 替代 LIMIT OFFSET 确实避免了跳行开销,但若分页深度极大(比如第 10000 页),子查询仍要对全量排序结果编号,内存和 CPU 压力不小。
- 真正稳定的方案是游标分页:
WHERE (created_at, id) ,依赖上一页最后一条的完整排序键 - 窗口函数适合中等深度分页(前几百页),且要求排序字段有高效索引支撑
- 如果业务允许,优先把分页逻辑下沉到应用层缓存,避免每次请求都重算窗口
别名和索引失效是隐形坑
很多人写了 ROW_NUMBER() OVER (...) AS rn,外层却写成 WHERE row_num BETWEEN ...,名字不一致直接报错;还有人建了索引但 EXPLAIN 显示 key 为 NULL,其实是 WHERE 条件字段没包含在联合索引里。
-
rn是什么,外层就必须严格用什么,大小写、下划线都不能错 - 索引必须覆盖所有参与排序和过滤的字段,例如带
WHERE status = 'active',索引就得是(status, created_at DESC, id DESC) - 字段类型要严格匹配:
TIMESTAMP字段不能建在DATETIME索引上,否则隐式转换让索引失效
窗口函数能解决排序漂移,但前提是排序本身稳定、索引真正生效、语法写得严丝合缝——少一个条件,就可能回到“第 N 页又看到上一页的 ID”这种老问题。











