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_PGSQL | ext/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(集中式复用),两者的取舍在详细说明里展开。
| 术语 | 英文 | 说明 |
|---|---|---|
| DSN | Data Source Name | PDO 的连接描述串,前缀 pgsql: |
| 服务端预处理 | server-side prepare | 语句模板在服务器解析,参数另行绑定 |
| SQLSTATE | SQLSTATE | 五字符标准错误码,如 23505 唯一约束冲突 |
| 持久连接 | persistent connection | PHP 进程复用连接,跨请求不关闭 |
| 连接池 | connection pool | 集中管理连接复用的中间件(PgBouncer) |
语法与用法
PDO 连接的最小可用样板:
<?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,
]);预处理与绑定(命名占位符与问号占位符都支持):
// 命名占位符
$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]);事务:
$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
$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
// 方式一:数组一步导入
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:,常用参数:
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 | 建连超时秒数,生产必配 |
sslmode | disable/allow/prefer/require/verify-ca/verify-full |
options | 传给服务器的命令行选项,--search_path= 是最常用法 |
application_name | 应用标识(部分 PHP 版本支持,便于 pg_stat_activity 溯源) |
user/password 既可写进 DSN 也可作为构造函数参数,二选一即可;凭据永远来自环境变量或密钥管理,不硬编码。options 参数能一次性把 search_path 定好,多 schema 应用(多租户)常用:
$dsn = "pgsql:host=$host;port=5432;dbname=appdb;options='--search_path=$tenant,public'";PDO 属性与错误处理策略
构造时的属性决定了整个会话的基调,三个最关键的:
$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() 拿到标准三元组:
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() 在服务端创建命名语句。它的生命周期管理有两个坑:
- 命名语句在连接存活期间累积。非持久连接随请求销毁、服务端自动清理,无感;持久连接(
ATTR_PERSISTENT)下语句会跨请求累积——同一个 FPM worker 跑数天后,服务端为该连接保留了数千条命名语句(pg_prepared_statements可查),内存与服务端资源缓慢泄漏; - 清理手段:连接复用前执行
DISCARD ALL(清空命名语句、SET 参数、临时表等全部会话状态),或至少DEALLOCATE ALL清语句。「借出会话状态干净」要成为持久连接的纪律,与 PgBouncer 的server_reset_query同构。
-- 观察某连接累积的预处理语句
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。
查询参数只有「值」这一种角色——表名、列名、排序方向不能参数化,动态标识符必须走白名单校验:
$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。三个工程细节:
- beginTransaction 之前不能已有活动事务,否则抛异常——管理好调用链上的事务开启者,避免多层函数重复开事务;
- PostgreSQL 支持真正的 SAVEPOINT,PDO 用
exec('SAVEPOINT sp1')/exec('ROLLBACK TO sp1')手工操作(部分框架有封装),局部失败不必整体回滚; - 长事务是生产事故之源:事务内不要做 HTTP 调用、文件 IO 等慢操作,连接被中断(FPM 超时杀死进程)时未提交事务由服务器回滚。
配合隔离级别:
$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 占位符 + 参数数组,走扩展查询协议):
// 错误示范(注入风险)
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 侧典型处理 |
|---|---|---|
23505 | unique_violation 唯一冲突 | 转业务提示(已注册/已下单),或改 upsert |
23503 | foreign_key_violation 外键冲突 | 校验引用数据,400 类错误 |
23502 | not_null_violation 非空违约 | 参数校验缺口,回 400 前先补齐 |
23514 | check_violation 检查约束违约 | 业务规则前置校验 |
40001 | serialization_failure 序列化失败 | 退避重试(可重试) |
40P01 | deadlock_detected 死锁 | 退避重试(可重试) |
25P02 | in_failed_sql_transaction 事务已中止 | 事务内前序语句失败后先 rollback |
57014 | query_canceled 查询被取消 | 区分超时/用户取消,幂等操作可重试 |
42501 | insufficient_privilege 权限不足 | 检查角色授权,禁止重试 |
42P01 | undefined_table 表不存在 | 迁移缺失告警 |
53300 | too_many_connections 连接数超限 | 指数退避 + 降级,接 PgBouncer |
08006 | connection_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
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 连接要经历:
- TCP 三次握手(内网约 1ms,跨机房 10ms+);
- TLS 握手(若启用 sslmode,额外多次往返);
- 认证交互(SCRAM-SHA-256 需要 1~2 个往返);
- 服务端 fork 一个后端进程并完成初始化(加载系统目录缓存、分配 work_mem 等结构,数 MB 起步);
- 应用层的会话初始化(时区、
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),要么使用支持语句级缓存的池化配置。
┌─────────────┐ ┌─────────────┐ ┌─────────────┐
FPM worker1 │ │ │ │ │ │
FPM worker2 ├─► PgBouncer ├────────►│ 池内复用 ├───────►│ PostgreSQL │
FPM workerN │ (transaction│ │ 20~40 连接 │ │ 每连接一进程 │
└─────────────┘ └─────────────┘ └─────────────┘一般结论:中小流量用 PDO 持久连接即可;连接数不可控(多实例、突发流量)或需要保护数据库时上 PgBouncer。池的规模与模式详见《PgBouncer》与《连接池最佳实践》。
Laravel 中的 PostgreSQL 配置
Laravel 的 PDO 封装让 pgsql 连接配置集中在 config/database.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 组即可:
'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
// 逐行消费(代码侧逐行处理)
$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 有三种形态,推荐第二种:
// 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 操作符:
// 包含匹配,可吃 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 数组编码为 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 侧的防注入手段汇总成检查清单:
- 值:一律占位符(PDO
:name/?或pg_query_params的$1),永不清洗后拼接; - 标识符:表名列名排序方向走白名单,或用
pg_quote_ident()类转义后拼入; - LIMIT/OFFSET:整数化校验(
(int)$n)或占位符; - LIKE 模式:值仍走占位符,通配符(
%/_)按业务决定是否转义; - 关闭模拟预处理(
EMULATE_PREPARES=false):让注入防护落在数据库协议层而不是字符串层; - 错误信息:异常信息记日志不回显,避免泄露表结构与 SQL。
错误处理的工程化封装
把 SQLSTATE 分支收敛成一个可复用的异常翻译层,业务代码只见领域异常:
<?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
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
$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 完整配置(含读写分离与池)
// 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
$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
$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,二选一。
最佳实践
- 新项目一律 PDO_PGSQL + ERRMODE_EXCEPTION + EMULATE_PREPARES=false。 这三行配置是 PHP + PostgreSQL 的默认起点。
- 凭据走环境变量,DSN 里配 connect_timeout 与 sslmode。 建连超时防止网络故障拖垮 worker;
verify-full级别的 TLS 在公网/跨机房链路上应是默认。 - 值占位符、标识符白名单、LIMIT 整数化。 三条纪律覆盖 PDO 场景的全部注入面。
- 事务内不做慢操作,事务边界放在服务层。 HTTP 调用、队列推送挪到 commit 之后;事务函数化(
DB::transaction或 try/commit/rollback 封装)保证必然收尾。 - 按 SQLSTATE 分支做错误处理。 23505 转「已存在」、23503 转「引用不存在」、40001/40P01 做退避重试——跨版本、跨库都稳定。
- 连接数规划先 PgBouncer 后 max_connections。 FPM 进程模型 + PostgreSQL 每连接一进程的组合,池是刚需而非优化。
- PgBouncer transaction 模式下审计会话状态依赖。 改用语句级预处理策略或框架层的连接上下文管理,而不是寄希望于「应该没问题」。
- 批量写入用 COPY 通道(pg_put_line / pg_copy_from)。 循环 INSERT 与 COPY 的差距是十倍量级;写入逻辑复用
\copy一页的性能清单。 - 金额用 numeric + bcmath,时间用 timestamptz。 让类型系统替你挡住精度与时区两类经典事故。
- Laravel 项目把 search_path、sslmode、读写分离写进配置管理。 环境差异全部收敛到 env 与 config,禁止散落在代码里的
SET search_path。 - 大文件导出用 pg_copy_to 之前先估内存。 它把整表读进 PHP 数组;大表改走
COPY TO STDOUT流式落盘。 - 监控 application_name。 连接串里带上应用与实例标识,
pg_stat_activity里的每条连接都能溯源,排障效率立竿见影。
下一节
继续学习:Python 连接 PostgreSQL