返回博客

ThinkPHP 5.0 分页接口一加关键词搜索就 HY093,根因是 count() 污染了 bindings

某后台管理系统的商品列表接口,平时跑得好好的,前端一传 keyword 参数就炸:

SQLSTATE[HY093]: Invalid parameter number: number of bound variables does not match number of tokens

报错位置指向 select(),一看代码好像没什么问题——直到盯着 Query builder 的复用方式看了五分钟。

现象

分页列表 API 的典型写法:先 count() 拿总数,再 page()->select() 拿当页数据,两步共享同一个 $query 对象:

$q = Db::name('product')
    ->where('status', 1)
    ->where('category_id', $catId)
    ->where('name', 'LIKE', '%' . $kw . '%');
 
$total = $q->count();                      // 看起来没问题
$list  = $q->page($page, $size)->select(); // HY093 就在这炸

奇怪的是:

  • 不传 keyword(去掉 LIKE 条件)完全不报错
  • 传了 keyword 100% 复现
  • 报错行是 select(),不是 count(),极具误导性

根因

TP 5.0 的 Query::count()(以及 sum/avg/max/min 等所有聚合方法)在底层把原始 SQL 重写成聚合语句时,会清空并重新绑定 PDO 参数(bindings)

聚合完成后,$query 对象里的 bindings 数组是聚合时留下的残留状态。等到后续 select() 执行时,SQL token 数量(原始 where 的 ? 占位符)和 bindings 数组长度不再一致,PDO 就抛出 HY093。

源码对应位置:thinkphp/library/think/db/Builder.phpselectaggregate 的 bind 管理逻辑不同步。

坑:TP 5.0 聚合方法会破坏 builder 的 bindings 状态

复用同一个 Query 对象做 count() + select() 是 TP 5.0 独有的陷阱。只要 where 里含有 bind 参数(LIKE / 用户输入 / 动态值),先 count 再 select 必报 HY093。

版本对照: | 框架 | 行为 | |------|------| | TP 5.0.x | aggregate 破坏 bindings,必须 clone | | TP 5.1+ | bindings 生命周期已修复,可复用 | | TP 6.x | 同 5.1+,安全 | | Laravel Eloquent | aggregate 不影响后续查询 |

从 TP 5.1+ 或 Laravel 迁过来的代码直接复用 builder,必踩这个坑。

正解

方式 1 — clone 后再 count(推荐,一行搞定)

$q = Db::name('product')
    ->where('status', 1)
    ->where('category_id', $catId)
    ->where('name', 'LIKE', '%' . $kw . '%');
 
$total = (clone $q)->count();              // 独立克隆,不污染原 builder
$list  = $q->page($page, $size)->select(); // 原 builder 状态干净

方式 2 — 构建函数(where 条件复杂时更清晰)

$build = function () use ($catId, $kw) {
    return Db::name('product')
        ->where('status', 1)
        ->where('category_id', $catId)
        ->where('name', 'LIKE', '%' . $kw . '%');
};
 
$total = $build()->count();
$list  = $build()->page($page, $size)->select();

方式 3 — 直接用 paginate() 内置助手

$result = Db::name('product')
    ->where('status', 1)
    ->paginate(20, false);
 
// $result->total()  拿总数
// $result->items()  拿当页数据

paginate() 内部自己处理两次查询,已对此问题免疫。

正解:聚合前先 clone,或让每次查询用独立的 builder 实例

(clone $q)->count() 是最小侵入的修法。如果项目里有多处分页逻辑,建议统一封装成 paginateList($query, $page, $size) 工具函数,内部自动 clone 做 count,避免每处手写。

快速扫描存量代码

# 找出同一变量先 count 再 select/find 的可疑模式
grep -rn "->count()" application/ --include="*.php" -A3 \
  | grep -E "->(select|find|page)\("

人眼复查上下文 3 行,凡是复用同一 $query 变量的都需要加 clone。

一句话外卖

TP 5.0 的 Query 对象在聚合后是脏的,分页接口每次 count 必须用 clone,这不是优化,是正确性要求。