热点





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

发表时间: 2026-10-25 17:45:01   人气:10 次   

在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的大数据列表才能真正做到稳定、高效,而且随着数据规模增长仍然保持可控。

分享 Facebook | X | Whatsapp | Linkedin

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

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



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

推荐阅读:


更多推荐文章:


相关新闻


更多新闻


推荐文章

川普暂缓选前打击伊朗 美军备战与中东多线冲突同步升级

美国与伊朗之间的对抗正在军事部署、经济制裁和海上安全三个方向同时升级。川普总统虽然公开表示不会在11月中期选举前对伊朗发动新的军事攻击,但五角大楼已经要求美军中...

2026年10月9日   147078

金正恩死亡传闻引爆网络 朝鲜断网与金与正接班说法缺乏证据

近日,社交平台流传一则有关朝鲜最高领导人金正恩死亡的紧急消息,声称金与正已经接掌政权、在全国实施紧急戒严,并命令切断互联网,以阻止金正恩死讯传播。相关帖子还...

2026年10月23日   15248

川普总统向加拿大划出选举红线 加美贸易战为何升级为政治冲突

发表:2026-10-23 12:45:55 | 更新:2026-10-23 12:45:55  人气: 18948

川普总统向加拿大划出选举红线 加美贸易战为何升级为政治冲突

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

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

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


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

编辑推荐文章:

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