LIMIT 和 OFFSET
LIMIT 限制最多返回多少行,OFFSET 跳过结果开头的若干行。它们常用于列表分页、预览数据和“取前 N 条”:
SELECT order_id, customer_id, created_at
FROM orders
ORDER BY created_at DESC, order_id DESC
LIMIT 20 OFFSET 40;
这条查询先按创建时间和订单编号倒序排列,再跳过 40 行,最多返回 20 行。可以理解为每页 20 条时读取第 3 页。
LIMIT 和 OFFSET 并不是所有数据库采用的标准拼写。本文先讲通用语义,再说明常见替代语法。
先排序,再限制行数
没有 ORDER BY 时,数据库可以任意选择符合条件的行:
-- 只保证最多 10 行,不保证是哪 10 行
SELECT order_id, created_at
FROM orders
LIMIT 10;
若需求是“最新 10 张订单”,必须写出排序:
SELECT order_id, created_at
FROM orders
ORDER BY created_at DESC, order_id DESC
LIMIT 10;
逻辑上,查询先形成并排序完整结果,再应用 OFFSET 和 LIMIT。优化器可以使用索引或 Top-N 算法避免真的处理全部数据,但返回语义必须与这个顺序一致。
LIMIT 的基本用法
SELECT product_id, product_name, price
FROM products
ORDER BY product_id
LIMIT 5;
结果最多 5 行;如果满足条件的行不足 5 行,就返回实际行数。LIMIT 0 通常返回空结果,但仍可能完成语法分析、权限检查和查询规划。
部分数据库允许 LIMIT ALL 表示不限制行数。可维护代码通常直接省略 LIMIT,因为 ALL 不是所有产品都支持。
应用程序应把限制值作为数值参数绑定,并在服务端设置合理上限:
LIMIT :page_size
驱动和数据库是否允许在此位置绑定参数存在差异。不要把未经校验的用户文本直接拼进 SQL。
OFFSET 的基本用法
OFFSET n 跳过排序结果中的前 n 行:
SELECT product_id, product_name
FROM products
ORDER BY product_id
LIMIT 20 OFFSET 20;
通常:
offset = (page_number - 1) * page_size
页码从 1 开始、每页 20 条时,第 2 页的偏移量是 20。应用层计算时应防止负数、整数溢出和过大的页码。
只使用 OFFSET 而不限制行数,意味着跳过开头后返回所有剩余行,通常不适合面向用户的分页接口。
常见方言
PostgreSQL、MySQL、SQLite 常见写法
LIMIT 20 OFFSET 40
MySQL 还支持 LIMIT 40, 20,其中第一个数字是偏移量、第二个才是数量。为了减少参数顺序误读,优先使用含 OFFSET 关键字的形式。
标准 SQL 风格
OFFSET 40 ROWS
FETCH NEXT 20 ROWS ONLY
只取开头若干行时可以写:
FETCH FIRST 20 ROWS ONLY
数据库对 FIRST、NEXT、ROW、ROWS 的允许组合可能不同。
SQL Server
现代 SQL Server 常使用:
ORDER BY created_at DESC, order_id DESC
OFFSET 40 ROWS
FETCH NEXT 20 ROWS ONLY;
OFFSET ... FETCH 通常要求存在 ORDER BY。只取前若干行也常使用 TOP:
SELECT TOP (20) order_id, created_at
FROM orders
ORDER BY created_at DESC, order_id DESC;
迁移 SQL 时,应同时检查语法、参数绑定和执行计划,不要只替换关键字。
分页排序必须稳定
下面的排序可能存在大量并列值:
ORDER BY created_at DESC
LIMIT 20 OFFSET 20
如果第 20 行附近有多条相同时间的记录,数据库可以在并列行之间采用任意顺序,导致某条记录在两页重复或完全漏掉。应增加唯一键作为最后的排序条件:
ORDER BY created_at DESC, order_id DESC
LIMIT 20 OFFSET 20
完整排序键组合需要唯一且在分页期间保持不变。若排序列本身会频繁更新,即使有唯一并列键,记录也可能因位置变化跨页移动。
OFFSET 分页的优点与局限
基于页码的分页容易理解,也允许用户直接跳到指定页。它适合:
- 数据量较小;
- 页数不深;
- 后台报表或低频查询;
- 必须支持跳页的界面;
- 数据在分页期间基本不变。
但它有两个重要局限。
深分页成本
OFFSET 100000 不表示数据库可以瞬间从第 100001 行开始。数据库通常仍要定位或读取前面的大量行,然后丢弃它们:
SELECT order_id, created_at
FROM orders
ORDER BY created_at DESC, order_id DESC
LIMIT 20 OFFSET 100000;
偏移越深,工作量通常越大。即使索引支持排序,被跳过的索引项也往往仍需扫描。
并发变更导致漂移
读取第 1 页后,如果前面插入了一条新记录,第 2 页的偏移位置就会整体后移,可能重复看到第 1 页末尾的记录。若前面删除记录,则可能漏掉一条。
事务隔离级别较高或数据库快照可以让多次查询看到同一版本,但长事务也会带来资源与并发成本,不能作为所有分页接口的默认方案。
基于游标的分页
游标分页也称键集分页。客户端不提交页码,而是提交上一页最后一行的完整排序键:
SELECT order_id, customer_id, created_at
FROM orders
WHERE
created_at < :last_created_at
OR (created_at = :last_created_at AND order_id < :last_order_id)
ORDER BY created_at DESC, order_id DESC
LIMIT 20;
排序是“时间倒序、编号倒序”,因此下一页读取比上一页末行更小的键。部分数据库支持等价的行值比较:
WHERE (created_at, order_id) < (:last_created_at, :last_order_id)
ORDER BY created_at DESC, order_id DESC
LIMIT 20;
行值比较及其空值行为存在方言差异,展开条件通常更容易跨数据库使用。
游标分页的优势是:
- 可以从索引边界继续查找,不必丢弃大量前置行;
- 前面新增或删除记录时,后续页面通常不易漂移;
- 性能不随页码线性恶化。
它的限制是:
- 不适合直接跳到任意页;
- 游标必须包含完整排序键;
- 向前翻页需要反转比较方向和显示顺序;
- 排序键更新后仍可能发生移动;
- 混合升降序、
NULL和复杂表达式会让边界条件更复杂。
实际 API 常把排序键编码成不透明游标,客户端只负责原样传回。服务端仍应校验游标结构,并使用绑定参数。
为什么游标必须包含唯一键
如果游标只有 created_at:
WHERE created_at < :last_created_at
上一页末尾可能还有其他相同时间的记录,它们会因严格小于条件而被遗漏。加入 order_id 后,可以准确描述上一页终点:
(created_at, order_id)
排序项、比较项、顺序方向和空值规则必须完全一致。任何一个缺失,都可能造成重复或遗漏。
WITH TIES:保留边界并列行
部分数据库支持标准风格的 WITH TIES:
SELECT product_id, score
FROM products
ORDER BY score DESC
FETCH FIRST 10 ROWS WITH TIES;
如果第 10 名的分数有并列,结果会包含所有同分记录,因此实际行数可能超过 10。它适合排行榜的公平边界,不适合必须固定页面大小的接口。
是否支持 WITH TIES 及具体语法取决于数据库版本。有些产品通过 TOP ... WITH TIES 提供类似能力。
分页与总记录数
分页列表经常还要展示总数。最直接的方法是单独统计:
SELECT COUNT(*)
FROM orders
WHERE status = 'paid';
列表查询与计数查询必须使用相同筛选条件,否则页数会不一致。复杂连接中还要确认统计的是连接行数还是业务实体数,必要时使用 COUNT(DISTINCT business_key)。
总数查询可能和列表查询一样昂贵,而且在并发修改下,两条语句的结果不一定来自同一数据快照。很多产品可以只返回“是否还有下一页”:请求 page_size + 1 行,若多出一行就设置 has_more = true,实际响应只返回前 page_size 行。
窗口函数也能在每一行附带总数:
SELECT
order_id,
created_at,
COUNT(*) OVER () AS total_count
FROM orders
WHERE status = 'paid'
ORDER BY created_at DESC, order_id DESC
LIMIT 20 OFFSET 0;
这种写法方便,但数据库通常仍需处理全部匹配行才能得出总数;应根据执行计划和接口需求选择。
限制修改行数要特别谨慎
某些数据库允许在 UPDATE 或 DELETE 中使用 LIMIT、TOP 或其他行数限制。若没有确定排序和唯一条件,“任意修改 N 行”通常不可预测,而且各产品语法差异很大。
需要分批修改时,更稳妥的思路是先在受控查询中按唯一键选出目标,再按键执行修改,并考虑锁、并发和重试。不能把查询分页语法直接假定为修改语句的通用能力。
常见错误
没有排序就取前 N 行
只能保证数量,不能保证具体记录。先定义“前”的业务含义,再写 ORDER BY。
只按非唯一列分页
并列行会在页面之间不稳定。使用完整且唯一的排序键。
对深分页性能抱有错误预期
OFFSET 跳过的行并非没有成本。连续滚动、时间线和大数据列表优先评估游标分页。
游标条件与排序方向不一致
倒序列表通常使用“小于上一页末键”读取下一页;升序则通常使用“大于”。混合方向必须逐项推导,不能机械套用。
无限制接受客户端页大小
过大的 LIMIT 会增加数据库、网络和应用内存压力。服务端应设置默认值和最大值。
检查清单
- 是否有明确、稳定且唯一的
ORDER BY; - 页大小、页码和偏移量是否经过范围校验;
- 是否需要跳页,还是更适合游标分页;
- 深分页是否检查过执行计划与索引;
- 游标是否包含完整排序键并正确处理方向;
- 并发插入、删除或更新时允许怎样的页面变化;
- 是否真的需要精确总数;
- 目标数据库使用
LIMIT、FETCH还是TOP。
分页不是简单地截取数组。先定义稳定顺序,再根据跳页需求、数据规模和并发变化选择 OFFSET 或游标,才能同时保证正确性与性能。