COPY 与批量数据加载优化

PostgreSQL 批量加载全解析:COPY 的三种形态(文件 / STDIN / PROGRAM)、COPY 为何比 INSERT 快几个数量级、synchronous_commit / wal_level / unlogged 表等参数调优、COPY FREEZE 与加载后维护、并行加载与 pg_bulkload、以及应用侧批量写入的实践取舍。

把一个 CSV 的 1000 万行灌进 PostgreSQL,用逐行 INSERT 可能要跑几十分钟甚至几小时,换成 COPY 往往只需几十秒。这不是魔法,而是因为 COPY 走了一条专用通道:它跳过 SQL 解析与规划、绕开逐行的事务与 WAL 开销、以流式方式直接写堆表。数据迁移、ETL、日志归档、初始化测试数据——所有「大批量写入」的场景,COPY 都应该是首选。

但 COPY 只是起点。真正决定批量加载速度的是写放大:索引维护、WAL 记录、触发器、约束校验、synchronous_commit 的刷盘等待。把加载拆成「先关掉一切能关的、灌完再一次性重建」,速度差距可以再拉开一个数量级。本文从 COPY 的原理讲到参数调优与加载后维护,覆盖文件、管道、应用侧三种入口。

一、COPY 的三种形态

1.1 服务端文件

最基础的用法是从服务端文件加载(文件必须在数据库服务器上,且需要超级用户权限):

COPY orders (id, tenant_id, amount, created_at)
FROM '/data/orders.csv'
WITH (FORMAT csv, HEADER true, DELIMITER ',', QUOTE '"', NULL '');

COPY 的常用选项:

选项说明
FORMAT csv / text / binary数据格式,csv 最常用
HEADER true首行是列名,自动跳过
NULL 'value'空值表示,如 NULL '' 或 NULL '\N'
ENCODING 'UTF8'指定文件编码
FORCE_NULL (col)CSV 空串按 NULL 处理
ON_ERROR ignore跳过坏行(PG 16+)

1.2 STDIN(管道)

客户端侧通过 psql 的 \copy(注意是反斜杠命令)加载本地文件:

psql -h db -U app -d shop \
  -c "\copy orders (id, tenant_id, amount) FROM '/local/orders.csv' CSV HEADER"

\copy 与服务端 COPY 的区别是数据流经客户端:\copy 在客户端读文件、通过协议发送,因此文件在客户端机器上,也不要求超级用户。代价是多一跳网络传输,但胜在权限与部署灵活,是生产中最常用的形式。

1.3 PROGRAM(外部命令)

COPY ... FROM PROGRAM 直接执行一个命令并把其标准输出当作输入流:

COPY orders FROM PROGRAM 'gzip -dc /data/orders.csv.gz'
WITH (FORMAT csv, HEADER true);

这省去了「先解压再加载」的中间步骤,对压缩归档文件很实用。等价地,客户端侧可以:

gzip -dc orders.csv.gz | psql -c "\copy orders FROM STDIN CSV HEADER"

导出方向的对称用法:

psql -c "\copy (SELECT * FROM orders WHERE created_at > '2026-01-01') TO STDOUT CSV HEADER" \
  | gzip > orders_2026.csv.gz

COPY (query) TO 支持任意 SELECT,是导出数据最灵活的方式。

1.4 二进制格式

FORMAT binary 跳过文本解析与类型转换,理论上最快,但它是平台相关的(依赖字节序与类型内部表示),且不能被其它工具直接读取:

COPY orders TO '/data/orders.bin' WITH (FORMAT binary);
COPY orders FROM '/data/orders.bin' WITH (FORMAT binary);

实践中,文本 csv 格式的加载速度已经足够快,瓶颈往往不在解析而在 WAL 与索引维护。只有在「同版本 PostgreSQL 之间搬数据」且解析确实成为瓶颈时,才值得用二进制格式。跨版本、跨平台搬运时不要用二进制格式,内部表示可能不兼容。

1.5 错误处理

默认情况下,COPY 遇到一行格式错误就整体失败、回滚整个加载。这在大文件导入时很痛苦——一条坏行就让几十分钟白跑。PG 16 起支持跳过坏行:

COPY orders FROM '/data/orders.csv' CSV HEADER
    ON_ERROR ignore
    LOG_VERBOSITY verbose;

被跳过的坏行会记录到服务器日志(LOG_VERBOSITY verbose 会带上行号与原始内容),加载完成后可以据此定位并修复。注意 ON_ERROR ignore 只跳过格式错误,唯一约束等完整性冲突仍会导致失败。

对于更复杂的情况,可以先把文件加载到一张 UNLOGGED 的暂存表(所有列都是 text),再用 SQL 做校验与转换:

CREATE UNLOGGED TABLE staging (line text);
COPY staging FROM '/data/orders.csv' CSV;
-- 逐列解析、过滤坏行、转换类型后插入正式表
INSERT INTO orders (id, tenant_id, amount)
SELECT (regexp_match(line, '^([^,]+),([^,]+),([^,]+)'))[1]::bigint, ...
FROM staging
WHERE line ~ '^\d+,\d+,[\d.]+$';

这个「先落地再清洗」的模式虽然慢一些,但容错性最好,适合来源不可控的外部数据。

二、COPY 为什么快

2.1 绕过 SQL 解析与规划

逐行 INSERT INTO t VALUES (...) 每一条都要经历「解析 → 重写 → 规划 → 执行」四阶段。规划阶段尤其昂贵——即使是简单的单行插入,规划器也要查系统表、评估索引。1000 万次 INSERT 就是 1000 万次规划。

COPY 只在开始时解析一次目标表结构,之后按二进制/文本流逐行写入,没有逐行规划开销。这一项就能带来数倍差距。

2.2 单事务、少往返

逐行 INSERT 若是自动提交模式,每一行都是一个独立事务:一次提交 = 一次 WAL 刷盘(fsync)。1000 万行 = 1000 万次 fsync,磁盘 IOPS 直接成为瓶颈。

COPY 默认在单个事务里完成(除非用 COPY 的 FREEZE 或分段),只有一次提交、一次刷盘。这一项通常带来数十倍差距。

2.3 简化的 WAL 记录

COPY 对 WAL 的优化体现在两方面:一是它知道自己在做纯插入,不需要记录「可能的更新」所需的额外信息;二是配合 wal_level = minimal + COPY 到「刚创建或 TRUNCATE 过的表」时,PostgreSQL 可以几乎不写 WAL,因为崩溃后该表会被直接丢弃重建。这一点是很多「批量加载加速技巧」的核心,WAL 的完整机制见 PostgreSQL WAL 与崩溃恢复原理 。

2.4 触发器的代价

如果目标表上有 BEFORE INSERT / AFTER INSERT 触发器(包括外键的约束触发器),COPY 也会逐行触发它们。一个写入审计日志的 AFTER 触发器,能把 COPY 的速度拉回 INSERT 的水平。批量加载前先禁用触发器是常规操作:

ALTER TABLE orders DISABLE TRIGGER ALL;
COPY orders FROM '/data/orders.csv' CSV HEADER;
ALTER TABLE orders ENABLE TRIGGER ALL;

DISABLE TRIGGER ALL 会连外键的约束触发器一起禁用,因此加载的数据必须自己保证引用完整性,加载后再启用并验证。

三、参数调优

3.1 事务与 WAL 参数

批量加载是「可重试、可重建」的操作,可以牺牲部分持久性换取速度:

BEGIN;
SET LOCAL synchronous_commit = off;
SET LOCAL maintenance_work_mem = '2GB';
SET LOCAL max_wal_size = '8GB';
COPY orders FROM '/data/orders.csv' CSV HEADER;
COMMIT;
参数作用
synchronous_commit = off提交不等待 WAL 刷盘,崩溃可能丢最后若干事务
maintenance_work_mem加速加载后的索引/约束构建
max_wal_size减少检查点频率,避免加载期间频繁 checkpoint
checkpoint_timeout配合调大,进一步减少 checkpoint

synchronous_commit = off 只影响「崩溃后可能丢失最近一小段时间的事务」,不会造成数据损坏。对可重跑的数据导入,这是最划算的一步。

3.2 无日志表(UNLOGGED)

若数据是「可重建的中间结果」,把表建成 UNLOGGED 可以完全不写 WAL:

CREATE UNLOGGED TABLE orders_staging (LIKE orders INCLUDING ALL);
COPY orders_staging FROM '/data/orders.csv' CSV HEADER;
-- 校验后转为正式表
ALTER TABLE orders_staging SET LOGGED;

UNLOGGED 表不写 WAL,写入速度可提升数倍,但崩溃后表会被自动清空(truncate)。因此它只适合「导入暂存表」,加载完校验后再 SET LOGGED 转正——SET LOGGED 本身要写全表 WAL,会慢,但一次性完成。

3.3 索引与约束的处理顺序

这是批量加载最大的优化点。索引维护是逐行插入的主要成本:每插一行,所有索引都要更新。1000 万行 × 5 个索引 = 5000 万次索引更新。

正确做法是先删索引,灌完再重建:

-- 1. 记录现有索引定义
-- 2. 删除非主键索引
DROP INDEX idx_orders_tenant_created;
-- 3. 加载
COPY orders FROM '/data/orders.csv' CSV HEADER;
-- 4. 重建(可用并行 + 大 maintenance_work_mem)
SET maintenance_work_mem = '2GB';
SET max_parallel_maintenance_workers = 4;
CREATE INDEX idx_orders_tenant_created ON orders (tenant_id, created_at);

外键与 CHECK 约束同理:可以先 ALTER TABLE ... DROP CONSTRAINT,加载后再 ADD CONSTRAINT,让约束一次性校验而不是逐行。NOT VALID 约束可以进一步把校验推迟:

ALTER TABLE orders ADD CONSTRAINT fk_tenant
    FOREIGN KEY (tenant_id) REFERENCES tenants(id) NOT VALID;
-- 之后在低峰期一次性校验
ALTER TABLE orders VALIDATE CONSTRAINT fk_tenant;

四、COPY FREEZE

4.1 原理

正常插入的元组带有「事务 ID(xmin)」,需要靠 VACUUM 冻结(freeze)才能被后续的可见性判断安全处理。COPY FREEZE 在加载时直接把元组标记为「已冻结」,跳过未来的冻结开销:

COPY orders FROM '/data/orders.csv' CSV FREEZE;

它的前提条件很严格:

  • 表必须是当前事务中创建或 TRUNCATE 过的。
  • 必须是同一事务里的 COPY。
  • 目标表上不能有索引(较新版本放宽了部分限制)。
BEGIN;
TRUNCATE orders;
COPY orders FROM '/data/orders.csv' CSV FREEZE;
COMMIT;

满足条件时,COPY FREEZE 能显著减少后续 VACUUM 的负担——因为这些行永远不需要冻结扫描。对「每次全量重灌」的场景(如每日快照表),这是很有效的优化。

4.2 加载后的维护

无论是否 FREEZE,批量加载后都要做三件事:

  1. ANALYZE:更新统计信息,否则后续查询可能选错计划。
ANALYZE orders;

统计信息对计划质量的影响极大:一个刚灌完 1000 万行却未 ANALYZE 的表,规划器仍按旧的行数估算,可能给关键查询选错索引甚至走全表扫描。

  1. VACUUM:加载本身不产生死元组,但如果加载过程中有并发更新,或使用了 ON CONFLICT,就需要清理。定期 VACUUM (ANALYZE) 是稳妥的。

  2. 检查膨胀:如果加载方式是「先 DELETE 再 COPY」,会留下大量死元组,必须 VACUUM 回收,否则表体积膨胀、顺序扫描变慢。膨胀的判定与治理见 PostgreSQL VACUUM 与膨胀治理 。更彻底的替代是 TRUNCATE 而非 DELETE——TRUNCATE 直接重置文件,无死元组。

4.3 加载后校验

大批量加载必须校验完整性,不能假设文件没问题。三个基本校验:

-- 1. 行数是否与源文件一致
SELECT count(*) FROM orders;

-- 2. 是否有 NULL 落到 NOT NULL 列(若曾禁用约束)
SELECT count(*) FROM orders WHERE tenant_id IS NULL;

-- 3. 主键/唯一键是否重复
SELECT id, count(*) FROM orders GROUP BY id HAVING count(*) > 1;

对账的黄金标准是校验和:加载前后对同一列做 sum() 或 md5 聚合,与源系统比对。

SELECT count(*), sum(amount), md5(string_agg(id::text, ',' ORDER BY id))
FROM orders WHERE created_at >= '2026-01-01';

如果加载前 DISABLE TRIGGER ALL 禁用了外键,加载后务必单独校验引用完整性,或重新 ADD CONSTRAINT 让数据库替你校验一遍。

4.4 加速效果的量化

把各项优化叠加起来,效果通常是这样的(以 1000 万行、5 个索引的表为参考):

方案相对耗时说明
逐行 INSERT(自动提交)100×基线
多值 INSERT(每批 1000 行)15×减少解析与往返
COPY(保留索引)5×跳过规划、单事务
COPY + 先删索引再重建2×消除逐行索引维护
COPY + UNLOGGED + 关 synchronous_commit1×消除 WAL 与刷盘

这张表是数量级参考,实际倍率取决于硬件与数据形态,但优化项的排序在大多数场景下稳定:先删索引、再关 WAL、最后才考虑并行与二进制格式。

五、进阶方案

5.1 并行加载

单个 COPY 是单线程的。要突破单核写入瓶颈,把数据按分区键切成多份,并行 COPY 到不同分区:

# 按 tenant_id 范围切分文件后并行加载
for i in 0 1 2 3; do
  psql -c "\copy orders_tenant_$i FROM '/data/orders_$i.csv' CSV HEADER" &
done
wait

前提是表已按 tenant_id 分区,每个 COPY 只写自己的分区,互不冲突。这能把加载吞吐提升到接近磁盘或网络的极限。分区表的加载与维护手法见本专题的分区相关文章。

5.2 pg_bulkload

pg_bulkload 是一个第三方工具,绕开 PostgreSQL 的缓冲区与 WAL,直接以「加载器」模式写入数据文件,速度远超 COPY。代价是:

  • 加载期间表必须离线(无法并发读写)。
  • 不走 WAL,崩溃后需要重建。
  • 安装与版本兼容性需要额外维护。

它适合「一次性全量初始化」的场景,不适合在线增量导入。

5.3 外部表与 file_fdw

对「反复加载同一批文件」的场景,file_fdw 可以把 CSV 当作外部表直接查询:

CREATE EXTENSION file_fdw;
CREATE SERVER csv_srv FOREIGN DATA WRAPPER file_fdw;
CREATE FOREIGN TABLE orders_csv (
    id bigint, tenant_id int, amount numeric, created_at timestamptz
) SERVER csv_srv
OPTIONS (filename '/data/orders.csv', format 'csv', header 'true');

-- 直接插入正式表
INSERT INTO orders SELECT * FROM orders_csv;

它省去了 COPY 的显式调用,但仍会逐行走 SQL 执行器,速度不如 COPY。优势在于可以用 SQL 做预处理(过滤、转换、JOIN)后再入库。

六、应用侧批量写入

并非所有加载都能走 COPY——应用运行时往往要「批量插入一批对象」。这时有三种选择。

6.1 多值 INSERT

把多行合并成一条 INSERT:

INSERT INTO orders (id, tenant_id, amount) VALUES
    (1, 42, 100.00),
    (2, 42, 200.00),
    (3, 43, 300.00);

这减少了解析与规划次数,但单条 SQL 的参数量有上限(65535),且 VALUES 列表越长,规划时间越长(尤其带 ON CONFLICT 时)。经验值是每批 500~1000 行。

6.2 使用 COPY 协议

主流驱动都暴露了 COPY 接口。以 Node.js 的 pg 为例,pg-copy-streams 可以把可读流直接 COPY 进表:

import { pipeline } from 'node:stream/promises';
import { from as copyFrom } from 'pg-copy-streams';

const stream = client.query(
  copyFrom('COPY orders (id, tenant_id, amount) FROM STDIN CSV')
);
await pipeline(Readable.from(csvChunks), stream);

这是应用侧最快的路径,速度可达多值 INSERT 的数倍。ORM 层的批量写入封装可参考 Prisma 与 PostgreSQL 集成 ,它在底层同样会把批量操作转换为高效的多值语句或 COPY。

6.3 何时不该批量

批量写入不是无条件更好。以下情况应退回逐行:

  • 需要每行的 RETURNING 结果(如返回自增 ID 并立刻使用)。
  • 需要行级触发器的副作用(如发消息、写审计)。
  • 单批数据里存在唯一键冲突且要精细处理冲突(ON CONFLICT 在超大批次上规划成本高)。
  • 事务必须尽快可见(长事务会阻塞 VACUUM、放大膨胀)。

批量的本质是「用更长的单个事务换吞吐」,而长事务有代价。选择批次大小时,要在吞吐与事务长度之间取平衡,通常 500~5000 行是合理区间。

6.4 加载吞吐与整体调优

批量加载的性能是「磁盘 IO + WAL + 索引维护 + CPU」共同作用的结果。加载前后对照 pg_stat_io(PG 16+)可以看清瓶颈在哪:

SELECT backend_type, object, context,
       reads, writes, write_bytes
FROM pg_stat_io
ORDER BY write_bytes DESC;

若 writes 集中在 relation 而非 wal,说明瓶颈是表数据本身(可考虑 UNLOGGED);若集中在 wal,则应优化 WAL 参数(wal_level、synchronous_commit、max_wal_size)。这类系统性调优的思路与 PostgreSQL 性能调优 中「先定位瓶颈再调参」的方法论一致。

小结

批量加载的核心是「减少每一行带来的固定开销」。COPY 相对逐行 INSERT 的优势来自跳过解析规划、单事务少刷盘、简化的 WAL。在此之上,把加载拆成「关索引/约束/触发器 → 高速灌入 → 重建索引并 ANALYZE」三段,能再拉开一个数量级。可重灌的数据用 UNLOGGED 暂存表、配合 COPY FREEZE 与调大的 maintenance_work_mem,是生产上的标准组合拳。应用侧的批量写入优先用驱动的 COPY 协议,其次是多值 INSERT,并注意长事务对 VACUUM 的副作用。最后别忘了加载后 ANALYZE——否则再快的加载也会因为统计信息陈旧而让查询变慢。

继续阅读

探索更多技术文章

浏览归档,发现更多关于系统设计、工具链和工程实践的内容。

全部文章 返回首页

「database」更多文章

  1. PostgreSQL 锁与阻塞分析
  2. pgvector 向量检索与混合查询
  3. Citus 分布式分片与水平扩展