登录
推荐 文章 Go 技术 课程 下载 专题 AI
首页 >  文章 >  php教程

PHP 用 PDO 预处理批量写入并保留事务错误上下文

来源:17golang原创

时间:2026-10-07 03:16:20 417浏览 收藏

用 PDO 循环插入一批数据时,如果事务失败后只剩一条数据库异常,通常很难判断究竟是哪一行、哪个批次出了问题。更稳妥的结构是:预处理语句放在循环外复用,事务包住一个明确的原子批次;循环中持续保存当前行上下文,异常时先判断事务状态再回滚,并把业务定位字段、SQLSTATE、驱动错误码和原始异常链一起保留下来。

PDO 官方手册:https://www.php.net/manual/en/book.pdo.php

为什么批量写入失败后只剩模糊日志

常见实现会在每次循环中重新调用 prepare(),执行失败后只记录 $e->getMessage(),随后抛出一个新的通用异常。这样会同时丢掉失败行在批次中的位置、可回查的业务键,以及 PDO 驱动提供的 SQLSTATE 与驱动错误码。

PDO 预处理语句适合“同一条 SQL、多组参数”的场景。把 prepare() 放到循环外,既表达了 SQL 结构稳定,也避免重复准备。需要注意的是,占位符只能代表完整的数据值,不能替代表名、列名、排序关键字等 SQL 结构;动态标识符必须先经过白名单映射。

PDO 连接、预处理语句、当前行上下文和数据表之间的静态结构
图1:PDO 批量写入结构说明图,展示批次输入、参数绑定与目标数据表的静态关系,不是运行截图。

一次 prepare、一个事务和当前行上下文

下面的函数把“一次调用”定义为一个原子批次。任何一行失败,整个批次回滚;日志只记录可定位但不敏感的字段,并通过 previous 保留原始异常链。

 $rows
 */
function insertUsers(PDO $pdo, array $rows, string $batchId): int
{
    // SQL 结构固定,只让占位符承载数据值。
    $sql = prepare($sql);
        $pdo->beginTransaction();

        foreach ($rows as $index => $row) {
            // 在 execute 前保存当前上下文,异常发生后仍能定位失败行。
            $currentIndex = $index;
            $currentRow = $row;

            $stmt->execute([
                'external_id' => $row['external_id'],
                'name' => $row['name'],
                'email' => $row['email'],
                'batch_id' => $batchId,
            ]);
        }

        $pdo->commit();

        return count($rows);
    } catch (Throwable $e) {
        // 只有事务仍有效时才回滚,避免回滚异常覆盖原始异常。
        if ($pdo->inTransaction()) {
            $pdo->rollBack();
        }

        // PDOException 优先读取 errorInfo;非数据库异常也保留统一上下文。
        $errorInfo = $e instanceof PDOException
            ? ($e->errorInfo ?? ($stmt?->errorInfo() ?? []))
            : [];

        $context = [
            'batch_id' => $batchId,
            'row_index' => $currentIndex,
            // 只保留可回查的稳定业务键,不记录整行数据、口令或令牌。
            'external_id' => $currentRow['external_id'] ?? null,
            'sqlstate' => $errorInfo[0] ?? null,
            'driver_code' => $errorInfo[1] ?? null,
            'driver_message' => $errorInfo[2] ?? $e->getMessage(),
        ];

        $encoded = json_encode(
            $context,
            JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES
        );

        if (is_string($encoded)) {
            // 实际项目应接入结构化日志组件,并按字段配置脱敏规则。
            error_log($encoded);
        }

        throw new RuntimeException(
            sprintf(
                '批次 %s 在第 %s 行写入失败',
                $batchId,
                $currentIndex === null ? 'unknown' : (string) $currentIndex
            ),
            0,
            $e // previous 保存原始 PDOException 或其他 Throwable。
        );
    }
}

这里有两个容易忽略的细节。第一,捕获的是 Throwable,这样参数类型错误等非 PDO 异常也不会让事务悬挂;只有在异常确实是 PDOException 时才读取数据库诊断字段。第二,新的 RuntimeException 使用整数错误码 0,原始 SQLSTATE 不强塞进异常码,而是留在结构化上下文和 previous 链中。

错误上下文应该保留哪些字段

建议把上下文分成三层:

  • 批次层:batch_id、任务来源、导入文件标识或请求追踪号。
  • 行层:row_index 与一个稳定、可回查的业务键,例如 external_id。
  • 数据库层:SQLSTATE、驱动错误码、驱动消息,以及原始异常对象。

不要直接记录整行数据。邮箱、手机号、证件号、密码散列、访问令牌都可能进入日志系统并被长期保存。更稳妥的方式是记录内部业务键或不可逆摘要,让排查人员回到受控数据源中查询。

PDOException、errorInfo、结构化日志和异常链之间的静态关系
图2:PDO 异常上下文说明图,展示数据库诊断字段与应用日志、异常链的归属关系,不是运行截图。

六个常见误区

误区问题修正
循环内反复 prepare()重复准备同一 SQL循环外准备一次,循环内只执行
只记录异常消息无法定位批次和失败行同时记录批次号、行号、业务键与 errorInfo
直接调用 rollBack()可能用回滚异常遮住根因先用 inTransaction() 判断
新异常不传 previous原始异常链被截断把原异常作为第三个构造参数
整行数据写入日志敏感信息泄露且日志膨胀只记录最小定位字段并脱敏
事务中混入 DDL部分数据库可能隐式提交批量 DML 与建表、改表分开

大批量数据是否应该一次提交

事务边界应由业务原子性决定,而不是只看行数。如果 20 万行必须“全成或全败”,就要评估锁时间、事务日志空间和超时风险;如果每 1000 行可以独立成功,可以在调用函数前分块,让每个分块拥有独立的 batch_id 和事务。

 $chunk) {
    $chunkBatchId = sprintf('%s-%04d', $importId, $chunkIndex);
    insertUsers($pdo, $chunk, $chunkBatchId);
}

分块后,一块失败不会自动撤销之前已经提交的块,因此调用方必须明确重试策略:按 batch_id 幂等重放、记录已完成分块,或者提供补偿操作。不要把“提高吞吐”误当成“仍然保持全批次原子性”。

相关问题

execute 返回 false 时还要手动检查吗

建议使用异常错误模式。PHP 8.0 起默认就是 PDO::ERRMODE_EXCEPTION,连接初始化时仍可显式设置,让项目约定更清楚。在异常模式下,数据库错误会抛出 PDOException,不要再混用一套忽略返回值、一套吞异常的逻辑。

为什么不能用一个占位符传整个数组

一个占位符只能代表一个完整的数据字面量,不能自动展开成多个值,也不能替代 SQL 关键字或标识符。多值语句仍要为每个值创建唯一占位符。

errorInfo 与 getMessage 有什么区别

getMessage() 适合人读,但结构不稳定;errorInfo 通常包含 SQLSTATE、驱动错误码和驱动消息,更适合日志字段化、告警聚合和故障分类。两者都应保留,但业务程序不要只根据消息文本判断错误类型。

唯一索引冲突时如何安全重试

先根据 SQLSTATE 和驱动错误码判断是否属于预期的唯一约束冲突,再决定跳过、更新还是终止。不要无差别重试所有数据库异常;死锁和瞬时连接问题可以有限重试,而数据约束错误通常需要修正输入或采用幂等写入策略。

小结

PDO 批量写入的关键不是简单地把循环包进事务,而是同时设计好语句复用、事务边界和可观测性。循环外准备语句,循环内保存当前行上下文;异常时守护式回滚,提取 SQLSTATE 与驱动信息,并通过 previous 留住原始异常链。这样即使整批数据已经回滚,失败原因仍然可定位、可分类,也更容易安全重试。

声明:本文转载于:17golang原创 如有侵犯,请联系study_golang@163.com删除
相关阅读
更多>
最新阅读
更多>
课程推荐
更多>