几乎每个 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 分页在大型数据集上会失效,然后介绍真正适合规模化场景的几种方案:

  • • Cursor 分页
  • • Keyset 分页
  • • 管理后台中的混合分页方案
  • • 流式导出与批处理
  • • 索引设计
  • • 一致性与生产环境注意事项

所有方案都会给出 PHP 或 Laravel 示例代码。

一、快速结论

如果只看结论,可以记住下面几条。

Offset 分页不适合大型数据集。

OFFSET N LIMIT K 在数据库层面的复杂度是:

O(N + K)

在 5000 万行数据表上,第 100,000 页可能需要读取 5000 万行,只为了返回 20 行。

Keyset / Cursor 分页更适合大数据量场景。

它的复杂度接近:

O(log N + K)

无论翻到多深,查询耗时基本保持稳定。

只要数据集可能超过几千行,就应该优先考虑它。

Cursor 分页必须依赖稳定排序。

单独使用 created_at 并不可靠,因为多行数据可能拥有相同时间戳。

正确做法是增加一个唯一列作为平局裁决列,例如:

ORDER BY created_at DESC, id DESC

也就是说,cursor 中不只保存 created_at,还要保存 id

管理后台需要页码时,可以使用混合方案。

不一定要强行保留传统页码。

可以使用:

  • • Cursor 分页浏览最近数据
  • • 日期筛选跳转到某个时间段
  • • 缓存或近似统计展示总量
  • • 物化统计表处理精确计数

导出 5000 万行数据时,不要一次性加载到内存。

必须使用:

  • • 生成器
  • • 非缓冲查询
  • • chunk
  • • chunkById
  • • 流式处理

否则 PHP 进程很容易因为内存耗尽而崩溃。

很多时候,数据库并不是真正瓶颈,PHP 内存才是。

如果 5000 万行数据中,每行只占用 500 字节 PHP 内存,也需要:

50,000,000 × 500B = 25GB

这远远超过大多数 PHP 进程的可用内存。

所以,处理大结果集时只有两个选择:

要么流式处理,要么进程崩溃。

二、你将学到什么

读完本文,你会理解:

  • • 为什么 offset 分页会在规模化场景下失效
  • • 数据库真实执行 OFFSET 查询时发生了什么
  • • 如何在 PHP 中实现 Keyset / Cursor 分页
  • • 如何在 Laravel 中使用 cursorPaginate()
  • • 为什么单独使用 ORDER BY created_at 会导致数据重复或丢失
  • • 如何使用 PDO 非缓冲查询处理巨大结果集
  • • 如何用生成器降低 PHP 内存占用
  • • 管理后台如何兼顾页码体验和深度分页性能
  • • 什么时候应该使用搜索引擎或预聚合表
  • • 生产环境中如何处理 cursor、不一致数据和双向分页

三、为什么 Offset 分页会失效

先看一个典型查询。

假设 MySQL 中有一张包含 5000 万行数据的 orders 表:

EXPLAIN
SELECT * FROM orders
ORDER BY created_at DESC
LIMIT 20 OFFSET 2000000;

即使 created_at 上已经有索引,执行计划中通常也会出现类似下面的结果:

rows: 2000020

这意味着数据库需要按索引顺序读取约 200 万行,跳过它们,然后返回最后 20 行。

更糟糕的是,如果 SELECT * 中包含未被索引覆盖的列,那么这 200 万行中的每一行,都可能触发一次回表查询。

于是,一个看起来只返回 20 行的分页查询,最终变成了 200 万行级别的扫描。

1. PHP 看不到这些细节

从 PHP 应用视角看,它只是发送了一条查询:

SELECT ... LIMIT 20 OFFSET 2000000

然后等待数据库返回结果。

但数据库可能正在做的是:

  1. 1. 扫描 2,000,020 行;
  2. 2. 丢弃前 2,000,000 行;
  3. 3. 返回最后 20 行;
  4. 4. 在整个过程中持续占用数据库连接。

如果这个查询耗时 45 秒,那么这 45 秒里,连接池中的一个连接就会一直被占用。

如果有人刷新页面,就会再占用一个连接。

如果管理员同时导出报表,又会占用更多连接。

最终,其他用户的登录、下单、查询等正常请求,也会因为连接池耗尽而开始排队。

2. Offset 分页还有正确性问题

Offset 分页不仅慢,还可能导致数据重复或跳过。

假设用户先打开第一页,然后准备点击第 5 页。

在这个过程中,如果有新订单插入,并且排序方式是:

ORDER BY created_at DESC

新数据会把原本的数据整体向后推。

于是:

  • • 原本在第 3 页的数据,可能移动到第 4 页;
  • • 某些数据可能被用户重复看到;
  • • 某些数据可能被直接跳过。

因此,Offset 分页在规模化场景下会以三种方式失效:

慢查询、连接占用、并发写入下结果不一致。

而且随着数据量增长,这三个问题都会持续恶化。

四、Keyset 分页才是真正答案

Keyset 分页也叫:

  • • Cursor 分页
  • • Seek 分页
  • • Where Pagination

它的核心思想是:

不再告诉数据库“跳过 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;

这里的关键是:

(created_at, id) < (...)

这是行比较。

MySQL 和 PostgreSQL 都支持这种写法。它表示:返回 (created_at, id) 这个组合值小于给定 cursor 的数据,并按照字典序比较。

只要存在合适的复合索引:

(tenant_id, created_at, id)

数据库就可以直接定位到 cursor 附近,然后读取后面的 20 行。

它不需要扫描并丢弃前面几百万行。

因此,第 1 页和第 1,000,000 页的查询耗时可以保持在同一量级。

五、原生 PHP 最小实现

下面是一个无框架依赖的 Cursor 分页实现。

class OrderCursorPagination
{
    public function __construct(
        private PDO $db,
        private int $perPage = 20,
    ) {}
 
    /**
     * @return array{rows: array, next_cursor: ?string}
     */
    public function 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,
        ];
    }
 
    private function encodeCursor(string $createdAt, int $id): string
    {
        return base64_encode(json_encode([$createdAt, $id]));
    }
 
    private function 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 行

代码中实际查询的是:

$this->perPage + 1

这是判断是否还有下一页的常见技巧。

例如每页 20 条,如果查到了 21 条,就说明还有下一页。

返回前把第 21 条移除即可。

这样可以避免额外执行一次 COUNT(*)

4. 没有下一页时返回 null

当没有更多数据时:

'next_cursor' => null

客户端只需要判断 cursor 是否为空即可。

七、稳定排序问题:最容易被忽略的坑

稳定排序是 Cursor 分页中最容易被忽略的问题。

假设只按照 created_at 排序:

ORDER BY created_at DESC

现在有 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

第一页使用:

LIMIT 3

数据库可能返回:

100, 101, 102

此时 cursor 是:

2026-04-22 14:32:00

第二页查询如果写成:

WHERE created_at < '2026-04-22 14:32:00'

那么 103 和 104 会被直接跳过。

因为它们的 created_at 等于 cursor,而不是小于 cursor。

如果改成:

WHERE created_at <= '2026-04-22 14:32:00'

又会导致 100101102 在第二页重复出现。

正确做法:加入唯一平局裁决列

解决方式始终相同:

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() 会自动处理:

  • • Cursor 编码
  • • 下一页 cursor
  • • 上一页 cursor
  • • 分页 URL
  • • 基础的排序字段处理

在生产环境中,如果数据集可能增长到几千行以上,建议优先使用:

cursorPaginate()

而不是:

paginate()

Laravel Cursor 分页注意事项

cursorPaginate() 要求排序字段真实存在于模型或查询结果中。

如果你使用的是:

DB::raw()

或者按照 JSON 提取字段、计算字段排序,就可能无法直接使用 cursor 分页。

这种情况下通常需要:

  • • 增加物化列;
  • • 使用生成列;
  • • 改用搜索引擎;
  • • 或者手动实现 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. 展示大概总量

有些后台页面确实需要显示:

约 5000 万条订单

这并不一定需要执行真实的:

SELECT COUNT(*) FROM orders

在大型表上,实时精确计数可能非常慢。

可以使用缓存或近似统计。

示例:

class OrderAdminPagination
{
    public function __construct(
        private PDO $db,
        private CacheInterface $cache,
        private int $perPage = 20,
    ) {}
 
    public function 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);
            }
        );
    }
 
    public function page(int $tenantId, ?string $cursor, ?string $dateFilter): array
    {
        // 在标准 Cursor 分页基础上增加可选日期条件
    }
}

EXPLAIN 返回的是估算值,不是精确值。

但它通常非常快,足够用于展示:

约 5000 万条订单

如果某些业务确实需要精确计数,例如财务、审计、结算,建议使用:

  • • 统计表
  • • 物化视图
  • • 定时任务预计算
  • • 事件驱动更新
  • • 触发器维护计数

不要在用户请求中实时执行大型表的 COUNT(*)

十、流式处理大型数据集

有些场景中,分页不是答案。

例如:

  • • 导出全部订单
  • • 批量迁移用户
  • • 重建索引
  • • 批量发送通知
  • • 数据清洗任务

这些任务确实需要处理全部 5000 万行数据。

但绝不能一次性加载到 PHP 内存。

1. 为什么不能使用 fetchAll()

默认情况下,很多代码会这样写:

$stmt = $pdo->query('SELECT * FROM users');
$rows = $stmt->fetchAll(PDO::FETCH_ASSOC);

如果结果集是 5000 万行,这意味着 PHP 会尝试把所有数据一次性放进内存。

即使每行只占 500 字节:

50,000,000 × 500B = 25GB

这还没有计算数组结构、字符串、对象和 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 都是一条独立查询。

它的优势是:

  • • 不会长时间占用同一个连接;
  • • 出错后可以从最后处理的 ID 恢复;
  • • 可以把进度存入数据库、Redis 或文件;
  • • 更适合长时间运行的后台任务。

例如,可以把 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;

重点关注:

  • • type 是否为 range 或 ref
  • • 是否出现 Using index condition
  • • rows 估算是否接近 LIMIT
  • • 是否出现 Using filesort
  • • 是否出现 type: ALL

如果看到:

type: ALL

或者:

Using filesort

通常说明索引没有正确匹配。

这时应该先修复索引,而不是继续改 PHP 代码。

3. PostgreSQL 中的检查方式

PostgreSQL 使用:

EXPLAIN ANALYZE
SELECT ...

重点关注是否出现:

  • • Index Scan
  • • Index Only Scan

如果看到:

Seq Scan

说明可能发生了全表扫描。

4. 覆盖索引可以进一步优化

如果表非常宽,而分页查询只需要少数字段,可以考虑覆盖索引。

PostgreSQL 示例:

CREATE INDEX idx_orders_tenant_time_covering
ON orders (tenant_id, created_at, id)
INCLUDE (status, total);

覆盖索引的好处是:

查询可以只访问索引,不必回表读取主表数据。

宽表中,这个优化通常非常明显。

MySQL 中可以把需要覆盖的列直接放进复合索引中,但要注意索引体积和写入成本。

十四、分页解决不了的问题

Cursor 分页并不是银弹。

有些查询无论如何优化分页,都不适合直接压在主数据库上。

典型场景包括:

  • • 5000 万行上的全文搜索;
  • • 多字段模糊查询;
  • • 十几个维度的组合筛选;
  • • 复杂分面过滤;
  • • 按相关性评分排序;
  • • 大规模 GROUP BY 聚合;
  • • 每个分类取 Top N 商品。

这类问题的本质不是“分页写得不够好”,而是:

不应该继续用事务数据库做这类查询。

1. 全文搜索应该使用搜索引擎

适合的工具包括:

  • • Elasticsearch
  • • Meilisearch
  • • OpenSearch
  • • Typesense

常见架构是:

  1. 1. 主数据库负责事务写入;
  2. 2. 通过 outbox 或 CDC 同步数据;
  3. 3. 搜索引擎负责搜索、排序、过滤;
  4. 4. PHP 应用通过 HTTP 查询搜索服务;
  5. 5. 必要时再回主库补充最新数据。

2. 聚合查询应该预计算

例如:

过去一年每天的订单总额

如果每次请求都扫描数百万行,就会非常慢。

更好的方式是维护物化汇总表:

daily_order_stats

由定时任务或事件驱动更新。

查询时只需要读取已经聚合好的结果,响应时间可以从秒级降低到毫秒级。

3. 判断是否该迁移出去

如果分页查询中出现下面几类特征,就应该重新考虑架构:

  • • 包含 GROUP BY
  • • 包含全文搜索
  • • 包含 LIKE '%keyword%'
  • • 包含大量动态筛选条件
  • • 排序字段不是简单数据库列
  • • 需要相关性评分
  • • 需要复杂分面统计

这些场景通常应该使用搜索引擎、物化视图或预聚合表。

十五、并发写入下的一致性问题

长期分页时,还需要考虑一个问题:

用户翻页过程中,如果有新数据插入或旧数据删除,会发生什么?

1. Cursor 分页如何处理插入

Cursor 分页对插入的处理相对自然。

如果新数据排序在当前 cursor 之前,用户当前分页过程中可能看不到它,但刷新第一页后可以看到。

如果新数据排序在 cursor 之后,它可能会出现在后续页面中。

关键是:

Cursor 分页不会因为 offset 移动而导致大面积重复或跳过。

2. 删除数据时会怎样

如果 cursor 对应的那一行被删除,分页仍然可以继续。

因为 cursor 本质上只是一个位置:

(created_at, id)

数据库会从这个位置之后继续查找下一批数据。

用户不会看到被删除的行,这通常也是符合预期的。

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 被阻塞;
  • • MySQL undo log 膨胀;
  • • 连接长时间占用;
  • • 复制延迟增加。

因此,一致性快照适合分钟级任务,不适合小时级任务。

如果任务非常大,通常应该使用:

  • • 分块处理;
  • • checkpoint;
  • • 可恢复任务;
  • • 数据版本号;
  • • 离线快照表。

十六、常见问题

1. Cursor 分页可以跳到第 X 页吗?

不能直接做到。

Cursor 分页只知道:

  • • 下一页
  • • 上一页
  • • 从某个位置继续

它不知道“第 X 页”对应哪个位置。

如果一定要近似跳转,可以通过日期或 ID 构造 cursor。

例如:

跳到 2024 年 3 月

可以转化为:

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 万行表,这可能是多秒级操作,并且会占用数据库连接。

更推荐:

  • • 缓存计数;
  • • 定时任务更新统计表;
  • • 使用近似统计;
  • • 使用 EXPLAIN 估算;
  • • 使用 MySQL information_schema.TABLES.TABLE_ROWS
  • • 使用 PostgreSQL pg_class.reltuples

如果业务必须精确计数,应把计数维护为数据模型的一部分,而不是每次请求实时扫描。

4. 复杂 Join 或视图可以做 Cursor 分页吗?

可以,但要谨慎。

如果排序字段来自单张主表,并且索引正确,通常可以工作。

更稳妥的方式是:

  1. 1. 先在根表上分页;
  2. 2. 拿到当前页 ID;
  3. 3. 再查询关联数据;
  4. 4. 组合返回结果。

如果是物化视图,可以把它当作普通表处理,并为排序字段建立索引。

5. 软删除数据如何处理?

如果基础条件中包含:

deleted_at IS NULL

那么索引也要考虑这个条件。

否则数据库虽然可以定位 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. 1. 不要在大型表上使用深度 Offset 分页。

    LIMIT 20 OFFSET 2000000

    这种写法会随着页码变深越来越慢。

  2. 2. 优先使用 Cursor / Keyset 分页。

    WHERE (created_at, id) < (?, ?)

  3. 3. 排序必须稳定。

    不要只用:

    ORDER BY created_at DESC

    应该使用:

    ORDER BY created_at DESC, id DESC

  4. 4. Cursor 中必须包含唯一平局裁决列。

    通常是:

    id

  5. 5. 索引要匹配过滤和排序。

    例如:

    CREATE INDEX idx_orders_tenant_time
    ON orders (tenant_id, created_at, id);

  6. 6. 使用 perPage + 1 判断是否还有下一页。

    避免额外执行 COUNT(*)

  7. 7. Cursor 应该对客户端不透明。

    使用 base64 JSON、签名 token 或版本化 cursor。

  8. 8. 导出和批处理任务应使用流式处理。

    不要一次性 fetchAll()

  9. 9. 长任务优先考虑 chunkById()

    因为它更容易恢复进度。

  10. 10. 搜索和复杂聚合不要硬压在主数据库上。

    该用搜索引擎或预聚合表时,就不要继续优化分页。

十九、什么时候说明 Cursor 分页已经不够用了?

如果你的查询出现下面这些特征,说明问题可能已经超出 Cursor 分页的能力范围:

  • • 需要全文搜索;
  • • 需要相关性排序;
  • • 有复杂 GROUP BY
  • • 有大量动态筛选条件;
  • • 使用 LIKE '%keyword%'
  • • 需要复杂分面统计;
  • • 排序字段来自 PHP 计算;
  • • 用户真的需要任意跳到第 N 页;
  • • 查询需要跨多张大表复杂 Join。

对应处理方式通常是:

| 场景 | 推荐方案 |
| | - |
| 全文搜索 | Elasticsearch / Meilisearch / OpenSearch |
| 复杂聚合 | 物化汇总表 |
| 多维筛选 | 搜索引擎 |
| 大规模导出 | 流式处理 / chunkById |
| 精确统计 | 统计表 / 预计算 |
| 任意页码跳转 | 重新设计 UX,改为筛选器 |

二十、结语

分页看起来是一个很小的问题。

一行代码就能完成:

LIMIT N OFFSET M

但在数据量变大后,它隐藏的是一个 O(N+M) 级别的性能灾难。

幸运的是,修复方式并不复杂。

大多数场景只需要把 Offset 分页替换为 Cursor 分页:

WHERE (created_at, id) < (?, ?)

再配合正确的复合索引,就能让原本几十秒的深度分页请求,重新回到毫秒级响应。

更重要的是,要把“如何遍历一个数据集”视为一个设计决策,而不是默认选择。

用户是在浏览最近数据,还是在跳转到某个时间段?

任务是在导出全部数据,还是只展示当前页面?

业务需要绝对一致性,还是可以接受近似统计?

这些问题的答案不同,选择的方案也应该不同。

工具其实早就在技术栈里:

  • • Cursor
  • • Keyset
  • • Generator
  • • Chunk
  • • ChunkById
  • • 非缓冲查询
  • • 复合索引
  • • 部分索引
  • • 物化汇总表

关键是在第 5000 万行数据到来之前,就开始正确使用它们。

把分页模式设计好,并在项目中复用。

原本需要 45 秒的请求,可以变成 30 毫秒。

连接池不会再被慢查询长期占满。

凌晨 3 点的数据库告警也会少很多。

这就是把分页做对之后的复利价值。