TP5 分页查询:count 之后 select 变空,根因是 Builder 状态被消费
在一个后台管理系统里,翻页接口返回的 total 与 list 长期保持一致。直到某天新增了一个筛选条件,测试时发现:total 显示 200,但翻到第 3 页就没有数据了——两个数字对不上。追了半小时,才发现根子不在新代码,而在一个被所有人当作"范式"的老写法里。
现象
FastAdmin / ThinkPHP 5 项目里,getList 控制器的经典写法是这样的:先拼一遍 WHERE 做 count(),再把同样的 WHERE 从头拼一遍做 select(),两段代码各自独立,总计 200+ 行。
// ❌ 旧写法:WHERE 条件手工复制两份
public function getList() {
$params = $this->request->param();
// 第一段:count 查询(50 行 where 组装)
$countDb = Db::name('order')->alias('o')
->leftJoin('member m', 'm.id = o.member_id')
->where('o.del_flag', 0);
if ($params['keyword']) {
$countDb->where('m.name', 'like', "%{$params['keyword']}%");
}
// ... 再写 40 行 ...
$total = $countDb->count('DISTINCT o.id');
// 第二段:select 查询(相同 50 行原样粘贴)
$listDb = Db::name('order')->alias('o')
->leftJoin('member m', 'm.id = o.member_id')
->where('o.del_flag', 0);
if ($params['keyword']) {
$listDb->where('m.name', 'like', "%{$params['keyword']}%");
}
// ... 再写 40 行 ...
$list = $listDb->field('o.id, o.status, m.name')
->order('o.created_at', 'desc')
->page($page, $size)
->select();
return ['total' => $total, 'list' => $list];
}危害显而易见:新增一个筛选参数,两处都要改;漏改一处,count 和 list 的过滤条件就悄悄分叉,出现"共 200 条,只能翻到第 3 页"的诡异分页。
根因
看到重复代码,第一反应是抽成公共 builder:
// ❌ 直觉改法:复用同一 builder 实例
$db = Db::name('order')->alias('o')
->leftJoin('member m', 'm.id = o.member_id')
->where(/* ... 50 行 ... */);
$total = $db->count('DISTINCT o.id'); // 执行后 builder 状态被消费
$list = $db->field('...')->page($page, $size)->select(); // ⚠️ 空结果 / 字段错乱这里有一个反直觉的硬限制:TP5 Query Builder 的聚合方法(count / sum / avg)会修改 builder 内部的 options 状态——它把 field 覆盖成 COUNT(...),执行后部分版本还会 reset 其他选项。后续的 select() 看到的是一个"已被用过"的 builder,字段、order、limit 均处于非预期状态。
$db->count() 执行后,$db 内部的 field 已被替换为 COUNT(...),options 部分被 reset。紧接着调 $db->select() 得到的是错误结果,而不是报错——这让 bug 极难被发现。
正解
正确做法不是复用 builder,而是抽一个返回 builder 的私有方法,在 getList 里调两次,拿到两个独立实例:
// ✅ 修复后:公共方法只返回 builder,不执行
private function buildOrderListQuery(array $params, bool $includeJoin = false)
{
$db = Db::name('order')->alias('o')->where('o.del_flag', 0);
if ($includeJoin) {
// count 不需要 member 字段,可选跳过 JOIN 提升性能
$db->leftJoin('member m', 'm.id = o.member_id');
}
if (!empty($params['keyword'])) {
$db->where('o.sn', 'like', '%' . $params['keyword'] . '%');
}
if (!empty($params['status'])) {
$db->where('o.status', $params['status']);
}
// ...
return $db; // 只返回,不执行
}
public function getList()
{
$params = $this->request->param();
$page = max(1, (int)$this->request->param('page', 1));
$size = min(100, (int)$this->request->param('size', 20));
// 调两次,各自独立
$total = $this->buildOrderListQuery($params, false)->count();
$list = $this->buildOrderListQuery($params, true)
->field('o.id, o.sn, o.status, m.name')
->order('o.created_at', 'desc')
->page($page, $size)
->select();
return ['total' => $total, 'list' => $list];
}私有方法每次调用都返回一个全新的 builder 实例,count 和 select 各自持有独立状态,互不干扰。同时,$includeJoin 参数让 count 查询可以省去与结果字段无关的 JOIN,顺手提了一次性能。
有人会想到 clone:
// ⚠️ clone 能 work,但不推荐
$db = $this->buildOrderListQuery($params, true);
$total = (clone $db)->count();
$list = (clone $db)->field('...')->select();clone 在大多数 TP5 版本可以跑通,但 Db 对象的 clone 语义在不同小版本之间存在差异,与 paginate、fetchSql 等功能混用时行为不稳定。直接调两次语义最清晰,成本也一样。
反模式备忘
- 禁止在
buildQuery方法内部调->count()/->select()—— 返回结果而非 builder,破坏可组合性 - count 查询通常不需要列表用的冗余 JOIN,别让两段共享一套 JOIN
- 抽完公共方法后,写一个断言:
count() == count(select(不分页)),防止双查询悄悄漂移
一句话外卖
TP5 聚合方法会消费 builder 状态,分页接口的 count + select 必须来自两次独立的 builder 构建,不要复用同一实例。