滚动新闻 →
密西西比州参议员科洛姆获川普公开肯定 民主党中期选举成果显现 SpaceX星舰第15次试飞突破轨道技术 机械哥斯拉首次亮相 唐元隽因涉嫌为中共搜集美国异议人士信息被纽约法院判缓刑三年 欧洲参与俄乌和谈遭分歧 战俘交换后冲突再升级 美国财长:伊朗黑客利用中国AI攻击法国超市冷藏系统 唐元隽涉非法搜集海外异议人士信息被判缓刑 双黄丑闻蔓延白宫 卢特尼克或因放水被问责 黄仁勋辞任事件引安全担忧 中共渗透手段升级 加拿大政界呼吁彻查中共海外警务据点 清华特等奖学金评选引争议 AI方向占比近全 前CIA局长之子因传播儿童色情制品被起诉 突发:新奥尔良港油轮撞上客船 多人落水 美国联邦航空总署实施全国航班停飞措施 退伍军人事务部长披露福利审批通过率高达94% 黄仁勋被美众议院三大委员会要求出席闭门听证会 法国10月通胀率突破3.7% 创近3年新高 山西男子砍伤妻岳母未被批捕 释放后勒死妻子引争议 黄仁勋辞职引台湾日本政界高度关注 唐元隽因向中共提供情报获三年缓刑 欧几里得望远镜发布首批观测数据 助力探索暗宇宙之谜 内蒙古化工厂氨气泄漏事件引争议,官方称属无毒催泪弹演练 英伟达共谍风暴升级 高阶芯片流向成焦点 西安村民因欠款问题冲击政务中心 无锡超算中心遭黑客入侵数据遭出售 网民质疑掩盖真相 Anthropic IPO超额认购六倍 加拿大各界在老兵纪念日缅怀阵亡将士并致敬军人 习近平称继承孙中山理念 学者质疑历史背离 孙中山纪念大会异常引关注 习健康状况引猜测 中国AI模型被指用于伊朗网络攻击 法国超市遭大规模破坏 雕刻大师现场展示技艺 纽约跨年水晶球亮相时代广场 Google Distributed Cloud 是什么,无法全部迁移到公有云的企业可以怎样部署 新疆库车市发生5.0级地震 乌鲁木齐民众普遍感知明显 美以海军高层会谈 土耳其卡塔尔签署防御协议 涉嫌为中共搜集情报 唐元隽纽约获三年缓刑 上海交大基因编辑实验致女童死亡 处罚仅限开除引争议 民主党参议院领袖选举引争议 范霍伦挑战舒默权威 江苏句容城管强抢餐车引摊贩冲突 中共借孙中山纪念进行统战操作 学者质疑继承论述模糊 黄乐与华为关联引调查 深圳数据中心牵出供应链疑云 京沪高铁山东段故障引争议 施工挖断电缆遭质疑

MySQL 游标分页怎么实现?适合 SaaS 大数据列表的实际场景

发布时间: 2026-10-25 17:45:01    最后更新: 2026-11-13 08:46:52    阅读:71  约12 分钟阅读     

MySQL 游标分页怎么实现?适合 SaaS 大数据列表的实际场景
图片说明:示意图   图片来源:Public Domain(公有领域)
在SaaS系统中,订单、用户、操作日志、消息记录等列表经常会随着业务增长达到百万甚至千万级。最初使用 LIMIT 0, 100 看起来完全没有问题,但当用户翻到很深的页面时,LIMIT 500000, 100 这样的查询就可能产生越来越高的扫描和丢弃成本。

这也是游标分页逐渐被采用的原因。这里所说的Cursor Pagination,并不是MySQL存储过程里的数据库游标,而是一种基于排序键继续向后取数据的分页方式,也常被称为Keyset Pagination。它不要求数据库先找到前面几十万条记录再跳过去,而是直接从上一页最后一条记录之后开始读取。

最简单的例子是订单表按照递增的order_id排列:

SELECT
order_id,
customer_id,
amount,
status,
created_at
FROM orders
WHERE tenant_id = ?
AND order_id > ?
ORDER BY order_id ASC
LIMIT 100;

第一次查询没有游标,可以从最小的order_id开始;返回100条以后,服务端把最后一条记录的order_id作为下一页的cursor。下一次请求携带这个值,数据库直接查找order_id更大的记录。

真正决定这种查询性能的不是SQL看起来有多短,而是索引是否与查询条件和排序顺序匹配。对于典型的SaaS多租户表,更有价值的索引通常是:

CREATE INDEX idx_orders_tenant_order
ON orders (tenant_id, order_id);

这样MySQL可以先定位某个租户的数据范围,再按照order_id的索引顺序继续读取。仅仅给tenant_id建立索引,或者单独给order_id建立索引,都可能无法达到联合索引带来的效果。

尤其要注意,tenant_id通常不应该建立唯一索引。一个租户显然需要拥有大量订单,因此正确的结构一般是普通联合索引,而不是:

UNIQUE INDEX (tenant_id)

如果需要保证订单编号在租户内部唯一,可以根据业务设计使用:

UNIQUE INDEX (tenant_id, order_id)

但如果order_id本身已经是全局唯一主键,则是否需要这个唯一联合索引,要根据实际查询和写入模式判断,不能为了分页盲目增加索引。

游标字段必须能够稳定地表达分页顺序。最理想的情况是使用唯一且单调的键,例如自增ID。如果业务要求按照创建时间排序,就不能简单使用created_at一个字段,因为多个订单完全可能拥有相同的时间戳。这时应该使用复合游标,例如:

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

对应的索引则可以设计为:

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

这里的order_id承担了最终的唯一排序作用。这样即使100条订单拥有完全相同的created_at,分页仍然能够确定下一条记录从哪里开始。

这也是游标分页最容易出错的地方之一。很多系统写成:

ORDER BY created_at DESC
LIMIT 100;

然后把最后一条记录的created_at作为下一页游标。如果大量记录拥有相同时间戳,就可能出现重复或者遗漏。真正可靠的游标应该覆盖完整的排序键,而不是只保存其中一个字段。

数据发生变化时,也需要区分不同场景。假设用户正在浏览订单列表,此时系统新增了一批订单。如果按照order_id ASC向后翻页,新订单通常不会影响已经读取过的位置;如果用户删除了一条尚未读取的记录,也只是下一页少了一条数据,不会要求数据库找到这个已经不存在的ID。因此,游标本身并不需要“指向一个仍然存在的记录”,它只是一个边界值。

但如果业务要求用户在整个分页过程中看到一个固定的数据快照,普通游标分页仍然不够。这时需要考虑事务快照、版本号、固定查询时间点或者其他一致性方案。单纯增加一个version字段,并不能自动把动态数据变成一致性快照。

SaaS系统还有一个非常现实的问题,就是租户隔离。分页查询必须把租户条件作为查询逻辑的一部分,而不能先根据游标查询大量数据,再在应用层过滤租户。典型查询应该始终类似:

WHERE tenant_id = ?
AND order_id > ?

这既关系到性能,也关系到数据隔离。如果系统使用共享数据库、共享表的多租户架构,tenant_id通常应该出现在主要业务查询的联合索引设计中。

对于倒序列表,例如后台订单通常希望“最新订单在最前面”,则可以使用:

SELECT
order_id,
amount,
status,
created_at
FROM orders
WHERE tenant_id = ?
AND order_id < ?
ORDER BY order_id DESC
LIMIT 100;

第一次查询不需要cursor,之后把当前页面最后一条记录的order_id传给下一次请求即可。

应用层最好不要直接把数据库字段全部暴露成cursor。更成熟的API可以把游标编码成一个不透明字符串,例如Base64编码的JSON,里面保存排序键、租户上下文以及必要的版本信息,并由服务端进行签名或校验。这样前端不需要理解数据库内部结构,也可以避免用户随意修改cursor造成异常查询。

例如API可以返回:

{
"data": [
{
"order_id": 1001,
"amount": 89.50
}
],
"next_cursor": "eyJvcmRlcl9pZCI6MTAwMX0=",
"has_more": true
}

下一次请求只需要携带next_cursor即可。对于很多SaaS列表,这比让前端保存一个last_order_id更加容易维护。

PHP PDO也应该使用参数化查询,但需要注意LIMIT参数的处理方式。为了避免不同驱动对LIMIT占位符处理产生兼容性问题,可以先在服务端把page size转换为整数,并限制其范围,然后将它安全地放入SQL。例如:

$pageSize = max(1, min((int)$pageSize, 500));

$sql = "
SELECT order_id, customer_id, amount, status, created_at
FROM orders
WHERE tenant_id = ?
AND order_id > ?
ORDER BY order_id ASC
LIMIT $pageSize
";

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

这里的关键不是“LIMIT一定不能使用参数”,而是不要把未经验证的用户输入直接拼进SQL。pageSize必须在服务端转换成整数并限制范围。

游标分页也并非所有场景都比OFFSET分页好。用户如果需要直接跳到第100页、第500页,或者页面必须显示“第几页/共多少页”,传统分页往往更加符合产品需求。游标分页最适合的是“下一页”“加载更多”“无限滚动”以及后台不断向后读取大量数据等场景。

此外,游标分页通常不适合依赖随机访问的报表界面。对于分析系统,如果用户需要按照多个条件筛选、排序并跳转到任意位置,真正需要解决的可能是查询模型、索引、汇总表甚至分析型数据库,而不是简单把OFFSET换成cursor。

至于覆盖索引,也不能理解为“把所有SELECT字段都塞进索引”。如果一个订单列表需要返回十几个甚至几十个字段,把这些字段全部加入索引会显著增加索引体积、写入成本和Buffer Pool压力。更合理的方式是先根据真实查询选择索引键,再通过EXPLAIN ANALYZE观察实际执行计划和回表成本。

分区表同样不能因为数据量大就自动启用。MySQL分区有明确的分区键、查询条件和运维限制。对于很多SaaS系统,一个设计良好的(tenant_id, created_at, id)或类似联合索引,可能已经足够应付大量列表查询。是否需要分区,应该根据数据规模、查询模式、生命周期管理和实际IO压力决定。

读写分离也不是游标分页的必要条件。如果主库完全能够承受列表查询,就没有必要为了分页专门引入从库。引入读副本以后,还必须考虑复制延迟:用户刚刚创建一笔订单,紧接着从从库查询列表时,可能暂时看不到这条记录。对于对实时性要求较高的后台系统,这种行为需要在应用层明确处理。

另外,MySQL 8.0已经移除了Query Cache,因此不能再把“开启Query Cache”作为现代MySQL分页优化方案。真正应该关注的是正确的索引、执行计划、Buffer Pool、IO能力、查询返回字段数量以及实际并发量。EXPLAIN ANALYZE、慢查询日志和Performance Schema比依赖已经不存在的Query Cache更加重要。

最后,还需要重新理解“百万级数据必须使用游标分页”这种说法。数据量本身并不是唯一决定因素。一张拥有数千万条记录、但查询始终只访问最近数据并且索引设计合理的表,可能运行得很好;反过来,一张只有几十万条数据、却频繁执行复杂排序和深OFFSET查询的表,也可能出现明显性能问题。

判断分页方案最可靠的方法,是观察真实的查询延迟、扫描行数、IO、CPU、锁等待和并发情况。可以先用EXPLAIN ANALYZE比较不同分页深度的实际执行成本,再决定是否需要从OFFSET分页迁移到Keyset Pagination。

对于典型的SaaS订单列表,比较成熟的结构通常是:使用稳定且唯一的排序键,建立与租户过滤和排序顺序匹配的联合索引,服务端生成并校验不透明cursor,API采用next_cursor和has_more方式向前端提供数据,同时根据业务要求处理动态数据的一致性。

游标分页真正解决的并不是“数据库数据很多”这么简单,而是避免深度OFFSET查询随着分页位置增加而产生越来越大的扫描成本。它也不是一个可以脱离索引独立存在的优化技巧。只有查询条件、排序字段、联合索引和API分页协议共同设计,SaaS的大数据列表才能真正做到稳定、高效,而且随着数据规模增长仍然保持可控。
本文作者
程越(Kevin Cheng)
IT与人工智能技术资深编辑

喜欢这篇报道?

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

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

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