CatchAdmin PHP 后台管理框架 Logo CatchAdmin

PHP 如何在不拖垮服务器的情况下分页 5000 万行数据

几乎每个 PHP 开发者都至少写过一次这样的查询:

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 页请求耗时 50ms,第 50,000 页请求耗时 45 秒,并且整个过程中一直占用数据库连接。

在规模化场景下,这已经不只是性能问题,而是正确性、内存和可用性问题的叠加。慢查询会长期占用连接,连接耗尽会级联导致其他用户遇到 500 错误。

本文讨论替代方案。内容会从最简单的问题开始,即为什么 offset 分页在大型数据集上会成为错误选择,然后进入真正能在规模化场景下工作的模式:游标分页、键集分页、适合管理后台的混合方案,以及在确实需要处理全部 5000 万行数据时使用的流式导出。每一种模式都会给出真实 PHP 代码。

TL;DR 快速版

Offset 分页(OFFSET N LIMIT K)在数据库层面的复杂度是 O(N+K)。在 5000 万行表上,第 100,000 页需要读取 5000 万行,只为返回 20 行。

Keyset(cursor)分页的复杂度是 O(log N + K),无论页深如何,耗时基本保持稳定。超过几千行的数据集都应优先使用它。

Cursor 需要稳定且有索引的排序列。单独使用 created_at 并不稳定,因为会出现重复值;应结合 id 这样的唯一列作为平局裁决列。

需要页码的管理后台可以缓存总数,并提供基于 cursor 的“跳转到最近数据”按钮。不同模式可以组合使用。

流式导出使用生成器和非缓冲查询。每次 fetch() 一行,而不是 fetchAll()。无论结果集大小如何,内存都保持恒定。

数据库很少是真正瓶颈,PHP 内存才是。5000 万行导出中,每行即便只分配 500 字节,也会消耗 25GB PHP 堆内存。要么流式处理,要么进程崩溃。

你将学到什么

  • 为什么 offset 分页会在规模化场景下失效,以及解释它的真实查询计划
  • 如何在 PHP 中构建 keyset/cursor 分页:从原生实现、Laravel 风格实现,到多排序列实现
  • 很少有人解释的稳定排序问题:为什么单独使用 ORDER BY created_at 会静默跳过或重复行
  • 如何使用 PDO 非缓冲查询和生成器流式处理大型数据集
  • 适合管理后台的混合模式,兼顾页码和快速深度分页
  • 何时预聚合,何时使用搜索引擎(Elasticsearch/Meilisearch),何时只需要添加索引
  • 生产环境注意事项:不透明 cursor、一致性快照、单向分页与双向分页

为什么 Offset 分页会失效

先从查询计划开始。假设 MySQL 中有一张包含 5000 万行的 orders 表:

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

输出通常会显示 rows: 2000020 或类似数字,即便 created_at 上已经有索引。数据库会按索引顺序读取 200 万行,跳过它们,然后返回 20 行。更糟糕的是,如果 SELECT 中包含未被索引覆盖的列,这 200 万行中的每一行都会触发一次主表回表查询。一个 20 行分页查询就这样变成了 200 万行扫描。

PHP 看不到这些细节。从应用视角看,它只是发送了 SELECT ... LIMIT 20 OFFSET 2000000,然后等待。查询耗时 45 秒。有人刷新页面,于是两个连接各自被占用 45 秒。管理员导出报表,第三个连接又被占用 60 秒。另一个用户尝试登录,然后开始等待,因为连接池已经耗尽。

正确性问题也会随着页深加重。用户加载第一页后点击“第 5 页”之前,可能已经有新行插入。使用 ORDER BY created_at DESC 和 offset 分页时,这些新行会把所有数据向后推。原本位于第 3 页的一行可能出现在第 4 页,或者用户可能完全跳过它,因为它在阅读期间移动到了其他页面。

Offset 分页在规模化场景下会以三种方式失效:速度慢、占用连接、并发写入下结果不一致。随着 N 增长,三者都会恶化。

Keyset 模式才是真正答案

Keyset 分页,也称 cursor 分页、seek 分页或 where-pagination,通过一种查询方式解决上述三个问题:不再说“跳过 N 行,给出接下来的 K 行”,而是说“从这个具体位置之后,给出接下来的 K 行”。

这个位置就是 cursor,通常由上一页最后一行的排序列值组成。核心思路如下:

sql
-- First page: no cursor
SELECT * FROM orders
WHERE tenant_id = 42
ORDER BY created_at DESC, id DESC
LIMIT 20;
-- Subsequent pages: cursor from last row of previous page
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;

WHERE (created_at, id) < (...) 是行比较。MySQL 和 Postgres 都支持这种语法。它的含义是:返回 (created_at, id) 元组小于给定元组的行,并按字典序排序。

有了 (tenant_id, created_at, id) 复合索引后,数据库可以直接定位到 cursor 位置,然后读取后面的 20 行,无需扫描并丢弃大量前置行。第 1 页和第 1,000,000 页的查询耗时相同。

原生 PHP 最小实现

下面是一个完整的、无框架依赖的实现。后续内容会在它之上继续扩展。

php
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;
        }
        // Fetch one extra row to know if there's a next page
        $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);   // drop the extra
        }
        $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]];
    }
}

用法如下:

php
$paginator = new OrderCursorPagination($pdo, perPage: 20);
// First page
$page1 = $paginator->page(tenantId: 42);
// Next page
$page2 = $paginator->page(tenantId: 42, cursor: $page1['next_cursor']);
// Keep going until next_cursor is null
while ($page = $paginator->page(42, $page['next_cursor'] ?? null)) {
    // process
    if ($page['next_cursor'] === null) break;
}

这段代码有四个重点。

Cursor 包含 created_atid。这是平局裁决模式。许多订单可以共享同一个时间戳,如果没有平局裁决列,相同时间戳的行会在分页过程中被跳过或重复。假设 id 唯一,它就能打破平局。复合索引 (tenant_id, created_at, id) 可以让 MySQL 高效利用这两列完成 seek。

Cursor 是 base64 编码的 JSON。这让它对客户端保持不透明,客户端不应解析、构造或猜测它。这样也允许后续修改 cursor 格式而不破坏已有客户端,例如解码后识别为 v1,再迁移。公开 API 中有团队会使用签名 JWT cursor 防止篡改;对大多数内部场景而言,base64 已经足够。

查询会获取 perPage + 1 行。这是判断“是否还有下一页”的标准技巧,避免额外执行 count 查询。如果请求 20 行却返回了 21 行,就说明后面还有数据。返回前把额外一行弹出即可。

没有下一页时,next_cursornull。客户端代码可以使用 while ($nextCursor !== null) 循环处理完整数据集。

稳定排序问题

这是很少有人解释的部分,也是 keyset 分页中最容易产生隐蔽 bug 的来源。

假设只按 created_at 分页,并且有五个订单创建于同一秒:

text
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

第 1 页查询使用 LIMIT 3。数据库可能以任意顺序返回 [100, 101, 102],因为它可以自由排列相同排序值。此时 cursor 是 2026-04-22 14:32:00。第 2 页查询 WHERE created_at < '2026-04-22 14:32:00' 时,行 103 和 104 就被跳过了,因为它们等于 cursor,而不是小于 cursor。

如果改成 <=,则会出现重复。行 100、101、102 会因为等于 cursor 而再次出现在第 2 页。

修复方式始终相同:添加一个具有唯一且单调值的列,并把它同时纳入排序和 cursor。自增表中主键 id 是自然选择:

sql
ORDER BY created_at DESC, id DESC
WHERE (created_at, id) < (:cursor_created_at, :cursor_id)

这种行比较语法会按照预期工作:它把这一对值视为一个字典序值。created_at 相同的行会用 id 作为平局裁决列排序,cursor 也同时跟踪两者。

Postgres 原生支持这种语法。MySQL 从 5.7+ 开始支持,但更早版本需要手动展开:

sql
-- MySQL <5.7 or for maximum portability
WHERE created_at < :cursor_created_at
   OR (created_at = :cursor_created_at AND id < :cursor_id)

语义相同,语法更繁琐。复合索引 (created_at, id) 可以服务这两种形式。

如果主键不是自增,例如 UUID,仍然可以使用它。但要注意 UUID 是随机的,所以 UUID 平局裁决列只能提供稳定分页,并不与插入时间相关。对于以 UUID 为键的表,(created_at, id) 中的 id 即使是 UUID,也仍然可以作为平局裁决列,只是不要期待单独使用 id 能反映时间顺序。

Laravel 实现

Laravel 从 8.x 开始内置 cursor 分页,能够封装大部分实现细节:

php
$orders = Order::where('tenant_id', 42)
    ->orderBy('created_at', 'desc')
    ->orderBy('id', 'desc')           // tiebreaker
    ->cursorPaginate(20);
// The response already includes next/prev cursors
return response()->json([
    'data'       => $orders->items(),
    'next_cursor' => $orders->nextCursor()?->encode(),
    'prev_cursor' => $orders->previousCursor()?->encode(),
]);

后续请求可以这样写:

php
Order::where('tenant_id', 42)
    ->orderBy('created_at', 'desc')
    ->orderBy('id', 'desc')
    ->cursorPaginate(20, ['*'], 'cursor', $request->query('cursor'));

Laravel 的 cursorPaginate 会在内部把 cursor 编码为 base64 JSON,处理平局裁决列,并生成下一页 URL。多数生产环境 Laravel 应用在数据集可能增长到几千行以上时,都应该优先使用它,而不是旧的 paginate()

需要注意一点:cursorPaginate() 要求 orderBy 列真实存在于模型上。无法在没有特殊处理的情况下对计算列做 cursor 分页。如果按 DB::raw() 或 JSON 提取结果排序,就需要物化列或替代方案。

深度分页与管理后台

“Cursor 分页很好,但管理后台需要页码。”这是合理需求。下面是既满足需求又不拖垮数据库的处理方式。

现实中的管理后台通常有三类访问模式,其中只有一种真正从 offset 分页获益。

大多数用户只会查看第 1 到第 5 页。这占管理后台流量的 99%,也就是最近的数据。Cursor 分页可以完美处理这种场景,界面可以渲染为“更旧”/“更新”按钮。

偶尔有用户想跳到某个具体日期。他们真实想要的通常不是第 47,832 页,而是“查看 2024 年 3 月的订单”。这可以通过日期过滤器处理,并成为 cursor 的 WHERE 条件的一部分。查询仍然保持 O(log N + K)

少数场景中,用户确实需要知道“大概已经浏览到多深”。这时可以缓存近似总行数,并展示类似“第 1–5 页,约 5000 万条”的信息,而无需执行真实的 COUNT(*)

一个覆盖这三类需求的混合模式如下:

php
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,   // 1 hour
            callback: function () use ($tenantId) {
                // Use EXPLAIN to get an estimate, not a real count
                $stmt = $this->db->prepare(
                    "EXPLAIN SELECT id FROM orders WHERE tenant_id = :t"
                );
                $stmt->execute([':t' => $tenantId]);
                $row = $stmt->fetch(PDO::FETCH_ASSOC);
                return (int) ($row['rows'] ?? 0);
            }
        );
    }
    public function page(int $tenantId, ?string $cursor, ?string $dateFilter): array
    {
        // ...standard cursor pagination, plus an optional date WHERE clause...
    }
}

EXPLAIN 会给出 MySQL 对查询将扫描多少行的估算。它并不精确,但速度很快,通常是微秒级,相比秒级真实统计,已经足够用于在界面中展示“约 5000 万订单”。Postgres 可以通过 pg_class.reltuplesEXPLAIN 使用相同技巧。

对于确实需要某个过滤条件精确计数的少数场景,应将计数物化。由后台任务更新的 tenant_statistics 表,或者具备汇总语义的数据库视图,都优于按需执行 COUNT(*)

流式处理大型数据集:生成器与非缓冲查询

有时分页并不是答案。导出、迁移、批处理任务确实需要处理全部 5000 万行。如果一次性加载到 PHP 内存中,进程会崩溃。如果在循环中每次分页 20 行,也只是更慢地崩溃,100 万次查询即便每次 5ms,也需要 83 分钟,而且还没有开始执行真正工作。

答案是使用非缓冲查询进行流式处理。

默认情况下,PDO 会在第一次 fetch() 调用返回之前,把整个结果集取到内存中。对于 5000 万行查询,这意味着数 GB PHP 堆内存,进程会在处理第一行之前就被 OOM killer 杀掉。

非缓冲查询会改变模型。数据库把行一条一条流式传给 PHP,PHP 同一时间只持有一行。

MySQL 示例:

php
$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);
    // Only one row in memory at a time
}

无论结果集是 20 行还是 2000 万行,内存都会保持在几 MB。代价是整个执行期间 MySQL 连接都处于忙碌状态,结果集耗尽或显式关闭之前,无法在这个连接上执行其他查询。对于长时间运行的批处理任务,这是可以接受的。对于 Web 请求,则不适合。

把它封装成生成器,就能得到清晰的迭代接口:

php
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);
}

foreach 不会加载全部用户,而是每次迭代拉取一行、处理一行,然后继续前进。yield 让这个函数成为生成器,而不是数组构造器。

Postgres 的非缓冲查询略有不同。使用 PDO_PGSQL 时,需要使用游标:

php
$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 中,这些方式都有封装:

php
// Lazy collection — loads one row at a time, generator-style
User::where('active', true)->lazy()->each(function ($user) {
    // process
});
// Chunk - loads N at a time, more efficient round trips
User::where('active', true)->chunk(1000, function ($users) {
    foreach ($users as $user) {
        // process
    }
});
// Cursor - lowest memory, one row at a time
User::where('active', true)->cursor()->each(function ($user) {
    // process
});

chunk() 通常是批处理任务的默认选择。cursor() 适合内存极度受限的场景。lazy() 是现代中间方案,它内部按块读取,但对外暴露生成器式接口。

Chunk-ID 模式:可恢复的流式处理

还有一个值得掌握的模式,因为非缓冲查询存在一个失败模式:如果进程在中途崩溃,当前位置就丢失了。处理数百万行的批处理任务需要具备可恢复能力。

技巧是按主键迭代,而不是按排序列上的 cursor 迭代:

php
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 都是一条新查询,连接不会跨 chunk 长时间占用。如果进程在第 12,345,678 行崩溃,可以从 $lastId = 12_345_000 重新开始,而不会丢失后续数据。把最后处理的 ID 存到某个位置,例如数据库 checkpoint 表、文件或 Redis,启动时读取即可。

这比逐行非缓冲流式处理更慢,但健壮性高得多。任何运行时间超过几分钟的任务,可恢复性通常比吞吐量更有价值。

Laravel 内置了 chunkById()

php
User::where('active', true)
    ->chunkById(1000, function ($users) {
        foreach ($users as $user) {
            processUser($user);
        }
        // If this callback throws or the job is killed, the next run
        // resumes from the last successfully processed ID.
    });

索引是不可见的前提

以上所有内容都假设分页查询实际命中了索引。如果没有命中索引,这些模式都无法发挥作用。

Cursor 分页所需索引通常会镜像排序顺序,并把过滤列放在最前面:

sql
-- For: WHERE tenant_id = X ORDER BY created_at DESC, id DESC
CREATE INDEX idx_orders_tenant_time ON orders (tenant_id, created_at, id);
-- For: WHERE status = 'pending' ORDER BY priority, id
CREATE INDEX idx_orders_status_priority ON orders (status, priority, id);

复合索引的经验法则是:等值过滤列在前,例如 tenant_id = X,然后按顺序放排序列。范围条件,例如 created_at > Y,放在末尾。

验证索引是否被使用:

sql
EXPLAIN SELECT ... WHERE tenant_id = 42 AND (created_at, id) < ('...', 1234)
ORDER BY created_at DESC, id DESC LIMIT 20;

关注 type: rangetype: refExtra: Using index condition,以及接近 LIMIT 的较低 rows 估算。如果看到 type: ALLUsing filesort,说明索引没有匹配,应先修复索引,再考虑其他优化。

Postgres 中,EXPLAIN ANALYZE 会给出类似报告;关注 Index ScanIndex Only Scan,而不是 Seq Scan

还有一条建议:如果分页查询需要多个排序列,并且表非常大,覆盖索引(包含 SELECT 所需全部列)可以避免回表:

sql
CREATE INDEX idx_orders_tenant_time_covering
  ON orders (tenant_id, created_at, id)
  INCLUDE (status, total);    -- Postgres syntax; MySQL 8+ uses the same column list

有了覆盖索引后,整个查询可以只访问索引而不触碰主表。响应时间会显著下降,宽表尤其明显。

分页不足以解决的问题:搜索引擎与预聚合

有一类查询,无论 cursor 多巧妙都无法修复。比如 5000 万行上的全文搜索、按评分排序展示 50 个分类中每个分类的前 10 个商品、包含十几个独立维度的分面过滤。

这类问题的模式不是“把分页做得更好”,而是“停止用主数据库执行这类查询”。

合适的工具是搜索引擎:Elasticsearch、Meilisearch、OpenSearch、Typesense。通过 outbox 模式或 CDC 异步索引数据,由搜索服务处理分页、排序和过滤,在这类规模下,关系型数据库很难匹配其能力。PHP 应用通过 HTTP 查询搜索服务,拿回 ID(或完整文档),如果对新鲜度有要求,再从主数据库补充最新数据。

对于聚合查询,例如“过去一年每天的订单总额”,应提前计算。由定时任务或触发器更新的物化汇总表,可以把原本每次请求都需要扫描数百万行的聚合读取变成常数时间读取。代价是汇总数据会有一定滞后;在大多数管理后台场景中,5 分钟前的计数完全可以接受。

规则是:如果分页查询包含 GROUP BY、全文 LIKE '%...%',或者超过两三个独立过滤维度,它大概率不应该落在事务数据库上。把它迁移出去。

并发写入下的一致性

长期分页中还有一个重要问题:用户翻页时,如果有行被插入或删除,会发生什么?

Cursor 分页可以优雅处理插入。页与页之间出现的新行,要么排序在 cursor “之后”,因此会在后续页面中被取到;要么排序在 cursor “之前”,只能在刷新后可见。不会出现重复或跳过行。

删除更复杂一些。如果当前持有 cursor 的那一行被删除,仍然可以继续分页,因为 cursor 只是一个“位置”,数据库会定位到该位置之后的下一行。但用户不会看到被删除的行。通常这是正确行为。

在绝对一致性重要的场景,例如导出任务、财务报表、审计日志,应使用一致性快照。MySQL InnoDB 在事务隔离级别设为 REPEATABLE READ(默认)时,会在事务持续期间持有一个快照。Postgres 的 SERIALIZABLE,甚至 REPEATABLE READ 也具备相同能力。开启事务、流式读取数据、提交事务。读取到的行就是事务开始时存在的那些行,即便并发写入已经修改了它们。

php
$pdo->beginTransaction();
try {
    foreach (streamUsers($pdo) as $user) {
        processUser($user);
    }
    $pdo->commit();
} catch (\Throwable $e) {
    $pdo->rollBack();
    throw $e;
}

这能保证一致性,但整个过程中会绑定到单个连接,而且长事务本身也有问题,例如会阻止 Postgres 的 autovacuum,或导致 MySQL undo log 膨胀。适合分钟级导出任务,不适合小时级任务。

小型问答

问:可以用 cursor 分页提供“跳到第 X 页”吗?

不能直接做到。Cursor 只知道从给定位置开始的“下一页”和“上一页”。可以通过日期或 ID 计算 cursor 后近似跳转,例如“跳到 2024 年 3 月”可以变成 cursor = encode('2024-03-01', 0),但如果没有单独的页边界索引,就无法把页码映射为 cursor。实践中,想要“第 47,832 页”的用户,几乎总是在表达“按 X 过滤”的需求。应构建过滤器。

问:UUID 是随机的,keyset 分页还能工作吗?

可以,只要把 UUID 纳入排序和 cursor。UUID 不需要具备时间顺序,只需要唯一且排序一致。这样会失去“按 ID 最新优先”的快捷方式,因此始终需要先按 created_at 排序,再用 UUID 作为平局裁决列。UUIDv7 具备时间排序能力,两者兼得;新项目应优先考虑它,而不是随机 v4。

问:总数统计可以用 SELECT COUNT(*) 吗?

不要在大型表的 Web 请求中这样做。对 5000 万行做完整计数是一个多秒级操作,会占用连接。准确计数应维护由触发器或定时任务更新的 table_row_counts 汇总表。近似计数可以使用 EXPLAINinformation_schema.TABLES.TABLE_ROWS(MySQL)/ pg_class.reltuples(Postgres)。

问:如何对视图或复杂 join 做分页?

如果排序列来自单张表、索引设计正确,并且视图底层查询支持下推,视图上的 cursor 分页可以工作。对于复杂 join,通常需要在根表上分页,也就是概念上正在迭代的那张表,然后单独补齐 join 数据,或者使用子查询。如果是物化视图,则把它当作表处理。

问:这适用于软删除行吗?

适用,但需要谨慎处理过滤条件。如果基础 WHERE 包含 deleted_at IS NULL,它需要和排序列一起被索引。否则数据库虽然可以定位 cursor 位置,却还要继续扫描来寻找未删除行。通常修复方式是在相同列上建立 WHERE deleted_at IS NULL 的部分索引。

问:可以对不按数据库列排序的数据做 cursor 分页吗?

不能。Keyset 分页本质上需要确定性的数据库级排序。如果需要按 PHP 计算出的分数或随机顺序分页,就进入了“物化或放弃”的范围:要么把分数计算到列中,要么使用内置评分能力的搜索引擎。

问:如果需要同时支持向前和向后分页怎么办?

只要 cursor 编码了足够信息,就可以双向工作。常见模式是在每页返回 next_cursorprev_cursorprev cursor 使用第一页第一行的值,比较运算符反转(降序场景下从 < 变为 >),并在查询中反转排序方向。Laravel 的 cursorPaginate() 已经处理这一点;自定义实现需要显式跟踪两个方向。

结语

分页是那种在数据变得真实之前看似微不足道的问题。LIMIT N OFFSET M 只有三个词,却隐藏了一个 O(N+M) 级灾难;修复通常也只是多几个词:WHERE (col1, col2) < (?, ?),却能让大多数用例中的问题消失。这类替换在开发环境中收益并不明显,却能在生产环境中救命。

更大的教训是,“如何遍历一个数据集”是一项设计决策,而不是默认选择。正确答案取决于用户是向前浏览、向后浏览、随机跳转、深度浏览,还是根本不需要浏览;取决于数据是热数据还是冷数据;取决于一致性是否比新鲜度更重要。所需工具已经在技术栈中:cursor、流式处理、部分索引、生成器。关键是在第 5000 万行到来之前使用它们。

把模式构建一次,然后在所有地方复用。原本需要 45 秒的请求变成 30 毫秒。连接池不再被慢查询长期占满。凌晨 3 点的告警也不会出现。这就是把分页做对后的复利价值。

快速参考卡片

Offset 分页,应避免在大型数据集上使用:

sql
SELECT * FROM orders ORDER BY created_at DESC LIMIT 20 OFFSET 2000000;
-- Reads 2,000,020 rows to return 20. Gets worse with depth.

Cursor 分页,适合作为会增长数据集的默认选择:

sql
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). Scales to billions.

Keyset 分页所需索引:

sql
CREATE INDEX idx_orders_time ON orders (created_at, id);
-- Or with filter: (tenant_id, created_at, id)

流式处理巨大结果集:

php
// MySQL unbuffered
$pdo = new PDO($dsn, $user, $pass, [
    PDO::MYSQL_ATTR_USE_BUFFERED_QUERY => false,
]);
// Laravel equivalents (pick one)
User::where(...)->cursor()->each(fn($u) => process($u));    // lowest memory
User::where(...)->lazy()->each(fn($u) => process($u));      // generator, modern
User::where(...)->chunk(1000, fn($batch) => ...);           // batched
User::where(...)->chunkById(1000, fn($batch) => ...);       // resumable

核心规则:

  • 始终在排序和 cursor 中包含唯一平局裁决列,通常是 id
  • 索引要匹配排序顺序。等值过滤列在前,排序列在后
  • 获取 perPage + 1 行,以便在不执行 count 查询的情况下判断是否有下一页
  • 不透明 cursor(base64 JSON)可以在不破坏客户端的情况下演进格式
  • 导出和批处理任务使用 chunkById() 或等价方案,进程崩溃也不会丢失进度

已经超出 cursor 分页能力范围的信号:

  • 全文搜索,应迁移到 Elasticsearch/Meilisearch
  • 复杂聚合,应迁移到物化汇总表
  • 超过 2–3 个独立过滤维度,应迁移到搜索引擎
  • 真实的“跳到第 N 页”需求,应重新考虑 UX;用户通常需要的是过滤器,而不是页码
本作品采用《CC 协议》,转载必须注明作者和本文链接