从“写入慢”的经历说起
很多 Go 项目在起步阶段都遇到过类似的状况:表结构简单,数据量也不大,用 for 循环一条一条往数据库里 INSERT,响应速度还挺正常。等到要做数据迁移、日志回放或者同步线上流量时,一张表一次性要写入几十万行,原来的写法立刻趴窝。最直观的表现是接口超时、连接堆积,数据库 CPU 却不高。这个现象很容易让人误以为是数据库配置不够,其实真正的问题出在客户端与数据库的交互方式上。

逐条 INSERT 慢,并不一定是单条语句本身慢。以 PostgreSQL 为例,即使只写一行数据,客户端也要完成一次完整的请求/响应往返;如果使用的是 database/sql 默认的 AutoCommit,每条 INSERT 背后还对应一个隐式事务。事务的提交需要等待 WAL 持久化,在 SSD 上也至少是毫秒级开销。加上 prepared statement 的 Parse、Bind、Execute 三个阶段,N 条数据就是 N 次网络往返,整体耗时会随着数据量线性放大,而且放大系数比大多数人想象的高。
很多团队遇到这个问题时,第一反应是换驱动、换 ORM,或者把插入逻辑拆成多个 goroutine 并发执行。但如果没有先消除“逐条请求”这个根源,并发只是把网络往返并行化,数据库连接数上去了,等待时间不会减少多少。真正需要考虑的是把多次交互合并成更少、更高效的写入操作。
先试批量 INSERT:减少往返,但仍有瓶颈
第一步通常是把逐条 INSERT 改成一次多条 VALUES 的批量 INSERT。假设每批 1000 条,原来的 1000 次请求变成一次请求,语句看起来类似:
INSERT INTO user_logs (user_id, action, created_at) VALUES
($1, $2, $3), ($4, $5, $6), ...
这种做法最直接的好处是把网络往返从 N 次降到 N/批量大小 次。同时因为所有语句在同一个事务里,提交和 WAL 刷盘也只发生一次,整体事务开销大幅下降。对于几万行以内、字段不多的数据,这个改动通常就能带来十几倍甚至更高的性能提升。
但批量 INSERT 并不是银弹。PostgreSQL 对 SQL 语句中的参数个数有限制,占位符总数超过 65535 就会报错,因此批次大小不能无限加大。更重要的是,这个 SQL 语句还是要经过解析、分析、规划,一个超大 VALUES 列表会让 parser 耗费更多时间和内存。如果字段很多,拼接出来的 SQL 文本可能到几十 KB,网络传输和数据库端解析成本都会明显增加。
批量 INSERT 还面临几个实际约束:
- 程序需要先把一批数据完整构造出来,无法做到真正的流式写入;
- 参数占位符数量有上限,批大小需要按字段数折算;
- 如果想拿到每行自增 ID,虽然可以用 RETURNING,但返回结果处理会复杂一些。
PostgreSQL COPY 协议:从“对话”变成“传送”
如果目标是批量导入大批量数据,PostgreSQL 的 COPY 协议比 INSERT 要更合适。COPY 不是普通的 SQL 语句,它走的是客户端和 PostgreSQL 之间的专用数据通道,数据以流式方式传送,无需构造一个大 SQL 文本,也不经过 SQL parser,数据库端直接进入写入路径。对于几十万行以上的数据,这个差异通常能从分钟级缩短到秒级。
在 Go 里使用 pgx 驱动,代码可以写得很简洁:
rows := [][]any{
{"user_1001", "login", "2025-01-01T10:00:00Z"},
{"user_1002", "purchase", "2025-01-01T10:00:05Z"},
}
_, err := conn.CopyFrom(
ctx,
pgx.Identifier{"user_logs"},
[]string{"user_id", "action", "created_at"},
pgx.CopyFromRows(rows),
)
这里的 CopyFrom 封装了 COPY FROM STDIN 协议。pgx.CopyFromRows 适合数据已经存在内存切片里的场景;如果数据来自上游流、CSV 解析或者其他 channel,还可以实现 pgx.CopyFromSource 接口,做到一行一行地生产、一条一条地写入,不需要把整批数据一次性堆到内存里。这一点在数据迁移和日志导入时特别有价值。
需要注意,COPY 协议是 PostgreSQL 专属能力。如果数据库是 MySQL,对应的高效导入方案是 LOAD DATA INFILE,效果类似,但接口和约束完全不同。团队在选型时不要把 COPY 当作所有数据库通用的优化手段。
三种写入方式怎么选
在真实工程里,没有绝对最优的方案,只有适合当前数据和业务约束的方案。下面这张表把三种方式的差异放在一起看:
| 写入方式 | 网络往返 | 数据库端成本 | 错误处理 | 典型场景 |
|---|---|---|---|---|
| 逐条 INSERT | N 次 | 每次 Parse、Exec、Commit | 细粒度,可定位单条 | 低并发、小数据量 |
| 批量 INSERT | N/批次 次 | 大 SQL 解析,参数多 | 整批失败,定位靠约束 | 中等数据量、需返回 ID |
| COPY 协议 | 流式,通常 1 次 | 极低,绕过 SQL 解析 | 整批失败,难逐条重试 | 迁移、日志导入、数仓同步 |
从表里能看出,COPY 协议的优势主要是数据库端开销低、网络浪费少,代价是错误处理和业务交互能力变弱。如果业务必须在写入过程中获取数据库生成的自增 ID,COPY 帮不上忙,批量 INSERT 配合 RETURNING 更合适。
常见误区与实践建议
围绕批量插入优化,有几个误区值得单独说清楚。
- 误区一:批量 INSERT 一定比逐条 INSERT 快。数据量只有几百行时,构造大 SQL 的代价可能超过网络往返节省,性能提升并不明显;数据量大时,也要先确认参数上限和事务大小,不是批越大越好。
- 误区二:COPY 替代 INSERT 无所谓代价。COPY 不经过 SQL parser,也不回传每行结果,所以如果业务依赖触发器、外键约束或者自增 ID 的返回行为,必须单独验证,不能直接换。
- 误区三:并发越多写入越快。COPY 是流式协议,一条 COPY 连接往往就能把磁盘写入压到瓶颈。多个 COPY 并发会放大 WAL 和锁竞争,反而让整体吞吐下降。正确的做法是先测单连接 COPY 的上限,再决定是否需要加并发。
如果现在正准备做优化,建议按下面的思路落地:
- 先评估数据规模和形态。几万行以内、需要保留业务 ID,批量 INSERT 是更稳妥的起点;几十万行以上的迁移、归档、日志同步,优先考虑 COPY 协议。
- 分批执行。单批 COPY 控制在 5 万到 10 万行,避免事务过大,降低重试成本。
- 完善错误处理。COPY 失败时,记录批次信息并做退避重试,重试前确认是否有部分数据已经写入。
- 把写入参数集中配置。批次大小、超时时间、并发数都做成配置项,后续按环境和数据量调整。
回到文章开头的问题:Go 数据库批量插入优化的关键,不是找一把更快的“循环”,而是改变客户端和数据库之间的交互模式。逐条 INSERT 像一次一次打电话确认,批量 INSERT 像把多条消息压缩成一封邮件,COPY 协议更像建立一条专用管道持续输送数据。理解了这条主线索,再根据业务对 ID、约束和错误粒度的要求去选择,就不会在盲目调优里绕圈子了。
原创文章,作者:fudengji,如若转载,请注明出处:https://fudengji.cn/article/858/