PG17:批量加载性能
批量加载数据——CSV 导入、异构库迁移、日志灌入、报表回填——是数据工程里最常见的高吞吐场景,也是最考验数据库 I/O 栈的场景。PostgreSQL 17 在这一版里对「把大量数据搬进库、搬给客户端」的整条链路做了系统性增强:
COPY ... ON_ERROR ignore(新增于 PG17):遇到坏行不再整批失败,跳过并继续,配合LOG_VERBOSITY(新增于 PG17)报告被跳过的行,让「脏数据加载」从「改数据重跑」变成「加载后核对跳过清单」;pg_stat_progress_copy.tuples_skipped(新增于 PG17):跳过行数进入进度视图,长任务可观测;- 流式 I/O(streaming I/O / read_stream API):PG17 重构了顺序读取路径,顺序扫描、ANALYZE 等操作的读取吞吐明显提升,配套的新 GUC
io_combine_limit让文件系统读可以组合成更大的批次; - 大数据块传输改进:服务器向客户端传输大数据块的路径优化,
COPY TO类工作负载受益。
本页先讲清这些增强的用法与适用边界,再用一个「千万行 CSV 实战」把它们串起来,并给出与 PG16 的行为对比。
前置知识
建议先阅读:PG17:EXPLAIN (MEMORY) 增强 与 psql 的 COPY 导入。
基础概念
| 概念 | 英文 / 记法 | 说明 |
|---|---|---|
| 批量加载 | bulk load | COPY、批量 INSERT 等大批量数据入库操作 |
ON_ERROR ignore | — | 新增于 PG17 的 COPY 选项:跳过转换失败的行继续加载 |
ON_ERROR stop | — | 默认行为:遇到错误立即整批失败 |
LOG_VERBOSITY | — | 新增于 PG17 的 COPY 选项:控制加载过程中的消息详细程度 |
| 流式 I/O | streaming I/O | PG17 引入的顺序读取路径重构,基于 read_stream API |
io_combine_limit | — | 新增于 PG17 的 GUC:允许把文件系统读组合成更大的批次 |
| 坏行 | error row | 列数不匹配、类型转换失败等无法入库的输入行 |
| 进度视图 | pg_stat_progress_copy | 实时观测 COPY 进度与跳过行数的系统视图 |
| 回填 | backfill | 向已有大表补灌历史数据的操作 |
PG17 批量加载增强一览
按官方 release notes,与批量加载直接相关的 PG17 变化:
| 增强 | 类别 | 说明 |
|---|---|---|
COPY ... ON_ERROR ignore | 语法 | 转换失败行被丢弃并继续;默认仍为 ON_ERROR stop |
COPY ... LOG_VERBOSITY | 语法 | verbose 模式下为每个被丢弃行输出 NOTICE(含行号与出错列) |
pg_stat_progress_copy.tuples_skipped | 监控 | COPY FROM 过程中报告已跳过行数 |
FORCE_NOT_NULL * / FORCE_NULL * | 语法 | CSV 的空串/NULL 处理可作用于全部列 |
| 流式 I/O(read_stream) | 性能 | 顺序读取路径重构,顺序扫描与 ANALYZE 提速 |
io_combine_limit GUC | 性能 | 允许组合更大的文件系统读批次 |
| 大数据块传输优化 | 性能 | 服务器向客户端传输大数据块更快 |
与 PG16 的 COPY 性能改进的关系
「COPY 批量协议合并多行为一条 INSERT」等 COPY 提速发生在 PG16。PG17 的 COPY 变化集中在容错加载(ON_ERROR/LOG_VERBOSITY)与通用顺序读取路径(流式 I/O)上。两者叠加,高版本做批量加载的基线体验远好于旧版本。
语法与用法
COPY 的 ON_ERROR 与 LOG_VERBOSITY
-- 语法骨架(PG17)
COPY 表名 [(列清单)] FROM '文件路径'
WITH (FORMAT csv, ON_ERROR ignore, LOG_VERBOSITY verbose);参数语义(均已对照 PG17 官方文档核实):
-- 默认行为:遇到错误行立即失败,整批回滚
COPY target FROM '/data/import.csv' WITH (FORMAT csv);
-- ERROR: invalid input syntax for type integer: "abc"
-- CONTEXT: COPY target, line 12345
-- PG17 新增:跳过错误行继续加载
COPY target FROM '/data/import.csv'
WITH (FORMAT csv, ON_ERROR ignore);
-- NOTICE: 42 rows were skipped during COPY (结束时报告跳过总数)
-- PG17 新增:verbose 模式逐行报告被丢弃的行与出错列
COPY target FROM '/data/import.csv'
WITH (FORMAT csv, ON_ERROR ignore, LOG_VERBOSITY verbose);
-- NOTICE: COPY file line 12345: column "qty" — invalid input syntax for type integer: "abc"三条硬性边界(官方文档明确):
ON_ERROR的取值是stop(默认)与ignore;ignore只适用于COPY FROM,且FORMAT为text或csv(binary 格式不支持);ON_ERROR ignore处理的是输入值转换失败(类型错误、列数不匹配等解析类错误);违反约束(唯一键冲突、外键、NOT NULL 之外的表级约束)的行仍会终止 COPY——它不是「全部错误都跳过」。
LOG_VERBOSITY 的取值是 default 与 verbose:default 只在结束时报告跳过总数(NOTICE);verbose 对每个被丢弃行单独发 NOTICE(含文件行号与失败列名)。行数少用 verbose 直接人工核对,行数多用默认 + 事后核对表——核对表的做法见实战示例六:跳过行明细落质检表(应用侧监听 NOTICE),或至少在加载日志里留下完整 NOTICE 流,日志系统按 run_id 聚合就是最朴素的跳过清单。
FORCE_NOT_NULL / FORCE_NULL 作用于全部列
PG17 之前这两个选项必须逐列列举,PG17 起支持 *:
-- 全部列:带引号的空串按空串处理(而非 NULL)
COPY target FROM '/data/import.csv'
WITH (FORMAT csv, FORCE_NOT_NULL *);
-- 全部列:带引号的空串按 NULL 处理
COPY target FROM '/data/import.csv'
WITH (FORMAT csv, FORCE_NULL *);对「几百列的宽表 + 上游对空值的表达不统一」的导入,* 版本省掉了逐列维护选项清单的负担。
进度视图:pg_stat_progress_copy
长任务加载过程中的观测:
SELECT
pid,
command,
datname,
phase,
tuples_processed,
tuples_skipped -- 新增于 PG17
FROM pg_stat_progress_copy
WHERE command = 'COPY FROM'; pid | command | phase | tuples_processed | tuples_skipped
-------+-----------+------------------+------------------+----------------
4711 | COPY FROM | loading data ... | 8123456 | 37
(1 row)tuples_skipped 让「跳了多少、跳的速度是否异常」成为可监控指标——批量加载任务接入监控后,脏数据比例的突变(上游数据源坏了)能在加载中途告警,而不是结束后发现。建议把 tuples_skipped / (tuples_processed + tuples_skipped) 的比值画进监控面板——脏行率是上游数据质量的直接函数,趋势比单点更有意义。
io_combine_limit 与流式 I/O
PG17 引入 io_combine_limit(新增于 PG17),允许把文件系统读组合成更大的批次,配合 read_stream 重构的顺序读取路径:
-- 查看与调整(通常保持默认即可,个别 I/O 栈特殊的环境才需要调)
SHOW io_combine_limit;
SET io_combine_limit = '256kB'; -- 示例值;默认值以官方文档为准这层优化对使用者是透明的:不需要改任何 SQL,PG17 上做 Seq Scan、ANALYZE、批量加载时顺序读取吞吐自然提升。了解它的意义在于——升级 PG17 后如果发现「同样的硬件,扫描类操作变快了」,这是版本能力而不是玄学。
详细说明
COPY 为什么是最快的批量加载方式
COPY 的吞吐优势来自三个层面:
- 协议层:COPY 是独立的数据流协议,不像 INSERT 那样每行都要解析 SQL 文本、做语法语义分析;
- 执行层:数据整块从文件/网络进入表文件,WAL 以块为单位批量写,索引按批量方式维护;
- 事务层:一次 COPY 一个事务快照,免去逐行的事务管理开销。
实测量级参考(与硬件相关,仅示意):单行 INSERT 逐条提交约 1 千~5 千行/秒;多值 INSERT 批量约 5 万~20 万行/秒;COPY 可达 50 万~100 万行/秒以上。加载路径的选择顺序永远是 COPY > 多值 INSERT > 逐行 INSERT。
COPY 与多值 INSERT、驱动批处理的取舍
COPY 不是唯一答案,三种批量路径的适用面不同:
| 路径 | 吞吐 | SQL 解析开销 | 适用场景 |
|---|---|---|---|
COPY FROM stdin | 最高 | 几乎为零 | 纯数据灌入(ETL、回填、时序采集) |
| 多值 INSERT(几千行/条) | 中高 | 每条语句解析一次 | 需与业务语句同事务、行内含表达式 |
| 驱动批处理(prepared batch) | 中 | 预编译 + 逐次绑定 | ORM/框架友好,通用业务写入 |
-- 多值 INSERT:一条语句几千行
INSERT INTO sales_import (order_id, sku, qty) VALUES
(1, 'SKU-001', 3),
(2, 'SKU-002', 1);<?php
// prepared batch:PDO 预编译批处理(框架友好,但网络往返逐行发生)
$stmt = $pdo->prepare('INSERT INTO sales_import (order_id, sku, qty) VALUES (?, ?, ?)');
foreach ($rows as $r) {
$stmt->execute([$r[0], $r[1], $r[2]]);
}选 COPY 的三个信号:行是纯数据(不需要服务端求值的表达式)、批量够大(千行以上)、加载与业务事务解耦(可独立提交)。反之,几百行的高频业务写入用多值 INSERT 或 prepared batch 更顺手——为几百行维护 COPY 通道是过度设计。
ON_ERROR ignore 的适用边界
ON_ERROR ignore 解决的是「转换失败」,不是「全部错误」。理解这个边界才能正确设计加载流程:
| 错误类型 | ON_ERROR ignore 是否跳过 | 说明 |
|---|---|---|
类型转换失败('abc' → int) | 是 | 解析类错误,逐行判定 |
| 列数不匹配 | 是 | 解析类错误 |
| 引号/转义损坏(text/csv) | 是 | 解析类错误 |
| 唯一键冲突 | 否,整批终止 | 表级约束错误 |
| 外键缺失 | 否,整批终止 | 表级约束错误 |
| CHECK / NOT NULL | 否,整批终止 | 表级约束错误 |
因此「源数据质量不可控」的加载流程通常是三段式:
- COPY 进 staging 表:staging 全部列用
text,任何行都不会转换失败(无需 ON_ERROR); - SQL 转换 + 质检:
INSERT INTO target SELECT ... FROM staging WHERE 数据合法,非法行落入质检表; - 核对与重灌:人工或规则修正后二次加载。
有了 ON_ERROR ignore,「明确知道源数据有少量脏行、愿意先加载再核对」的场景可以直接一步 COPY 进目标表(转换类脏行自动跳过),流程从三段简化为两段;但约束类冲突仍需 staging 或预清洗——按需选择,别把新特性当万能药。
客户端侧的 COPY 通道
服务端 COPY FROM '文件路径' 要求文件在数据库主机上;应用服务器的数据要走 stdin 通道——客户端把数据流写进 COPY 协议,文件不必落到数据库机器:
# psql 的 \copy:客户端本地文件(注意是客户端元命令,反斜杠开头)
\copy sales_import FROM '/app/data/import.csv' WITH (FORMAT csv)
# 管道形式:数据流直接进 COPY
cat import.csv | psql -c "COPY sales_import FROM STDIN WITH (FORMAT csv)"PHP 侧两条路径(实战示例展开):
pg_copy_from():把 PHP 数组一次性灌进 COPY 通道,实现简单,数组需整体驻留内存;pg_put_line()+pg_end_copy():逐行写流式协议,内存占用恒定,适合超大批量。
选型口诀:文件在数据库主机 → 服务端 COPY;文件在应用侧 → \copy/stdin;PHP 数组中等大小 → pg_copy_from;百万行级流式 → pg_put_line。ON_ERROR ignore 等选项对所有通道一视同仁——它们是 SQL 层的能力,与数据从哪里来无关。
加载后的 ANALYZE:让计划器跟上数据
批量加载让表的行数与分布剧变,而计划器的统计是「采样快照」——加载完立刻查询,优化器可能仍按旧行数估算、选错计划:
-- 大批量 COPY 后立即刷新统计
ANALYZE sales_import;
-- 分区表按分区逐个(裁剪后的计划用分区级统计)
ANALYZE metrics_2026_10_07;三个要点:autovacuum 的 analyze 触发有延迟(scale factor 门槛),批量管道里显式 ANALYZE 是标准步骤;分区表的父表与各分区都要有统计;加载与 ANALYZE 写进同一个调度任务——「只加载不统计」的管道是计划回归的温床。staging 表用完即弃可以跳过,目标表不行。
主键与唯一键冲突的处理策略
ON_ERROR ignore 不管约束冲突,而「重复导入」恰恰是最常见的冲突来源(任务重跑、上游重复推送)。标准解法按优先级:
- 合并语义走 staging +
ON CONFLICT/DISTINCT ON。COPY 没有ON CONFLICT子句;需要「重复行合并」时先落 staging,再决定保留规则:
-- 重复 order_id 保留最新一条后写入
INSERT INTO sales_import
SELECT DISTINCT ON (order_id) *
FROM sales_staging
ORDER BY order_id, sold_at DESC;- 整批幂等用「清了重灌」。调度型任务(每天全量刷新某分区/某范围)最简单的幂等是:
DELETE WHERE loaded_run_id = :run_id或直接重建分区后重灌,不存在「重复」问题。 - 唯一索引承担最终裁决。无论哪条路径,目标表的唯一约束都必须存在——应用层去重迟早有并发漏洞,约束是最后防线。
反模式是把「跳过冲突」寄托在删除唯一索引上:加载提速立刻变成数据腐化,修复成本远高于一次 staging。
流式 I/O:read_stream API 带来的顺序读提速
PG17 在源码层面引入 read_stream API,把顺序访问路径(顺序扫描、ANALYZE、COPY FROM 文件等)的读请求从「逐块发起」改为「按访问模式预取并组合」。对使用者的可见影响:
- 顺序扫描吞吐提升。PG16 与 PG17 同硬件对比
EXPLAIN ANALYZE的 Seq Scan 时间,通常可见两位数百分比的改善(数据集越大越明显); - ANALYZE 提速。ANALYZE 的随机页采样也受益于读路径重构,大表统计信息维护更快;
io_combine_limit提供了组合批次的上限控制,默认值对绝大多数负载合适,无需调整。
验证方法:
-- 同一数据集在 PG16 与 PG17 上分别执行
EXPLAIN (ANALYZE, BUFFERS)
SELECT count(*) FROM big_table;对比 Execution Time 与 Shared Blocks Read 的比例,即可量化版本收益。
大数据块传输优化
PG17 优化了服务器向客户端传输大数据块的路径(官方 release notes: "Improve performance when transferring large blocks of data to a client")。受益场景:
COPY ... TO STDOUT/COPY (SELECT ...) TO导出大结果集;- 返回大
text/bytea/jsonb值的查询; pg_dump类基于 COPY 协议的逻辑备份。
对「每日导出报表给下游」这类任务,PG17 上直接受益;配合 EXPLAIN (ANALYZE, SERIALIZE)(PG17 的序列化观测,见上一页)可以把传输成本纳入量化管理。
与 PG16 的行为对比
| 维度 | PG16 | PG17 |
|---|---|---|
| 遇到转换错误行 | 整批失败,需预清洗或 staging | ON_ERROR ignore 可跳过继续 |
| 跳过行明细 | —(无此能力) | LOG_VERBOSITY verbose 逐行 NOTICE |
| 加载进度中的跳过计数 | 无 | pg_stat_progress_copy.tuples_skipped |
| CSV 空/NULL 处理 | 逐列列举 FORCE_* | 支持 * 全列 |
| 顺序读吞吐 | 逐块读取路径 | read_stream 流式 I/O,更快 |
| 大结果导出 | 常规路径 | 大块传输优化,更快 |
| 多行合并的单条 INSERT | 已有(PG16) | 保持 |
迁移建议:从 PG16 升级到 PG17 后,先给现有导入脚本加 ON_ERROR ignore + 进度监控(收益立竿见影且零风险),再评估是否简化 staging 流程。
千万行加载的完整决策树
数据从哪来?
├── 文件(CSV/text)→ COPY ... FROM(首选)
│ └── 有脏行风险?→ ON_ERROR ignore + LOG_VERBOSITY(PG17)
│ └── 有约束冲突风险?→ staging 表 + SQL 转换
├── 另一个 PostgreSQL → pg_dump | COPY TO/FROM | 逻辑复制
└── 应用内存数据 → 驱动 COPY API(推荐)> 多值 INSERT
加载到哪?
├── 空表/新分区 → 直接 COPY,事后再建索引更快
├── 已有大表 → 分批 + 低峰 + 观察 WAL 与复制延迟
└── 计入业务可见 → 目标表;先隔离可见性 → staging + 交换核心原则:索引与约束是加载速度的最大变量。空表加载时先 COPY 后建索引;已有表大批回填时评估「删索引 → 加载 → 重建索引」的净收益。
实战示例
示例一:千万行 CSV 导入(PG17 完整流程)
准备目标表与测试数据:
CREATE TABLE sales_import (
order_id bigint,
sku text,
qty integer,
price numeric(10,2),
sold_at timestamptz,
region text
);
-- 千万行源文件 /data/sales_2026.csv(示意)执行加载:
# 空表:先移除不必要的索引(本例暂无),直接 COPY
psql -d warehouse -c "
COPY sales_import FROM '/data/sales_2026.csv'
WITH (
FORMAT csv,
HEADER true,
ON_ERROR ignore, -- 新增于 PG17
LOG_VERBOSITY verbose -- 新增于 PG17
);"NOTICE: COPY file line 812345: column "qty" — invalid input syntax for type integer: "1,5"
NOTICE: COPY file line 2345678: column "sold_at" — invalid input syntax for type timestamp: "2026-13-45 99:99"
NOTICE: 2 rows were skipped during COPY
COPY 9999998加载后核对:
-- 总量核对:文件 10000000 行,入库 9999998,跳过 2
SELECT count(*) FROM sales_import;
-- 进度视图在加载过程中实时反映跳过数(长任务时)
SELECT pid, phase, tuples_processed, tuples_skipped
FROM pg_stat_progress_copy;
-- 加载完成后建索引、收统计
CREATE INDEX sales_import_sku_idx ON sales_import (sku);
ANALYZE sales_import;示例二:staging 表 + 转换的三段式加载
约束冲突风险高(如 sku 需要主键唯一)时用 staging:
-- 1. staging:全 text 列,天然免疫转换错误
CREATE UNLOGGED TABLE sales_staging (
order_id text, sku text, qty text, price text, sold_at text, region text
);
-- 2. COPY 全量入 staging(无需 ON_ERROR)
COPY sales_staging FROM '/data/sales_2026.csv' WITH (FORMAT csv, HEADER true);
-- 3. 转换 + 质检:合法行入库,非法行留档
BEGIN;
INSERT INTO sales_import
SELECT order_id::bigint, sku, qty::int, price::numeric, sold_at::timestamptz, region
FROM sales_staging
WHERE qty ~ '^\d+$'
AND sold_at ~ '^\d{4}-\d{2}-\d{2}';
INSERT INTO sales_reject
SELECT *, 'qty or sold_at malformed'
FROM sales_staging
WHERE NOT (qty ~ '^\d+$' AND sold_at ~ '^\d{4}-\d{2}-\d{2}');
COMMIT;
-- 4. 清理 staging
TRUNCATE sales_staging;UNLOGGED staging 表不写 WAL,加载更快;代价是崩溃后内容丢失——对可重放的导入源完全可接受。
三段式的真正威力在可重跑:staging 数据不动,转换 SQL 改了(比如放宽日期格式)直接重跑第 3 步,不用重新 COPY 千万行。把「加载」与「转换」解耦,是批量管道设计的基本功:
-- 转换前先 ANALYZE staging,让转换 SELECT 有可用的统计
ANALYZE sales_staging;
-- 正则过滤高频执行时可建表达式索引(可选)
CREATE INDEX ON sales_staging ((qty ~ '^\d+$'));示例三:ON_ERROR ignore 的边界验证
-- 准备目标表与数据
CREATE TABLE t (id int PRIMARY KEY, v text);
COPY t FROM stdin WITH (ON_ERROR ignore);
1,ok
2,x
\.COPY 2-- 转换失败被跳过:'x' 无法转 int,行 2 被丢弃,行 1 入库 ✓
-- 约束冲突不会被跳过:
COPY t FROM stdin WITH (ON_ERROR ignore);
1,duplicate
\.ERROR: duplicate key value violates unique constraint "t_pkey"
CONTEXT: COPY t, line 1唯一键冲突终止了 COPY——这就是「ignore 只管转换错误」的实证。同理可验证外键与 CHECK。
示例四:PHP 驱动 COPY(含容错)
<?php
// BulkLoad/copy.php —— PHP 原生 pg 扩展的 COPY 写入
$conn = pg_connect('host=127.0.0.1 dbname=warehouse user=etl password=secret');
$rows = [
"1,SKU-001,3,29.90,2026-10-07 10:00:00+00,APAC\n",
"2,SKU-002,1,99.00,2026-10-07 10:01:00+00,APAC\n",
];
// 方式一:pg_copy_from(内部走 COPY,适合内存中的批量行)
pg_copy_from($conn, 'sales_import (order_id, sku, qty, price, sold_at, region)', $rows);
// 方式二:流式 COPY,适合超大文件逐行喂入
if (pg_put_line($conn, "COPY sales_import FROM stdin WITH (FORMAT csv)\n")) {
$fh = fopen('/data/sales_2026.csv', 'r');
while (($line = fgets($fh)) !== false) {
pg_put_line($conn, $line);
}
pg_put_line($conn, "\\.\n");
pg_end_copy($conn);
fclose($fh);
}大规模导入的 PHP 侧建议:用 pg_put_line 流式喂入(内存占用与文件大小解耦);脚本级容错(ON_ERROR ignore)由 SQL 侧承担,PHP 侧只需透传与统计 NOTICE。
示例五:版本对比基准
-- 在 PG16 与 PG17 上分别执行同一数据集的加载与扫描
\timing on
-- 1) 顺序扫描吞吐(验证流式 I/O)
SELECT count(*) FROM big_table;
-- PG16: Time: 18230.441 ms
-- PG17: Time: 14102.830 ms
-- 2) ANALYZE(统计维护)
ANALYZE big_table;
-- PG16: Time: 41200.500 ms
-- PG17: Time: 33880.120 ms
-- 3) 容错加载(PG16 需 staging,PG17 一步完成)
COPY sales_import FROM '/data/sales_2026.csv'
WITH (FORMAT csv, ON_ERROR ignore);记录为版本升级的收益基线,用数据回应「升级值不值」的质疑。
示例六:坏行复盘报表
ON_ERROR ignore 跳过的行信息在 NOTICE 里一闪而过,长任务需要留档复盘。应用侧监听 NOTICE 并落质检表:
CREATE TABLE copy_skipped_rows (
run_id uuid NOT NULL,
file_line int NOT NULL,
column_name text,
raw_line text,
error_msg text,
occurred_at timestamptz NOT NULL DEFAULT now()
);
-- 复盘:本次加载的错误类型分布
SELECT error_msg, count(*) AS cnt
FROM copy_skipped_rows
WHERE run_id = 'b3f1c2a9-1111-2222-3333-444455556666'
GROUP BY error_msg
ORDER BY cnt DESC; error_msg | cnt
------------------------------------+-----
invalid input syntax for type integer | 41
invalid input syntax for type timestamp | 7复盘报表的价值是把「脏数据」变成「上游数据质量问题清单」:千分位逗号混进数字、日期格式换列、编码异常——按类型催修上游,比每次加载都跳过一遍更治本。
质检表本身也要定期治理:按 occurred_at 定期归档,别让「记录脏数据」的表自己变成最大的脏数据源;错误类型分布可以接到 IM 群机器人,上游修复的闭环由数据驱动。
示例七:日更管道的调度骨架
把本页全部要素串成一个 cron 任务骨架:
#!/bin/bash
# daily_load.sh —— COPY → 转换 → ANALYZE → 核对
set -euo pipefail
psql -v run_id="$(uuidgen)" \
-v csv="/data/inbox/sales_$(date +%F).csv" <<'SQL'
BEGIN;
\copy sales_staging FROM :'csv' WITH (FORMAT csv, ON_ERROR ignore, LOG_VERBOSITY verbose)
SELECT load_sales_from_staging(:'run_id'); -- 转换 + 去重 + 入目标表(含质检表)
ANALYZE sales_import;
SELECT assert_load_complete(:'run_id'); -- 行数核对,失败抛异常
COMMIT;
SQL四个环节各自可观测:staging 行数(\copy 回显)、跳过数(NOTICE)、质检表、目标行数(assert 函数)。set -euo pipefail 加事务包裹保证「任一环节失败、整批回滚、脚本退出」——这是批量加载的最小可靠性标准;:'var' 是 psql 的变量插值语法,避免 shell 拼接 SQL 的注入风险。
接入调度框架(cron、Airflow、K8s CronJob)时再补三件事:
- 文件到位检查:上游文件晚到或为空时直接退出并告警,不要让
\copy把空文件当成功; - 运行日志归档:每次运行的
\copy回显、NOTICE 与 assert 结果落到日志系统,按run_id可追溯; - 失败重跑语义:staging 加载前先 TRUNCATE,重跑永远幂等安全——这是骨架能进生产的前提。
注意事项
ON_ERROR ignore 不是全量容错。 它只跳过转换类错误行;唯一键、外键、CHECK 等约束冲突仍会终止整个 COPY。把「跳过一切」当成预期的人,会在第一次撞上约束冲突时怀疑特性失效。
ignore 只支持 text/csv 格式的 COPY FROM。 binary 格式与其他方向(COPY TO)不支持 ON_ERROR ignore,官方文档明确此边界。
LOG_VERBOSITY verbose 的 NOTICE 是逐行发出的。 千万级脏行 × 逐行 NOTICE = 日志风暴;脏行比例可能高时用默认模式 + tuples_skipped 监控,或先把脏行比例降下来。
跳过即静默丢失,必须有核对闭环。 COPY 9999998 与源文件行数的差值要有人核对(行数统计 + 跳过明细),否则脏数据问题会从「加载失败」退化为「数据悄悄少了一部分」。
stdin 通道的行终止与转义遵循 COPY 的格式规则。 text 格式下 \N 表示 NULL、反斜杠需转义;CSV 格式按 RFC 4180 的引号规则——「自己拼字符串喂 COPY」最大的翻车点就在这里,能用驱动封装就别手拼。
合并语义要在加载前定稿。 ON_ERROR ignore 管坏行、去重规则管重行,是两件事。定义含糊的「重复」(保留第一条还是最新一条?)会让同一份文件两次导入产生双倍数据。
空表先 COPY 后建索引。 逐行维护索引是大表加载的最大拖慢项;CREATE INDEX 的批量构建比逐行维护快数倍。已有表大批回填时评估删索引重建的净收益。
staging 用 UNLOGGED 表。 不写 WAL、加载更快,崩溃后内容丢失对可重放源无影响;但它的崩溃丢失特性意味着不能当持久层用。
大批量导入会放大 WAL 与复制延迟。 回填任务限流分批、避开备份窗口、观察 pg_stat_replication 的 replay 延迟;必要时临时调大 max_wal_size。
io_combine_limit 保持默认。 它是为特殊 I/O 栈准备的调节阀;没有实测数据支持不要调整,默认值已是通用最优。
进度视图按 backend 维度查询。 pg_stat_progress_copy 每个执行 COPY 的后端一行;监控接入要按 pid 关联任务,不要用全表聚合掩盖单任务异常。
最佳实践
- PG17 的导入脚本统一升级为
ON_ERROR ignore+ 行数核对。 容错能力免费获得,核对闭环保证「跳过」不退化为「丢失」。 - 转换错误与约束错误分层处理。 转换错误交给
ON_ERROR ignore一步跳过;约束冲突走 staging 预清洗——按错误类型选工具,不要一个特性打天下。 LOG_VERBOSITY按脏行比例选模式。 预期脏行少用verbose直接看明细;脏行可能多用默认模式 +tuples_skipped告警。- staging 表一律 UNLOGGED,用完即 TRUNCATE。 免 WAL 的中转层,加载速度与目标表维护互不干扰。
- 空表加载遵循「COPY → 建索引 → ANALYZE」顺序。 逐行索引维护是最大性能黑洞;已有表回填先测算删索引重建的净收益。
- 长任务加载接入
pg_stat_progress_copy监控。tuples_processed看进度、tuples_skipped看脏数据突变,中途告警优于事后复盘。 - 回填与批量导入分批限流。 单批几万到几十万行、批次间留间隔,控制 WAL 峰值与复制延迟,保护线上服务。
- 版本升级后重建加载基准。 顺序扫描、ANALYZE、COPY 三项在 PG16/PG17 各跑一轮,量化收益写入升级报告。
- 大结果导出优先 COPY 协议,避免逐行 fetch。 PG17 的大块传输优化让 COPY TO 更快;应用层导出用驱动的 COPY API 而不是
SELECT+ 逐行遍历。 - 把「加载 SOP」文档化:源 → staging/直载 → 核对 → 索引 → 统计。 每个批量加载任务都有明确的流程与回滚路径,事故时按 SOP 处置而不是现想。
下一节
继续学习:PG17:VACUUM 内存改进
参考链接
- PostgreSQL 17 Release Notes(官方发布说明)
- PostgreSQL 官方文档:COPY(ON_ERROR / LOG_VERBOSITY)
- PostgreSQL 官方文档:pg_stat_progress_copy(tuples_skipped)
- PostgreSQL 官方文档:io_combine_limit
- PostgreSQL 官方文档:Populating a Database With Rows(批量加载官方指南)
- PostgreSQL 官方文档:Unlogged Tables
- PostgreSQL 官方文档:CREATE INDEX(并行构建)
- PostgreSQL 官方文档:pg_stat_replication(复制延迟观测)
- PostgreSQL 官方 Wiki: Bulk Loading and Inserts
- PHP 手册:pg_put_line / pg_copy_from