事情得从两年前说起。当时我在一个中大型项目里维护一套 Laravel 代码,表面上一切很美好,但一旦线上出问题就是灾难:一个列表页莫名其妙多出几十条 SQL,某个关联查询把数据库连接池打满,翻 Eloquent 源码翻了三个小时也没搞明白是哪层作用域加了条件。后来我痛下决心,在另一个独立模块里把 ORM 换成了自己写的极简查询构建器,代号就叫caveman。项目的定位很简单:不追求强大抽象,只追求直白可控,像穴居人一样用最原始的方式把 SQL 表达清楚。它能做的是让 PHP 开发者用链式方法安全地拼 SQL,拿到关联数组或轻模型结果,同时不隐藏任何一条最终执行语句。
如果你也被各种 ORM 的"黑魔法"坑过,或者在中小型项目里既不想背一整个框架、又不想每次手写 PDO 绑定参数,这篇文章应该正合口味。我会从设计思路、核心源码、周边功能到排查经验,把这个项目的里里外外拆一遍,你甚至可以照着代码自己撸一个出来。
1. Caveman在解决什么问题
1.1 被重ORM折腾过的人,才会懂"返祖"的诱惑
ORM 时代,我们习惯写$user->posts()->where('published', true)->get(),看起来优雅,但问题就藏在"看起来"里。几个月后代码需要优化,你根本不知道这条链式调用背后生成了什么样的 SQL,是不是又触发了一次隐式的关联查询。我在实践中碰到过最夸张的一次,一个简单的分页接口因为关联预加载失效,硬生生打出了 N+1 条查询,而问题定位花了整整两天。
Caveman 的第一原则叫做"SQL 可见"。它不是一个完整的 ORM,而是一个处于原生 PDO 与重度 ORM 之间的查询构建器。你写where就是 where,写orderBy就是 orderBy,最终生成的 SQL 通过toSql()方法可以直接打印,绑定参数也全部存放在公开属性的数组里。这种"返祖"的设计不是技术倒退,而是刻意把不可控的因素削减到最少,让每一行查询都像你亲手写出来的一样清晰。
另外,大部分 ORM 的重量来自事件系统、中间件、全局作用域、关联装载器这些附加能力。它们确实强大,但如果你只是想让一个 CRUD 后台跑得明白,这些机制大部分时间都在消耗内存和阅读成本。Caveman 砍掉了所有它认为不属于"查询"本身的东西,换来的是单个构建器类不到一千行代码,算上模型和集合辅助类,整个库的体积也足够小,可以无痛塞进任何已有项目。
1.2 整体设计:不是ORM,是"准ORM"
Caveman 的定位我习惯称为"准 ORM"。它保留了三层结构:连接层(Connection)、查询构建层(QueryBuilder)、薄模型层(Model),但没有实体元数据、没有注解解析、没有事件钩子。
为什么不做成完整 ORM?因为一旦引入元数据映射,就必须处理类型转换、关联关系、继承映射这一堆复杂的规则,而这些规则恰恰是黑盒的源头。Caveman 选择把"对象映射"这个能力做成可选的薄壳:查询构建器默认返回关联数组,如果你需要简单实体,可以用 Model 基类做一层属性赋值;如果你需要关联查询,就在模型里手动写方法,用 Caveman 的查询构建器组装子查询。一切都是显式调用,没有任何隐形的自动行为。
这个取舍在真实项目里非常受用。举个例子,我的一个后台报表系统有三十多张表,但查询需求几乎全是联表聚合。用 Eloquent 时我得不断在模型里定义关系,否则就享受不到便利;用 Caveman 之后,我直接写query()->join(...)->groupBy(...)->having(...),SQL 自由度反而更高,报表拿到数组丢给前端渲染,根本不经过模型的类型转换。
1.3 为什么绑定PDO而不是自研驱动
数据库连接这块,Caveman 直接站在 PDO 的肩膀上。原因很实在:第一,PDO 是 PHP 官方扩展,支持 MySQL、PostgreSQL、SQLite 等多种驱动,不需要为每种数据库单独实现协议;第二,PDO 自带的预处理能力正好契合查询构建器"值和结构分离"的需求,我们只需要把 SQL 骨架拼好,值全部通过绑定传递,这件事 PDO 是现成的。
可能有朋友会问,为什么不基于 mysqli 封装或者干脆像 Swoole 那样自研协程驱动?这得看项目的适用范围。Caveman 的目标是普通 PHP-FPM 项目,追求的是兼容性和可移植性,而不是极致性能极限。使用 PDO 还带来一个隐藏好处:PDO::ATTR_EMULATE_PREPARES => false开启后,MySQL 会使用真正的预处理协议,值从客户端单独发给服务端,从协议层面就避免了拼接产生的注入风险。后面代码篇会细讲这个配置。
2. Caveman的四个关键设计,读代码前先看这里
2.1 链式调用:每次返回$this
链式调用是查询构建器的灵魂。如果你看过一些实现,会发现原理并不复杂:where()、orderBy()、limit()这些方法在完成自己的职责后,统一return $this,把当前对象实例传回调用方,这样后续就能继续调用同一个对象的其他方法。
Caveman 的每个方法内部只有两件事:记录调用参数,更新内部状态。以where('status', 1)为例,方法拿到列名、操作符、值以后,往$wheres数组压入一条三元组,再把值追加到$bindings数组,返回$this。它不立即拼 SQL、不立即查询数据库,真正的 SQL 生成发生在toSql()被调用的那一刻。这种延迟构建(lazy building)模式让我们可以在循环里动态追加条件,非常灵活。
这里有个细节容易踩坑:因为同一个构建器对象是可变的,如果在一个方法里把构建器传给另一个函数,那边往里面追加了一个 where,你原来的条件就会被"污染"。我的解决办法是提供clone语义:在处理可选条件时先$query = (clone $baseQuery),再把分支条件加在副本上。下面代码段演示了这种用法。
$baseQuery = $caveman->table('orders')->where('paid', 1); // 分支一:按用户查询,不能污染基础查询 if ($userId) { $query = (clone $baseQuery)->where('user_id', $userId); } // 分支二:按日期查询 if ($startDate) { $query = (clone $baseQuery)->where('created_at', '>=, $startDate); }2.2 参数绑定:值全部走预处理
我在早期版本里犯过一个错误:图省事,直接把值和列名一起拼进 SQL 字符串,虽然功能正常,但一旦值来自用户输入,那就是定时炸弹。后来把所有值统一交给 PDO 预处理,整个安全模型才彻底清晰。
核心做法是:SQL 语句中只出现 SQL 骨架和占位符?,所有动态值都追加到$bindings数组。执行时调用executing阶段把绑定数组传给 PDOStatement 的execute($bindings)。这里强调一个关键点——绝对不要用PDO::quote()来做安全防护,因为它是字符串转义而非真正的协议级绑定,在复杂字符集和旧版驱动下存在转义绕过风险。
// toSql() 生成的语句大致长这样 SELECT * FROM orders WHERE status = ? AND amount >= ? // 执行时绑定值 $stmt = $pdo->prepare($sql); $stmt->execute([1, 500.00]); // 值在客户端和服务端之间走独立通道还有一点要提醒:绑定顺序必须与?在 SQL 中的出现顺序完全一致。条件一多,特别是 where、having、limit 混在一起时,很容易错位。我后面会在排查章节专门讲怎么做一个debugSql()方法来定位这类问题。
2.3 结果映射:薄薄一层,不玩魔法
查询构建器的默认返回是关联数组,但实际业务里大家还是习惯拿对象。Caveman 的做法是提供Model基类,它的映射逻辑非常直白:查询结果数组的键,直接作为对象的公开属性;对象初始化时接受一个数组参数,遍历赋值。没有反射、没有注解、没有属性标签。
这个设计有一个好处:简单到可以预测任何行为。没有类型转换器,数据库返回什么就是什么,棘手的日期格式问题你自己决定处理;没有隐藏的关联装载器,不会在你访问$model->posts时偷偷执行查询。牺牲一点"魔法便利",换来的是熬夜排查问题的时间大幅减少。
abstract class Model implements ArrayAccess { public function __construct(array $attributes = []) { foreach ($attributes as $key => $value) { $this->{$key} = $value; } } public function toArray(): array { return get_object_vars($this); } }2.4 预加载:把多个查询压成一个
虽然 Caveman 不自称 ORM,但关联查询是业务刚需,不能完全回避。我的做法是提供loadRelations()这类显式预加载方法:先执行主查询拿到结果集合,再根据外键字段一次性查出关联数据,最后在 PHP 内存里按主键分组拼装。这样无论列表里有几百条记录,关联查询始终只执行一次。
$orders = $caveman->table('orders')->limit(100)->get(); $orderIds = array_column($orders, 'id'); $details = $caveman->table('order_details') ->whereIn('order_id', $orderIds) ->get(); // 按 order_id 分组 $grouped = []; foreach ($details as $detail) { $grouped[$detail['order_id']][] = $detail; }为什么不在模型里做自动预加载?因为自动判断"这次查询需要哪些关联"是 ORM 中最复杂的逻辑,它需要分析你后续会访问哪些属性,这只能靠魔法方法拦截,性能开销和不确定性都太高。Caveman 把决策权还给你——要加载哪些子表,自己在代码里写清楚。
3. 手写Caveman核心:从Connection到QueryBuilder
3.1 项目骨架和Composer配置
先看一眼目录结构。Caveman 保持最小化,四个核心文件就够了:连接管理器、查询构建器、模型基类、集合辅助类。如果你不需要集合类,三个文件也能跑。
caveman/ ├── src/ │ ├── Connection.php │ ├── QueryBuilder.php │ ├── Model.php │ └── Collection.php ├── composer.json └── examples/ └── demo.phpcomposer.json 里的配置很简单,核心是 PSR-4 自动加载和 PHP 版本约束。我建议要求 PHP 7.4 以上,这样可以用箭头函数、类型属性和协变返回,代码能瘦不少。
{ "name": "yourname/caveman", "description": "A minimal database query builder for PHP", "require": { "php": ">=7.4", "ext-pdo": "*" }, "autoload": { "psr-4": { "Caveman\\": "src/" } } }3.2 Connection:单例PDO连接
连接层最核心的工作是创建 PDO 实例,并设置正确的属性。我踩过最大的坑是忘记开启ERRMODE_EXCEPTION,结果 SQL 报错时只拿到一个false返回,错误原因完全看不到。把这个模式打开后,所有 SQL 层面的异常都会直接抛出,配合全局异常处理器,问题当场暴露,而不是在三天后由用户投诉才发现。
另一个关键配置是ATTR_EMULATE_PREPARES,这个参数决定 PDO 使用原生预处理还是本地模拟预处理。在 MySQL 8 上我推荐设置为false,强制走原生预处理协议。不过要注意:某些老版本 MySQL 驱动对原生预处理的支持有 bug,如果线上环境较旧,建议先做一轮全量回归再改这个配置。
<?php namespace Caveman; use PDO; use PDOException; class Connection { private static ?PDO $pdo = null; public static function open(array $config): PDO { if (self::$pdo instanceof PDO) { return self::$pdo; } $driver = $config['driver'] ?? 'mysql'; $dsn = self::buildDsn($driver, $config); try { self::$pdo = new PDO($dsn, $config['username'], $config['password'], [ PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION, PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC, PDO::ATTR_EMULATE_PREPARES => false, PDO::ATTR_TIMEOUT => 3, ]); } catch (PDOException $e) { throw new ConnectionException('Database connection failed: ' . $e->getMessage(), 0, $e); } return self::$pdo; } private static function buildDsn(string $driver, array $config): string { return match ($driver) { 'mysql' => sprintf( 'mysql:host=%s;port=%d;dbname=%s;charset=%s', $config['host'] ?? '127.0.0.1, $config['port'] ?? 3306, $config['database'], $config['charset'] ?? 'utf8mb4' ), 'sqlite' => 'sqlite:' . $config['database'], 'pgsql' => sprintf( 'pgsql:host=%s;port=%d;dbname=%s', $config['host'] ?? '127.0.0.1, $config['port'] ?? 5432, $config['database'] ), default => throw new \InvalidArgumentException("Unsupported driver: {$driver}"), }; } }这里match表达式是 PHP 8 的语法,如果你还在 PHP 7.4 就换成switch。静态$pdo变量实现了进程内单例,避免同一请求重复建立连接。但要提醒一句:这个单例设计在 PHP-FPM 下没问题,在 Swoole 等常驻内存环境中需要按协程重新设计,否则连接会被多个协程共享,埋下大坑。
3.3 QueryBuilder:SQL组装与执行
QueryBuilder 是整个库的发动机舱。它的内部状态我用数组属性表示:$wheres存条件、$orders存排序、$bindings存绑定值。每次调用方法只是在数组上追加元素,toSql()则负责把数组翻译成合法的 SQL 语句。
<?php namespace Caveman; use PDO; use PDOStatement; class QueryBuilder { protected array $columns = ['*']; protected string $from = ''; protected array $wheres = []; protected array $orders = []; protected int $limit = 0; protected int $offset = 0; protected array $bindings = []; public function __construct(protected PDO $pdo) { } public function table(string $table): static { $this->from = $table; return $this; } public function select(string ...$columns): static { $this->columns = $columns ?: ['*']; return $this; } public function where(string $column, string $operator, mixed $value = null): static { // 支持 where('status', 1) 的简写,自动补 = 操作符 if ($value === null) { $value = $operator; $operator = '='; } $this->wheres[] = [$column, $operator]; $this->bindings[] = $value; return $this; } public function orderBy(string $column, string $direction = 'asc'): static { $direction = strtolower($direction) === 'desc' ? 'desc' : 'asc'; $this->orders[] = "{$column} {$direction}"; return $this; } public function limit(int $limit): static { $this->limit = $limit; return $this; } public function offset(int $offset): static { $this->offset = $offset; return $this; } public function toSql(): string { $sql = 'SELECT ' . implode(', ', $this->columns); $sql .= ' FROM ' . $this->from; if ($this->wheres) { $conditions = array_map( fn(array $w) => "{$w[0]} {$w[1]} ?", $this->wheres ); $sql .= ' WHERE ' . implode(' AND ', $conditions); } if ($this->orders) { $sql .= ' ORDER BY ' . implode(', ', $this->orders); } if ($this->limit > 0) { $sql .= ' LIMIT ' . $this->limit; if ($this->offset > 0) { $sql .= ' OFFSET ' . $this->offset; } } return $sql; } public function run(): array { $stmt = $this->executeStatement(); $rows = $stmt->fetchAll(PDO::FETCH_ASSOC); return $rows ?: []; } public function executeStatement(): PDOStatement { $stmt = $this->pdo->prepare($this->toSql()); $stmt->execute($this->bindings); return $stmt; } }toSql()的拼接顺序是固定的:SELECT字段、FROM表、WHERE条件、ORDER BY排序、LIMIT和OFFSET分页。这个顺序不是随意定的,SQL 语法有严格的层级要求,LIMIT必须出现在WHERE之后。如果业务上需要HAVING,你可以在toSql()里加一个类似的流程,但要注意HAVING与WHERE的执行语义不同,别混为一谈。
3.4 跑一个真实查询
现在把三块拼起来,写一个真实的调用示例。为了演示,我模拟一个订单查询场景:筛选出已支付、金额大于某个值的订单,按创建时间倒序,分页取第一页的二十条。
$config = [ 'driver' => 'mysql, 'host' => '127.0.0.1, 'port' => 3306, 'database' => 'shop', 'username' => 'root', 'password' => 'secret', 'charset' => 'utf8mb4', ]; $builder = new QueryBuilder(Connection::open($config)); $rows = $builder ->table('orders') ->select('id, order_no, user_id, total_amount, created_at') ->where('paid', 1) ->where('total_amount', '>=', 500.00) ->orderBy('created_at', 'desc') ->limit(20) ->offset(0) ->run(); echo json_encode($rows, JSON_PRETTY_PRINT);调toSql()会得到:
SELECT id, order_no, user_id, total_amount, created_at FROM orders WHERE paid = ? AND total_amount >= ? ORDER BY created_at desc LIMIT 20 OFFSET 0绑定数组是[1, 500.00]。你可以清楚看到每个值都不会直接进 SQL,而是通过 PDO 的预处理通道。这套拼接逻辑我们已经验证过很多次,中小规模查询基本零踩坑。
4. 落到真实项目,我还补了这些功能
4.1 事务处理的正确姿势
查询构建器只负责组装和执行单条 SQL,但业务里经常需要多条 SQL 一起成功、一起失败。Caveman 提供一个transaction()辅助函数,内部包好 begin/commit/rollback 的模板逻辑。
function transaction(PDO $pdo, callable $callback): mixed { if ($pdo->inTransaction()) { return $callback(); } $pdo->beginTransaction(); try { $result = $callback(); $pdo->commit(); return $result; } catch (\Throwable $e) { $pdo->rollBack(); throw $e; } }有几个容易忽略的细节:第一,inTransaction()检查要放在开始之前,否则嵌套事务会在回滚时把外层事务也回滚掉;第二,回调里抛出的异常必须是\Throwable而不是\Exception,因为 PHP 7 以后Error也有可能需要回滚;第三,commit 成功后记得不要吞掉异常,直接抛出让上层处理。
实践中我还会给transaction()加一个重试参数,用于处理 MySQL 的锁等待超时(1213错误码)。死锁发生时,简单粗暴地重跑整个事务往往就成功了。不过要控制重试次数,我一般设为两次,再多就说明业务本身的锁设计有问题。
function transactionWithRetry(PDO $pdo, callable $callback, int $retries = 2): mixed { for ($attempt = 0; ; $attempt++) { try { return transaction($pdo, $callback); } catch (\PDOException $e) { if ($attempt >= $retries || $e->getCode() !== 1213) { throw $e; } usleep(100 * 1000); // 100ms } } }4.2 分页和批量写入
分页是很高频的需求,但很多项目直接LIMIT ? OFFSET ?一把梭,数据量一上去就慢得一塌糊涂。Caveman 提供一个paginate()方法,它先算总数,再取数据。深分页的问题这里也说得直白:当 offset 很大时,MySQL 仍会扫描被跳过的所有行,这不是查询构建器能优化的,需要业务上用游标分页来规避。
public function paginate(int $perPage, int $page = 1): array { $countBuilder = clone $this; $countBuilder->columns = ['COUNT(*) as aggregate']; $total = (int)($countBuilder->run()[0]['aggregate'] ?? 0); $rows = (clone $this) ->limit($perPage) ->offset(($page - 1) * $perPage) ->run(); return [ 'data' => $rows, 'total' => $total, 'per_page' => $perPage, 'page' => $page, 'last_page' => (int)ceil($total / $perPage), ]; }批量写入是另一个刚需。手写多条INSERT INTO会造成大量网络往返,Caveman 的insertBatch()方法会把这些合成一条 SQL,多组VALUES一次发送。注意占位符的数量要精确:每行多少个字段,就得生成多少个?。
public function insertBatch(string $table, array $rows): int { if (!$rows) return 0; $columns = array_keys($rows[0]); $columnList = implode(', ', $columns); $placeholders = []; foreach ($rows as $row) { $placeholders[] = '(' . implode(', ', array_fill(0, count($columns), '?')) . ')'; foreach ($columns as $col) { $this->bindings[] = $row[$col]; } } $sql = "INSERT INTO {$table} ({$columnList}) VALUES " . implode(', ', $placeholders); $stmt = $this->pdo->prepare($sql); $stmt->execute(array_slice($this->bindings, -count($rows) * count($columns))); return $stmt->rowCount(); }这个方法有个前提,就是$rows里每行的字段顺序必须一致,而且数组的键名完全对齐。如果业务上层的数据来源乱七八糟,建议先统一清洗一遍。
4.3 SQL调试日志
我始终认为,查询构建器最重要的一点就是最终 SQL 必须可观测。Caveman 内置一个简单的日志机制:在executeStatement()里把 SQL 和绑定值推进一个静态数组,便于在请求结束时统一打印或写入日志文件。
public static array $logs = []; private function executeStatement(): PDOStatement { $sql = $this->toSql(); self::$logs[] = [ 'sql' => $sql, 'bindings' => $this->bindings, 'time' => microtime(true), ]; $stmt = $this->pdo->prepare($sql); $stmt->execute($this->bindings); return $stmt; }生产环境不建议每条查询都写日志,否则日志量和性能开销都上去了。我的做法是:只在慢查询时才记录,也就是给run()增加一个执行时间统计,超过阈值(比如 500ms)就把 SQL 单独告警。排查线上问题的时候,这个日志就是救命的现场录像。
5. 和Eloquent、Doctrine、裸PDO对比,边界在哪
5.1 先上一张对比表
为了把 Caveman 的定位讲清楚,我拿它和 Laravel 的查询构建器、Doctrine ORM、原生 PDO 做一个直接对比。不同的人需求不同,没有所谓"巅峰对决",只有适不适合。
| 维度 | 原生 PDO | Caveman | Laravel Query Builder | Doctrine ORM |
|---|---|---|---|---|
| 定位 | 数据库访问底层 | 轻量查询构建器 | 框架内置查询构建器 | 完整 ORM 解决方案 |
| 依赖 | PHP 扩展 | 仅 PDO | 整个 Laravel 容器 | Doctrine 全家桶 |
| SQL 可见性 | 手动控制 | 直接可控 | 半隐藏 | 高度抽象 |
| 学习成本 | 低 | 低 | 中 | 高 |
| 关联加载 | 手写 | 手动预加载 | 自动/手动 | 自动 |
| 调试排查 | 直观 | 直观 | 需要绕弯 | 复杂 |
| 类型映射 | 无 | 轻量 | 无 | 强类型 |
| 迁移工具 | 无 | 无 | 有 | 有 |
Caveman 和原生 PDO 的区别在于它帮你把拼接、绑定、分页这些重复动作封装好了,减少了低级错误;和 Laravel 查询构建器的区别是没有全局作用域、模型事件、宏指令这些额外心智负担;和 Doctrine 的区别是完全不碰实体元数据和数据库结构管理。
5.2 什么时候我推荐用Caveman
我个人的使用场景基本是这三类:第一,中小型项目或内部后台系统,数据模型不复杂,但查询语句多变,需要直接掌控 SQL;第二,在已有老项目里做局部优化,不想为了几个接口引入整个框架的数据库组件;第三,教学和源码阅读,如果想知道查询构建器内部发生了什么,Caveman 的代码量非常适合通读。
反过来说,如果你的系统需要强大的领域模型、复杂的继承映射、自动化的 Schema 迁移,或者需要开箱即用的实体仓库模式,那 Caveman 帮不了你,直接上 Doctrine 这类完整 ORM 更合适。另外,如果项目已经用了 Laravel,也没有必要为了让代码"返祖"而强行替换,直接在需要的模块里用DB::table()就足够,这本质上和我写 Caveman 的思路一致。
6. 排查问题实录:bindings错位、空IN子句和慢查询
6.1 定位参数绑定错位
查询构建器最常见的运行时错误是参数顺序不对。比如你先where('a', 1)又where('b', 2),但代码里哪一步多放了一个值,最后 SQL 变成WHERE a = ? AND b = ?,绑定数组却是[1, 2, 3],数据库可能报Column not found,也可能静默匹配错误数据。
遇到这种情况不要盲改,我用一个辅助方法把 SQL 里的占位符替换为实际绑定值,直接在日志里看到"伪 SQL":
public function debugSql(): string { $sql = $this->toSql(); foreach ($this->bindings as $value) { $value = is_numeric($value) ? $value : "'" . addslashes((string)$value) . "'"; $sql = preg_replace('/\?/', $value, $sql, 1); } return $sql; }这个方法只是用来人工排查的,不要实际执行——毕竟值经过了转义,但不代表它拼回去之后是合法 SQL。不过看一眼基本就能发现是哪个条件多放值、少放值了。
6.2 空IN子句的隐藏坑
whereIn('id', [])看起来人畜无害,但生成的WHERE id IN ()在 MySQL 里直接就是语法错误。这是查询构建器最容易忽略的边界情况。Caveman 的处理方式是:如果传入的数组为空,就生成一条永远为假的条件1 = 0,这样查询不会崩溃,返回空结果集。
public function whereIn(string $column, array $values): static { if (empty($values)) { $this->wheres[] = ['1', '=']; $this->bindings[] = 0; // WHERE 1 = 0 return $this; } $placeholders = implode(', ', array_fill(0, count($values), '?')); $this->wheres[] = [$column, "IN ({$placeholders})"]; array_push($this->bindings, ...$values); return $this; }可别小看这个细节,有次我处理一个筛选表单,用户把地区多选框全部取消,前端传了个空数组,结果后台接口直接 500。从那以后,我对所有"可能为空"的条件都做了兜底。
6.3 关于性能和长连接的几个观察
最后聊点性能实测的观察。Caveman 比裸 PDO 慢多少?我做过一个压测,在十万行表上跑一百次简单查询,裸 PDO 平均 0.42ms,Caveman 平均 0.58ms,差距约 0.16ms。这个差异来自数组初始化、方法调用栈和 SQL 字符串拼接,对业务系统来说完全可以接受。作为对比,Eloquent 查询构建器在同场景下大约需要 0.9ms 到 1.5ms,差距主要来自容器解析和事件分发。
关于长连接,PHP-FPM 下推荐短连接,因为每个请求结束后连接自动关闭,单例Connection::$pdo其实只在单个请求内复用。如果你部署在 Swoole 或 RoadRunner 这类常驻进程里,必须把连接绑定到协程或 Worker 维度,否则多个协程共享同一个连接,事务会互相干扰。这个坑我印象很深,第一次把 Caveman 移植到 Swoole 时,死活查不出为什么两个并发请求会出现"事务已回滚但另一方提交了"的诡异现象,后来才反应过来是连接共享导致的。
说一下我个人在实际项目里使用 Caveman 到现在的一些体感:它确实不如 Eloquent 那样开箱即用,很多关联关系、事件通知都要自己补,但换来的是每个查询行为都可预期,出问题时能很快定位,这对我维护老项目来说是实打实的红利。如果你也在"重 ORM 看不懂 SQL"和"裸 PDO 重复劳动"之间纠结,不如先写一个最小可用的查询构建器试试,Caveman 的源码完全可以当作起点。另外有个小技巧是,构建器对象在循环里使用时要记得 clone,否则上一次循环的条件会悄悄带进下一次查询,这个看似微小的失误在跑批量数据处理时能把结果偏差放大到难以追踪的程度。