php方案 API 分页策略(Cursor vs Offset vs Keyset)用最好的库
【代码】php方案 API 分页策略(Cursor vs Offset vs Keyset)用最好的库。
·
三种分页策略各有适用场景,没有单一"最好的库"——用 league/fractal 做 API ────────
响应层,底层分页逻辑自己实现最灵活。下面给出三种策略的完整对比和代码。
--- 三种策略对比
┌────────┬──────────────────────┬────────────────────────┬──────────────────────────┐
│ 策略 │ 适用场景 │ 优点 │ 缺点 │
├────────┼──────────────────────┼────────────────────────┼──────────────────────────┤
│ Offset │ 小数据集、需跳页 │ 实现简单、支持随机跳页 │ 大数据集性能差、数据漂移 │
├────────┼──────────────────────┼────────────────────────┼──────────────────────────┤
│ Cursor │ 无限滚动、实时数据 │ 性能稳定、无漂移 │ 不能跳页 │
├────────┼──────────────────────┼────────────────────────┼──────────────────────────┤
│ Keyset │ 超大数据集、排序稳定 │ 最快、数据库友好 │ 只能按索引列排序 │
└────────┴──────────────────────┴────────────────────────┴──────────────────────────┘
---
安装
composer require league/fractal
---
1. Offset 分页(传统)
<?php
// offset_pagination.php
use League\Fractal\Manager;
use League\Fractal\Resource\Collection;
use League\Fractal\Pagination\IlluminatePaginatorAdapter;
class UserTransformer extends \League\Fractal\TransformerAbstract
{
public function transform(array $user): array
{
return [
'id' => $user['id'],
'name' => $user['name'],
'email' => $user['email'],
];
}
}
function offsetPaginate(PDO $pdo, int $page, int $limit): array
{
$offset = ($page - 1) * $limit;
// 数据查询
$stmt = $pdo->prepare('SELECT * FROM users ORDER BY id LIMIT :limit OFFSET :offset');
$stmt->bindValue(':limit', $limit, PDO::PARAM_INT);
$stmt->bindValue(':offset', $offset, PDO::PARAM_INT);
$stmt->execute();
$users = $stmt->fetchAll(PDO::FETCH_ASSOC);
// 总数
$total = $pdo->query('SELECT COUNT(*) FROM users')->fetchColumn();
$lastPage = (int)ceil($total / $limit);
$fractal = new Manager();
$resource = new Collection($users, new UserTransformer());
// 手动构建分页元数据
$resource->setMetaValue('pagination', [
'total' => (int)$total,
'per_page' => $limit,
'current_page' => $page,
'last_page' => $lastPage,
'from' => $offset + 1,
'to' => min($offset + $limit, $total),
'links' => [
'prev' => $page > 1 ? "/api/users?page=" . ($page - 1) . "&limit=$limit" : null,
'next' => $page < $lastPage ? "/api/users?page=" . ($page + 1) . "&limit=$limit" : null,
],
]);
return $fractal->createData($resource)->toArray();
}
// 使用
$result = offsetPaginate($pdo, page: 2, limit: 10);
echo json_encode($result, JSON_PRETTY_PRINT);
输出:
{
"data": [
{ "id": 11, "name": "Alice", "email": "alice@example.com" }
],
"meta": {
"pagination": {
"total": 95,
"per_page": 10,
"current_page": 2,
"last_page": 10,
"links": {
"prev": "/api/users?page=1&limit=10",
"next": "/api/users?page=3&limit=10"
}
}
}
}
---
2. Cursor 分页(推荐用于无限滚动)
Cursor 是对某列值的编码指针,不依赖 OFFSET。
<?php
// cursor_pagination.php
function cursorPaginate(PDO $pdo, ?string $cursor, int $limit): array
{
// cursor 是 base64 编码的 id
$afterId = $cursor ? (int)base64_decode($cursor) : 0;
$stmt = $pdo->prepare(
'SELECT * FROM users WHERE id > :after_id ORDER BY id ASC LIMIT :limit'
);
$stmt->bindValue(':after_id', $afterId, PDO::PARAM_INT);
$stmt->bindValue(':limit', $limit, PDO::PARAM_INT);
$stmt->execute();
$users = $stmt->fetchAll(PDO::FETCH_ASSOC);
// 判断是否还有下一页(多取一条)
$hasMore = count($users) === $limit;
$nextCursor = $hasMore ? base64_encode((string)end($users)['id']) : null;
$fractal = new Manager();
$resource = new Collection($users, new UserTransformer());
$resource->setMetaValue('pagination', [
'per_page' => $limit,
'has_more' => $hasMore,
'next_cursor' => $nextCursor,
'links' => [
'next' => $nextCursor
? "/api/users?cursor=$nextCursor&limit=$limit"
: null,
],
]);
return $fractal->createData($resource)->toArray();
}
// 第一页
$result = cursorPaginate($pdo, cursor: null, limit: 10);
// 下一页(用上一次返回的 next_cursor)
$nextCursor = $result['meta']['pagination']['next_cursor'];
$result2 = cursorPaginate($pdo, cursor: $nextCursor, limit: 10);
输出:
{
"data": [...],
"meta": {
"pagination": {
"per_page": 10,
"has_more": true,
"next_cursor": "MTU=",
"links": {
"next": "/api/users?cursor=MTU=&limit=10"
}
}
}
}
---
3. Keyset 分页(最快,适合超大表)
基于索引列的范围查询,完全避免 OFFSET 扫描。
<?php
// keyset_pagination.php
// 支持多列排序的 Keyset 分页
function keysetPaginate(
PDO $pdo,
?int $lastId,
?string $lastCreatedAt,
int $limit
): array {
if ($lastId && $lastCreatedAt) {
// 使用复合 keyset:(created_at, id) 保证唯一排序
$stmt = $pdo->prepare(
'SELECT * FROM users
WHERE (created_at, id) > (:last_created_at, :last_id)
ORDER BY created_at ASC, id ASC
LIMIT :limit'
);
$stmt->bindValue(':last_created_at', $lastCreatedAt);
$stmt->bindValue(':last_id', $lastId, PDO::PARAM_INT);
} else {
$stmt = $pdo->prepare(
'SELECT * FROM users ORDER BY created_at ASC, id ASC LIMIT :limit'
);
}
$stmt->bindValue(':limit', $limit, PDO::PARAM_INT);
$stmt->execute();
$users = $stmt->fetchAll(PDO::FETCH_ASSOC);
$hasMore = count($users) === $limit;
$lastRow = $hasMore ? end($users) : null;
$fractal = new Manager();
$resource = new Collection($users, new UserTransformer());
$resource->setMetaValue('pagination', [
'per_page' => $limit,
'has_more' => $hasMore,
'next_keyset' => $lastRow ? [
'last_id' => $lastRow['id'],
'last_created_at' => $lastRow['created_at'],
] : null,
]);
return $fractal->createData($resource)->toArray();
}
// 第一页
$result = keysetPaginate($pdo, null, null, limit: 10);
// 下一页
$keyset = $result['meta']['pagination']['next_keyset'];
$result2 = keysetPaginate($pdo, $keyset['last_id'], $keyset['last_created_at'], 10);
---
4. 统一封装(策略模式)
<?php
// PaginationService.php
class PaginationService
{
public function __construct(private PDO $pdo) {}
public function paginate(string $strategy, array $params): array
{
return match($strategy) {
'offset' => offsetPaginate($this->pdo, $params['page'], $params['limit']),
'cursor' => cursorPaginate($this->pdo, $params['cursor'] ?? null, $params['limit']),
'keyset' => keysetPaginate(
$this->pdo,
$params['last_id'] ?? null,
$params['last_created_at'] ?? null,
$params['limit']
),
default => throw new \InvalidArgumentException("Unknown strategy: $strategy"),
};
}
}
// API 入口
$service = new PaginationService($pdo);
// 根据请求参数自动选择策略
$strategy = $_GET['strategy'] ?? 'cursor';
$result = $service->paginate($strategy, $_GET);
header('Content-Type: application/json');
echo json_encode($result);
---
选择建议
数据量 < 10万,需要跳页 → Offset
无限滚动 / 实时 feed → Cursor
数据量 > 100万,高性能要求 → Keyset
更多推荐



所有评论(0)