如果在 Laravel 的错误日志里看到这就话,大概率会让后端兄弟们血压升高:
SQLSTATE[HY000]: General error: 2014 Cannot execute queries while other unbuffered queries are active.
最近我在维护一个高并发项目时就撞上了这个鬼东西。环境是 Laravel + Supervisor (多进程队列) 。
最诡异的是,报错的那行代码看起来完全无辜,而真正的凶手却躲在一个为了"优化性能"而写的底层方法里。今天把整个排查过程记录下来,希望能帮大家避坑。
01. 案发现场
报错的代码是一段再普通不过的查询
php
$sql = "SELECT * FROM `{$tableName}` WHERE `companyId` = ? ...";
// 报错就发生在这里 👇
$results = DB::connection('xxx')
->select($sql, [$companyId, $inspectionSn, $toWarehouseId]);
报错提示非常明确:当前连接上有未完成的查询(Unbuffered queries active),无法执行新查询。
这就好比你去银行柜台办事,柜员告诉你:"上一位客户的业务还没办完,数据还在传,你先等着。"但问题是,代码逻辑里并没有明显的"上一位客户"。
02. 第一轮排查:谁没关门?
根据经验,这个错误通常有三个嫌疑人:
- 使用了
cursor()或chunk():遍历中途break或抛出异常,导致 MySQL 还在吐数据,PHP 这边却断了。 - 存储过程:返回了多个结果集,PHP 只取了第一个。
- 配置问题 :
PDO::MYSQL_ATTR_USE_BUFFERED_QUERY被设为了false。
我把代码翻了个底朝天:
- ❌ 全局搜索
->cursor(),没有相关业务代码。 - ❌ 检查
config/database.php,默认缓冲查询是开启的(Laravel 默认行为)。 - ❌ 检查之前的逻辑,都是标准的 Eloquent 查询,比如
Model::where(...)->get(),这些底层都会自动处理缓冲。
这就见鬼了。 代码看起来全是"良民",为什么连接会被占用?
03. 抽丝剥茧:被污染的连接
既然当前代码没问题,那肯定是在这个请求(或任务)执行之前,连接就已经脏了。
考虑到我们使用了 Supervisor 跑队列任务。在 daemon 模式下,PHP 进程是长驻的,一个进程会处理成千上万个 Job。
如果 Job A 把数据库连接搞脏了(比如改了设置、或者留下了未读完的数据),Job B 复用了同一个进程和同一个数据库连接,Job B 就会无辜躺枪。
我开始审查业务逻辑中那些"看起来有点骚操作"的地方,终于锁定了这一段自定义的分表查询逻辑:
php
// 业务代码调用
WarehouseModel::query($companyId)
->where(...)
->first();
点进去看 query 方法的实现:
php
public static function query($companyId) {
// 👇 引起我警觉的是这一行
$instance = self::me();
$table = $instance->setSuffix($companyId);
$instance->setTable($table);
$instance->setConnection($conn);
return $instance->newQuery();
}
再看 self::me() 的实现,好家伙,一个手写的单例模式:
php
public static function me() {
$class = get_called_class();
// 如果缓存里有,就直接返回旧对象
if(! isset(self::$instanceMap[$class])) {
self::$instanceMap[$class] = new $class();
}
return self::$instanceMap[$class];
}
破案了!凶手就是这个"带状态的单例"!
04. 为什么单例会炸?
在常规的 Web 请求(PHP-FPM)中,这个单例可能仅仅导致逻辑错误(A用户的请求改了表名,B用户进来复用了A的表名)。
但在 Supervisor (多进程长驻) 环境下,这简直是灾难:
- Job 1 拿到了单例对象
Obj,把它的连接设置为了Connection_A。 - Job 1 执行结束了,但
Obj依然活在内存里(因为是静态变量缓存的)。 - Job 2 进来了,调用
self::me(),拿到了同一个Obj(也就是拿到了Connection_A)。 - 如果 Job 1 在结束前因为某种异常(甚至只是简单的网络波动)导致连接状态异常(比如有未读完的数据流),Job 2 拿着这个脏连接一查,直接爆出 Error 2014。
甚至不需要异常,光是状态的 "串台" 就足够致命。Laravel 底层会尝试重置连接,但我们在 Model 层强行复用旧对象,绕过了框架的保护机制。
05. 修复方案:断舍离
很多时候我们写单例是为了"省内存"、"高性能"。但在 PHP 中,创建一个 Model 对象的开销极小(纳秒级),为了这点微不足道的性能去冒着"状态污染"的风险,绝对是得不偿失。
修复非常简单:把单例改成工厂模式。 修改 query 方法,直接 new static():
php
public static function query($companyId)
{
// ❌ 删掉单例调用
// $instance = self::me();
// ✅ 改为每次创建新对象
// 确保每个 Job、每个逻辑拿到的都是干干净净的实例
$instance = new static();
// ... 后续设置表名、连接的逻辑保持不变 ...
$instance->setTable(...);
return $instance->newQuery();
}
为什么用 new static() 而不是 new ModelName()?
new static() 是后期静态绑定,支持继承。如果你把这段代码复制到其他 Model 里,它会自动实例化当前的类,而不用改代码,更优雅。
06. 总结与反思
改完上线后,General error: 2014 彻底消失,世界清静了。
几点血泪教训:
- 慎用单例(Singleton): 特别是那些带有状态 (会有
setXxx操作)的单例。在常驻内存环境(Swoole/Octane/Supervisor)下,这通常是 Bug 之源。 - Model 是轻量级的: 不要舍不得
new。Laravel 里的 Model 设计就是随用随弃的,不要试图去缓存它。 - 排查 2014 错误的思路: 如果当前代码没问题,一定往上看,查"上一个请求"或"上一个逻辑"留下的坑。
- 保命大招: 如果实在找不到原因,为了线上止血,可以在报错查询前加上
DB::reconnect()强制重连,虽然暴力,但有效。
希望这篇复盘能帮到同样在深夜看着日志发愁的你。👋