做后台管理系统或者列表页的时候,分页几乎是绕不开的功能。用户要看的可能只是第3页的10条数据,如果你把几万条记录全部查出来交给前端处理,不仅浪费带宽,数据库的压力也会非常大。本文就从PHP连接MySQL讲起,一步步实现一个完整的分页查询功能,把页码计算、SQL拼接、导航条生成这些细节都说清楚。

一、PHP连接MySQL数据库的两种常用方式
在PHP里操作MySQL,主流的有两种方式:mysqli扩展和PDO。mysqli是MySQL专用的扩展,提供面向对象和面向过程两种写法,上手简单;PDO则是PHP官方推荐的数据库抽象层,支持多种数据库,写法统一,并且天然支持预处理语句,防注入能力更强。
先看mysqli的面向对象写法,注意数据库名、用户名和密码要换成自己的:
<?php
// mysqli 面向对象方式连接
$conn = new mysqli('127.0.0.1', 'root', '123456', 'test');
if ($conn->connect_error) {
die('连接失败: ' . $conn->connect_error);
}
$conn->set_charset('utf8mb4'); // 设置字符集,避免中文乱码
?>再看PDO的写法,PDO需要通过DSN字符串来指定数据库类型和地址:
<?php
// PDO方式连接
try {
$pdo = new PDO('mysql:host=127.0.0.1;dbname=test;charset=utf8mb4', 'root', '123456');
$pdo->setAttribute(PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION); // 开启异常模式
} catch (PDOException $e) {
die('连接失败: ' . $e->getMessage());
}
?>两种方式各有侧重。小项目或者只用MySQL的场景,mysqli足够了;如果考虑以后可能换数据库,或者团队规范要求统一使用预处理语句,那就选PDO。分页的核心逻辑与连接方式无关,后面的示例为了通用性,会用PDO来演示。
二、分页的核心原理:LIMIT与页码计算
分页的本质,就是在SQL里用LIMIT限制每次取出的条数,用OFFSET指定从第几条开始取。假设每页显示10条,第1页取第1到10条,第2页取第11到20条,以此类推。换算成SQL就是:
SELECT * FROM articles ORDER BY id DESC LIMIT 10 OFFSET 0; -- 第1页 SELECT * FROM articles ORDER BY id DESC LIMIT 10 OFFSET 10; -- 第2页 SELECT * FROM articles ORDER BY id DESC LIMIT 10 OFFSET 20; -- 第3页
由此可以推导出核心公式:偏移量 = (当前页码 - 1) × 每页条数。当前页码一般通过URL参数传递,比如list.php?page=2,用$_GET['page']获取。这里有个很容易踩的坑:用户可能手动输入page=0、page=-5甚至page=abc,如果不做校验,轻则查出空结果,重则SQL报错。所以拿到页码后必须做强制转换和范围校验:
<?php $page = isset($_GET['page']) ? intval($_GET['page']) : 1; // 强制转整数 $page = max($page, 1); // 最小为1 $pageSize = 10; // 每页条数 $offset = ($page - 1) * $pageSize; ?>
另一个必须处理的细节是总页数的计算。总页数等于总记录数除以每页条数再向上取整,比如103条数据、每页10条,结果是10.3,需要显示11页。PHP里用ceil()函数就能搞定。而总记录数需要额外执行一次COUNT(*)查询,千万别用count(查询结果数组)来统计,那样等于把全表数据都取了一遍,分页就失去意义了。
三、完整的分页实现代码
把前面的知识点串起来,下面是一个可以直接运行的完整示例,包含取页码、算偏移、查总数、取当前页数据、生成导航条五个步骤:
<?php
$pdo = new PDO('mysql:host=127.0.0.1;dbname=test;charset=utf8mb4', 'root', '123456');
// 1. 获取并校验页码
$page = isset($_GET['page']) ? intval($_GET['page']) : 1;
$page = max($page, 1);
$pageSize = 10;
// 2. 查询总记录数
$total = $pdo->query('SELECT COUNT(*) FROM articles')->fetchColumn();
$totalPages = ceil($total / $pageSize);
// 3. 页码超出范围时纠正到最后一页
if ($page > $totalPages && $totalPages > 0) {
$page = $totalPages;
}
$offset = ($page - 1) * $pageSize;
// 4. 用预处理语句查询当前页数据
$stmt = $pdo->prepare('SELECT id, title, created_at FROM articles ORDER BY id DESC LIMIT :limit OFFSET :offset');
$stmt->bindValue(':limit', $pageSize, PDO::PARAM_INT);
$stmt->bindValue(':offset', $offset, PDO::PARAM_INT);
$stmt->execute();
$list = $stmt->fetchAll(PDO::FETCH_ASSOC);
// 5. 输出数据和分页导航
foreach ($list as $row) {
echo '<div>' . htmlspecialchars($row['title']) . '</div>';
}
echo '<div class="pager">';
if ($page > 1) {
echo '<a href="?page=' . ($page - 1) . '">上一页</a> ';
}
for ($i = 1; $i <= $totalPages; $i++) {
$style = ($i == $page) ? 'font-weight:bold' : '';
echo "<a href=\"?page=$i\" style=\"$style\">$i</a> ";
}
if ($page < $totalPages) {
echo '<a href="?page=' . ($page + 1) . '">下一页</a>';
}
echo '</div>';
?>这段代码有几个值得注意的地方。LIMIT和OFFSET用了bindValue并显式指定PDO::PARAM_INT,因为PDO默认会把参数当成字符串拼接,写成LIMIT '10'会导致SQL语法错误。输出标题时用了htmlspecialchars转义,防止存储在数据库里的内容被当成HTML执行,造成XSS漏洞。
分页导航条这里为了演示只做了最简单的全部页码输出,数据量大的列表页通常会做成「上一页 1 2 3 ... 9 10 下一页」这种带省略号的样式,思路是计算当前页附近的页码区间,首尾页固定显示,中间用省略号占位,逻辑稍微复杂一点但原理相同。
四、深分页的性能问题与优化思路
上面这套写法在中小数据量下完全够用,但当数据达到百万级,问题就会暴露出来:LIMIT 10 OFFSET 999990这种深分页查询,MySQL实际要取出前100万条记录再丢弃,页码越靠后越慢。很多人以为LIMIT本身是优化手段,其实OFFSET越大,扫描的行数越多,这是个常见的误区。
优化方案常见的有两种。第一种是子查询定位法,先用覆盖索引定位到起始id,再回表取数据:
SELECT * FROM articles
WHERE id <= (
SELECT id FROM articles ORDER BY id DESC LIMIT 999990, 1
)
ORDER BY id DESC LIMIT 10;内层子查询只走主键索引,速度非常快;外层根据定位到的id直接取后续10条,避免了大量无效回表。第二种是游标分页,也就是不传页码,改传上一页最后一条记录的id,SQL写成WHERE id < :lastId ORDER BY id DESC LIMIT 10。这种方式每次都从索引定位点开始扫描,性能与页码深度无关,特别适合App里的下拉加载更多场景,缺点是没法直接跳转到指定页,需要根据业务权衡。
总结一下,PHP做数据库分页的完整链路是:连接数据库、校验页码、算偏移量、COUNT统计总数、LIMIT取数据、生成导航条。把这六步的逻辑理清楚,再结合预处理语句防注入、htmlspecialchars防XSS,一个既安全又好用的分页功能就成型了。数据量上来之后再考虑深分页优化,循序渐进即可。