在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的大数据列表才能真正做到稳定、高效,而且随着数据规模增长仍然保持可控。
MySQL 游标分页怎么实现?适合 SaaS 大数据列表的实际场景
图片说明:示意图 图片来源:Public Domain(公有领域)
喜欢这篇报道?
使用下面的功能,方便以后继续阅读和分享 MNewsTV
关于文章收藏
收藏不需要注册帐号,收藏信息仅保存在当前浏览器中。
删除收藏请进入「我的收藏」进行管理。
捐助(Paypal): https://www.paypal.me/observeccp 订阅中国观察电报 Telegram : https://t.me/s/ObserveCCP