几乎每个 PHP 开发者,都至少写过一次这样的分页查询:
$results = Order::orderBy('created_at', 'desc')
->offset(($page - 1) * $perPage)
->limit($perPage)
->get();
在 1 万行数据 的表上,这样写通常没有问题。
在 10 万行数据 的表上,性能影响已经可以感知,但多数情况下仍然可以接受。
但如果这张表有 5000 万行数据,这条查询就不再是普通分页,而是一件瞄准数据库的武器。
原因很简单:
MySQL 并不会“瞬移”到第 2,000,000 行,然后返回后面的 20 行。
它会先读取 2,000,020 行,丢弃前 2,000,000 行,再返回最后 20 行。
也就是说,越往后翻页,数据库要扫描和丢弃的数据就越多。
第 1 页可能只需要 50 ms。
第 50,000 页可能需要 45 秒。
而且在这 45 秒内,数据库连接会一直被占用。
在规模化场景下,这已经不只是性能问题,而是 正确性、内存和可用性 问题的叠加。
慢查询会长期占用连接。连接耗尽后,其他正常请求也会被拖慢,甚至开始出现 500 错误。
本文会从最基础的问题开始,解释为什么 offset 分页在大型数据集上会失效,然后介绍真正适合规模化场景的几种方案:
所有方案都会给出 PHP 或 Laravel 示例代码。
一、快速结论
如果只看结论,可以记住下面几条。
Offset 分页不适合大型数据集。
OFFSET N LIMIT K 在数据库层面的复杂度是:
在 5000 万行数据表上,第 100,000 页可能需要读取 5000 万行,只为了返回 20 行。
Keyset / Cursor 分页更适合大数据量场景。
它的复杂度接近:
无论翻到多深,查询耗时基本保持稳定。
只要数据集可能超过几千行,就应该优先考虑它。
Cursor 分页必须依赖稳定排序。
单独使用 created_at 并不可靠,因为多行数据可能拥有相同时间戳。
正确做法是增加一个唯一列作为平局裁决列,例如:
ORDER BY created_at DESC, id DESC
也就是说,cursor 中不只保存 created_at,还要保存 id。
管理后台需要页码时,可以使用混合方案。
不一定要强行保留传统页码。
可以使用:
导出 5000 万行数据时,不要一次性加载到内存。
必须使用:
否则 PHP 进程很容易因为内存耗尽而崩溃。
很多时候,数据库并不是真正瓶颈,PHP 内存才是。
如果 5000 万行数据中,每行只占用 500 字节 PHP 内存,也需要:
这远远超过大多数 PHP 进程的可用内存。
所以,处理大结果集时只有两个选择:
要么流式处理,要么进程崩溃。
二、你将学到什么
读完本文,你会理解:
- • 为什么
offset 分页会在规模化场景下失效 - • 数据库真实执行
OFFSET 查询时发生了什么 - • 如何在 PHP 中实现 Keyset / Cursor 分页
- • 如何在 Laravel 中使用
cursorPaginate() - • 为什么单独使用
ORDER BY created_at 会导致数据重复或丢失 - • 生产环境中如何处理 cursor、不一致数据和双向分页
三、为什么 Offset 分页会失效
先看一个典型查询。
假设 MySQL 中有一张包含 5000 万行数据的 orders 表:
EXPLAIN
SELECT * FROM orders
ORDER BY created_at DESC
LIMIT 20 OFFSET 2000000;
即使 created_at 上已经有索引,执行计划中通常也会出现类似下面的结果:
这意味着数据库需要按索引顺序读取约 200 万行,跳过它们,然后返回最后 20 行。
更糟糕的是,如果 SELECT * 中包含未被索引覆盖的列,那么这 200 万行中的每一行,都可能触发一次回表查询。
于是,一个看起来只返回 20 行的分页查询,最终变成了 200 万行级别的扫描。
1. PHP 看不到这些细节
从 PHP 应用视角看,它只是发送了一条查询:
SELECT ... LIMIT 20 OFFSET 2000000
然后等待数据库返回结果。
但数据库可能正在做的是:
如果这个查询耗时 45 秒,那么这 45 秒里,连接池中的一个连接就会一直被占用。
如果有人刷新页面,就会再占用一个连接。
如果管理员同时导出报表,又会占用更多连接。
最终,其他用户的登录、下单、查询等正常请求,也会因为连接池耗尽而开始排队。
2. Offset 分页还有正确性问题
Offset 分页不仅慢,还可能导致数据重复或跳过。
假设用户先打开第一页,然后准备点击第 5 页。
在这个过程中,如果有新订单插入,并且排序方式是:
新数据会把原本的数据整体向后推。
于是:
- • 原本在第 3 页的数据,可能移动到第 4 页;
因此,Offset 分页在规模化场景下会以三种方式失效:
慢查询、连接占用、并发写入下结果不一致。
而且随着数据量增长,这三个问题都会持续恶化。
四、Keyset 分页才是真正答案
Keyset 分页也叫:
它的核心思想是:
不再告诉数据库“跳过 N 行,返回接下来的 K 行”,而是告诉数据库“从某个具体位置之后,返回接下来的 K 行”。
这个“具体位置”就是 cursor。
Cursor 通常由上一页最后一行的排序字段组成。
例如第一页查询:
SELECT * FROM orders
WHERE tenant_id = 42
ORDER BY created_at DESC, id DESC
LIMIT 20;
拿到第一页后,把最后一行的 created_at 和 id 作为 cursor。
下一页查询变成:
SELECT * FROM orders
WHERE tenant_id = 42
AND (created_at, id) < ('2026-04-22 14:32:00', 1234567)
ORDER BY created_at DESC, id DESC
LIMIT 20;
这里的关键是:
这是行比较。
MySQL 和 PostgreSQL 都支持这种写法。它表示:返回 (created_at, id) 这个组合值小于给定 cursor 的数据,并按照字典序比较。
只要存在合适的复合索引:
(tenant_id, created_at, id)
数据库就可以直接定位到 cursor 附近,然后读取后面的 20 行。
它不需要扫描并丢弃前面几百万行。
因此,第 1 页和第 1,000,000 页的查询耗时可以保持在同一量级。
五、原生 PHP 最小实现
下面是一个无框架依赖的 Cursor 分页实现。
class OrderCursorPagination
{
publicfunction __construct(
private PDO $db,
private int $perPage = 20,
) {}
/**
* @return array{rows: array, next_cursor: ?string}
*/
publicfunction page(int $tenantId, ?string $cursor = null): array
{
$sql = 'SELECT id, tenant_id, total, status, created_at
FROM orders
WHERE tenant_id = :tenant_id';
$params = [
':tenant_id' => $tenantId,
];
if ($cursor !== null) {
[$cursorCreatedAt, $cursorId] = $this->decodeCursor($cursor);
$sql .= ' AND (created_at, id) < (:cursor_created_at, :cursor_id)';
$params[':cursor_created_at'] = $cursorCreatedAt;
$params[':cursor_id'] = $cursorId;
}
$sql .= ' ORDER BY created_at DESC, id DESC LIMIT :limit';
$params[':limit'] = $this->perPage + 1;
$stmt = $this->db->prepare($sql);
foreach ($params as $key => $value) {
$type = $key === ':limit' ? PDO::PARAM_INT : PDO::PARAM_STR;
$stmt->bindValue($key, $value, $type);
}
$stmt->execute();
$rows = $stmt->fetchAll(PDO::FETCH_ASSOC);
$hasMore = count($rows) > $this->perPage;
if ($hasMore) {
array_pop($rows);
}
$nextCursor = null;
if ($hasMore && !empty($rows)) {
$last = end($rows);
$nextCursor = $this->encodeCursor($last['created_at'], $last['id']);
}
return [
'rows' => $rows,
'next_cursor' => $nextCursor,
];
}
privatefunction encodeCursor(string $createdAt, int $id): string
{
return base64_encode(json_encode([$createdAt, $id]));
}
privatefunction decodeCursor(string $cursor): array
{
$decoded = json_decode(base64_decode($cursor), true);
if (!is_array($decoded) || count($decoded) !== 2) {
throw new InvalidArgumentException('Invalid cursor');
}
return [$decoded[0], (int) $decoded[1]];
}
}
使用方式如下:
$paginator = new OrderCursorPagination($pdo, perPage: 20);
// 第一页
$page1 = $paginator->page(tenantId: 42);
// 下一页
$page2 = $paginator->page(
tenantId: 42,
cursor: $page1['next_cursor']
);
如果要持续处理完整数据集,可以这样写:
$cursor = null;
do {
$page = $paginator->page(42, $cursor);
foreach ($page['rows'] as $row) {
processOrder($row);
}
$cursor = $page['next_cursor'];
} while ($cursor !== null);
六、这段代码的四个重点
1. Cursor 同时包含 created_at 和 id
Cursor 不能只包含 created_at。
原因是多个订单可能拥有相同的创建时间。
如果只用 created_at 作为 cursor,分页过程中就可能出现数据跳过或重复。
所以需要把 id 作为平局裁决列。
正确排序方式是:
ORDER BY created_at DESC, id DESC
对应的 cursor 查询条件是:
WHERE (created_at, id) < (:cursor_created_at, :cursor_id)
2. Cursor 对客户端保持不透明
这里使用 base64 编码 JSON:
base64_encode(json_encode([$createdAt, $id]))
这样做的目的不是加密,而是让客户端不要关心 cursor 的内部结构。
客户端只需要把服务端返回的 cursor 原样传回来。
这样以后你可以调整 cursor 格式,例如:
{
"v": 2,
"created_at": "...",
"id": 123
}
而不破坏已有客户端。
如果是公开 API,还可以使用签名 cursor,防止客户端伪造或篡改。
3. 查询 perPage + 1 行
代码中实际查询的是:
这是判断是否还有下一页的常见技巧。
例如每页 20 条,如果查到了 21 条,就说明还有下一页。
返回前把第 21 条移除即可。
这样可以避免额外执行一次 COUNT(*)。
4. 没有下一页时返回 null
当没有更多数据时:
客户端只需要判断 cursor 是否为空即可。
七、稳定排序问题:最容易被忽略的坑
稳定排序是 Cursor 分页中最容易被忽略的问题。
假设只按照 created_at 排序:
现在有 5 条订单创建于同一秒:
id | created_at
-+
100 | 2026-04-22 14:32:00
101 | 2026-04-22 14:32:00
102 | 2026-04-22 14:32:00
103 | 2026-04-22 14:32:00
104 | 2026-04-22 14:32:00
第一页使用:
数据库可能返回:
此时 cursor 是:
第二页查询如果写成:
WHERE created_at < '2026-04-22 14:32:00'
那么 103 和 104 会被直接跳过。
因为它们的 created_at 等于 cursor,而不是小于 cursor。
如果改成:
WHERE created_at <= '2026-04-22 14:32:00'
又会导致 100、101、102 在第二页重复出现。
正确做法:加入唯一平局裁决列
解决方式始终相同:
ORDER BY created_at DESC, id DESC
然后使用:
WHERE (created_at, id) < (:cursor_created_at, :cursor_id)
这样,即使 created_at 相同,也可以用 id 决定稳定顺序。
兼容写法
PostgreSQL 原生支持行比较语法。
MySQL 5.7+ 也支持类似写法。
如果需要兼容更早版本,或者希望语义更明确,可以展开成:
WHERE created_at < :cursor_created_at
OR (
created_at = :cursor_created_at
AND id < :cursor_id
)
这与下面的行比较语义一致:
WHERE (created_at, id) < (:cursor_created_at, :cursor_id)
UUID 可以作为平局裁决列吗?
可以。
即使主键是 UUID,也可以作为平局裁决列。
但要注意:
UUID 只能提供稳定顺序,不代表时间顺序。
所以仍然应该先按 created_at 排序,再用 UUID 作为平局裁决列:
ORDER BY created_at DESC, id DESC
如果是新项目,可以考虑 UUIDv7。它具备更好的时间排序能力。
八、Laravel 中的实现
Laravel 从 8.x 开始内置了 Cursor 分页能力。
可以直接使用:
$orders = Order::where('tenant_id', 42)
->orderBy('created_at', 'desc')
->orderBy('id', 'desc')
->cursorPaginate(20);
返回 JSON 时,可以这样处理:
return response()->json([
'data' => $orders->items(),
'next_cursor' => $orders->nextCursor()?->encode(),
'prev_cursor' => $orders->previousCursor()?->encode(),
]);
后续请求中传入 cursor:
$orders = Order::where('tenant_id', 42)
->orderBy('created_at', 'desc')
->orderBy('id', 'desc')
->cursorPaginate(
20,
['*'],
'cursor',
$request->query('cursor')
);
Laravel 的 cursorPaginate() 会自动处理:
在生产环境中,如果数据集可能增长到几千行以上,建议优先使用:
而不是:
Laravel Cursor 分页注意事项
cursorPaginate() 要求排序字段真实存在于模型或查询结果中。
如果你使用的是:
或者按照 JSON 提取字段、计算字段排序,就可能无法直接使用 cursor 分页。
这种情况下通常需要:
九、管理后台如何处理深度分页
很多人会说:
Cursor 分页很好,但管理后台需要页码。
这个需求很常见,但要先区分用户真正想要的是什么。
管理后台通常有三类访问模式。
1. 查看最近几页数据
这是最常见的场景。
大多数用户只会看第 1 到第 5 页。
例如:
这种场景完全适合 Cursor 分页。
界面上可以把传统页码改成:
2. 跳转到某个时间段
很多用户说想跳到第 47,832 页,本质上并不是真的关心页码。
他们真正想要的是:
查看 2024 年 3 月的订单。
这种需求应该通过筛选器完成:
WHERE created_at >= '2024-03-01'
AND created_at < '2024-04-01'
然后继续使用 Cursor 分页。
这样查询依然可以保持高效。
3. 展示大概总量
有些后台页面确实需要显示:
这并不一定需要执行真实的:
SELECT COUNT(*) FROM orders
在大型表上,实时精确计数可能非常慢。
可以使用缓存或近似统计。
示例:
class OrderAdminPagination
{
publicfunction __construct(
private PDO $db,
private CacheInterface $cache,
private int $perPage = 20,
) {}
publicfunction approximateTotal(int $tenantId): int
{
return $this->cache->remember(
"orders:count:$tenantId",
ttl: 3600,
callback:function () use ($tenantId) {
$stmt = $this->db->prepare(
"EXPLAIN SELECT id FROM orders WHERE tenant_id = :tenant_id"
);
$stmt->execute([
':tenant_id' => $tenantId,
]);
$row = $stmt->fetch(PDO::FETCH_ASSOC);
return (int) ($row['rows'] ?? 0);
}
);
}
publicfunction page(int $tenantId, ?string $cursor, ?string $dateFilter): array
{
// 在标准 Cursor 分页基础上增加可选日期条件
}
}
EXPLAIN 返回的是估算值,不是精确值。
但它通常非常快,足够用于展示:
如果某些业务确实需要精确计数,例如财务、审计、结算,建议使用:
不要在用户请求中实时执行大型表的 COUNT(*)。
十、流式处理大型数据集
有些场景中,分页不是答案。
例如:
这些任务确实需要处理全部 5000 万行数据。
但绝不能一次性加载到 PHP 内存。
1. 为什么不能使用 fetchAll()
默认情况下,很多代码会这样写:
$stmt = $pdo->query('SELECT * FROM users');
$rows = $stmt->fetchAll(PDO::FETCH_ASSOC);
如果结果集是 5000 万行,这意味着 PHP 会尝试把所有数据一次性放进内存。
即使每行只占 500 字节:
这还没有计算数组结构、字符串、对象和 zval 的额外开销。
所以实际内存占用可能更高。
结果通常只有一个:
PHP 进程被 OOM killer 杀掉。
2. MySQL 非缓冲查询
解决方案是使用非缓冲查询。
$pdo = new PDO($dsn, $user, $pass, [
PDO::MYSQL_ATTR_USE_BUFFERED_QUERY => false,
PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION,
]);
$stmt = $pdo->prepare(
'SELECT id, email, created_at FROM users WHERE active = 1'
);
$stmt->execute();
while ($row = $stmt->fetch(PDO::FETCH_ASSOC)) {
processRow($row);
}
这样 PHP 同一时间只持有一行数据。
无论结果集是 20 行还是 2000 万行,内存都能保持在较低水平。
但它也有代价:
在结果集读取完成前,这个 MySQL 连接会一直处于忙碌状态。
因此,非缓冲查询适合 CLI 脚本、队列任务、批处理任务,不适合普通 Web 请求。
3. 用生成器封装流式处理
可以把流式读取封装成生成器:
function streamUsers(PDO $pdo): Generator
{
$stmt = $pdo->prepare(
'SELECT id, email, created_at FROM users WHERE active = 1'
);
$stmt->execute();
while ($row = $stmt->fetch(PDO::FETCH_ASSOC)) {
yield $row;
}
}
使用时:
foreach (streamUsers($pdo) as $user) {
sendEmail($user);
}
yield 会让函数变成生成器。
它不会一次性构建完整数组,而是每次迭代时取出一行、处理一行,然后继续读取下一行。
4. PostgreSQL 游标读取
PostgreSQL 的流式处理方式略有不同,可以使用数据库游标:
$pdo->beginTransaction();
$pdo->exec(
"DECLARE user_cursor CURSOR FOR
SELECT id, email FROM users WHERE active = true"
);
while (true) {
$stmt = $pdo->query("FETCH 1000 FROM user_cursor");
$batch = $stmt->fetchAll(PDO::FETCH_ASSOC);
if (empty($batch)) {
break;
}
foreach ($batch as $row) {
processRow($row);
}
}
$pdo->exec("CLOSE user_cursor");
$pdo->commit();
这里每次读取 1000 行。
这种方式在网络往返次数和内存占用之间取得了平衡。
十一、Laravel 中的流式处理方案
Laravel 已经封装了几种常用方式。
1. cursor():最低内存占用
User::where('active', true)
->cursor()
->each(function ($user) {
processUser($user);
});
cursor() 每次只读取一条数据,内存占用最低。
适合内存极度敏感的任务。
2. lazy():生成器式接口
User::where('active', true)
->lazy()
->each(function ($user) {
processUser($user);
});
lazy() 对外表现类似生成器,但内部会按块读取。
它通常是一个更平衡的选择。
3. chunk():批量处理
User::where('active', true)
->chunk(1000, function ($users) {
foreach ($users as $user) {
processUser($user);
}
});
chunk() 每次读取一批数据。
它的网络往返次数更少,适合大多数批处理任务。
4. chunkById():可恢复处理
User::where('active', true)
->chunkById(1000, function ($users) {
foreach ($users as $user) {
processUser($user);
}
});
如果任务中途失败,可以基于最后处理的 ID 继续执行。
对于长时间运行的批处理任务,chunkById() 通常是更稳妥的默认选择。
十二、Chunk-ID 模式:让批处理任务可恢复
非缓冲查询有一个问题:
如果进程中途崩溃,当前读取位置就丢失了。
对于处理几百万甚至几千万行数据的任务来说,这是不能接受的。
更稳妥的方式是按主键分块处理。
function streamByChunkedId(PDO $pdo, int $chunkSize = 1000): Generator
{
$lastId = 0;
while (true) {
$stmt = $pdo->prepare(
'SELECT id, email, created_at
FROM users
WHERE id > :last_id
ORDER BY id ASC
LIMIT :limit'
);
$stmt->bindValue(':last_id', $lastId, PDO::PARAM_INT);
$stmt->bindValue(':limit', $chunkSize, PDO::PARAM_INT);
$stmt->execute();
$rows = $stmt->fetchAll(PDO::FETCH_ASSOC);
if (empty($rows)) {
return;
}
foreach ($rows as $row) {
yield $row;
}
$lastId = end($rows)['id'];
}
}
每个 chunk 都是一条独立查询。
它的优势是:
例如,可以把 checkpoint 存起来:
saveCheckpoint('users_export_last_id', $lastId);
下次启动任务时读取:
$lastId = loadCheckpoint('users_export_last_id');
这比逐行非缓冲查询略慢,但健壮性更强。
对于任何运行时间超过几分钟的任务,可恢复性通常比极限吞吐量更重要。
十三、索引是所有优化的前提
无论 Cursor 分页、Keyset 分页还是 Chunk-ID 模式,都依赖正确索引。
如果没有命中索引,这些模式都无法发挥作用。
1. Cursor 分页索引设计
Cursor 分页的索引通常要匹配过滤条件和排序方式。
例如查询是:
WHERE tenant_id = 42
ORDER BY created_at DESC, id DESC
索引应该是:
CREATE INDEX idx_orders_tenant_time
ON orders (tenant_id, created_at, id);
如果查询是:
WHERE status = 'pending'
ORDER BY priority ASC, id ASC
索引可以是:
CREATE INDEX idx_orders_status_priority
ON orders (status, priority, id);
复合索引的基本规则是:
等值过滤列在前,排序列在后。
例如:
tenant_id = 42 → 放前面
created_at DESC → 放后面
id DESC → 放最后,用于稳定排序
2. 验证索引是否生效
使用 MySQL 时,可以执行:
EXPLAIN
SELECT *
FROM orders
WHERE tenant_id = 42
AND (created_at, id) < ('2026-04-22 14:32:00', 1234567)
ORDER BY created_at DESC, id DESC
LIMIT 20;
重点关注:
- • 是否出现
Using index condition
如果看到:
或者:
通常说明索引没有正确匹配。
这时应该先修复索引,而不是继续改 PHP 代码。
3. PostgreSQL 中的检查方式
PostgreSQL 使用:
EXPLAIN ANALYZE
SELECT ...
重点关注是否出现:
如果看到:
说明可能发生了全表扫描。
4. 覆盖索引可以进一步优化
如果表非常宽,而分页查询只需要少数字段,可以考虑覆盖索引。
PostgreSQL 示例:
CREATE INDEX idx_orders_tenant_time_covering
ON orders (tenant_id, created_at, id)
INCLUDE (status, total);
覆盖索引的好处是:
查询可以只访问索引,不必回表读取主表数据。
宽表中,这个优化通常非常明显。
MySQL 中可以把需要覆盖的列直接放进复合索引中,但要注意索引体积和写入成本。
十四、分页解决不了的问题
Cursor 分页并不是银弹。
有些查询无论如何优化分页,都不适合直接压在主数据库上。
典型场景包括:
这类问题的本质不是“分页写得不够好”,而是:
不应该继续用事务数据库做这类查询。
1. 全文搜索应该使用搜索引擎
适合的工具包括:
常见架构是:
2. 聚合查询应该预计算
例如:
如果每次请求都扫描数百万行,就会非常慢。
更好的方式是维护物化汇总表:
由定时任务或事件驱动更新。
查询时只需要读取已经聚合好的结果,响应时间可以从秒级降低到毫秒级。
3. 判断是否该迁移出去
如果分页查询中出现下面几类特征,就应该重新考虑架构:
这些场景通常应该使用搜索引擎、物化视图或预聚合表。
十五、并发写入下的一致性问题
长期分页时,还需要考虑一个问题:
用户翻页过程中,如果有新数据插入或旧数据删除,会发生什么?
1. Cursor 分页如何处理插入
Cursor 分页对插入的处理相对自然。
如果新数据排序在当前 cursor 之前,用户当前分页过程中可能看不到它,但刷新第一页后可以看到。
如果新数据排序在 cursor 之后,它可能会出现在后续页面中。
关键是:
Cursor 分页不会因为 offset 移动而导致大面积重复或跳过。
2. 删除数据时会怎样
如果 cursor 对应的那一行被删除,分页仍然可以继续。
因为 cursor 本质上只是一个位置:
数据库会从这个位置之后继续查找下一批数据。
用户不会看到被删除的行,这通常也是符合预期的。
3. 绝对一致性场景需要快照
某些场景中,一致性比实时性更重要,例如:
这时应该使用事务快照。
示例:
$pdo->beginTransaction();
try {
foreach (streamUsers($pdo) as $user) {
processUser($user);
}
$pdo->commit();
} catch (Throwable $e) {
$pdo->rollBack();
throw $e;
}
MySQL InnoDB 在 REPEATABLE READ 隔离级别下,会在事务开始时创建一致性快照。
PostgreSQL 的 REPEATABLE READ 和 SERIALIZABLE 也可以提供类似能力。
4. 长事务也有代价
一致性快照不是免费午餐。
长事务可能导致:
- • PostgreSQL autovacuum 被阻塞;
因此,一致性快照适合分钟级任务,不适合小时级任务。
如果任务非常大,通常应该使用:
十六、常见问题
1. Cursor 分页可以跳到第 X 页吗?
不能直接做到。
Cursor 分页只知道:
它不知道“第 X 页”对应哪个位置。
如果一定要近似跳转,可以通过日期或 ID 构造 cursor。
例如:
可以转化为:
WHERE created_at >= '2024-03-01'
AND created_at < '2024-04-01'
实践中,用户说“跳到第 47,832 页”,通常真正想表达的是:
按某个条件筛选数据。
这时应该提供筛选器,而不是坚持页码。
2. UUID 是随机的,还能做 Keyset 分页吗?
可以。
只要 UUID 被纳入排序和 cursor,就可以提供稳定分页。
例如:
ORDER BY created_at DESC, id DESC
其中 id 可以是 UUID。
但 UUIDv4 不具备时间顺序,所以不要单独用它表示“最新数据”。
如果希望主键本身具备时间排序能力,可以考虑 UUIDv7。
3. 总数统计可以使用 COUNT(*) 吗?
小表可以。
大表中,尤其是 Web 请求内,不建议直接执行:
SELECT COUNT(*) FROM orders;
对于 5000 万行表,这可能是多秒级操作,并且会占用数据库连接。
更推荐:
- • 使用 MySQL
information_schema.TABLES.TABLE_ROWS; - • 使用 PostgreSQL
pg_class.reltuples。
如果业务必须精确计数,应把计数维护为数据模型的一部分,而不是每次请求实时扫描。
4. 复杂 Join 或视图可以做 Cursor 分页吗?
可以,但要谨慎。
如果排序字段来自单张主表,并且索引正确,通常可以工作。
更稳妥的方式是:
如果是物化视图,可以把它当作普通表处理,并为排序字段建立索引。
5. 软删除数据如何处理?
如果基础条件中包含:
那么索引也要考虑这个条件。
否则数据库虽然可以定位 cursor,但仍然可能扫描大量已删除数据。
PostgreSQL 可以使用部分索引:
CREATE INDEX idx_orders_active_time
ON orders (tenant_id, created_at, id)
WHERE deleted_at IS NULL;
MySQL 中可以考虑组合索引:
CREATE INDEX idx_orders_deleted_tenant_time
ON orders (deleted_at, tenant_id, created_at, id);
具体顺序要结合实际过滤条件和数据分布判断。
6. 可以对 PHP 计算出来的字段做 Cursor 分页吗?
不能直接做。
Keyset 分页需要数据库层面的确定性排序。
如果排序字段只存在于 PHP 计算结果中,数据库就无法基于索引定位 cursor。
解决方式通常是:
7. 如何同时支持上一页和下一页?
需要 cursor 同时支持两个方向。
常见做法是每页返回:
{
"next_cursor": "...",
"prev_cursor": "..."
}
下一页使用最后一行作为 cursor。
上一页使用第一页第一行作为 cursor,并反转比较符号。
例如当前是降序:
ORDER BY created_at DESC, id DESC
下一页:
WHERE (created_at, id) < (:created_at, :id)
上一页:
WHERE (created_at, id) > (:created_at, :id)
同时查询时可能需要反转排序,取出结果后再恢复展示顺序。
Laravel 的 cursorPaginate() 已经处理了大部分细节。
十七、快速参考卡片
1. 应避免的大型数据集分页方式
SELECT *
FROM orders
ORDER BY created_at DESC
LIMIT 20 OFFSET 2000000;
问题:
为了返回 20 行,需要读取 2,000,020 行。
页码越深,性能越差。
2. 推荐的 Cursor 分页方式
SELECT *
FROM orders
WHERE (created_at, id) < (:cursor_created_at, :cursor_id)
ORDER BY created_at DESC, id DESC
LIMIT 20;
优势:
复杂度接近 O(log N + K)
适合百万级、千万级甚至更大数据集
3. 推荐索引
CREATE INDEX idx_orders_time
ON orders (created_at, id);
如果有租户过滤:
CREATE INDEX idx_orders_tenant_time
ON orders (tenant_id, created_at, id);
4. Laravel Cursor 分页
Order::where('tenant_id', 42)
->orderBy('created_at', 'desc')
->orderBy('id', 'desc')
->cursorPaginate(20);
5. Laravel 大数据处理
User::where('active', true)
->cursor()
->each(fn ($user) => processUser($user));
User::where('active', true)
->lazy()
->each(fn ($user) => processUser($user));
User::where('active', true)
->chunk(1000, function ($users) {
foreach ($users as $user) {
processUser($user);
}
});
User::where('active', true)
->chunkById(1000, function ($users) {
foreach ($users as $user) {
processUser($user);
}
});
十八、核心规则总结
处理大型数据集分页时,记住这些规则:
- 1. 不要在大型表上使用深度 Offset 分页。
这种写法会随着页码变深越来越慢。
- 2. 优先使用 Cursor / Keyset 分页。
WHERE (created_at, id) < (?, ?)
- 3. 排序必须稳定。
不要只用:
应该使用:
ORDER BY created_at DESC, id DESC
- 4. Cursor 中必须包含唯一平局裁决列。
通常是:
- 5. 索引要匹配过滤和排序。
例如:
CREATE INDEX idx_orders_tenant_time
ON orders (tenant_id, created_at, id);
- 6. 使用
perPage + 1 判断是否还有下一页。避免额外执行 COUNT(*)。
- 7. Cursor 应该对客户端不透明。
使用 base64 JSON、签名 token 或版本化 cursor。
- 8. 导出和批处理任务应使用流式处理。
不要一次性 fetchAll()。
- 9. 长任务优先考虑
chunkById()。因为它更容易恢复进度。
- 10. 搜索和复杂聚合不要硬压在主数据库上。
该用搜索引擎或预聚合表时,就不要继续优化分页。
十九、什么时候说明 Cursor 分页已经不够用了?
如果你的查询出现下面这些特征,说明问题可能已经超出 Cursor 分页的能力范围:
对应处理方式通常是:
| 场景 | 推荐方案 |
| | - |
| 全文搜索 | Elasticsearch / Meilisearch / OpenSearch |
| 复杂聚合 | 物化汇总表 |
| 多维筛选 | 搜索引擎 |
| 大规模导出 | 流式处理 / chunkById |
| 精确统计 | 统计表 / 预计算 |
| 任意页码跳转 | 重新设计 UX,改为筛选器 |
二十、结语
分页看起来是一个很小的问题。
一行代码就能完成:
但在数据量变大后,它隐藏的是一个 O(N+M) 级别的性能灾难。
幸运的是,修复方式并不复杂。
大多数场景只需要把 Offset 分页替换为 Cursor 分页:
WHERE (created_at, id) < (?, ?)
再配合正确的复合索引,就能让原本几十秒的深度分页请求,重新回到毫秒级响应。
更重要的是,要把“如何遍历一个数据集”视为一个设计决策,而不是默认选择。
用户是在浏览最近数据,还是在跳转到某个时间段?
任务是在导出全部数据,还是只展示当前页面?
业务需要绝对一致性,还是可以接受近似统计?
这些问题的答案不同,选择的方案也应该不同。
工具其实早就在技术栈里:
关键是在第 5000 万行数据到来之前,就开始正确使用它们。
把分页模式设计好,并在项目中复用。
原本需要 45 秒的请求,可以变成 30 毫秒。
连接池不会再被慢查询长期占满。
凌晨 3 点的数据库告警也会少很多。
这就是把分页做对之后的复利价值。