BLOG

大列表分页如何保持筛选条件和访问性能

分页不仅是加LIMIT,还要保留筛选、排序和稳定的查询顺序。

分类:PHP 全栈开发 阅读:190
大列表分页如何保持筛选条件和访问性能
大列表分页如何保持筛选条件和访问性能

分页查询在大列表场景下,真正的难点往往不在 LIMIT 本身,而在于翻页之后筛选条件是否还在、排序是否稳定、以及深页码访问是否变慢。这三个问题互相牵连,处理顺序错了,后面返工的成本会很高。

先确认筛选条件丢在哪一环

筛选条件丢失,通常不是分页代码写错,而是链接构造或请求方式出了问题。后台列表页常见的翻页方式有两种:一种是点击页码触发整页刷新,筛选值通过 URL 参数传递;另一种是前端用 Ajax 请求下一页数据,只替换表格区域。

整页刷新时,最容易出错的是分页链接只拼接了页码参数,没有把当前筛选字段一并带上。比如搜索框里填了用户名,点击第 2 页后 URL 变成 ?page=2,用户名条件自然就丢了。判断方法很简单:翻页后看地址栏参数,再刷新页面看列表内容是否和第一页的筛选逻辑一致。

Ajax 方式的问题往往出在请求参数没有持久化。如果每次翻页都只发送页码,后端拿不到筛选条件,返回的自然是全量数据的第二页。检查时可以直接看网络请求的 Query String 或 Form Data,确认筛选字段是否随每次翻页请求一起发送。

另一个隐蔽问题是筛选条件存进了 Session,但用户开了多个标签页,后打开的标签页覆盖了之前的筛选状态。这种设计在后台系统中容易造成“我明明选了状态,怎么列表不对”的困惑。相对稳妥的做法是让筛选条件跟随页面 URL,而不是依赖服务端会话状态。

排序稳定性和深分页性能

排序不稳定表现为翻页时出现重复记录或漏掉记录。原因通常是 ORDER BY 的字段不是唯一的。假设列表按创建时间排序,而同一秒内有大量数据写入,那么这一秒内的记录顺序在数据库层面是不确定的,两次查询可能返回不同的排列。

解决方式是在排序字段后面追加一个唯一字段作为次级排序,通常是主键 ID。这样即使时间相同,ID 的先后顺序也能保证每次查询结果一致。判断排序是否稳定,可以在翻页过程中记录每页第一条和最后一条记录的主键,连续翻几页后检查是否有重复或遗漏。

深分页性能下降是另一个独立问题。LIMIT 100000, 20 这种写法,数据库需要先扫描前 10 万行再丢弃,页码越深,扫描量越大,响应时间越长。这不是加索引能解决的,因为索引只能加速定位,无法跳过扫描。

可行的处理方式有两种。一种是限制最大翻页深度,超过一定页码后提示用户缩小筛选范围或改用搜索。另一种是使用“键集分页”,即不传页码,而是传上一页最后一条记录的排序字段值,用 WHERE create_time < ? ORDER BY create_time DESC LIMIT 20 的方式取下一页。这种方式每次查询都能直接定位到起始位置,不随页码加深而变慢。

键集分页的代价是无法直接跳转到任意页,只支持上一页和下一页。如果业务上必须支持跳页,那就要权衡是接受深页码变慢,还是限制可访问的页数范围。

核心处理逻辑与检查点

后台列表页面的数据流可以拆成三段:接收并校验参数、查询总数和当前页数据、渲染结果。三段之间不要混在一起写。

参数校验是第一步。页码、排序字段、排序方向都必须做白名单校验。页码不能直接信任前端传值,要转成整数并判断范围,小于 1 的按 1 处理。排序字段尤其要注意,不能把用户传入的字符串直接拼进 SQL,否则会产生注入风险。正确做法是维护一个允许排序的字段映射表,只接受映射表中存在的键名。

总数查询和当前页数据查询要分开执行。总数用 COUNT(*),数据查询用 LIMIT 或键集条件。两者不要合并成一条 SQL,也不要先查数据再数总数,那样在数据量大的时候会多一次不必要的全表扫描。

下面是一个简化但完整的原生 PHP 示例,展示筛选、排序和分页参数的处理骨架:

```php
<?php
// 允许的筛选字段和排序字段,不在白名单内的直接忽略
$allowedFilters = ['username' => 'u.name', 'status' => 'u.status'];
$allowedSorts = ['id' => 'u.id', 'created_at' => 'u.created_at'];

$conditions = [];
$params = [];

foreach ($allowedFilters as $inputKey => $column) {
if (isset($_GET[$inputKey]) && $_GET[$inputKey] !== '') {
$conditions[] = "$column = ?";
$params[] = $_GET[$inputKey];
}
}

$sortKey = $_GET['sort'] ?? 'id';
$direction = strtolower($_GET['dir'] ?? 'desc') === 'asc' ? 'ASC' : 'DESC';
$sortColumn = $allowedSorts[$sortKey] ?? 'u.id';

$page = max(1, (int)($_GET['page'] ?? 1));
$perPage = 20;
$offset = ($page - 1) * $perPage;

$whereSql = $conditions ? 'WHERE ' . implode(' AND ', $conditions) : '';

// 总数查询
$countStmt = $pdo->prepare("SELECT COUNT(*) FROM users u $whereSql");
$countStmt->execute($params);
$total = (int)$countStmt->fetchColumn();

// 当前页数据查询
$dataStmt = $pdo->prepare(
"SELECT u.id, u.name, u.status, u.created_at
FROM users u
$whereSql
ORDER BY $sortColumn $direction, u.id DESC
LIMIT $perPage OFFSET $offset"
);
$dataStmt->execute($params);
$rows = $dataStmt->fetchAll();

$totalPages = (int)ceil($total / $perPage);
```

示例中排序字段和方向都经过白名单或三元表达式约束,LIMIT 的偏移量由整数页码计算得出,不会出现字符串拼接注入。筛选条件在翻页时需要由前端拼接到分页链接中,后端只负责读取并校验。

这里有一个容易忽略的细节:$whereSql 中的占位符和 $params 数组的顺序必须一一对应。如果后续增加筛选字段,要同时修改两处,否则会出现参数错位,导致查询结果不符合预期。

容易出错的位置

第一类是翻页后筛选框的值没有回显。用户选了状态为“已发布”,翻到第 3 页,页面顶部的筛选下拉框却恢复成“全部”。这会让用户误以为筛选已失效,实际上数据是筛选后的,只是界面没有保留选择状态。检查方法是翻页后看表单控件的选中值是否和 URL 参数一致。

第二类是总数与当前页数据不一致。常见原因是两次查询之间数据发生了变化,比如用户删除了几条记录,导致当前页数据不足 $perPage 条,但总数还是旧值。处理方式是在渲染前重新计算总页数,如果当前页码超出范围,跳转到最后一页。

第三类是排序字段没有加次级排序。只按 created_at 排序时,如果同一秒有多条记录,翻页过程中可能出现重复。加上 id DESC 作为次级排序后,顺序就确定了。

第四类是筛选条件中的特殊字符处理。用户名或标题中包含 %、_ 时,如果使用 LIKE 查询,这些字符会被当作通配符。要么在拼接前转义,要么改用 = 精确匹配,具体取决于业务是否需要模糊搜索。

检查清单与验收方法

改完分页逻辑后,不要只看第一页就结束。按下面的顺序过一遍:

先验证筛选条件在翻页后是否保留。设置一个筛选条件,翻到第 2 页、第 3 页,再点回第 1 页,确认列表内容和 URL 参数始终一致。

再验证排序稳定性。按创建时间倒序排列,连续翻 5 页以上,记录每页的主键范围,确认没有重复和遗漏。

然后验证边界情况。搜索一个不存在的关键词,确认列表为空且分页控件不报错。页码传负数、传 0、传超出总页数的值,确认后端都做了容错处理。

最后检查数据库索引。用 EXPLAIN 查看筛选字段和排序字段是否命中索引。如果 WHERE 条件中的字段没有索引,数据量过万后查询会明显变慢。索引不是越多越好,但列表页常用的筛选组合值得单独建一个复合索引。

完成这些检查后,可以用简短文字记录改动内容、验证过的页面和异常恢复方式。这份记录不需要很长,但对后续维护和排查问题会有实际帮助。

评论

登录后可发表评论。