滚动新闻 →
PHP SaaS 列表分页使用 OFFSET 有什么问题?什么时候应该改用游标分页 哈里斯县提前投票首日三小时超两万人 习为何偏爱蔡奇? 电脑某一个USB接口无法使用,怎样判断是接口损坏还是系统问题 美国强化福利欺诈打击行动 逾40人涉案;中共五中全会前夕人事风向引关注 Windows 11 用户账户密码怎么修改?忘记密码之前应该掌握这些方法 MySQL GRANT 怎么使用?给 PHP 网站创建合适的数据库访问权限 加州两万名非公民身份核查引争议 涉选举犯罪 日本防卫专家列三红线应对中共区域安全挑战 古代诗词中的春天为什么如此丰富 美国情报机构披露伊朗暗杀阴谋 川普警告或重启军事行动 168断食法并非万能!营养师详解适用人群与饮食禁忌 纽约提前投票首日选民热议民生议题 期待选举带来实质影响 日本冲绳遭遇超级台风蔷琵袭击 超12.8万户断电 21大前人事大洗牌 中共高层内斗白热化 法国部署7万警力应对蒙面示威者冲突 莲藕与中国传统饮食文化 张婉莹涉台籍官员家属监控案不认罪 将于11月30日受审 哥大中国项目主任孙哲被指曾执教中共军校 毛笔为什么需要经常保养 培养孩子欣赏别人优点的能力 为什么视频拍摄前应该检查存储空间 海南航空航班疑似误入跑道遭空管拦截 机组人员质疑操作规范 朱元璋为什么能够从贫苦农民成为皇帝 问界智能座椅行驶中异常移动引车主恐慌 改善居住环境时为什么不应该只追求外观 胡塞武装袭击亚丁机场,红海局势持续升级 美军中东基地人员撤离 航母备战应对伊朗威胁 米粉文化为什么在中国南方如此丰富 江西警方跨省抓捕环保志愿者 张文斌疑因曝光污染遭拘 住进森林深处,加拿大芬迪国家公园把秋天藏在树影之间 网约车司机遗弃病重男子致其死亡,家属索赔96万元 汽车故障码到底应该怎么看 川普称美军返航途中将处理古巴问题 卢比奥呼吁内部变革 金正恩突访丹东之际 网络频传其猝逝传闻 美国五角大厦公开第七批UFO档案 神秘黑三角引发热议 美战争部:孔子学院改名后仍渗透校园,80个组织被清除 汽车雷达到底是怎么工作的 台风蔷琵袭击台湾后直扑日本 冲绳40航班取消 川普威胁动武:伊朗若拒协议或面临新一轮打击

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

发布时间: 2026-10-24 20:00:01    最后更新: 2026-10-24 21:05:24    阅读:8  约16 分钟阅读     

PHP SaaS 列表分页使用 OFFSET 有什么问题?什么时候应该改用游标分页
图片说明:示意图   图片来源:Public Domain(公有领域)
在 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”,而是知道数据库究竟为这一次分页扫描了多少行,以及这些扫描是否真的有价值。

喜欢这篇报道?

使用下面的功能,方便以后继续阅读和分享 MNewsTV

设为 Google 新闻首选来源 让 Google 新闻优先显示 MNewsTV 的最新报道 ›
★ 我的收藏 查看已经收藏的文章
关于文章收藏 收藏不需要注册帐号,收藏信息仅保存在当前浏览器中。 删除收藏请进入「我的收藏」进行管理。
分享这篇报道

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