
PHP实例:如何编写一个简单的分页查询功能
列表页通常不能一次加载全部数据:用户要浏览文章、订单或商品时,分页可以限制单次查询和页面展示的数据量,也能让用户定位到特定页。实现分页并不只是给 SQL 加上 LIMIT,还要处理页码和每页条数的校验、总数统计、空结果、排序稳定性,以及用户数据的安全输出。
本文使用 PHP PDO 和 MySQL 演示一个可运行的分页示例。代码采用传统的页码分页,适合数据规模适中、需要“上一页、下一页、跳转到第 N 页”的列表。若数据量很大、用户主要连续向后翻页,后文也会说明为什么要考虑游标分页。
一、分页查询的基本原理
假设每页显示 per_page 条,当前页为 page,从零开始计算的偏移量是:
offset = (page - 1) * per_page
例如每页 10 条,第 1 页从第 0 条开始,第 2 页从第 10 条开始。MySQL 查询通常写成:
SELECT id, title, created_at
FROM articles
ORDER BY created_at DESC, id DESC
LIMIT :per_page OFFSET :offset
查询列表只能得到当前页的数据。若界面还要显示“共多少条”和“共多少页”,通常还需要一次 COUNT(*) 查询。总页数的计算为:
total_pages = ceil(total_rows / per_page)
当总数为 0 时,总页数也为 0。此时不存在有效的数据页,页面仍可以正常展示空列表。
二、准备数据表
以下示例使用一张简单的文章表。请先在 MySQL 中创建数据库,再执行建表语句并插入几条测试数据。字符集可按项目实际情况调整。
CREATE TABLE articles (
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
title VARCHAR(200) NOT NULL,
body TEXT NOT NULL,
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
PRIMARY KEY (id),
INDEX idx_articles_created_id (created_at, id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
INSERT INTO articles (title, body, created_at) VALUES
('PHP 分页入门', '分页的基础示例。', '2025-01-03 10:00:00'),
('PDO 查询实践', '使用预处理语句访问数据库。', '2025-01-04 11:30:00'),
('列表性能提示', '了解索引和大偏移量的影响。', '2025-01-05 09:15:00');
本例的排序条件是 created_at DESC, id DESC。仅按时间排序时,如果多条记录时间相同,它们之间的先后顺序并不确定;增加唯一的 id 作为第二排序键,可以让查询结果的顺序更稳定。复合索引是否合适,要结合真实的查询条件和执行计划评估。
三、完整示例:PDO 查询与分页导航
下面的文件可保存为 articles.php。示例运行环境为 PHP 8.1 或更高版本,并要求启用 PDO MySQL 扩展。请把数据库连接参数换成自己的配置;生产环境不要把密码写进公开代码仓库,宜通过环境变量或安全的配置管理方式提供。
示例约定:页码必须是正整数;每页条数由服务器固定为 10,客户端不能通过参数把它改成任意数值;超过本例设定的最大页码时返回 HTTP 400。最大页码是应用策略,不是 PHP 或 MySQL 的通用限制,实际项目应按数据规模和产品需求设定。
<?php
declare(strict_types=1);
const PER_PAGE = 10;
const MAX_PAGE = 100000;
function badRequest(string $message): never
{
http_response_code(400);
header('Content-Type: text/plain; charset=utf-8');
echo $message;
exit;
}
/**
* 读取并校验正整数型 GET 参数。
* 缺省时使用默认值;数组、非数字、0、负数及超出范围的值均拒绝。
*/
function getPositiveInt(
string $name,
int $default,
int $maximum
): int {
if (!array_key_exists($name, $_GET)) {
return $default;
}
$value = $_GET[$name];
// 拒绝 page[]=1 这类数组参数。
if (!is_string($value) || !preg_match('/^[0-9]+$/D', $value)) {
badRequest('参数格式不正确:' . $name);
}
// 先去掉前导零,避免把任意长的数字字符串直接转换为整数。
$normalized = ltrim($value, '0');
if ($normalized === '') {
badRequest('参数必须大于 0:' . $name);
}
// 在转换前检查长度和字典序,避免超大数字转换时发生溢出或截断。
$maximumString = (string) $maximum;
if (
strlen($normalized) > strlen($maximumString)
|| (
strlen($normalized) === strlen($maximumString)
&& strcmp($normalized, $maximumString) > 0
)
) {
badRequest('参数超出允许范围:' . $name);
}
return (int) $normalized;
}
function h(string $value): string
{
return htmlspecialchars(
$value,
ENT_QUOTES | ENT_SUBSTITUTE,
'UTF-8'
);
}
$page = getPositiveInt('page', 1, MAX_PAGE);
$perPage = PER_PAGE;
// 即使 page 和 perPage 都已限制,也显式检查乘法边界。
// 这样当应用调整限制时,不会悄悄产生错误的偏移量。
if ($page - 1 > intdiv(PHP_INT_MAX, $perPage)) {
badRequest('请求页码过大');
}
$offset = ($page - 1) * $perPage;
$dsn = 'mysql:host=127.0.0.1;dbname=demo;charset=utf8mb4';
$dbUser = 'demo_user';
$dbPassword = 'replace-with-your-password';
$options = [
PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION,
PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC,
PDO::ATTR_EMULATE_PREPARES => false,
];
try {
$pdo = new PDO($dsn, $dbUser, $dbPassword, $options);
} catch (PDOException $e) {
// 详细错误应记录到服务端日志,不要直接返回给访客。
error_log($e->getMessage());
http_response_code(500);
exit('数据库连接失败');
}
try {
// 统计符合条件的记录数。本例没有额外筛选条件。
$countStatement = $pdo->query('SELECT COUNT(*) FROM articles');
$totalRows = (int) $countStatement->fetchColumn();
$totalPages = (int) ceil($totalRows / $perPage);
// 页码合法但超出当前总页数时,返回空列表,不擅自改成最后一页。
// 例如数据被删除后,用户浏览器中保存的旧页码可能变得无效。
$statement = $pdo->prepare(
'SELECT id, title, created_at
FROM articles
ORDER BY created_at DESC, id DESC
LIMIT :per_page OFFSET :offset'
);
$statement->bindValue(':per_page', $perPage, PDO::PARAM_INT);
$statement->bindValue(':offset', $offset, PDO::PARAM_INT);
$statement->execute();
$articles = $statement->fetchAll();
} catch (PDOException $e) {
error_log($e->getMessage());
http_response_code(500);
exit('查询失败');
}
function pageUrl(int $page): string
{
return '?' . http_build_query(['page' => $page]);
}
?>
<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>文章列表</title>
</head>
<body>
<h1>文章列表</h1>
<p>
共 <?= h((string) $totalRows) ?> 条,
当前第 <?= h((string) $page) ?> 页,
共 <?= h((string) $totalPages) ?> 页
</p>
<?php if ($articles === []): ?>
<p>当前页没有数据。</p>
<?php else: ?>
<ul>
<?php foreach ($articles as $article): ?>
<li>
<strong><?= h((string) $article['title']) ?></strong>
<span>
<?= h((string) $article['created_at']) ?>
</span>
</li>
<?php endforeach; ?>
</ul>
<?php endif; ?>
<nav aria-label="分页">
<?php if ($page > 1): ?>
<a href="<?= h(pageUrl($page - 1)) ?>">上一页</a>
<?php endif; ?>
<?php if ($totalPages > 0): ?>
<span>第 <?= h((string) $page) ?> 页</span>
<?php endif; ?>
<?php if ($page < $totalPages): ?>
<a href="<?= h(pageUrl($page + 1)) ?>">下一页</a>
<?php endif; ?>
</nav>
</body>
</html>
四、代码中的边界处理
1. 页码不是可信输入
浏览器地址栏中的参数由客户端控制,不能因为参数名叫 page,就直接拿来计算偏移量。示例要求它是只含数字的字符串,并拒绝数组、负数、零和超出策略范围的数值。诸如 ?page=abc、?page[]=2、?page=0 都会得到 400 响应。
如果产品希望对非法页码采用默认第一页,而不是返回 400,也可以改成回退策略;关键是要明确行为并统一处理。不能未经校验就把输入强制转换为整数,因为这可能掩盖格式错误,也可能导致极大数字转换不符合预期。
2. 每页条数应由服务端控制
示例把每页条数固定为 10。若产品需要允许用户选择每页条数,应从一组明确的选项中选择,例如 10、20、50,而不是接受任意大的客户端数值。即使查询使用预处理语句,允许一次取几十万条数据仍可能造成数据库、内存和响应时间压力。
3. 空数据与超出末页不是同一件事
当表中没有记录时,totalPages 为 0,列表显示空状态即可。如果请求页码大于总页数,示例仍执行带偏移量的查询并得到空数组,页面会显示“当前页没有数据”,但不会显示下一页链接。另一种常见设计是把超出范围的页码重定向到最后一页;两种行为都可以,选择时要考虑页面链接、缓存和产品预期。
要注意总数和列表查询是两次独立查询。在它们之间如果有其他事务插入或删除数据,总数与列表可能短暂不完全一致。对大多数普通列表,这种轻微差异可以接受;如果业务要求两次读取看到严格一致的快照,需要结合数据库隔离级别和事务设计处理。仅仅把两条语句放进事务,并不意味着所有配置下都自动满足同一种一致性要求。
五、筛选条件如何加入分页
文章列表经常还要按状态、分类或关键词筛选。筛选条件必须同时用于总数查询和列表查询,否则显示的总数与实际分页结果会不一致。值应通过 PDO 参数绑定,不能把用户输入直接拼接进 SQL。
以下是“按状态筛选”的查询结构示例。状态值先做业务校验,然后两条 SQL 都使用同一个条件:
$status = $_GET['status'] ?? 'published';
$allowedStatuses = ['published', 'draft'];
if (!is_string($status) || !in_array($status, $allowedStatuses, true)) {
badRequest('状态参数不正确');
}
$countStatement = $pdo->prepare(
'SELECT COUNT(*) FROM articles WHERE status = :status'
);
$countStatement->execute([':status' => $status]);
$totalRows = (int) $countStatement->fetchColumn();
$listStatement = $pdo->prepare(
'SELECT id, title, created_at
FROM articles
WHERE status = :status
ORDER BY created_at DESC, id DESC
LIMIT :per_page OFFSET :offset'
);
$listStatement->bindValue(':status', $status, PDO::PARAM_STR);
$listStatement->bindValue(':per_page', $perPage, PDO::PARAM_INT);
$listStatement->bindValue(':offset', $offset, PDO::PARAM_INT);
$listStatement->execute();
以上片段假设表中存在 status 列。不能把表名、列名或排序方向当作普通参数绑定;它们属于 SQL 结构,若需要由用户选择,应该通过服务端白名单映射。例如只允许 created_at 和 title 两种排序字段,再由程序选择对应的固定 SQL 片段,而不是直接拼接请求参数。
对关键词搜索也应绑定参数。使用 LIKE 时,绑定可以防止输入内容被解释成 SQL 代码,但百分号和下划线仍是 LIKE 的通配符。如果业务要求把用户输入视为普通文本,还需要按 MySQL 的转义规则处理这些通配符,并正确设置 ESCAPE;“参数化”不等于自动取消 LIKE 的模式含义。
六、安全要点:SQL 注入与 XSS 是两类问题
SQL 参数绑定保护查询值
LIMIT、OFFSET 和筛选值在示例中都通过 PDO 绑定,并指定整数或字符串类型。这样可以避免把值直接拼进 SQL。不同 PDO 驱动对预处理的实现细节可能不同;本例使用 MySQL PDO 驱动,并明确将分页数值按整数绑定。不要把参数绑定误认为所有 SQL 片段都能参数化:列名、表名、关键字和排序方向仍需使用固定代码或白名单。
HTML 输出还要做上下文转义
数据库里的标题可能来自用户输入。即使它已经安全地存入数据库,输出到 HTML 时仍应转义,避免恶意内容被浏览器当作标签或脚本执行。示例的 h() 使用 htmlspecialchars,适用于 HTML 文本内容和经过相同 HTML 转义的属性值。
HTML、JavaScript、CSS 和 URL 是不同的输出上下文,不能把一种转义方法机械地用于所有地方。例如,不要把未经处理的数据库内容直接放进脚本代码。示例链接通过 http_build_query 构建查询字符串,并在写入 href 时进行 HTML 转义。
七、常见错误
- 直接拼接页码:把请求参数拼进 SQL 会引入注入风险,也容易在非法输入下产生错误。应校验格式、范围,并通过参数绑定传递查询值。
- 只查列表,不查总数却显示总页数:若需要总页数,应按相同筛选条件执行
COUNT(*);若业务不需要总数,可以只提供“加载更多”或上一页、下一页,不必为显示数字页码付出统计成本。 - 没有稳定排序:不带
ORDER BY的查询结果没有可靠顺序;只按非唯一字段排序时,边界记录也可能前后移动。添加唯一的次级排序键能让顺序更确定。 - 把用户给出的每页条数直接采用:恶意或意外的大值会带来过多查询和输出。应设置上限,或使用服务端定义的选项。
- 认为预处理语句能解决 XSS:参数绑定用于数据库查询安全,不会替代 HTML 输出转义。
- 页码超范围时发生算术问题:先限制页码和每页数量,并在计算偏移量前检查整数边界。数据库和应用仍可能对非常大的偏移量表现不佳,因此范围限制也有资源保护作用。
- 用
rowCount()统计 SELECT 行数:PDO 对不同驱动和查询类型的行为可能不同。要统计符合条件的总记录数,使用明确的SELECT COUNT(*)。
八、性能与适用场景
页码分页易于理解,适用于中小规模数据、后台管理列表,以及用户确实需要跳到某一页的场景。它的两个主要成本是总数统计和大偏移量查询。
当用户请求很靠后的页面时,数据库通常仍需定位并跳过前面的记录;偏移量越大,查询可能越慢。索引能帮助过滤和排序,但不一定能消除跳过大量记录的成本。应根据实际查询条件建立合适索引,并用 EXPLAIN 查看执行计划,而不是仅凭字段名称猜测索引效果。
如果数据量很大,或界面只提供“加载更多”,可以考虑游标分页(也常称 keyset pagination)。它使用上一批最后一条记录的排序键定位下一批,而不是跳过固定数量的行。例如按时间和 ID 倒序时,下一页可用上一页末尾的 created_at 和 id 作为条件:
SELECT id, title, created_at
FROM articles
WHERE created_at < :last_created_at
OR (created_at = :last_created_at AND id < :last_id)
ORDER BY created_at DESC, id DESC
LIMIT :per_page
游标分页通常更适合连续翻页和数据持续增长的列表,但不能自然地支持“跳转到第 500 页”,也需要正确设计游标条件、索引和筛选逻辑。若排序字段可变,或游标条件未覆盖完整排序键,仍可能出现重复或漏项。它不是所有场景下都比页码分页更简单。
此外,频繁执行精确的 COUNT(*) 可能成为成本。若界面只需判断“还有没有下一页”,可以多取一条:每页需要 10 条时查询 11 条,若结果多于 10 条就说明还有下一页,展示时再移除多出的记录。这种设计避免了精确总数统计,但也无法直接显示总记录数和总页数。
九、实际落地前的检查清单
- 明确非法页码、超出末页和空列表的产品行为。
- 对页码做格式与范围校验,对每页数量设置服务端上限。
- 用稳定的
ORDER BY,必要时加入唯一次级排序键。 - 总数查询和列表查询使用完全一致的筛选条件。
- 查询值使用参数绑定;动态排序、字段和表名使用白名单。
- 输出数据库内容时根据输出上下文进行转义。
- 根据数据量和查询计划评估索引、精确计数与大偏移量的成本。
一个可靠的分页功能,重点不在于 SQL 写得多复杂,而在于把输入范围、排序规则、总数口径和空结果行为定义清楚。先用页码分页满足常规列表需求,再依据实际数据规模和性能指标决定是否引入缓存、减少精确计数或改用游标分页。



