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 连接流式搬运:
服务端 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 | 制表符 | \N | PostgreSQL 之间的互导,紧凑且无歧义 |
csv | 逗号(可配) | 空串或自定义 null 值 | 与 Excel/外部系统交换,事实标准 |
binary | 无(类型二进制布局) | 无 | 同架构同版本间的大批量搬运,最快但不可移植 |
同一段数据在三种格式下的实际形态(字段值为 NULL、a,b、"quote" 各一):
-- text 格式(Tab 分隔,NULL 写作 \N,值内特殊字符用反斜杠转义)
1 \N alpha
2 a,b say "hi"
3 "q" \\N-- csv 格式(引号包裹含逗号的字段,引号自身翻倍转义,空字段即 NULL)
1,,alpha
2,"a,b","say ""hi"""
3,"""q"",quoted",\N-- binary 格式(无文本形态,按列类型的二进制表示连续排列)
-- 仅 PostgreSQL 自身可读写,人类不可直接审阅可以看到:csv 的引号规则解决了「内容里出现分隔符」的问题,text 的转义解决了「内容里出现换行/Tab」的问题,而 binary 干脆跳过文本层。选格式时先问一个问题——「这份文件最终谁消费?」给 Excel/外部系统选 csv,给另一个 PostgreSQL 选 text 或 binary,给「要人肉排查的现场」永远选 csv。
语法与用法
服务端 COPY(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(元命令):
-- 客户端文件 → 表
\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(脚本管道场景):
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)"常用选项速查:
| 选项 | 说明 | 默认 |
|---|---|---|
FORMAT | text / csv / binary | text |
DELIMITER | 字段分隔符 | text 用 Tab,csv 用 , |
QUOTE | csv 引号字符 | " |
ESCAPE | csv 转义字符 | 与 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_ERROR | stop(默认)/ignore:忽略格式错误行继续(PG17+,text/csv) | stop |
LOG_VERBOSITY | verbose:逐行记录被忽略的错误行(PG17+) | default |
FREEZE | 导入即冻结行(配合同事务 TRUNCATE 的批量装载优化) | 关 |
详细说明
\copy 与 COPY 的权限模型
服务端 COPY 让服务器进程以文件系统权限读写文件,等价于把文件系统暴露给了数据库用户,因此权限被牢牢收紧:
-- 普通用户直接 COPY 服务端文件
COPY orders FROM '/data/import/orders.csv' WITH (FORMAT csv);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+ 内置预定义角色):
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 一致,但有两条独特的语法纪律:
- 必须写在一行。
\copy是元命令,取「本行剩余部分」作为参数,换行即截断。长 SQL 塞在\(SELECT …)里要保持单行; - 大小写不敏感(
from/TO均可),文件路径含空格用单引号包住。
\copy ... from stdin 是个特别的存在:它从psql 自己的输入源逐行读数据,直到遇到 . 结束行。在交互与 -f 脚本里可用但容易失控,更推荐用命令行重定向喂 COPY FROM STDIN(见上节用法)。program 'command' 形态(如 \copy t from program 'zcat /data/t.csv.gz')在客户端侧执行命令并流式接入,注意它需要 psql 运行环境里有对应工具。
from stdin 在脚本里的样子(了解即可,谨慎使用):
-- import_inline.sql
\copy small_dict (code, label) from stdin with (format csv)
A,启用
B,停用
C,冻结
\.COPY 3数据行紧随 \copy 行之后、以单独一行的 \. 结束。它的可用场景非常窄——「脚本里内嵌一小段字典数据」;超过几行就该改用独立 CSV 文件加重定向,混在 SQL 脚本里的数据行既难维护也容易因为行尾空格/编码问题静默错位。
psql 命令行选项与格式选项的配合
\copy 之外,psql 的命令行选项也能拼出「一条命令完成导入导出」的形态,适合 cron 与 CI:
# --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转义; - 不需要也不支持引号包裹。
1 alpha 1234.5
2 \N 7.25
3 beta \\path\to\filetext 格式的定位是 PostgreSQL 之间的互导(如 pg_dump 的纯文本部分、跨实例搬运),紧凑、无歧义;对外部系统则一律用 csv——外部工具几乎不认识 PostgreSQL 的转义约定。
CSV 格式与选项实战
CSV 的规则遵循 RFC 4180 风格:引号内的分隔符/换行不拆分,引号本身用双引号转义。几个高频问题的标准解法:
-- 分号分隔(欧洲 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 让数据按类型的二进制内部表示传输,跳过文本序列化/反序列化,速度通常最快:
\copy big_table to '/data/big.bin' with (format binary)
\copy big_table from '/data/big.bin' with (format binary)但它有三条硬边界:
- 不可移植:跨机器架构(字节序)与 PostgreSQL 大版本都可能有差异,官方明确提示二进制文件不具备跨版本可移植性;
- 不可读:出问题时无法用文本工具排查,坏一行难以定位;
- 不利于复用:下游基本只有 PostgreSQL 自己能消费。
实践中 binary 多见于「同实例/同版本集群内的大表搬迁」;通用交换请用 csv,纯 PostgreSQL 之间用 text 也更稳妥。
编码与字符集
当文件编码与数据库编码不一致时,用 encoding 选项显式声明,COPY 会做转换:
-- 库是 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 引入了优雅的降级:
\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 | 未经清洗的外部文件一律先进暂存表,正式表与外部数据之间隔一层 |
| 导出文件的去敏 | 导出即复制了一份数据,考虑落盘目录权限、加密与用后清理 |
| 审计留痕 | 大批量导入导出记录时间、账号、行数、文件名,出事可追溯 |
一个「路径安全校验」的脚本入口示例:
\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)。围绕它有一套成熟的优化清单,按收益排序:
- 先删索引,导完再建。每行导入都要同步维护所有索引;大表上「DROP INDEX → COPY → CREATE INDEX」往往比带索引导入快数倍。重建索引时吃
maintenance_work_mem——它对 COPY 过程本身没有影响(这是常见误解),影响的是索引重建与 VACUUM 等维护操作; - 外键与触发器。外键每行都要做参照检查,触发器每行都会执行;批量装载场景通常先删除/禁用,导完后统一校验。超级用户可用
SET session_replication_role = replica;让普通触发器不触发; - WAL 与检查点。导入期间临时调大
max_wal_size、减少 checkpoint 频率;对可丢弃的库/表,SET synchronous_commit = off;(仅本会话)能显著减少 WAL 刷盘等待——注意它只影响本会话、崩溃时该会话已确认事务可能丢失,适合可重跑的导入; work_mem影响有限。纯 COPY 不做排序/哈希,调work_mem帮助不大;它影响的是「导入后跑 ANALYZE、建索引、做转换查询」的那些步骤;- 表侧优化。导入目标表用
UNLOGGED建临时副本再ALTER TABLE ... SET LOGGED(PG9.5+),或直接写入分区表的最新分区; - 客户端侧。
\copy的瓶颈常在网络与 psql 本身的转发,同机操作用服务端COPY更快;远程导入时压缩传输(gzip -dc f.csv.gz | psql -c 'COPY …')通常净赚。
与 \i 配合:完整流程脚本化
\copy 可以直接写进 .sql 脚本由 \i/-f 执行,配合变量做成可传参的导入工具:
-- 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;psql -X -d appdb -v src_file=/data/orders_202609.csv -f import_orders.sql注意 \copy 的文件路径参数也吃插值——:'src_file' 生成的带引号路径在这里是安全的(路径不是 SQL 字面量,引号只是 shell/psql 层面的包裹)。
从 CSV 建表:字段的类型推断
拿到没有 DDL 的 CSV,第一步是定表结构。可用手段按可靠度排序:
- 人工看样:
head -50+ 按业务语义定类型(金额numeric(12,2)、时间timestamptz、状态text加 CHECK),永远比自动推断可靠; - 单列 staging 法:全部建成
text列灌进去,再用 SQL 探查(SELECT min(x), max(x), count(*) FILTER (WHERE x !~ '^-?\d+$') FROM staging;)后决定真实类型; - 借助外部工具(
csvkit、Python pandas 等)做预推断,结论仍需人工确认。
无脑全 text 的表只能当临时跳板——统计、索引、约束在 text 上全部失效,清洗完成务必落到规范类型的正式表。
实战示例
示例一:CSV 建表 + 导入 + 校验三步走
-- 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; loaded | distinct_ids | null_emails | earliest | latest
--------+--------------+-------------+------------+------------
18234 | 18234 | 1297 | 2019-01-03 | 2026-09-30loaded 与 distinct_ids 相等说明主键无重复,与源文件行数(减去表头)比对确认无丢行。
示例二:导出业务报表 CSV(含自定义列名)
\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 导入的完整优化流程
# 原始文件 2.1 GB、约 1200 万行
wc -l events_raw.csv-- ① 建无索引、无约束的 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)COPY 11999997-- ④ 导入期间记录被忽略的坏行(从 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+)可以分别观测。
进度观测的标准查询:
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,同样可见。
示例四:管道 + 压缩传输
# 不落盘的流式导入:远端压缩包直接进库
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.gzCOPY 的流式语义与 Unix 管道天然契合:全程零中间文件,网络传输量压缩后只剩零头。
示例五:超大文件分片导入与进度记录
20 GB 以上的单文件导入,一旦第 1900 万行出错就全部重来是不可接受的。工程做法是先切片、逐片导入、逐片校验,把「整体回滚」换成「断点续跑」:
#!/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"-- 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」只解决数据交换,恢复能力请交给备份体系。
最佳实践
- 应用与日常导入一律 \copy,服务端 COPY 留给服务器侧任务。 权限最小化从导入导出这类「顺手操作」开始。
- 导入走 staging 表三段式:装载 → 清洗校验 → 落正式表。 坏数据在跳板表上被发现,而不是在生产表上被回忆。
- 大表导入先摘索引与外键,导完再建。 这是单笔收益最大的优化,配合
maintenance_work_mem调优重建阶段。 - PG17+ 优先
ON_ERROR ignore + LOG_VERBOSITY verbose处理脏数据。 旧版本则用单列 staging + SQL 清洗,别让一个坏行毁掉整批。 - 导入会话用
synchronous_commit = off提速,且只用于可重跑装载。 理解它的崩溃语义后再使用。 - 跨网络传输永远带压缩。
COPY TO STDOUT | gzip与zcat | COPY FROM STDIN是零成本的数量级优化。 - 导出用
header true+ 查询别名定制表头。 让交付文件开箱即读,下游不再要字段字典。 - NULL 语义三件套配置化:
null记号 +force_not_null/force_null按列声明。 把「这文件里空字段算什么」写成导入脚本的一部分。 - PG15+ 用
header match防列序漂移。 上游悄悄换列顺序时,导入第一时间报错而不是错位入库。 - 超大导入用
pg_stat_progress_copy观测。 进度可视化的导入才有排期与告警,黑盒跑批是事故温床。 - 导入完成后 ANALYZE。 让统计信息跟上新数据,否则导入后第一批查询全是糟糕计划。
- 把常用导入导出固化成带参数的脚本。 配合
-v src_file=...与\copy插值,数据交接从「人工操作」变成「一条命令」。 - 超大文件先切片再导入。 每片独立事务 + 进度文件,把「全量重试」变成「断点续跑」,回滚半径与重试成本同时可控。
- 导出交付物附一份列说明。 列名、类型、NULL 语义、编码、生成时间写成 README 同目录存放,下游不再靠猜。
下一节
继续学习:PHP 连接 PostgreSQL