《Go 语言编程实战》6.3 N+1、批量与查询优化

一个 500 条的列表接口,背后可能是 501 次查询。本节在 2 万行真实数据上实测 N+1 的 1.999 秒与 JOIN 的 29 毫秒,对比 JOIN、批量 IN、预加载三种修法的取舍,用 EXPLAIN 分析 33% 选择性下索引到底值不值,并诚实记录覆盖索引建了却不被规划器采用的情况,最后实测 2 万行写入中逐条 1 分 10 秒与 COPY 25 毫秒的量级差。

6.3 N+1、批量与查询优化

6.2 保证了写的正确性,但读还停留在「一条一条查」的直觉里。ORM 时代 N+1 是老大难,很多人以为手写 SQL 就免疫了——其实手写 SQL 照样会写出 N+1,只是更隐蔽:循环里调一个仓库方法,看着很自然,一次请求就打出了 501 条 SQL。

本节把 TaskHub 推进到:列表接口从 N+1 改为单次 JOIN 或批量 IN,索引按选择性评估而非「见字段就建」,批量写入按数据量选择 Batch 或 COPY。

6.3.1 N+1 是怎么产生的

TaskHub 的任务列表要显示项目名。最自然的写法是:查出一页任务,再为每条任务查它的项目:

for _, t := range page {
	var name string
	if err := db.QueryRowContext(ctx, `select name from projects where id=$1`, t.Project).Scan(&name); err != nil {
		return err
	}
	names[t.ID] = name
}

问题在于:500 条任务就是 500 次查询。每次查询都有一次网络往返(本机约几微秒,跨机房约几百微秒到几毫秒),500 次累加起来就是灾难。这就是 N+1:1 次查主表 + N 次查关联表。

在 2 万行任务、1000 个项目的真实数据上实测,页大小 500:

N+1    : 501 次查询(1 次页查询 + 500 次逐条查项目名)耗时=1.999s  err=<nil>
批量IN : 2 次查询(页 + 1 次 IN,去重后项目数=500)耗时=53ms  err=<nil>
JOIN   : 1 次查询(一条 SQL 带出项目名)耗时=29ms  err=<nil>

501 次查询 1.999 秒,一次 JOIN 29 毫秒——差 69 倍。 而且这还是在本机、无网络延迟的数据库上。真实环境里应用与数据库之间有网络,N+1 的代价会成倍放大:假设每次往返 1ms,500 次就是 0.5 秒的纯网络时间。

这里有个容易被忽略的细节:批量 IN 只用了 2 次查询,耗时 53ms,比 JOIN 的 29ms 慢。为什么?因为它要多传 500 个 ID 参数、要多一次往返、且应用层要做去重与合并。JOIN 是首选,批量 IN 是备选。

6.3.2 三种修法的取舍

修法查询数优点缺点
JOIN1最快、一次往返一对多时结果会膨胀,需要应用层去重
批量 IN2主表与关联表解耦要多一次往返;IN 列表过长有上限
预加载(先批量、再 map 拼)2复用度高、可缓存代码量大,仍需去重

JOIN 在「一对一 / 多对一」时是最优解。但注意一对多会放大结果集:如果一个项目有 100 个任务,join 后每个任务都会带一份项目字段,网络传输量翻倍。TaskHub 的任务列表是「多对一」(多个任务属于一个项目),所以 JOIN 没有膨胀问题。

批量 IN 需要注意两点:去重(500 条任务可能只涉及 20 个项目,重复的 ID 要合并)和参数上限(Postgres 的绑定参数上限是 65535,但更实际的问题是超长 IN 列表会让查询计划变差)。实践中的做法是分批,比如每 1000 个 ID 一批。

预加载的代码形态是「先批量查出来放进 map,再在内存里拼」:

// 1. 批量取项目名
rows, err := db.QueryContext(ctx, `select id, name from projects where id = any($1)`, ids)
if err != nil {
	return err
}
defer rows.Close()
byID := map[int64]string{}
for rows.Next() {
	var id int64
	var name string
	_ = rows.Scan(&id, &name)
	byID[id] = name
}
// 2. 在内存里拼装,不再查库
for i := range page {
	page[i].ProjectName = byID[page[i].ProjectID]
}

id = any($1) 比 id in ($1,$2,...) 更好——它只需要一个参数,不用拼动态占位符,也就没有 SQL 注入与参数个数上限的顾虑。pgx 会把它编码成 Postgres 的数组类型。

6.3.3 怎么发现 N+1

N+1 的问题在于它不报错,只是慢。发现手段有三层:

手段做法能发现什么
代码审查搜索「循环里调仓库方法」大部分 N+1
SQL 日志计数一次请求内统计 SQL 条数隐蔽的动态查询
pg_stat_statements按 calls 排序生产环境的高频 SQL

最实用的是在测试里断言 SQL 条数:一个列表接口无论返回多少条,SQL 条数应该是常数。可以用 sqlmock 或者一个统计包装器:

type countingDB struct {
	DBTX
	n int64
}

func (c *countingDB) QueryContext(ctx context.Context, q string, args ...any) (*sql.Rows, error) {
	atomic.AddInt64(&c.n, 1)
	return c.DBTX.QueryContext(ctx, q, args...)
}

断言写成「返回 500 条时 SQL 条数 ≤ 3」,一旦有人加了循环查询,测试立刻失败。这比任何 review 都可靠,因为它是自动执行的。

pg_stat_statements 那一层留给生产:如果看到某条「按 ID 查项目」的 SQL calls 数远超请求数,就是 N+1 的铁证。第 10 章会把它的输出接进监控。

6.3.4 索引:不是见字段就建

N+1 解决后,下一个瓶颈是单条查询本身。索引的价值取决于选择性——过滤条件能筛掉多少行。用 2 万行数据实测一个「租户 + 状态」的过滤:

--- 无 status 索引 ---
Aggregate  (actual time=5.404..5.405 rows=1 loops=1)
  ->  Seq Scan on tasks_n1  (actual time=0.013..4.584 rows=6667 loops=1)
        Filter: ((tenant_id = 'tnt_1'::text) AND (status = 'doing'::text))
        Rows Removed by Filter: 13333
        Buffers: shared hit=191
Execution Time: 5.435 ms

全表扫描 2 万行,过滤掉 13333 行,耗时 5.435ms。加上 (tenant_id, status) 复合索引后:

--- 建了 (tenant_id, status) 索引后 ---
Aggregate  (actual time=0.930..0.931 rows=1 loops=1)
  ->  Bitmap Heap Scan on tasks_n1  (actual time=0.146..0.605 rows=6667 loops=1)
        Recheck Cond: ((tenant_id = 'tnt_1'::text) AND (status = 'doing'::text))
        Heap Blocks: exact=191
        Buffers: shared hit=191 read=7
        ->  Bitmap Index Scan on idx_tasks_n1_tenant_status  (actual time=0.128..0.128 rows=6667 loops=1)
Execution Time: 0.952 ms

从 5.435ms 降到 0.952ms,约 5.7 倍。但要看清楚:规划器选的是 Bitmap Heap Scan 而不是 Index Scan,而且 Heap Blocks: exact=191 说明它仍然要回表读 191 个数据块。

为什么?因为这里的选择性是 6667/20000 ≈ 33%——三分之一的表都要读。索引在这里的价值是「避免逐行比较」,而不是「避免读表」。选择性越低,索引的收益越小:

选择性计划索引价值
< 1%Index Scan极高,几乎只读几行
1%~10%Bitmap Index Scan + Heap Scan高
> 30%可能仍走 Seq Scan低,甚至更慢

所以「见字段就建索引」是错的。索引要按实际查询的选择性来建:高选择性的等值查询(按 ID、按唯一键)值得;低选择性的(按状态、按布尔值)要谨慎。给 status 单独建索引几乎没有价值——它只有三个取值,选择性天然差。上面这个复合索引有价值,是因为 tenant_id 在前,先按租户缩小范围。

6.3.5 覆盖索引:建了不一定被用

覆盖索引(covering index)的思路是把查询要读的列也放进索引,这样就不用回表。INCLUDE 子句能做到:

create index idx_tasks_n1_covering on tasks_n1 (tenant_id, status) include (title)

建完之后跑一个取 title 的查询,实测结果出乎意料:

Bitmap Heap Scan on tasks_n1  (actual time=0.109..0.680 rows=6667 loops=1)
  Recheck Cond: ((tenant_id = 'tnt_1'::text) AND (status = 'doing'::text))
  Heap Blocks: exact=191
  Buffers: shared hit=198
  ->  Bitmap Index Scan on idx_tasks_n1_tenant_status on ...
Execution Time: 0.941 ms

规划器没有用覆盖索引,它仍然选了那个更窄的 (tenant_id, status) 索引 + 回表。原因是 33% 的选择性下,读 6667 行本来就要碰 191 个数据块,用更窄的索引扫描反而更便宜。把选择性提高再看:

--- 高选择性 + 覆盖索引 ---
Index Scan using tasks_n1_pkey on tasks_n1  (actual time=0.028..0.035 rows=17 loops=1)
  Index Cond: ((id >= 100) AND (id <= 150))
Execution Time: 0.049 ms

这里规划器选了主键索引——因为 id between 100 and 150 的选择性更高,主键索引直接赢。

结论很诚实:覆盖索引在本例中一次都没被用上,而且额外占空间。这是一个重要的认知——「建了索引」不等于「索引会被用」。规划器按成本估算选计划,你的直觉(「覆盖索引一定更快」)它不认。所以:

  1. 先用 EXPLAIN (ANALYZE, BUFFERS) 看实际计划,再决定建不建索引。
  2. 建完索引要再 EXPLAIN 一次确认它真的被用了,没被用就删掉(索引有写入代价)。
  3. 覆盖索引只在高选择性查询上有意义,比如「按 ID 取某几列」。

补充一个反例:我试着关掉 bitmap scan 强制它用索引,结果规划器干脆选了全表扫描——Execution Time: 1.785 ms。这说明在 33% 选择性下,索引和全表扫描本来就在同一个量级,规划器的选择是合理的。

6.3.6 批量写入:三个数量级

读优化完了,写也一样。导入 2 万条任务,三种写法实测:

写入 20000 行: 逐条=1m10.421s  Batch=120ms  COPY=25ms (最终行数=20000)
写法耗时每次往返的行数适用
逐条 Exec1 分 10 秒1几乎不要用
pgx.Batch120ms一批多条需要返回值、有逻辑
CopyFrom25ms全部纯导入、不需要逐行结果

逐条 70 秒,COPY 25 毫秒——差 2800 倍。 逐条慢的原因不是 SQL 解析,而是每次 Exec 都是一次网络往返:2 万次往返累加起来就是 70 秒。

pgx.Batch 把多条语句攒起来一次发出(利用 Postgres 的扩展查询协议),适合「需要每条的结果」的场景。写法:

b := &pgx.Batch{}
for _, t := range tasks {
	b.Queue(`insert into tasks (id, tenant_id, title) values ($1,$2,$3)`, t.ID, t.TenantID, t.Title)
}
br := pool.SendBatch(ctx, b)
defer br.Close()
for range tasks {
	if _, err := br.Exec(); err != nil {
		return err
	}
}

COPY 是最快的,因为它是 Postgres 的原生批量导入协议,跳过逐行的语句解析与计划。pgx 的 CopyFrom 封装了它:

rows := make([][]any, len(tasks))
for i, t := range tasks {
	rows[i] = []any{t.ID, t.TenantID, t.Title}
}
_, err := pool.CopyFrom(ctx,
	pgx.Identifier{"tasks"},
	[]string{"id", "tenant_id", "title"},
	pgx.CopyFromRows(rows),
)

三条实践规则:

  • 导入 / 迁移用 CopyFrom,但要记住它照样会触发触发器并检查约束(不会因为走 COPY 就跳过),且不能直接拿到 RETURNING。
  • 业务写入用 Batch,一批 100~1000 条,兼顾延迟与内存。
  • 逐条 Exec 只用于单条操作。如果代码里出现「循环里 Exec」,那基本就是 bug。

还有一个边界要注意:Batch 和 CopyFrom 都在一个隐式事务里(SendBatch 的所有语句要么全成要么全败)。这通常是好事,但意味着一批里有一条约束冲突,整批都会回滚。如果需要「部分成功」,得拆批或改用 ON CONFLICT DO NOTHING。

6.3.7 小结

  • N+1 = 1 次主查询 + N 次关联查询;手写 SQL 一样会犯。
  • 实测 500 条页:N+1 是 501 次查询 1.999s,批量 IN 53ms,JOIN 29ms。
  • 优先 JOIN;一对多会膨胀时用批量 IN 或预加载;id = any($1) 优于拼 IN 占位符。
  • 在测试里断言「SQL 条数是常数」,比人工 review 可靠。
  • 索引按选择性建:< 1% 极高价值,> 30% 可能不如全表扫描;status 这类低基数字段单建索引没用。
  • 实测覆盖索引建了没被规划器采用——建完必须 EXPLAIN 确认,没用到就删。
  • 实测写入 2 万行:逐条 1m10s、Batch 120ms、COPY 25ms;导入用 COPY,业务写入用 Batch。

数据访问这一层到这就完整了:连接有池、超时分层、事务边界清晰、查询不 N+1、批量写入有量级意识。但这些查询每次都在打数据库——下一章开始加缓存。

阅读导航:上一节:6.2 事务边界与并发控制 · 下一节:7.1 本地缓存与失效(singleflight) 。

继续阅读

探索更多技术文章

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

全部文章 返回首页

「golang」更多文章

  1. 《Go 语言编程实战》目录
  2. 《Go 语言编程实战》18.3 上线、观测与迭代
  3. 《Go 语言编程实战》18.2 故障演练