
为什么 OFFSET LIMIT 在大表上越来越慢
因为数据库要先扫过前面所有 OFFSET 行,哪怕你只想要 20 条。100 万行偏移?它真会老老实实数到第 1000020 行才开始取数据。SELECT * FROM orders ORDER BY id DESC LIMIT 20 OFFSET 1000000 这类语句在 MySQL 或 PostgreSQL 上执行时间可能从几毫秒跳到秒级,且随偏移量线性恶化。
常见错误现象:Query execution time spikes after page > 5000、slow query log shows high rows_examined。
- 别用
OFFSET做深分页,尤其是用户能手动输页码或滑动到底部无限加载的场景 - 排序字段必须有索引,且不能是
ORDER BY created_at这种高重复值字段(会导致索引失效或回表加重) - 如果业务允许,优先用「游标分页(cursor-based pagination)」替代页码分页
用 WHERE id
适用于按主键或唯一单调字段(如 id、created_at)倒序分页,比如“加载更多”类接口。核心是把“第 N 页”转化成“id 小于上一页最后一条的 id”。
示例:上一页最后返回的 id 是 98765,则下一页查询写成:SELECT * FROM posts WHERE id 。数据库直接走 <code>id 索引,不扫描无关行。
立即学习“go语言免费学习笔记(深入)”;
- 必须确保排序字段值全局唯一且单调(推荐用自增
id或ULID,慎用created_at) - 前端需保存并传递上一页末尾记录的游标值,不是页码;后端校验该值存在且类型正确(防止传
""或负数) - 正向翻页(回到上一页)要用
WHERE id > ? ORDER BY id ASC LIMIT 20,注意方向和排序要同步反转
如何安全支持“跳转任意页码”的需求
真要支持输入页码(比如后台管理),又不想拖垮 DB,就得绕开 OFFSET。最实用的是「延迟关联 + 覆盖索引」:先用索引查出 ID,再回表取完整数据。
MySQL 示例:SELECT p.* FROM posts p INNER JOIN (SELECT id FROM posts ORDER BY id DESC LIMIT 20 OFFSET 100000) t ON p.id = t.id ORDER BY p.id DESC。子查询只走索引,ID 列极小,快得多。
- PostgreSQL 可用
LATERAL JOIN或物化 CTE,但要注意OFFSET仍在子查询里,只是范围更小 - 对超大表(亿级),建议加缓存层:把「页码 → 起始 id 映射」用 Redis 按固定步长预计算(如每 1000 条存一个锚点)
- 永远限制最大可跳页码(如
max_page = 10000),并在 API 返回中明确告知前端当前是否已达边界
Go 服务层该怎么做分页参数校验和封装
别让脏数据进 SQL。Golang 接口收到 page 和 size 后,第一件事不是拼 SQL,而是做防御性检查。
示例校验逻辑:if size 100 { size = 20 }、if page 。游标模式下更要检查 <code>cursor 是否为合法整数或时间戳格式。
- 用结构体绑定参数时,给
page和size加validate:"min=1,max=100"标签(如用go-playground/validator) - 封装分页工具函数时,统一返回
next_cursor和has_more字段,不要暴露total_count(避免COUNT(*)全表扫) - 日志里记录实际生效的
limit和游标值,方便排查「为什么第 3 页数据重复」这类问题
真正麻烦的不是写对一次查询,而是让所有接口都遵守同一套游标规则、索引策略和错误处理路径。漏掉一个 ORDER BY 方向,或者某处忘了加索引,整条链路就退回 OFFSET 泥潭。











