Skip to content

psql \copy 数据导入导出 ​

如果说 INSERT 是数据的「零售」通道,那么 COPY 就是 PostgreSQL 的「大宗货运」通道——它是官方文档盖章认证的最快的向表写入数据的方式,没有之一。而 \copy 是 psql 给这条通道接上的「客户端月台」:让你不登录服务器、不需要文件系统权限,就能把本地的 CSV/文本文件成吨灌进表里,或者把查询结果成吨倒回本地文件。

这一页把 \copy 与服务端 COPY 的关系、三种数据格式的规则、编码与错误处理、以及千万行级导入的性能工程完整讲一遍。它是 psql 篇的收官内容,也是前几页所有技巧(输出控制、变量、事务)最终「落地成吨数据」的地方。学完之后,处理「业务方发来一个 2GB 的 CSV,今晚要入库」这类任务时,你手里会有完整的工具链。

需要澄清的第一件事是:COPY 与 \copy 共享同一套格式与选项语法,但运行位置、权限模型、性能表现完全不同。混淆两者的权限差异,要么被「Permission denied」卡住,要么图省事开放了不该开放的权限。

前置知识

建议先阅读:psql 事务控制(AUTOCOMMIT)

基础概念 ​

COPY 是一条 SQL 命令,它让服务器进程直接读写服务器本地的文件或标准输入/输出;\copy 是 psql 的元命令,它在客户端把指令改写成 COPY ... FROM STDIN / COPY ... TO STDOUT,然后由 psql 自己把客户端文件的内容通过 libpq 连接流式搬运:

text
服务端 COPY:
  服务器进程 ◄──► 服务器本地文件(需要 pg_read_server_files/pg_write_server_files 权限)

客户端 \copy:
  客户端文件 ◄──► psql ◄──libpq 流式协议──► 服务器(只需要表上的普通读写权限)
维度COPY(服务端)\copy(客户端)
读写谁的文件服务器文件系统运行 psql 的机器
权限要求超级用户,或 pg_read_server_files / pg_write_server_files(程序模式还需 pg_execute_server_program)仅表/列上的 INSERT/SELECT 权限
文件路径以服务器视角解析以客户端视角解析
典型场景服务器上的备份与归档、ETL 中转目录开发者导入导出、应用侧数据交换
SQL 里可用是(存储过程、函数内可执行)否(元命令,只在 psql 交互与脚本里可用)

因为 \copy 只是「改写 + 搬运」,格式与选项层面它与服务端 COPY 完全一致——学一次,两边通用。

三种数据格式先有个总览:

格式分隔符NULL 记法适用
text制表符\NPostgreSQL 之间的互导,紧凑且无歧义
csv逗号(可配)空串或自定义 null 值与 Excel/外部系统交换,事实标准
binary无(类型二进制布局)无同架构同版本间的大批量搬运,最快但不可移植

同一段数据在三种格式下的实际形态(字段值为 NULL、a,b、"quote" 各一):

text
-- text 格式(Tab 分隔,NULL 写作 \N,值内特殊字符用反斜杠转义)
1	\N	alpha
2	a,b	say "hi"
3	"q"	\\N
text
-- csv 格式(引号包裹含逗号的字段,引号自身翻倍转义,空字段即 NULL)
1,,alpha
2,"a,b","say ""hi"""
3,"""q"",quoted",\N
text
-- binary 格式(无文本形态,按列类型的二进制表示连续排列)
-- 仅 PostgreSQL 自身可读写,人类不可直接审阅

可以看到:csv 的引号规则解决了「内容里出现分隔符」的问题,text 的转义解决了「内容里出现换行/Tab」的问题,而 binary 干脆跳过文本层。选格式时先问一个问题——「这份文件最终谁消费?」给 Excel/外部系统选 csv,给另一个 PostgreSQL 选 text 或 binary,给「要人肉排查的现场」永远选 csv。

语法与用法 ​

服务端 COPY(SQL 命令):

sql
-- 导入:从服务器文件读入
COPY orders FROM '/data/import/orders.csv' WITH (FORMAT csv, HEADER true);

-- 导出:查询结果写到服务器文件
COPY (SELECT * FROM orders WHERE created_at >= '2026-09-01')
  TO '/data/export/orders_202609.csv' WITH (FORMAT csv, HEADER true);

-- 标准输入/输出版(给驱动、给管道用)
COPY orders FROM STDIN WITH (FORMAT csv);
COPY orders TO STDOUT WITH (FORMAT csv);

psql 的 \copy(元命令):

sql
-- 客户端文件 → 表
\copy orders from 'orders.csv' with (format csv, header true)

-- 表 → 客户端文件
\copy orders to 'orders_dump.csv' with (format csv, header true)

-- 查询结果 → 客户端文件(列名以 SELECT 输出为准)
\copy (SELECT id, status, amount FROM orders WHERE amount > 100) to 'big.csv' with csv

-- 指定列
\copy orders (id, status) from 'partial.csv' with (format csv, header true)

从标准输入喂给服务端 COPY(脚本管道场景):

bash
psql -d appdb -c "COPY orders FROM STDIN WITH (FORMAT csv, HEADER true)" < orders.csv
gzip -dc orders.csv.gz | psql -d appdb -c "COPY orders FROM STDIN WITH (FORMAT csv)"

常用选项速查:

选项说明默认
FORMATtext / csv / binarytext
DELIMITER字段分隔符text 用 Tab,csv 用 ,
QUOTEcsv 引号字符"
ESCAPEcsv 转义字符与 QUOTE 相同
NULL表示 NULL 的字符串text 为 \N,csv 为空串
DEFAULT表示列默认值的字符串(PG17+)无
HEADER [true|false|match]是否读写表头行;match 要求表头与列序一致(PG15+)false
ENCODING文件编码(与库编码不同时)库编码
FORCE_QUOTE * / FORCE_QUOTE (col, …)强制给指定列加引号(仅导出 csv)无
FORCE_NOT_NULL (col, …)指定列的空串按空串处理而非 NULL(仅导入 csv)无
FORCE_NULL (col, …)指定列的空串按 NULL 处理(仅导入 csv)无
ON_ERRORstop(默认)/ignore:忽略格式错误行继续(PG17+,text/csv)stop
LOG_VERBOSITYverbose:逐行记录被忽略的错误行(PG17+)default
FREEZE导入即冻结行(配合同事务 TRUNCATE 的批量装载优化)关

详细说明 ​

\copy 与 COPY 的权限模型 ​

服务端 COPY 让服务器进程以文件系统权限读写文件,等价于把文件系统暴露给了数据库用户,因此权限被牢牢收紧:

sql
-- 普通用户直接 COPY 服务端文件
COPY orders FROM '/data/import/orders.csv' WITH (FORMAT csv);
text
ERROR:  permission denied to COPY from a file
HINT:  Only roles with privileges of the "pg_read_server_files" role may COPY from a file.

授权方式(PG11+ 内置预定义角色):

sql
GRANT pg_read_server_files TO etl_user;     -- 可 COPY 读服务器任意文件
GRANT pg_write_server_files TO etl_user;    -- 可 COPY 写服务器任意文件
GRANT pg_execute_server_program TO etl_user; -- 可 COPY PROGRAM 执行命令

这三个角色都是「超级用户级别的文件系统能力」,能读 postgresql.conf、能覆写任意文件,绝不能随手授给业务账号。日常导入导出一律走 \copy:它只需要目标表的普通权限,文件在客户端,服务器文件系统零暴露。

语法形态的细节与坑 ​

\copy 虽然格式选项与 COPY 一致,但有两条独特的语法纪律:

  1. 必须写在一行。\copy 是元命令,取「本行剩余部分」作为参数,换行即截断。长 SQL 塞在 \(SELECT …) 里要保持单行;
  2. 大小写不敏感(from/TO 均可),文件路径含空格用单引号包住。

\copy ... from stdin 是个特别的存在:它从psql 自己的输入源逐行读数据,直到遇到 . 结束行。在交互与 -f 脚本里可用但容易失控,更推荐用命令行重定向喂 COPY FROM STDIN(见上节用法)。program 'command' 形态(如 \copy t from program 'zcat /data/t.csv.gz')在客户端侧执行命令并流式接入,注意它需要 psql 运行环境里有对应工具。

from stdin 在脚本里的样子(了解即可,谨慎使用):

sql
-- import_inline.sql
\copy small_dict (code, label) from stdin with (format csv)
A,启用
B,停用
C,冻结
\.
text
COPY 3

数据行紧随 \copy 行之后、以单独一行的 \. 结束。它的可用场景非常窄——「脚本里内嵌一小段字典数据」;超过几行就该改用独立 CSV 文件加重定向,混在 SQL 脚本里的数据行既难维护也容易因为行尾空格/编码问题静默错位。

psql 命令行选项与格式选项的配合 ​

\copy 之外,psql 的命令行选项也能拼出「一条命令完成导入导出」的形态,适合 cron 与 CI:

bash
# --csv:查询结果直接以 CSV 落到 stdout(等价 \pset format csv,无需交互)
psql -X -d appdb --csv -c "SELECT id, status, amount FROM orders" > orders.csv

# -At:tuples_only + unaligned 的组合,产出最干净的文本流
psql -X -d appdb -At -c "SELECT unnest(enum_range(NULL::order_status))"

# -F:自定义字段分隔符(配合 -A)
psql -X -d appdb -A -F';' -c "SELECT id, status FROM orders LIMIT 5"

# 导入:重定向喂给 COPY FROM STDIN
psql -X -d appdb -c "COPY stg_orders FROM STDIN WITH (FORMAT csv, HEADER true)" < orders.csv
需求推荐形态理由
一次性导出结果集psql --csv -c "SELECT …" > f.csv不进交互,重定向即文件
大批量导出\copy (SELECT …) TO 'f.csv' WITH (csv)协议流式,快
一次性导入psql -c "COPY … FROM STDIN …" < f.csv命令行重定向最简洁
大批量导入\copy t FROM 'f.csv' WITH (csv)同为流式,路径写在命令里
复杂流程(建表+导入+校验)-f 脚本 + \copy变量、事务、校验一体

这组命令行形态与 \pset 家族的关系是「同一状态机的静态入口」:--csv 等于启动时就 \pset format csv,-At 等于 -A -t。交互里验证过的导出语句,换成命令行形态即可直接进调度系统。

文本格式(text)的规则 ​

text 是默认格式,规则简单而严格:

  • 字段以制表符分隔,行以换行结束;
  • NULL 写作 \N(区分于空串);
  • 字段内含 \、\t、\n 时以 \\、\t、\n 转义;
  • 不需要也不支持引号包裹。
text
1	alpha	1234.5
2	\N	7.25
3	beta	\\path\to\file

text 格式的定位是 PostgreSQL 之间的互导(如 pg_dump 的纯文本部分、跨实例搬运),紧凑、无歧义;对外部系统则一律用 csv——外部工具几乎不认识 PostgreSQL 的转义约定。

CSV 格式与选项实战 ​

CSV 的规则遵循 RFC 4180 风格:引号内的分隔符/换行不拆分,引号本身用双引号转义。几个高频问题的标准解法:

sql
-- 分号分隔(欧洲 CSV)、自定义 NULL 记号
\copy t from 'data.csv' with (format csv, delimiter ';', null 'NULL', header true)

-- 原始空串与 NULL 的区分:col_c 的空串按空串入库,col_d 的空串按 NULL
\copy t from 'data.csv' with (format csv, header true, force_not_null (col_c), force_null (col_d))

-- 导出时强制全部加引号(兼容某些吹毛求疵的下游解析器)
\copy t to 'out.csv' with (format csv, force_quote *, header true)

-- 导入时校验表头与列序一致(PG15+ 的 header match)
\copy t from 'data.csv' with (format csv, header match)

force_not_null / force_null 是「CSV 语义噪声」的解药:CSV 文件里 NULL 与空串长得一样(都是空字段),但数据库里是两个值。按列逐个声明哪边算空串、哪边算 NULL,比事后 UPDATE 补救干净得多。

DEFAULT 选项(PG17+)则解决「文件里用 \D 之类的记号表示『这一列请用表默认值』」的场景:COPY t FROM 'f' WITH (FORMAT csv, DEFAULT '\D')。

binary 格式与适用边界 ​

FORMAT binary 让数据按类型的二进制内部表示传输,跳过文本序列化/反序列化,速度通常最快:

sql
\copy big_table to '/data/big.bin' with (format binary)
\copy big_table from '/data/big.bin' with (format binary)

但它有三条硬边界:

  1. 不可移植:跨机器架构(字节序)与 PostgreSQL 大版本都可能有差异,官方明确提示二进制文件不具备跨版本可移植性;
  2. 不可读:出问题时无法用文本工具排查,坏一行难以定位;
  3. 不利于复用:下游基本只有 PostgreSQL 自己能消费。

实践中 binary 多见于「同实例/同版本集群内的大表搬迁」;通用交换请用 csv,纯 PostgreSQL 之间用 text 也更稳妥。

编码与字符集 ​

当文件编码与数据库编码不一致时,用 encoding 选项显式声明,COPY 会做转换:

sql
-- 库是 UTF8,文件是 GBK(Windows 导出的中文 CSV 很常见)
\copy t from 'gbk_file.csv' with (format csv, header true, encoding 'GBK')

-- 导出为 GBK 给老旧系统
\copy t to 'out_gbk.csv' with (format csv, header true, encoding 'GBK')

文件里出现目标编码无法表示的字符时导入报错中止——先验证再放量,或用 PG17 的 ON_ERROR ignore(见下节)跳过个别坏行并复查日志。

导出给 Excel 的经典坑:Excel 对「无 BOM 的 UTF-8 CSV」的兼容性差,双击打开中文全是乱码。可选解法:① 用新版 Excel 的「数据 → 从文本/CSV 导入」并指定 UTF-8;② 导出时 encoding 'GBK'(注意 GBK 覆盖不了 emoji 等字符);③ 导出后用 sed/脚本给文件头加 BOM(EF BB BF)。方案 ① 最干净,不引入编码转换风险。

错误处理:PG17 的 ON_ERROR 与 LOG_VERBOSITY ​

PG17 之前,COPY 遇到任何一行格式错误(列数不对、类型转换失败、编码非法)都会整批中止——文件第 500 万行有一个坏单元格,前面 499 万行的工作全部作废,只能人工清洗后重来。PG17 引入了优雅的降级:

sql
\copy events from 'events.csv' with (format csv, header true, on_error ignore, log_verbosity verbose)
  • ON_ERROR ignore:格式错误的行被跳过,合法行照常入库,命令最终正常结束(仅在 text/csv 格式可用,binary 不支持);
  • LOG_VERBOSITY verbose:把每一个被忽略的行(行号、涉及的列、原始内容)记进日志/输出,是「事后修复坏行」的唯一线索。

组合出的工作流是:ignore + verbose 先放量导入 → 从日志捞坏行 → 人工修整后补导。旧版本(PG16 及以下)想要类似效果只能把文件切小批量试、或导入到单列 staging 表再用 SQL 清洗,成本高得多。

导入导出的权限与安全清单 ​

数据搬运是权限与注入风险的交汇点,一份可执行的检查清单:

检查项说明
表权限最小化导入账号只需要目标表/暂存表的 INSERT/SELECT,不要给全库 ALL
服务端 COPY 角色pg_read_server_files/pg_write_server_files 仅授予受信 ETL 角色,业务账号永不授
路径来自外部输入\copy t from :'src_file' 用变量插值并在脚本入口校验前缀目录,防路径穿越
program '…' 双重确认该形态在客户端执行任意 shell 命令,脚本里出现时必须评审来源
落地到 staging未经清洗的外部文件一律先进暂存表,正式表与外部数据之间隔一层
导出文件的去敏导出即复制了一份数据,考虑落盘目录权限、加密与用后清理
审计留痕大批量导入导出记录时间、账号、行数、文件名,出事可追溯

一个「路径安全校验」的脚本入口示例:

sql
\if :{"src_file"}?
\else
\warn 缺少 src_file 参数
\quit
\endif

-- 简单前缀校验:文件必须位于约定的导入目录
\if :src_file like '/data/import/%'
\else
\warn src_file 必须位于 /data/import/ 目录
\quit
\endif

\copy stg_orders from :'src_file' with (format csv, header true)

psql 的 \if 表达式能力有限(不支持函数调用),like 前缀判断是少数可用的手段之一;更强的校验(规范化路径、防止 .. 穿越)应放到 shell 包装层用 realpath 完成,再把安全路径传给 psql。

大文件导入的性能工程 ​

官方文档「填充数据库」一章明确:COPY 是最快的导入途径,远胜 INSERT(哪怕批量 INSERT)。围绕它有一套成熟的优化清单,按收益排序:

  1. 先删索引,导完再建。每行导入都要同步维护所有索引;大表上「DROP INDEX → COPY → CREATE INDEX」往往比带索引导入快数倍。重建索引时吃 maintenance_work_mem——它对 COPY 过程本身没有影响(这是常见误解),影响的是索引重建与 VACUUM 等维护操作;
  2. 外键与触发器。外键每行都要做参照检查,触发器每行都会执行;批量装载场景通常先删除/禁用,导完后统一校验。超级用户可用 SET session_replication_role = replica; 让普通触发器不触发;
  3. WAL 与检查点。导入期间临时调大 max_wal_size、减少 checkpoint 频率;对可丢弃的库/表,SET synchronous_commit = off;(仅本会话)能显著减少 WAL 刷盘等待——注意它只影响本会话、崩溃时该会话已确认事务可能丢失,适合可重跑的导入;
  4. work_mem 影响有限。纯 COPY 不做排序/哈希,调 work_mem 帮助不大;它影响的是「导入后跑 ANALYZE、建索引、做转换查询」的那些步骤;
  5. 表侧优化。导入目标表用 UNLOGGED 建临时副本再 ALTER TABLE ... SET LOGGED(PG9.5+),或直接写入分区表的最新分区;
  6. 客户端侧。\copy 的瓶颈常在网络与 psql 本身的转发,同机操作用服务端 COPY 更快;远程导入时压缩传输(gzip -dc f.csv.gz | psql -c 'COPY …')通常净赚。

与 \i 配合:完整流程脚本化 ​

\copy 可以直接写进 .sql 脚本由 \i/-f 执行,配合变量做成可传参的导入工具:

sql
-- import_orders.sql
\set ON_ERROR_STOP on
\if :{"src_file"}?
\else
\warn 缺少参数 src_file
\quit
\endif

CREATE TABLE IF NOT EXISTS orders_staging (LIKE orders INCLUDING DEFAULTS);
TRUNCATE orders_staging;

\copy orders_staging from :'src_file' with (format csv, header true)

INSERT INTO orders (id, status, amount, created_at)
SELECT id, status, amount, created_at
FROM orders_staging
ON CONFLICT (id) DO UPDATE
  SET status = EXCLUDED.status, amount = EXCLUDED.amount;

\warn staging 行数:
SELECT count(*) FROM orders_staging;

DROP TABLE orders_staging;
bash
psql -X -d appdb -v src_file=/data/orders_202609.csv -f import_orders.sql

注意 \copy 的文件路径参数也吃插值——:'src_file' 生成的带引号路径在这里是安全的(路径不是 SQL 字面量,引号只是 shell/psql 层面的包裹)。

从 CSV 建表:字段的类型推断 ​

拿到没有 DDL 的 CSV,第一步是定表结构。可用手段按可靠度排序:

  1. 人工看样:head -50 + 按业务语义定类型(金额 numeric(12,2)、时间 timestamptz、状态 text 加 CHECK),永远比自动推断可靠;
  2. 单列 staging 法:全部建成 text 列灌进去,再用 SQL 探查(SELECT min(x), max(x), count(*) FILTER (WHERE x !~ '^-?\d+$') FROM staging;)后决定真实类型;
  3. 借助外部工具(csvkit、Python pandas 等)做预推断,结论仍需人工确认。

无脑全 text 的表只能当临时跳板——统计、索引、约束在 text 上全部失效,清洗完成务必落到规范类型的正式表。

实战示例 ​

示例一:CSV 建表 + 导入 + 校验三步走 ​

sql
-- 1. 按人工看样结论建表
CREATE TABLE customers (
  id          bigint PRIMARY KEY,
  name        text NOT NULL,
  email       text,
  signup_date date,
  balance     numeric(12,2) DEFAULT 0
);

-- 2. 导入(NULL 记号、编码、错误忽略一次配齐)
\copy customers from 'customers.csv' with (
  format csv, header true, null '', encoding 'UTF8'
)

-- 3. 校验
SELECT count(*) AS loaded,
       count(DISTINCT id) AS distinct_ids,
       count(*) FILTER (WHERE email IS NULL) AS null_emails,
       min(signup_date) AS earliest,
       max(signup_date) AS latest
FROM customers;
text
 loaded | distinct_ids | null_emails | earliest   | latest
--------+--------------+-------------+------------+------------
  18234 |        18234 |        1297 | 2019-01-03 | 2026-09-30

loaded 与 distinct_ids 相等说明主键无重复,与源文件行数(减去表头)比对确认无丢行。

示例二:导出业务报表 CSV(含自定义列名) ​

sql
\copy (
  SELECT o.id AS "订单号",
         c.name AS "客户",
         o.amount AS "金额",
         to_char(o.created_at, 'YYYY-MM-DD HH24:MI') AS "下单时间"
  FROM orders o JOIN customers c ON c.id = o.customer_id
  WHERE o.created_at >= '2026-09-01'
  ORDER BY o.created_at
) TO 'report_202609.csv' WITH (format csv, header true)

AS 的中文名经 header true 直接成为文件表头,导出即可交付。更大批量、更少装饰的导出请用上一页 \pset format csv 与本页 \copy 的对比结论来选。

示例三:千万行 CSV 导入的完整优化流程 ​

bash
# 原始文件 2.1 GB、约 1200 万行
wc -l events_raw.csv
sql
-- ① 建无索引、无约束的 UNLOGGED 停泊表
CREATE UNLOGGED TABLE events_stage (
  like events INCLUDING DEFAULTS
);

-- ② 关掉本会话 WAL 刷盘等待(可重跑的装载才允许)
SET synchronous_commit = off;

-- ③ 导入(PG17 起坏行可忽略)
\copy events_stage from 'events_raw.csv' with (format csv, header true, on_error ignore, log_verbosity verbose)
text
COPY 11999997
sql
-- ④ 导入期间记录被忽略的坏行(从 psql 日志或 \o 文件中复查)

-- ⑤ 落正式表:先禁索引再装
SET session_replication_role = replica;  -- 超级用户;跳过普通触发器
INSERT INTO events SELECT * FROM events_stage;

-- ⑥ 恢复并重建索引、收尾
SET session_replication_role = DEFAULT;
CREATE INDEX CONCURRENTLY idx_events_ts ON events (event_ts);
ANALYZE events;
DROP TABLE events_stage;

执行清单化后,同样的数据量导入耗时通常能从小时级压到十几分钟;瓶颈依次是磁盘顺序写、索引重建与网络传输,用 \timing 与 pg_stat_progress_copy(PG14+)可以分别观测。

进度观测的标准查询:

sql
SELECT pid,
       datname,
       relid::regclass AS target_table,
       command,
       type,
       processed,
       processed / greatest(extract(epoch from now() - query_start), 1) AS rows_per_sec
FROM pg_stat_progress_copy p
JOIN pg_stat_activity a USING (pid);

pg_stat_progress_copy 展示当前 COPY 的目标表、进度(processed/total)、以及 text/csv/binary 阶段信息;把它接进监控脚本,长导入就不再是黑盒。注意该视图只覆盖正在进行的 COPY,\copy 因本质是服务端 COPY,同样可见。

示例四:管道 + 压缩传输 ​

bash
# 不落盘的流式导入:远端压缩包直接进库
ssh backup-host 'zcat /backups/orders_202609.csv.gz' \
  | psql -X -d appdb -c "COPY orders FROM STDIN WITH (FORMAT csv, HEADER true)"

# 导出即压缩
psql -X -d appdb -c "COPY orders TO STDOUT WITH (FORMAT csv, HEADER true)" \
  | gzip > orders_dump.csv.gz

COPY 的流式语义与 Unix 管道天然契合:全程零中间文件,网络传输量压缩后只剩零头。

示例五:超大文件分片导入与进度记录 ​

20 GB 以上的单文件导入,一旦第 1900 万行出错就全部重来是不可接受的。工程做法是先切片、逐片导入、逐片校验,把「整体回滚」换成「断点续跑」:

bash
#!/bin/bash
set -euo pipefail
SRC="/data/big_export.csv"
CHUNK_DIR=$(mktemp -d)
STATE=/tmp/import_progress.txt

# 1) 按行切片(每片 500 万行,含表头,保证每片可独立 COPY)
head -1 "$SRC" > "$CHUNK_DIR/header"
tail -n +2 "$SRC" | split -l 5000000 - "$CHUNK_DIR/part_"

# 2) 逐片导入
: > "$STATE"
for part in "$CHUNK_DIR"/part_*; do
  cat "$CHUNK_DIR/header" "$part" > "$part.with_header"

  ROWS=$(psql -X -d appdb -v ON_ERROR_STOP=1 -At -c \
    "COPY stg_orders FROM STDIN WITH (FORMAT csv, HEADER true)" \
    < "$part.with_header" || true)

  if [ -z "${ROWS:-}" ]; then
    echo "FAILED: $part" >> "$STATE"
    exit 1
  fi
  echo "$part -> $ROWS rows" >> "$STATE"
  rm -f "$part.with_header"
done

echo "all chunks done" >> "$STATE"
rm -rf "$CHUNK_DIR"
sql
-- 3) 全部切片完成后,做整体校验与落表
SELECT count(*) AS staged FROM stg_orders;

INSERT INTO orders SELECT * FROM stg_orders ON CONFLICT (order_id) DO NOTHING;
SELECT count(*) FILTER (WHERE id IN (SELECT id FROM stg_orders)) AS landed FROM orders;

设计要点:

  • 切片含统一表头,每片都是合法 CSV,可独立重跑;
  • 进度文件记录每片行数,失败时从断点继续而不是从头来;
  • 切片间各自提交(每次 psql 一个事务),把回滚半径限制在一片之内;
  • 最终落表前的整体校验(行数对账、主键去重)保证业务一致性不因分片而破损。

与 PG17 的 ON_ERROR ignore 组合时,可以把「坏行容忍度」做成分片参数:坏行率超阈值的切片单独人工处理,好片照常推进。

注意事项 ​

\copy 的路径是客户端视角。 它找的是「运行 psql 那台机器」上的文件;远程服务器上跑的 \copy 用的是服务器的客户端路径。反过来,服务端 COPY 的路径是服务器视角。路径混淆是导入失败的第一大原因。

服务端 COPY 的授权是超级用户级风险。 pg_read_server_files 能读任意服务器文件(含配置与私钥),pg_write_server_files 能覆写任意文件。这两个角色只授予受信 ETL 账号,永不授予业务应用账号。

\copy 必须单行。 换行会截断命令;长 SQL 写成 \(SELECT ...) 时保持单行,必要时先把查询存成视图再 \copy 视图名 to ...。

csv 里 NULL 与空串的歧义要显式处理。 默认 csv 导入把空字段当 NULL;需要空串语义的列用 force_not_null,需要「空串即 NULL」的列用 force_null,别靠事后 UPDATE 猜。

ON_ERROR ignore 不是数据清洗器。 它跳过的行不会进任何「错误表」,只留日志痕迹;大批量使用后必须用 LOG_VERBOSITY verbose 的日志闭环核对,否则丢行无人知晓。binary 格式不支持该选项。

导入中断不自动续传。 COPY 是单事务,中断即整体回滚(这正是它的一致性优点)。超大文件要么接受重来,要么按行数切片分批导入并自行记录进度。

Excel 的 UTF-8 乱码不是 PostgreSQL 的锅。 无 BOM 的 UTF-8 CSV 被 Excel 按 ANSI 解读;用 Excel 的导入向导指定编码,或导出为 GBK,不要去改数据库编码。

staging 表模式比「直接灌正式表」安全一个量级。 类型清洗、去重、约束校验都在 staging 上做完再落到正式表,坏数据永远碰不到生产表。

binary 格式文件不要跨版本搬运。 二进制布局与服务器版本/架构相关,跨大版本迁移或跨架构(x86↔ARM)恢复时可能整体失败;跨环境搬运统一用 csv 或 text,binary 只用于同环境内的临时搬运。

导出不是备份。 \copy 导出的 CSV 缺少 DDL、索引、约束与序列状态,不能替代 pg_dump;「把数据导成 CSV」只解决数据交换,恢复能力请交给备份体系。

最佳实践 ​

  1. 应用与日常导入一律 \copy,服务端 COPY 留给服务器侧任务。 权限最小化从导入导出这类「顺手操作」开始。
  2. 导入走 staging 表三段式:装载 → 清洗校验 → 落正式表。 坏数据在跳板表上被发现,而不是在生产表上被回忆。
  3. 大表导入先摘索引与外键,导完再建。 这是单笔收益最大的优化,配合 maintenance_work_mem 调优重建阶段。
  4. PG17+ 优先 ON_ERROR ignore + LOG_VERBOSITY verbose 处理脏数据。 旧版本则用单列 staging + SQL 清洗,别让一个坏行毁掉整批。
  5. 导入会话用 synchronous_commit = off 提速,且只用于可重跑装载。 理解它的崩溃语义后再使用。
  6. 跨网络传输永远带压缩。 COPY TO STDOUT | gzip 与 zcat | COPY FROM STDIN 是零成本的数量级优化。
  7. 导出用 header true + 查询别名定制表头。 让交付文件开箱即读,下游不再要字段字典。
  8. NULL 语义三件套配置化:null 记号 + force_not_null/force_null 按列声明。 把「这文件里空字段算什么」写成导入脚本的一部分。
  9. PG15+ 用 header match 防列序漂移。 上游悄悄换列顺序时,导入第一时间报错而不是错位入库。
  10. 超大导入用 pg_stat_progress_copy 观测。 进度可视化的导入才有排期与告警,黑盒跑批是事故温床。
  11. 导入完成后 ANALYZE。 让统计信息跟上新数据,否则导入后第一批查询全是糟糕计划。
  12. 把常用导入导出固化成带参数的脚本。 配合 -v src_file=... 与 \copy 插值,数据交接从「人工操作」变成「一条命令」。
  13. 超大文件先切片再导入。 每片独立事务 + 进度文件,把「全量重试」变成「断点续跑」,回滚半径与重试成本同时可控。
  14. 导出交付物附一份列说明。 列名、类型、NULL 语义、编码、生成时间写成 README 同目录存放,下游不再靠猜。

下一节

继续学习:PHP 连接 PostgreSQL

参考链接 ​