热点





PHP SaaS 列表分页使用 OFFSET 有什么问题?什么时候应该改用游标分页

发表时间: 2026-10-24 20:00:01   人气:11 次   

在 PHP SaaS 系统中,后台订单、客户、账单、日志、消息、操作记录等列表几乎都离不开分页。最容易实现的方式是 LIMIT 配合 OFFSET,例如 LIMIT 10000, 20,数据库返回第10001至10020条记录。

这种分页方式本身没有错。对于数据量不大、用户主要浏览前几页、同时还需要支持“跳到第 N 页”的后台管理系统,OFFSET 依然是非常实用的方案。真正的问题出现在数据规模、访问频率和分页深度不断增加以后:OFFSET 越大,数据库为了找到目标位置,通常需要先定位并跳过前面的记录,即使这些记录最终不会返回给应用程序。这样一来,深分页的工作量会随着 offset 增加。

例如:

SELECT id, name, email
FROM users
WHERE tenant_id = 't1'
ORDER BY id
LIMIT 10000, 20;

如果存在合适的 (tenant_id, id) 索引,MySQL 并不一定需要扫描整张表,更不能简单地说这是“全表扫描”。但它仍然需要沿着索引找到符合条件的记录,并跳过前面的10000条,最后才能取得20条真正需要返回的数据。offset 越深,需要处理的前置记录通常越多,这就是 OFFSET 分页最核心的成本。

MySQL 可以在适当的索引条件下直接利用索引满足 ORDER BY,从而避免额外排序;如果无法通过索引完成排序,则可能出现 Using filesort。因此,看到 OFFSET 就断言“索引失效”也是不准确的。真正应该看的,是实际执行计划,而不是分页方式本身。

对于 SaaS 系统,最常见的正确索引反而非常明确。例如查询固定租户的数据,并按照 ID 排序:

CREATE INDEX idx_users_tenant_id
ON users (tenant_id, id);

对应查询:

SELECT id, name, email
FROM users
WHERE tenant_id = ?
ORDER BY id
LIMIT 10000, 20;

这个索引能够同时帮助 MySQL 缩小租户范围和维持 ID 的排序顺序。是否真正采用该索引,则应该通过 EXPLAIN 或 EXPLAIN ANALYZE 验证,而不是凭经验猜测。索引本身也不是越多越好,因为每增加一个索引,INSERT、UPDATE 和 DELETE 都需要维护额外的索引结构。

OFFSET 的第二个问题是深分页的可预测性。假设一个 SaaS 后台有几十万甚至几百万条订单,用户访问第一页时:

LIMIT 50 OFFSET 0

通常没有什么压力。但如果某个报表页面请求:

LIMIT 50 OFFSET 500000

数据库仍然需要找到前面的大量符合条件的记录,然后跳过它们。真正返回给 PHP 的仍然只有50条,但数据库承担的工作并没有因为最终只返回50条而变成50条。

这也是为什么 OFFSET 分页最容易在测试环境里表现良好,却在生产环境深分页、复杂筛选和高并发条件下逐渐暴露问题。

不过,原文章中“查询耗时呈指数级上升”的说法应该删除。OFFSET 增大带来的成本通常更接近随需要跳过的记录数增加而增加,并不是一个可以普遍宣称的指数函数。具体耗时还受到索引、数据分布、磁盘缓存、返回字段、WHERE 条件、JOIN、排序方式以及并发负载影响。

游标分页,也经常被称为 Keyset Pagination,其思路完全不同。它不是告诉数据库“跳过前面10000条”,而是告诉数据库“从上一页最后一条记录之后继续找”。

例如按照 ID 升序:

SELECT id, name, email
FROM users
WHERE tenant_id = ?
AND id > ?
ORDER BY id
LIMIT 20;

假设上一页最后一条记录的 ID 是 10520,下一次查询实际上是在索引中从这个位置继续向后读取,而不是重新计算前面已经浏览过的记录。

如果索引为:

CREATE INDEX idx_users_tenant_id
ON users (tenant_id, id);

那么查询:

WHERE tenant_id = ?
AND id > ?
ORDER BY id
LIMIT 20

就非常适合使用索引范围扫描。MySQL 官方文档也列举了使用索引处理 WHERE ... > constant ORDER BY ... 以及复合索引配合固定 WHERE 条件的情况。

因此,游标分页真正的优势不是“数据库不会扫描”,而是把分页从“跳过 N 条记录”变成“从一个已知索引位置继续读取 N 条记录”。

这两个查询的差异非常直观:

-- OFFSET
SELECT *
FROM users
WHERE tenant_id = ?
ORDER BY id
LIMIT 20 OFFSET 100000;

和:

-- Keyset / Cursor
SELECT *
FROM users
WHERE tenant_id = ?
AND id > ?
ORDER BY id
LIMIT 20;

前者的分页深度越深,需要处理的前置记录越多;后者的工作量主要与本次需要取得的记录数量以及索引定位成本有关,因此特别适合“下一页”“加载更多”“无限滚动”这种访问模式。

这里还有一个经常被 PHP 开发者忽略的问题:游标分页并不是 MySQL 的“数据库游标”。这里的 Cursor 实际上只是应用层保存的分页边界,例如最后一条记录的 ID、时间戳和 ID 组合。数据库执行的仍然是普通 SELECT。

例如:

function getUsers(PDO $pdo, string $tenantId, ?int $cursor, int $limit = 20): array
{
$limit = max(1, min($limit, 100));

if ($cursor === null) {
$sql = "
SELECT id, name, email
FROM users
WHERE tenant_id = ?
ORDER BY id ASC
LIMIT {$limit}
";

$stmt = $pdo->prepare($sql);
$stmt->execute([$tenantId]);
} else {
$sql = "
SELECT id, name, email
FROM users
WHERE tenant_id = ?
AND id > ?
ORDER BY id ASC
LIMIT {$limit}
";

$stmt = $pdo->prepare($sql);
$stmt->execute([$tenantId, $cursor]);
}

return $stmt->fetchAll(PDO::FETCH_ASSOC);
}

这里有一个实际开发细节值得注意:PHP PDO 对 LIMIT 参数的处理方式和数据库驱动有关,因此工程上经常先在 PHP 中严格限制 $limit 的范围,然后将经过验证的整数放入 SQL,而不是把用户提交的字符串直接拼进去。

下一页的游标应该来自上一页最后一条实际返回的记录:

$last = end($users);

if ($last !== false) {
$nextCursor = (int)$last['id'];
}

而不是使用类似 $users[-1] 这样的写法,因为 PHP 数组并不因为 fetchAll() 返回结果就自动拥有 Python 风格的负数索引。

如果排序字段不是唯一值,事情就会复杂一些。

例如:

ORDER BY created_at DESC

大量订单可能拥有完全相同的 created_at。此时仅仅把时间戳作为游标并不可靠,因为可能出现重复记录或者漏记录。

更稳妥的方式是使用时间戳加唯一 ID:

SELECT id, created_at, amount
FROM orders
WHERE tenant_id = ?
AND (
created_at < ?
OR (created_at = ? AND id < ?)
)
ORDER BY created_at DESC, id DESC
LIMIT 50;

对应索引:

CREATE INDEX idx_orders_tenant_created_id
ON orders (tenant_id, created_at, id);

这里的 id 是稳定的唯一排序键,而 created_at 提供主要的时间顺序。对于 SaaS 订单、日志、审计记录等数据,这种复合游标通常比单独使用时间戳更加可靠。

数据发生变化时,游标分页也不是天然“保证一致性”。

例如用户读取第一页以后,系统又插入了一批新订单。如果采用“最新数据优先”的排序方式,新插入的记录可能出现在第一页顶部,而下一页继续按照游标读取时,用户看到的是当前数据集,而不是一个永远不变的数据库快照。

删除记录通常不会导致游标本身失效,因为游标保存的是一个值,而不是某个数据库内部行指针。上一页最后一条记录即使随后被删除,只要分页条件仍然使用它的 ID 作为边界,数据库仍然可以从这个边界继续查询。

如果业务要求用户在整个分页过程中看到一个严格稳定的快照,那么问题就已经不再只是“OFFSET 还是 Cursor”。这需要考虑事务隔离级别、快照生命周期或者业务层面的版本边界。单纯增加一个 version 字段,并不会自动让整个分页过程获得数据库快照。

还有一个常见误区是认为“高并发写入时游标分页能够减少锁竞争”。普通 InnoDB 一致性读的 SELECT 通常不会因为读取数据就像 SELECT ... FOR UPDATE 那样主动锁住普通记录。因此,不能把 OFFSET 的性能问题简单描述成“SELECT * 导致行锁等待”。

真正应该区分的是:普通 SELECT 的扫描成本、排序成本、Buffer Pool 和磁盘 I/O,以及业务代码是否使用了 FOR UPDATE、显式事务或其他锁定读取方式。游标分页的主要优势是减少深分页时无意义的扫描,而不是一个通用的“解决锁竞争”机制。

那么,什么时候应该从 OFFSET 改成游标分页?

没有一个“超过100万条就必须改”的绝对数字。100万条数据的表,如果用户永远只看前几页,而且查询有合适索引,OFFSET 完全可能工作得很好;反过来,一张只有几十万条数据的表,如果后台经常出现几十万 offset 的深分页,同样可能产生明显问题。

更实际的判断标准是分页深度和访问模式。

如果后台管理系统需要“第1页、第2页、第3页”,并且偶尔允许用户跳到第100页,OFFSET 很方便,因为页码和 offset 之间存在直接关系。

如果业务主要是“下一页”“加载更多”“无限滚动”,那么游标分页通常更加自然。

如果一个 API 经常出现:

offset=50000
offset=100000
offset=500000

就应该认真检查执行计划和实际耗时,而不是继续单纯增加数据库硬件。

如果排序方式是:

ORDER BY created_at DESC, id DESC

并且数据持续增长,那么采用 (tenant_id, created_at, id) 这样的索引和 Keyset Pagination,通常比无限增加 OFFSET 更适合长期运行。

对于 SaaS 系统,还有一个非常重要的原则:不要把“分页算法”与“租户索引”分开考虑。

例如:

WHERE tenant_id = ?
ORDER BY id DESC
LIMIT 50;

最直接的索引通常就是:

(tenant_id, id)

而不是单独给 tenant_id 建一个索引,再给 id 建另一个索引,然后期待优化器自动组合出最理想的分页路径。

同样,如果按照时间排序:

WHERE tenant_id = ?
ORDER BY created_at DESC, id DESC

则应该围绕实际查询模式设计:

(tenant_id, created_at, id)

具体是否需要覆盖索引,则应该根据 SELECT 的字段、数据量和执行计划决定。没有必要为了分页而把整张表的几十个字段全部塞进索引。索引越宽,写入维护和存储成本也越高。

因此,OFFSET 和游标分页并不是“旧方案”和“新方案”的简单关系。

OFFSET 最大的优势是简单,而且天然支持页码跳转;游标分页最大的优势是深分页效率和连续读取效率。前者更适合传统后台管理界面,后者更适合 API、无限滚动、消息流、订单流和大型 SaaS 数据列表。

真正成熟的做法也不是在数据库达到某个神奇数字之后突然把整个系统改成 Cursor,而是在系统设计阶段就观察实际查询模式。

先用 EXPLAIN、EXPLAIN ANALYZE、慢查询日志以及实际生产延迟确认瓶颈,再决定是否改变分页方式。MySQL 本身会根据索引、WHERE、ORDER BY 和 LIMIT 等条件选择执行计划,不能用“OFFSET 一定全表扫描”或者“游标一定快5倍”这样的固定结论代替实际测试。

对于一个典型 PHP SaaS 项目,可以采用一个非常实用的策略:普通后台列表默认 OFFSET;只要出现深分页、无限滚动、高频 API 列表或者百万级以上的持续增长数据,再针对具体查询改成 Keyset Pagination。

这样既不会为了所谓“大数据架构”过早增加复杂度,也不会等到生产数据库已经被几十万甚至几百万的 OFFSET 请求拖慢以后才开始补救。

分页本身看起来只是一个 LIMIT 参数,真正决定它能不能撑住 SaaS 长期增长的,却是排序字段、复合索引、查询模式和数据变化方式。对于开发者来说,最重要的不是记住“OFFSET 多少条就必须换 Cursor”,而是知道数据库究竟为这一次分页扫描了多少行,以及这些扫描是否真的有价值。

分享 Facebook | X | Whatsapp | Linkedin

捐助(Paypal): https://www.paypal.me/observeccp
订阅中国观察电报 Telegram : https://t.me/s/ObserveCCP

责任编辑:雨轩  来源:中国观察  转载请注明作者、出处並保持完整。



今日热点:
更多新闻...
近期焦点:
最新视频

推荐阅读:


更多推荐文章:


相关新闻


更多新闻


推荐文章

美加关税战全面升级 9000亿美元贸易关系正在接受重新定价

美国与加拿大原本是世界上最紧密的经济伙伴之一,但8月下旬突然升级的关税冲突,正在把这种长期形成的贸易关系推向一个新的转折点。美国政府8月22日起对约20...

2026年8月23日   29284

FTC盯上YouTube封号机制 平台可以管理内容却不能把规则变成一张任意伸缩的网

YouTube封禁一个账号,表面看只是平台与用户之间的一次纠纷;但当美国联邦贸易委员会开始调查这种封禁是否违反消费者保护法,事情的性质就发生了变化。美国政府...

2026年8月28日   26176

五中全会前北京官场传闻升温 夏林茂去向牵动蔡奇旧部与高层权力观察

发表:2026-10-20 22:30:54 | 更新:2026-10-20 22:30:54  人气: 88286

五中全会前北京官场传闻升温 夏林茂去向牵动蔡奇旧部与高层权力观察

本文由中国观察編輯部原创。为了促进信息自由传播,本平台原创内容全面开放转载,无须申请授权,只需注明来源、保留原文链接,即可全文转载、引用、翻译。我们不追究版权,只在乎公正的传播。

捐助(Paypal): https://www.paypal.me/observeccp
订阅中国观察电报 Telegram : https://t.me/s/ObserveCCP

最近文章:
更多文章...


近期要闻:
更多新闻...

编辑推荐文章:

最新发表   ⏬
更多文章...
疫情