Skip to content

PHP 连接 PostgreSQL ​

PHP 连接 PostgreSQL 有两条官方路径:PDO_PGSQL 驱动(现代、推荐,统一在 PDO 接口之下,可无缝切换其他数据库)与旧版 ext/pgsql 扩展(pg_connect/pg_query_params 那一族函数,老项目常见,功能上也有 PDO 没有的能力,比如大对象函数与 COPY 辅助函数)。本页以 PDO_PGSQL 为主线,把 DSN、预处理、事务、错误处理讲透,再补齐 ext/pgsql 的 COPY 与大对象这两个 PDO 覆盖不到的场景,最后落到 Laravel 配置与连接池策略。

与 MySQL 生态相比,PHP + PostgreSQL 有几个必须建立的概念差异:PostgreSQL 的连接是每连接一个进程的重型资源(连接开销远高于 MySQL),因此 PHP-FPM 短生命周期进程与「每请求新建连接」的模式放大了连接压力,连接池(PgBouncer)几乎是生产标配;PostgreSQL 的预处理走服务端(PREPARE/扩展查询协议),PDO 的 EMULATE_PREPARES=false 正是启用它的开关,也是性能与安全双赢的设置。

本页所有代码假定 PHP 8.x 与 PostgreSQL 16/18 环境;扩展安装(pdo_pgsql、pgsql)与连接参数全集以官方手册为准。

前置知识

建议先阅读:psql \copy 数据导入导出

基础概念 ​

先把两条路径的全貌摆出来:

维度PDO_PGSQLext/pgsql
接口风格面向对象,PDO 抽象层过程函数(pg_*)
预处理prepare() + 占位符pg_prepare() / pg_query_params()
事务beginTransaction() 等SQL 显式 BEGIN/COMMIT
大对象不支持pg_lo_* 系列函数
COPY不直接支持pg_copy_from / pg_copy_to / pg_put_line
可移植性换数据库成本低绑定 PostgreSQL
官方态度推荐维护中,老项目兼容

选择建议一目了然:新项目一律 PDO_PGSQL;只有用到大对象或 COPY 数组接口时才落到 ext/pgsql,且两者可以在同一项目共存(连同一个库)。

PHP 与 PostgreSQL 的连接拓扑还需要理解一层:PHP-FPM 的每个 worker 进程在「非持久连接」模式下每个请求都新建连接,而 PostgreSQL 服务端每连接对应一个后端进程(数 MB 内存起步)。100 个 FPM worker × 频繁建连 = 服务器上成百的并发后端进程。这引出两条缓解路径——PDO 持久连接(进程内复用)与外部连接池 PgBouncer(集中式复用),两者的取舍在详细说明里展开。

术语英文说明
DSNData Source NamePDO 的连接描述串,前缀 pgsql:
服务端预处理server-side prepare语句模板在服务器解析,参数另行绑定
SQLSTATESQLSTATE五字符标准错误码,如 23505 唯一约束冲突
持久连接persistent connectionPHP 进程复用连接,跨请求不关闭
连接池connection pool集中管理连接复用的中间件(PgBouncer)

语法与用法 ​

PDO 连接的最小可用样板:

php
<?php
$dsn = 'pgsql:host=localhost;port=5432;dbname=appdb;user=app_rw;password=secret';
$pdo = new PDO($dsn, null, null, [
    PDO::ATTR_ERRMODE            => PDO::ERRMODE_EXCEPTION,
    PDO::ATTR_EMULATE_PREPARES   => false,   // 启用 PostgreSQL 服务端预处理
    PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC,
]);

预处理与绑定(命名占位符与问号占位符都支持):

php
// 命名占位符
$stmt = $pdo->prepare('SELECT id, name FROM users WHERE status = :status AND age > :age');
$stmt->execute(['status' => 'active', 'age' => 18]);
$rows = $stmt->fetchAll();

// 问号占位符
$stmt = $pdo->prepare('INSERT INTO orders (user_id, amount) VALUES (?, ?)');
$stmt->execute([$userId, $amount]);

事务:

php
$pdo->beginTransaction();
try {
    $pdo->prepare('UPDATE accounts SET balance = balance - ? WHERE id = ?')
        ->execute([100, $from]);
    $pdo->prepare('UPDATE accounts SET balance = balance + ? WHERE id = ?')
        ->execute([100, $to]);
    $pdo->commit();
} catch (Throwable $e) {
    $pdo->rollBack();
    throw $e;
}

ext/pgsql 的连接与查询:

php
<?php
$conn = pg_connect("host=localhost port=5432 dbname=appdb user=app_rw password=secret connect_timeout=5 sslmode=require");
if (!$conn) { die('连接失败'); }

$result = pg_query_params($conn, 'SELECT id, name FROM users WHERE id = $1', [$id]);
$row = pg_fetch_assoc($result);

COPY 批量导入(ext/pgsql):

php
<?php
// 方式一:数组一步导入
pg_copy_from($conn, 'staging_rows', $rowsArray);

// 方式二:流式写入(大文件推荐,逐行生成而不是先全读进内存)
pg_query($conn, 'COPY staging_rows FROM STDIN WITH (FORMAT csv)');
$fp = fopen('data.csv', 'r');
while (($line = fgets($fp)) !== false) {
    pg_put_line($conn, $line);
}
pg_put_line($conn, "\\.\n");
pg_end_copy($conn);

详细说明 ​

PDO_PGSQL 的 DSN 与连接参数 ​

PDO_PGSQL 的 DSN 前缀是 pgsql:,常用参数:

text
pgsql:host=db1.prod.internal;port=5432;dbname=appdb;
       user=app_rw;password=secret;
       connect_timeout=5;
       sslmode=verify-full;
       options='--search_path=tenant_a,public'
参数说明
host / port / dbname服务器地址、端口、库名
user / password凭据(也可放在构造函数第 2、3 参数)
connect_timeout建连超时秒数,生产必配
sslmodedisable/allow/prefer/require/verify-ca/verify-full
options传给服务器的命令行选项,--search_path= 是最常用法
application_name应用标识(部分 PHP 版本支持,便于 pg_stat_activity 溯源)

user/password 既可写进 DSN 也可作为构造函数参数,二选一即可;凭据永远来自环境变量或密钥管理,不硬编码。options 参数能一次性把 search_path 定好,多 schema 应用(多租户)常用:

php
$dsn = "pgsql:host=$host;port=5432;dbname=appdb;options='--search_path=$tenant,public'";

PDO 属性与错误处理策略 ​

构造时的属性决定了整个会话的基调,三个最关键的:

php
$pdo = new PDO($dsn, null, null, [
    PDO::ATTR_ERRMODE          => PDO::ERRMODE_EXCEPTION, // 出错抛异常,绝不静默
    PDO::ATTR_EMULATE_PREPARES => false,                  // 真预处理,交给服务器
    PDO::ATTR_STRINGIFY_FETCHES => false,                 // 按原生类型返回
]);
  • ERRMODE_EXCEPTION 是唯一推荐的错误模式:静默失败(ERRMODE_SILENT)让数据损坏无人知晓,ERRMODE_WARNING 在 API 场景同样不可控;
  • EMULATE_PREPARES=false 让每次 prepare() 走 PostgreSQL 扩展查询协议(语句在服务端解析规划、参数单独传输),注入防护由数据库层保证,且类型转换发生在服务端;
  • PostgreSQL 对预处理的执行计划策略见《连接与预处理的缓存》小节。

错误发生时通过 errorInfo() 拿到标准三元组:

php
try {
    $pdo->exec("INSERT INTO users (email) VALUES ('a@b.com')");
} catch (PDOException $e) {
    [$sqlstate, $driverCode, $message] = $e->errorInfo;
    if ($sqlstate === '23505') {
        // unique_violation:唯一约束冲突,可安全重试或转业务提示
    }
    error_log("DB error [$sqlstate]: $message");
    throw $e;
}

SQLSTATE 是跨数据库的标准码(23505 唯一冲突、23503 外键冲突、40001 序列化失败、57014 查询被取消),按它分支而不是按错误文本匹配,才能写出健壮的错误处理。

预处理语句与持久连接的生命周期 ​

EMULATE_PREPARES=false 时,PDO_PGSQL 为每次 prepare() 在服务端创建命名语句。它的生命周期管理有两个坑:

  1. 命名语句在连接存活期间累积。非持久连接随请求销毁、服务端自动清理,无感;持久连接(ATTR_PERSISTENT)下语句会跨请求累积——同一个 FPM worker 跑数天后,服务端为该连接保留了数千条命名语句(pg_prepared_statements 可查),内存与服务端资源缓慢泄漏;
  2. 清理手段:连接复用前执行 DISCARD ALL(清空命名语句、SET 参数、临时表等全部会话状态),或至少 DEALLOCATE ALL 清语句。「借出会话状态干净」要成为持久连接的纪律,与 PgBouncer 的 server_reset_query 同构。
sql
-- 观察某连接累积的预处理语句
SELECT name, statement, prepare_time
FROM pg_prepared_statements
WHERE name LIKE 'pdo_pgsql%';

高吞吐 API 服务的两条正路:持久连接 + 每请求收尾 DISCARD ALL;或者交给 PgBouncer transaction 模式(池侧统一 reset)。二选一,不要「持久连接直连数据库还不做清理」。

预处理、类型与结果集 ​

PDO_PGSQL 对 PostgreSQL 类型的映射总体诚实:integer/bigint 返回 PHP int,numeric 在 ATTR_STRINGIFY_FETCHES=false 时返回 PHP 字符串(保留精度,避免浮点误差),bool 返回 PHP bool,timestamp 返回字符串,bytea 返回二进制串,数组类型(text[])在较新 PHP 版本可自动转 PHP 数组。金额计算永远用 numeric 字符串配合 bcmath,不要转 float。

查询参数只有「值」这一种角色——表名、列名、排序方向不能参数化,动态标识符必须走白名单校验:

php
$allowed = ['created_at', 'amount', 'id'];
$sort = in_array($_GET['sort'] ?? '', $allowed, true) ? $_GET['sort'] : 'id';
$stmt = $pdo->prepare("SELECT * FROM orders ORDER BY $sort");

这是 PDO 层面防注入的最后一块拼图:值交给占位符,标识符交给白名单。

事务与保存点 ​

beginTransaction()/commit()/rollBack() 直接映射 BEGIN/COMMIT/ROLLBACK。三个工程细节:

  1. beginTransaction 之前不能已有活动事务,否则抛异常——管理好调用链上的事务开启者,避免多层函数重复开事务;
  2. PostgreSQL 支持真正的 SAVEPOINT,PDO 用 exec('SAVEPOINT sp1') / exec('ROLLBACK TO sp1') 手工操作(部分框架有封装),局部失败不必整体回滚;
  3. 长事务是生产事故之源:事务内不要做 HTTP 调用、文件 IO 等慢操作,连接被中断(FPM 超时杀死进程)时未提交事务由服务器回滚。

配合隔离级别:

php
$pdo->exec('SET TRANSACTION ISOLATION LEVEL REPEATABLE READ');
$pdo->beginTransaction();
// …

ext/pgsql:pg_connect 与 pg_query_params ​

ext/pgsql 的连接串是 libpq 的 keyword/value 格式,参数全集(connect_timeout、sslmode、application_name 等)与 libpq 一致。查询族函数的安全原则只有一条:永远不要 pg_query() 拼接用户输入,一律 pg_query_params()($1/$2 占位符 + 参数数组,走扩展查询协议):

php
// 错误示范(注入风险)
pg_query($conn, "SELECT * FROM users WHERE name = '" . $_GET['name'] . "'");

// 正确姿势
pg_query_params($conn, 'SELECT * FROM users WHERE name = $1', [$_GET['name']]);

预处理复用场景可用 pg_prepare($conn, 'find_user', 'SELECT … WHERE id=$1') + pg_execute($conn, 'find_user', [$id]),语句名在同一连接内有效。

ext/pgsql 的 COPY 接口 ​

ext/pgsql 把 COPY 协议封装成了三组函数,适合大批量数据:

函数方向特点
pg_copy_from($conn, $table, $rows)数组 → 表简单,但数组必须已在内存
pg_copy_to($conn, $table)表 → 数组导出整表到 PHP 数组,慎用于大表
pg_put_line + pg_end_copy流式 → 表配合 COPY FROM STDIN,可逐行读文件写入,内存平稳

流式版注意三个细节:先 pg_query($conn, 'COPY … FROM STDIN …') 发起、每行以 \n 结尾发送、最后发送 \\.\n 结束符并调用 pg_end_copy() 完成同步。它与 \copy 的性能同源——都是走服务器的 COPY 通道,比循环 INSERT 快一个数量级。

常用 SQLSTATE 速查表 ​

SQLSTATE含义PHP 侧典型处理
23505unique_violation 唯一冲突转业务提示(已注册/已下单),或改 upsert
23503foreign_key_violation 外键冲突校验引用数据,400 类错误
23502not_null_violation 非空违约参数校验缺口,回 400 前先补齐
23514check_violation 检查约束违约业务规则前置校验
40001serialization_failure 序列化失败退避重试(可重试)
40P01deadlock_detected 死锁退避重试(可重试)
25P02in_failed_sql_transaction 事务已中止事务内前序语句失败后先 rollback
57014query_canceled 查询被取消区分超时/用户取消,幂等操作可重试
42501insufficient_privilege 权限不足检查角色授权,禁止重试
42P01undefined_table 表不存在迁移缺失告警
53300too_many_connections 连接数超限指数退避 + 降级,接 PgBouncer
08006connection_failure 连接故障重建连接,检查网络与池

这张表覆盖了 PHP 应用九成的错误分支;分类的工程化封装见下面的错误翻译层。

大对象:pg_lo_* 函数 ​

PostgreSQL 大对象(Large Object,lo/oid)的流式读写只能通过 libpq 层接口,PHP 侧由 ext/pgsql 的 pg_lo_* 函数族提供:pg_lo_create/pg_lo_open/pg_lo_write/pg_lo_read/pg_lo_close/pg_lo_unlink,以及绕过 PHP 内存的 pg_lo_import/pg_lo_export(服务器端文件搬运)。大对象必须在事务内操作,这是与普通列最大的差别:

php
<?php
pg_query($conn, 'BEGIN');
$oid = pg_lo_create($conn);
$lo  = pg_lo_open($conn, $oid, 'w');
pg_lo_write($lo, $fileContent);
pg_lo_close($lo);
pg_query($conn, 'COMMIT');

大对象的适用边界(与 bytea 的取舍、垃圾回收、备份影响)见《大对象总览》与《大对象操作》两页;多数场景 bytea 更简单,只有流式读写超大文件时才需要 lo。

连接建立的开销解剖与 FPM 进程模型 ​

在谈池之前,先把「一次连接到底贵在哪」拆开看。PHP 建立一次 PostgreSQL 连接要经历:

  1. TCP 三次握手(内网约 1ms,跨机房 10ms+);
  2. TLS 握手(若启用 sslmode,额外多次往返);
  3. 认证交互(SCRAM-SHA-256 需要 1~2 个往返);
  4. 服务端 fork 一个后端进程并完成初始化(加载系统目录缓存、分配 work_mem 等结构,数 MB 起步);
  5. 应用层的会话初始化(时区、search_path、框架自动执行的 SET)。

第 4 步是 PostgreSQL 与 MySQL 最大的差别:MySQL 的线程创建轻量,PostgreSQL 的后端是真进程,fork + 初始化代价高一个量级。单次建连通常毫秒级完成,但在「连接风暴」下——比如发布重启后几百个 FPM worker 同时冷启动——服务端会瞬间承受数百次 fork,表现为 CPU 尖峰,随后可能撞上 max_connections 报错 53300(too_many_connections)。

FPM 的进程模型让问题雪上加霜:pm.max_children 个 worker 各自独立持有连接,非持久模式下每请求断开重连。连接总数上界就是 pm.max_children × 实例数——40 worker × 10 实例 = 400 连接,还没算 cron、队列消费者。由此得出按部署形态的选型:

部署形态建议做法
单机小应用PDO 持久连接 + 用 pm.max_children 管住连接上界
多实例 / 流量波动大PgBouncer(session 或 transaction 模式)
队列消费者 / 常驻进程进程内单例连接,天然复用,无需额外池

顺带一提,FPM 的 pm 模式也影响连接行为:static 下 worker 数恒定、连接数可预测;dynamic/ondemand 下连接数随流量波动,更依赖池来削峰。

连接池:PDO::ATTR_PERSISTENT 与 PgBouncer ​

PHP 没有内建的跨进程连接池,缓解手段有两级:

第一级:PDO 持久连接。 构造时加 PDO::ATTR_PERSISTENT => true,FPM worker 进程把连接保留在进程内,下一请求直接复用,省掉每次的 TCP + 认证 + 后端 fork 开销。代价是会话状态跨请求残留:上一个请求 SET 的参数、打开的未提交事务、临时表都会延续给下一个请求——「事务忘收尾」在持久连接下会污染后续请求。

第二级:外部池 PgBouncer。 在 PHP 与 PostgreSQL 之间架一层池,应用连的是池(连接秒回),池再复用少量真实连接。PgBouncer 的三种池化模式中,transaction 模式池化效果最好,但要求应用不依赖会话状态(SET、命名预处理语句、LISTEN)。PDO 的服务端预处理语句默认按连接命名缓存,在 transaction 模式下可能落到不同的真实连接而报错——因此 PgBouncer transaction 模式下要么让 PDO 模拟预处理(EMULATE_PREPARES=true),要么使用支持语句级缓存的池化配置。

text
              ┌─────────────┐        ┌─────────────┐        ┌─────────────┐
  FPM worker1 │             │        │             │        │             │
  FPM worker2 ├─► PgBouncer ├────────►│  池内复用    ├───────►│ PostgreSQL  │
  FPM workerN │  (transaction│        │  20~40 连接  │        │  每连接一进程 │
              └─────────────┘        └─────────────┘        └─────────────┘

一般结论:中小流量用 PDO 持久连接即可;连接数不可控(多实例、突发流量)或需要保护数据库时上 PgBouncer。池的规模与模式详见《PgBouncer》与《连接池最佳实践》。

Laravel 中的 PostgreSQL 配置 ​

Laravel 的 PDO 封装让 pgsql 连接配置集中在 config/database.php:

php
'connections' => [
    'pgsql' => [
        'driver'         => 'pgsql',
        'url'            => env('DATABASE_URL'),
        'host'           => env('DB_HOST', '127.0.0.1'),
        'port'           => env('DB_PORT', '5432'),
        'database'       => env('DB_DATABASE', 'app'),
        'username'       => env('DB_USERNAME', 'app'),
        'password'       => env('DB_PASSWORD', ''),
        'charset'        => 'utf8',
        'prefix'         => '',
        'search_path'    => 'public',
        'sslmode'        => 'prefer',
    ],
],

读写分离在同一连接里声明 read/write 组即可:

php
'pgsql' => [
    'driver' => 'pgsql',
    'read'   => [
        'host' => ['10.0.0.11', '10.0.0.12'],  // 只读库轮询
    ],
    'write'  => [
        'host' => ['10.0.0.10'],               // 写主库
    ],
    // …其余参数同上
],

Eloquent 层的注意点:PostgreSQL 的 schema 概念通过 search_path 配置体现(多租户切 schema 常在中间件里 SET search_path);upsert() 底层生成 ON CONFLICT 语句,性能远优于逐条 updateOrCreate;DB::transaction(fn () => …) 封装了重入保护与回滚。

PDO 侧的结果集形态与游标 ​

PDO 的取数形态由 fetch 模式与游标配置决定:

php
<?php
// 逐行消费(代码侧逐行处理)
$stmt = $pdo->query('SELECT id, payload FROM events LIMIT 1000');
while ($row = $stmt->fetch()) {
    process($row);
}

// 全量载入:行数可控时最方便
$rows = $stmt->fetchAll(PDO::FETCH_ASSOC);

// 可滚动游标:PDO_PGSQL 支持(事务内有效)
$stmt = $pdo->prepare(
    'SELECT id, name FROM users ORDER BY id',
    [PDO::ATTR_CURSOR => PDO::CURSOR_SCROLL]
);
$stmt->execute();
$stmt->fetch(PDO::FETCH_ASSOC, PDO::FETCH_ORI_ABS, 100);  // 直接定位第 100 行

必须建立的内存模型:PDO_PGSQL 的查询结果在客户端整体缓冲(底层 libpq 一次取回全部行),fetch() 与 fetchAll() 的差别只是 PHP 侧数组的构建时机。因此「大结果集」的正确出路是 SQL 层分页(keyset 分页优于 OFFSET 深翻)或 COPY 导出,而不是「改成逐行 fetch」——这与 psycopg 命名游标、pg-cursor 等「服务端游标流式」方案有本质差异,是 PHP 驱动生态的特性边界。

JSONB 与数组类型的读写 ​

PostgreSQL 的 JSONB 是 PHP 应用的高频特性。PDO 下写 jsonb 有三种形态,推荐第二种:

php
// 1) json_encode 后手工拼 SQL —— 容易漏转义,不推荐
$pdo->exec("INSERT INTO events (payload) VALUES ('" . json_encode($data) . "')");

// 2) 占位符 + 显式 ::jsonb 转换(推荐,语义清晰)
$stmt = $pdo->prepare('INSERT INTO events (payload) VALUES (?::jsonb)');
$stmt->execute([json_encode($data, JSON_THROW_ON_ERROR | JSON_UNESCAPED_UNICODE)]);

// 3) 占位符直插:列类型为 jsonb 时,合法 JSON 文本会被隐式转换
$stmt = $pdo->prepare('INSERT INTO events (payload) VALUES (?)');
$stmt->execute([json_encode($data)]);

读回来时 jsonb 以 JSON 文本形态返回,json_decode($row['payload'], true) 还原为数组。查询侧直接使用 jsonb 操作符:

php
// 包含匹配,可吃 GIN 索引
$stmt = $pdo->prepare("SELECT id FROM events WHERE payload @> ?::jsonb");
$stmt->execute([json_encode(['kind' => 'order_created'])]);

// 路径取值做等值过滤,需要表达式索引才有索引扫描
$stmt = $pdo->prepare("SELECT id FROM events WHERE payload->>'channel' = ?");
$stmt->execute(['alipay']);

数组类型(text[]、int[])在较新的 PHP/PDO_PGSQL 组合下可以以 PHP 数组形态自动往返,但该行为随 PHP 小版本存在差异,生产代码建议显式处理——写侧编码为 PostgreSQL 数组字面量,读侧自己解析:

php
// 把 PHP 数组编码为 PostgreSQL 数组字面量(元素加引号、转义反斜杠与双引号)
function pg_text_array(array $items): string
{
    return '{' . implode(',', array_map(
        static fn (string $v): string => '"' . str_replace(['\\', '"'], ['\\\\', '\\"'], $v) . '"',
        $items
    )) . '}';
}

$stmt = $pdo->prepare('INSERT INTO docs (tags) VALUES (?::text[])');
$stmt->execute([pg_text_array(['php', 'postgres'])]);

原则与防注入一节同源:类型转换显式写在 SQL 里(::jsonb/::text[]),让「值是什么类型」在语句里可读、可审计。

防注入的全景图 ​

把 PHP 侧的防注入手段汇总成检查清单:

  1. 值:一律占位符(PDO :name/? 或 pg_query_params 的 $1),永不清洗后拼接;
  2. 标识符:表名列名排序方向走白名单,或用 pg_quote_ident() 类转义后拼入;
  3. LIMIT/OFFSET:整数化校验((int)$n)或占位符;
  4. LIKE 模式:值仍走占位符,通配符(%/_)按业务决定是否转义;
  5. 关闭模拟预处理(EMULATE_PREPARES=false):让注入防护落在数据库协议层而不是字符串层;
  6. 错误信息:异常信息记日志不回显,避免泄露表结构与 SQL。

错误处理的工程化封装 ​

把 SQLSTATE 分支收敛成一个可复用的异常翻译层,业务代码只见领域异常:

php
<?php
class RepoException extends RuntimeException {}

final class ErrorMapper
{
    public static function translate(PDOException $e): Throwable
    {
        [$sqlstate, , $message] = $e->errorInfo;

        return match ($sqlstate) {
            '23505' => new RepoException('DUPLICATE', previous: $e),
            '23503' => new RepoException('MISSING_REFERENCE', previous: $e),
            '42501', '42P01' => new RepoException('SCHEMA_OR_GRANT', previous: $e),
            '40001', '40P01' => new RepoException('RETRYABLE', previous: $e),
            default  => new RepoException("DB_ERROR:$sqlstate", previous: $e),
        };
    }

    public static function isRetryable(Throwable $t): bool
    {
        return $t instanceof RepoException && $t->getMessage() === 'RETRYABLE';
    }
}

// 调用侧:翻译一次,分支业务语义
try {
    $repo->createOrder($order);
} catch (PDOException $e) {
    throw ErrorMapper::translate($e);
}

这个模式的价值在边界收敛:驱动异常只在仓储层出现一次,重试策略(isRetryable)与业务语义(DUPLICATE / MISSING_REFERENCE)对上层可见;跨数据库迁移时只改映射表,业务代码不动。重试的实现配合指数退避 + 抖动,与下节的并发冲突示例衔接。

实战示例 ​

示例一:生产级 PDO 连接封装 ​

php
<?php
final class Db
{
    private static ?PDO $pdo = null;

    public static function pdo(): PDO
    {
        if (self::$pdo === null) {
            $host = getenv('PG_HOST') ?: 'localhost';
            $db   = getenv('PG_DB')   ?: 'app';
            $user = getenv('PG_USER');
            $pass = getenv('PG_PASS');
            $dsn  = "pgsql:host=$host;port=5432;dbname=$db;connect_timeout=5;sslmode=require";

            self::$pdo = new PDO($dsn, $user, $pass, [
                PDO::ATTR_ERRMODE            => PDO::ERRMODE_EXCEPTION,
                PDO::ATTR_EMULATE_PREPARES   => false,
                PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC,
                PDO::ATTR_PERSISTENT         => true,
            ]);
        }
        return self::$pdo;
    }
}

$rows = Db::pdo()->prepare('SELECT id, name FROM users WHERE created_at >= :since')
                 ->execute(['since' => '2026-01-01']);

示例二:流式 COPY 导入百万行 CSV ​

php
<?php
$conn = pg_connect(getenv('PG_CONNSTR'));

pg_query($conn, 'BEGIN');
pg_query($conn, 'TRUNCATE stg_events');
pg_query($conn, "COPY stg_events (event_id, user_id, kind, payload, event_time)
                 FROM STDIN WITH (FORMAT csv, HEADER true)");

$fp = fopen('/data/events_2026.csv', 'r');
$n = 0;
while (($line = fgets($fp)) !== false) {
    pg_put_line($conn, $line);
    if (++$n % 100000 === 0) { echo "buffered $n\n"; }
}
pg_put_line($conn, "\\.\n");
pg_end_copy($conn);
pg_query($conn, 'COMMIT');
echo "imported rows: $n\n";

示例三:Laravel pgsql 完整配置(含读写分离与池) ​

php
// config/database.php
'pgsql' => [
    'driver'   => 'pgsql',
    'host'     => env('DB_HOST', 'pgbouncer.internal'),   // 应用连池,不直连库
    'port'     => env('DB_PORT', '6432'),                 // PgBouncer 端口
    'database' => env('DB_DATABASE'),
    'username' => env('DB_USERNAME'),
    'password' => env('DB_PASSWORD'),
    'charset'  => 'utf8',
    'prefix'   => '',
    'search_path' => 'public',
    'sslmode'  => 'require',
    'read'     => ['host' => [env('DB_READ_HOST', 'pgbouncer-ro.internal')]],
    'write'    => ['host' => [env('DB_WRITE_HOST', 'pgbouncer-rw.internal')]],
],

示例四:按 SQLSTATE 处理并发冲突 ​

php
<?php
$pdo = Db::pdo();
$stmt = $pdo->prepare('INSERT INTO orders (id, user_id, amount) VALUES (?, ?, ?)');

for ($attempt = 1; $attempt <= 3; $attempt++) {
    try {
        $pdo->beginTransaction();
        $stmt->execute([$orderId, $userId, $amount]);
        $pdo->prepare('INSERT INTO order_items SELECT * FROM cart_items WHERE user_id = ?')
            ->execute([$userId]);
        $pdo->commit();
        break;                       // 成功即退出重试循环
    } catch (PDOException $e) {
        $pdo->rollBack();
        [$sqlstate] = $e->errorInfo;
        if ($sqlstate === '40001' || $sqlstate === '40P01') {
            usleep(50 * 1000 * $attempt);   // 序列化失败/死锁:退避后重试
            continue;
        }
        throw $e;                    // 其他错误不重试
    }
}

示例五:大对象的完整上传与下载 ​

php
<?php
$conn = pg_connect(getenv('PG_CONNSTR'));

// 上传(必须在事务内)
pg_query($conn, 'BEGIN');
$oid = pg_lo_create($conn);
$lo  = pg_lo_open($conn, $oid, 'w');
$in  = fopen('/tmp/report.pdf', 'rb');
while (!feof($in)) {
    pg_lo_write($lo, fread($in, 8192));
}
fclose($in);
pg_lo_close($lo);
pg_query_params($conn, 'INSERT INTO documents (name, lo_oid) VALUES ($1, $2)',
                ['report.pdf', $oid]);
pg_query($conn, 'COMMIT');

// 下载
pg_query($conn, 'BEGIN');
$res = pg_query_params($conn, 'SELECT lo_oid FROM documents WHERE name = $1',
                       ['report.pdf']);
$oid = pg_fetch_result($res, 0, 0);
$lo  = pg_lo_open($conn, $oid, 'r');
$out = fopen('php://output', 'wb');
while (($chunk = pg_lo_read($lo, 8192)) !== false && $chunk !== '') {
    fwrite($out, $chunk);
}
pg_lo_close($lo);
pg_query($conn, 'COMMIT');

逐块读写让内存占用与文件大小解耦;pg_lo_import / pg_lo_export 可在服务器端直接搬运文件(省去 PHP 中转),但要评估应用与数据库的文件系统可达性。生产实现还要补上失败路径的事务回滚与 pg_lo_unlink 孤儿清理——大对象与 bytea 的完整取舍见大对象总览。

注意事项 ​

PDO::ATTR_EMULATE_PREPARES 默认值随 PHP 版本而异,务必显式设置。 模拟预处理在客户端做字符串替换,类型语义与转义细节不如服务端预处理可靠;生产代码永远显式写 false(除非接 PgBouncer transaction 模式,见下条)。

PgBouncer transaction 模式与会话状态互斥。 SET 参数、命名预处理、LISTEN、临时表在 transaction 模式下都可能落到不同真实连接。上池之前先审计应用的会话状态依赖,必要时切换模式或调整预处理策略。

持久连接不清理会话。 ATTR_PERSISTENT=true 时,上一请求的 SET、未关闭事务会延续;连接出口处保证 COMMIT/ROLLBACK 收尾,必要时执行 DISCARD ALL 级别的重置(或交给 PgBouncer 的 server_reset_query)。

PostgreSQL 连接是重资源。 每个 FPM worker 都可能持有连接,worker 数 × 实例数很容易超过 PostgreSQL 的 max_connections。先上池再扩容,不要用「调大 max_connections」硬扛。

numeric/decimal 以字符串返回。 这是精度保护而非 bug;金额运算用 bcmath/decimal 扩展,千万不要 (float) 强转。

大对象必须在事务内操作。 pg_lo_* 系列离开事务会失败;同时大对象不被逻辑复制、备份方式特殊,选型前先读大对象章节。

错误信息不外泄。 PDOException 的 message 包含 SQL 片段与约束名,直接回显给前端是信息泄露;日志分级记录、对外返回统一错误码。

ext/pgsql 与 PDO 混用要分清连接。 两套扩展的连接资源互不相通,同一请求里别混用两种连接对象,事务边界才会清晰。

PDO_PGSQL 的结果是整体缓冲的。 大结果集的出路是 SQL 分页或 COPY,不是「逐行 fetch 省内存」;可滚动游标也持有完整服务端结果,别当流式用。

持久连接会累积预处理语句与会话状态。 长命 worker 上的命名语句、SET 参数跨请求留存;收尾 DISCARD ALL 或上 PgBouncer 统一 reset,二选一。

最佳实践 ​

  1. 新项目一律 PDO_PGSQL + ERRMODE_EXCEPTION + EMULATE_PREPARES=false。 这三行配置是 PHP + PostgreSQL 的默认起点。
  2. 凭据走环境变量,DSN 里配 connect_timeout 与 sslmode。 建连超时防止网络故障拖垮 worker;verify-full 级别的 TLS 在公网/跨机房链路上应是默认。
  3. 值占位符、标识符白名单、LIMIT 整数化。 三条纪律覆盖 PDO 场景的全部注入面。
  4. 事务内不做慢操作,事务边界放在服务层。 HTTP 调用、队列推送挪到 commit 之后;事务函数化(DB::transaction 或 try/commit/rollback 封装)保证必然收尾。
  5. 按 SQLSTATE 分支做错误处理。 23505 转「已存在」、23503 转「引用不存在」、40001/40P01 做退避重试——跨版本、跨库都稳定。
  6. 连接数规划先 PgBouncer 后 max_connections。 FPM 进程模型 + PostgreSQL 每连接一进程的组合,池是刚需而非优化。
  7. PgBouncer transaction 模式下审计会话状态依赖。 改用语句级预处理策略或框架层的连接上下文管理,而不是寄希望于「应该没问题」。
  8. 批量写入用 COPY 通道(pg_put_line / pg_copy_from)。 循环 INSERT 与 COPY 的差距是十倍量级;写入逻辑复用 \copy 一页的性能清单。
  9. 金额用 numeric + bcmath,时间用 timestamptz。 让类型系统替你挡住精度与时区两类经典事故。
  10. Laravel 项目把 search_path、sslmode、读写分离写进配置管理。 环境差异全部收敛到 env 与 config,禁止散落在代码里的 SET search_path。
  11. 大文件导出用 pg_copy_to 之前先估内存。 它把整表读进 PHP 数组;大表改走 COPY TO STDOUT 流式落盘。
  12. 监控 application_name。 连接串里带上应用与实例标识,pg_stat_activity 里的每条连接都能溯源,排障效率立竿见影。

下一节

继续学习:Python 连接 PostgreSQL

参考链接 ​