本文目录导读:

在 PHP 中处理大分页(比如百万级数据)时,传统 LIMIT offset, size 方式会随着 offset 增大而性能急剧下降,以下是系统性的优化方案:
核心问题分析
传统方式为什么慢:
-- offset 越大,MySQL 需要扫描并丢弃的行越多 SELECT * FROM users ORDER BY id LIMIT 1000000, 20; -- MySQL 需要扫描 1,000,020 行,然后丢弃前 1,000,000 行
优化方案(按推荐程度排序)
延迟关联(最推荐)
-- 先只取主键,再关联查询
SELECT u.*
FROM users u
INNER JOIN (
SELECT id
FROM users
ORDER BY id
LIMIT 1000000, 20
) AS tmp ON u.id = tmp.id;
PHP 实现:
// 使用 PDO 预处理
$stmt = $pdo->prepare("
SELECT u.*
FROM users u
INNER JOIN (
SELECT id
FROM users
ORDER BY id
LIMIT :offset, :limit
) AS tmp ON u.id = tmp.id
");
$stmt->bindValue(':offset', $offset, PDO::PARAM_INT);
$stmt->bindValue(':limit', $limit, PDO::PARAM_INT);
$stmt->execute();
游标/键集分页(最佳性能)
// 基于上次查询的最后 ID(前提是必须有连续的唯一标识,如自增ID)
function getUsersAfter($lastId, $limit = 20) {
$sql = "SELECT * FROM users
WHERE id > :lastId
ORDER BY id ASC
LIMIT :limit";
$stmt = $pdo->prepare($sql);
$stmt->bindValue(':lastId', $lastId, PDO::PARAM_INT);
$stmt->bindValue(':limit', $limit, PDO::PARAM_INT);
$stmt->execute();
return $stmt->fetchAll();
}
// 使用时
$lastId = isset($_GET['last_id']) ? (int)$_GET['last_id'] : 0;
$users = getUsersAfter($lastId);
// 下一页的 last_id = end($users)['id']
复合索引优化
-- 建立合适的复合索引 CREATE INDEX idx_status_created ON users(status, created_at); -- 查询条件包含 status 时常这样 SELECT * FROM users WHERE status = 'active' ORDER BY id LIMIT 1000000, 20;
使用覆盖索引(Using index)
$sql = "SELECT id, name, email
FROM users
FORCE INDEX (PRIMARY)
ORDER BY id
LIMIT :offset, :limit";
极端方案
缓存总页数 + 分段缓存
class PaginationCache {
const PAGE_CACHE_PREFIX = 'page_data_';
public static function getPage($table, $page, $limit = 20) {
$cacheKey = self::PAGE_CACHE_PREFIX . $table . '_' . $page;
if ($data = Redis::get($cacheKey)) {
return json_decode($data, true);
}
// 计算 offset
$offset = ($page - 1) * $limit;
// 执行查询(使用上面优化的方法)
$data = self::queryOptimized($table, $offset, $limit);
// 缓存(设置合理过期时间)
Redis::setex($cacheKey, 300, json_encode($data));
return $data;
}
}
NoSQL + 搜索索引
// 使用 Elasticsearch 等搜索引擎
$params = [
'index' => 'users',
'body' => [
'from' => $offset,
'size' => $limit,
'query' => [
'match_all' => new \stdClass()
],
'sort' => ['id' => ['order' => 'desc']]
]
];
$response = $esClient->search($params);
实用工具函数
class QueryOptimizer {
/**
* 最大可接受的 offset
*/
const MAX_OFFSET = 10000;
/**
* 智能分页决策
*/
public static function smartPaginate($query, $offset, $limit) {
// offset 较小,直接用传统方式
if ($offset < self::MAX_OFFSET) {
return $query->limit($offset, $limit);
}
// 大数据量时使用游标方式
return $query->where('id', '>',
self::getLastIdFromOffset($offset))
->limit($limit);
}
/**
* 估算 offset 位置的 ID(基于统计)
*/
private static function getLastIdFromOffset($offset) {
// 方案:从统计信息中估算
$avgRowsPerId = DB::table('users')
->count() / DB::table('users')->max('id');
return intval($offset / $avgRowsPerId);
}
}
前端体验优化
// 1. 禁止跳页(使用"加载更多")
echo '<div class="load-more" data-page="' . $page . '">加载更多</div>';
// JavaScript
$('.load-more').on('click', function() {
var nextPage = $(this).data('page') + 1;
$.get('/api/users?page=' + nextPage, function(data) {
if (data.has_more) {
$('.user-list').append(data.html);
$('.load-more').data('page', nextPage);
} else {
$('.load-more').hide();
}
});
});
// 2. 告知用户总计但用部分加载
性能监控
// 记录慢查询
$start = microtime(true);
$result = $pdo->query($sql);
$elapsed = microtime(true) - $start;
if ($elapsed > 0.5) { // 500ms
\Log::warning('Slow pagination query', [
'sql' => $sql,
'time' => $elapsed,
'offset' => $offset
]);
}
完整示例
class OptimizedPagination {
public function paginate($table, $page, $perPage = 20, $where = []) {
// 1. 参数校验
$page = max(1, (int)$page);
$perPage = min(100, max(1, (int)$perPage));
// 2. 数据量判断
$total = $this->getTotal($table, $where);
$maxPage = (int)ceil($total / $perPage);
$page = min($page, $maxPage);
// 3. 策略选择
$offset = ($page - 1) * $perPage;
if ($offset < 10000) {
// 小数据量:传统分页
$data = $this->simplePaginate($table, $offset, $perPage, $where);
} else {
// 大数据量:延迟关联
$data = $this->lazyJoinPaginate($table, $offset, $perPage, $where);
}
return [
'data' => $data,
'pagination' => [
'page' => $page,
'per_page' => $perPage,
'total' => $total,
'has_more' => $page < $maxPage
]
];
}
private function lazyJoinPaginate($table, $offset, $limit, $where) {
$whereClause = $where ? ' WHERE ' . implode(' AND ', $where) : '';
$sql = "SELECT * FROM {$table}
INNER JOIN (
SELECT id FROM {$table}
{$whereClause}
ORDER BY id ASC
LIMIT {$offset}, {$limit}
) as tmp ON {$table}.id = tmp.id";
return DB::select($sql);
}
}
总结建议
- 最推荐:使用游标/键集分页,彻底避免 offset 性能问题
- 次推荐:使用延迟关联,不改需求下优化明显
- 数据量 > 1000万:建议引入搜索引擎(ES)或分区表
- 前端配合:改为"加载更多"模式,避免跳页
核心思想:永远不要让数据库做无谓的扫描,尽量使用索引和主键定位来缩小查询范围。