前言
典型症状是这样的:接口偶尔返回一个空白的 JSON,前端报Unexpected end of JSON input;或者更诡异——数据库连接正常、SQL 也写对了,但某张表就是没有新数据,日志里什么都没有。排查半天,最后在代码里发现这么一段:
$stmt = $pdo->query("SELECT * FROM orders WHERE id = $id");
$row = $stmt->fetch();当 SQL 出错时query()返回false,false->fetch()会直接抛致命错误——但如果开了输出缓冲或者错误被吞掉,你只会看到一个空白响应。
根因在于 PDO 的错误报告机制有三档,而很多人压根没设置过它。历史上 PDO 的默认错误模式是「静默」:出错不抛异常、不报错,只把错误码挂在那里等你主动检查,而绝大多数人不会检查。从 PHP 8.0 起,PDO 的默认错误模式变成了PDO::ERRMODE_EXCEPTION,这也是为什么老代码在升级到 PHP 8 之后突然开始「报错」——其实错误一直都在,只是以前被静默吞掉了。
本文讲一套可以直接落到项目里的方案:显式配置 PDO → 定义领域异常 → 全局异常/错误/致命错误三件套处理器 → 统一的结构化错误响应,并保证不把 SQL 细节泄露给调用方。示例用 SQLite 内存库,装上pdo_sqlite的 PHP 就能直接跑,换成 MySQL 只需改 DSN。
一、三档错误模式,只有一档值得用
| 模式 | 常量 | 出错时的表现 | 适用场景 |
|---|
| 静默 | PDO::ERRMODE_SILENT | 不抛异常,需手动检查errorCode()/errorInfo() | 历史遗留代码,不推荐新项目使用 |
| 警告 | PDO::ERRMODE_WARNING | 触发E_WARNING,脚本继续往下跑 | 调试期临时观察,不适合生产 |
| 异常 | PDO::ERRMODE_EXCEPTION | 抛PDOException,控制流立即转移 | 生产环境的唯一合理选择 |
三档里只有异常模式能保证「出错就停」,其余两档都会让脚本带着错误状态继续执行。而把错误模式设成异常之后,还有一个更重要的问题:异常在哪里被捕获、被谁记录、最后以什么形式返回给调用方。如果每处catch各自echo一段不同的文案,前端永远不知道该按什么格式解析。
二、连接层就把配置定死
不要在业务代码里依赖任何默认值,全部显式声明:
function makePdo(): PDO
{
// 生产环境改成 mysql:host=...;dbname=...;charset=utf8mb4
$dsn = getenv('DB_DSN') ?: 'sqlite::memory:';
return new PDO($dsn, null, null, [
PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION, // 关键:出错即抛
PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC, // 不要依赖 FETCH_BOTH
PDO::ATTR_EMULATE_PREPARES => false, // 交给数据库做真预处理
PDO::ATTR_STRINGIFY_FETCHES => false, // 让 INT 字段返回 int
]);
}两点说明:
ATTR_EMULATE_PREPARES只对 MySQL 驱动有实际作用,SQLite、PostgreSQL 原生就支持服务端预处理。关掉模拟预处理后,SQL 与数据是分两次真正发给数据库的,注入面更小,同时也能让数据库自己的执行计划缓存生效。- 关掉模拟预处理后,
LIMIT :n这类位置会变得挑剔:MySQL 的原生预处理要求LIMIT的参数是整数,而 PDO 默认按字符串绑定,会直接报 SQL 语法错误。正确写法是$stmt->bindValue(':n', $n, PDO::PARAM_INT),或者干脆把已经校验过的整数内插进 SQL 字符串(只有确认是整数才可以)。
三、把 PDOException 翻译成领域异常
PDOException的消息里通常带着完整的 SQL 语句、表名、字段名,甚至一部分绑定值。这些东西直接回给调用方就等于把数据库结构送出去了。所以要包一层:
final class DbException extends RuntimeException
{
public static function fromPdo(PDOException $e, string $context): self
{
// 只带出 SQLSTATE 与驱动级错误码,SQL 语句与绑定值不进异常消息,
// 它们只写进 error_log,供运维排查
$sqlState = (string)($e->errorInfo[0] ?? $e->getCode());
$driverCode = (int)($e->errorInfo[1] ?? 0);
return new self(
sprintf('数据库操作失败(%s):SQLSTATE=%s 驱动错误码=%d', $context, $sqlState, $driverCode),
0,
$e // 原始异常挂在前一个异常上,日志里仍然拿得到完整堆栈
);
}
}$e->errorInfo是个三元组:[0]是 SQLSTATE,[1]是驱动级别的错误码(MySQL 里 1062 表示唯一键冲突),[2]是驱动返回的消息。只把前两个放出去,业务代码需要判断「是不是唯一键冲突」时用驱动错误码,比用消息文本做字符串匹配可靠得多。
四、全局异常、错误、致命错误三件套
只写catch是不够的,因为不是所有失败都会变成你能 catch 的异常:Warning 不会,内存耗尽不会,语法错误更不会。需要三层处理器叠加。
function install_error_handlers(bool $debug): void
{
// 第一层:把 Warning / Notice / Deprecated 转成异常,纳入统一处理
set_error_handler(static function (int $no, string $msg, string $file, int $line): bool {
if ((error_reporting() & $no) === 0) {
return false; // 被 @ 抑制或不在当前报告级别,交还给 PHP 默认处理
}
throw new ErrorException($msg, 0, $no, $file, $line);
});
// 第二层:兜住所有漏网的未捕获异常
set_exception_handler(static function (Throwable $e) use ($debug): void {
error_log(sprintf(
'[uncaught] %s: %s in %s:%d',
$e::class, // PHP 8.0 起支持对对象使用 ::class
$e->getMessage(),
$e->getFile(),
$e->getLine()
));
if (!headers_sent()) {
http_response_code(500);
header('Content-Type: application/json; charset=utf-8');
}
echo json_encode([
'ok' => false,
'error' => $debug ? $e->getMessage() : '服务器内部错误',
], JSON_UNESCAPED_UNICODE), PHP_EOL;
});
// 第三层:致命错误(内存耗尽、E_PARSE 等)不会走异常处理器,只能靠 shutdown 兜底
register_shutdown_function(static function () use ($debug): void {
$err = error_get_last();
$fatal = [E_ERROR, E_PARSE, E_CORE_ERROR, E_COMPILE_ERROR];
if ($err === null || !in_array($err['type'], $fatal, true)) {
return;
}
error_log(sprintf('[fatal] %s in %s:%d', $err['message'], $err['file'], $err['line']));
if (!headers_sent()) {
http_response_code(500);
}
});
}Throwable接口是 PHP 7.0 引入的,它能同时接住Exception和Error,所以处理器签名用Throwable而不是Exception——这样「调用不存在的方法」这类引擎错误也会被统一处理,而不是把堆栈直接吐给用户。
五、完整可运行示例
<?php
declare(strict_types=1);
/**
* db.php —— PDO 统一初始化 + 全局错误响应
* 最低 PHP 8.0
* 运行:php db.php
* 说明:示例使用 SQLite 内存库,换 MySQL 只改 makePdo() 里的 DSN
*/
if (!in_array('sqlite', PDO::getAvailableDrivers(), true)) {
exit('需要 pdo_sqlite 扩展,或改用 MySQL DSN' . PHP_EOL);
}
// 上面三节的代码原样放在这里:DbException / makePdo() / install_error_handlers()
/** 事务模板:把回滚这件事从业务代码里拿掉,避免漏写 */
function withTransaction(PDO $pdo, callable $work): mixed
{
$pdo->beginTransaction();
try {
$result = $work($pdo);
$pdo->commit();
return $result;
} catch (Throwable $e) {
if ($pdo->inTransaction()) {
$pdo->rollBack();
}
throw $e; // 回滚后继续往上抛,交给全局处理器决定怎么响应
}
}
install_error_handlers(true);
$pdo = makePdo();
$pdo->exec('CREATE TABLE orders (id INTEGER PRIMARY KEY, sn TEXT UNIQUE NOT NULL)');
try {
withTransaction($pdo, static function (PDO $pdo): void {
$stmt = $pdo->prepare('INSERT INTO orders (sn) VALUES (:sn)');
$stmt->execute([':sn' => 'A-1001']);
$stmt->execute([':sn' => 'A-1001']); // 唯一键冲突
});
} catch (PDOException $e) {
$wrapped = DbException::fromPdo($e, '创建订单');
echo '捕获到:', $wrapped->getMessage(), PHP_EOL;
echo '前一个异常:', get_class($wrapped->getPrevious()), PHP_EOL;
echo '事务是否已回滚:', $pdo->inTransaction() ? '否' : '是', PHP_EOL;
}
// 验证回滚后确实没有留下脏数据
$count = (int)$pdo->query('SELECT COUNT(*) FROM orders')->fetchColumn();
echo 'orders 表行数:', $count, PHP_EOL;捕获到:数据库操作失败(创建订单):SQLSTATE=23000 驱动错误码=19
前一个异常:PDOException
事务是否已回滚:是
orders 表行数:0输出里的行数是 0,说明第一条 INSERT 也被回滚了——这正是事务模板的价值:异常路径下不需要业务代码记得写rollBack()。
常见坑点
- ❌ 依赖老版本的静默默认值,写
$stmt = $pdo->query($sql);后直接$stmt->fetch()。
✅ 显式设置PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION,出错立刻抛出,不要等 PHP 8 的默认值来救你。
- ❌
catch (PDOException $e) { echo $e->getMessage(); }把原始消息回给前端。
✅ 消息只进error_log(),对外统一返回「服务器内部错误」加一个可追踪的请求 ID。
- ❌ 在事务中抛异常后没有回滚,直接用同一个连接继续下一个请求。
✅ 用统一的事务模板,在catch里判断inTransaction()后回滚再抛;使用持久连接(persistent connection)时这一点尤其致命,未提交的事务会跟着连接一起被下一个请求复用。
- ❌ 只有
set_exception_handler,以为它什么都能接住。
✅ 再叠加register_shutdown_function+error_get_last()处理致命错误,否则内存耗尽时你会得到一个纯白页面。
- ❌ 用字符串拼接构造 SQL,尤其是
LIMIT和ORDER BY。
✅ 值用参数绑定,LIMIT用bindValue(':n', $n, PDO::PARAM_INT);确实无法绑定的部分(如列名)必须走白名单映射。
- ❌ 关掉模拟预处理后仍然按老习惯写
LIMIT :n并只调execute()。
✅ 显式bindValue指定PDO::PARAM_INT,或者确认参数已被强转为整数再内插。
- ❌ 在异常处理器里又抛新异常,或者在处理器里
exit,把原始堆栈覆盖掉。
✅ 处理器只做「记日志 + 回响应」;要终止流程就用exit之前先把日志写完,并且不要在处理器内部调用可能抛异常的代码。
- ❌ 生产环境
display_errors=On导致报错直接输出到响应体,把 JSON 响应污染成非法格式。
✅ 生产统一display_errors=Off+log_errors=On+ 明确的error_log路径,并确认该目录对 PHP-FPM 运行用户可写。
总结
| 配置 / 做法 | 推荐值 | 原因 |
|---|
PDO::ATTR_ERRMODE | ERRMODE_EXCEPTION | 出错即中断,杜绝带错继续执行 |
PDO::ATTR_EMULATE_PREPARES | false | 使用服务端真预处理,缩小注入面 |
PDO::ATTR_DEFAULT_FETCH_MODE | FETCH_ASSOC | 避免结果里混入数字键,也避免依赖默认值 |
| 异常包装 | 自定义领域异常 +getPrevious() | 对外不泄露 SQL,对内保留完整堆栈 |
| 事务 | 统一模板 +finally/catch回滚 | 异常路径不会留下脏数据 |
| 处理器 | 异常 + 错误 + 致命错误三层 | 覆盖所有失败路径 |
| 响应 | 统一 JSON 结构 | 调用方不必解析各种错误文案 |
PDO 错误处理的核心不是「多写几个 try」,而是把错误模式、异常翻译、事务边界、响应格式这四件事各收敛到一个地方。做到之后,业务代码里几乎不需要出现try,而任何一次失败都会留下可检索的日志和结构一致的响应——这才是「快速定位问题」的前提。