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 三种修法的取舍
| 修法 | 查询数 | 优点 | 缺点 |
|---|---|---|---|
| JOIN | 1 | 最快、一次往返 | 一对多时结果会膨胀,需要应用层去重 |
| 批量 IN | 2 | 主表与关联表解耦 | 要多一次往返;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 的选择性更高,主键索引直接赢。
结论很诚实:覆盖索引在本例中一次都没被用上,而且额外占空间。这是一个重要的认知——「建了索引」不等于「索引会被用」。规划器按成本估算选计划,你的直觉(「覆盖索引一定更快」)它不认。所以:
- 先用
EXPLAIN (ANALYZE, BUFFERS)看实际计划,再决定建不建索引。 - 建完索引要再
EXPLAIN一次确认它真的被用了,没被用就删掉(索引有写入代价)。 - 覆盖索引只在高选择性查询上有意义,比如「按 ID 取某几列」。
补充一个反例:我试着关掉 bitmap scan 强制它用索引,结果规划器干脆选了全表扫描——Execution Time: 1.785 ms。这说明在 33% 选择性下,索引和全表扫描本来就在同一个量级,规划器的选择是合理的。
6.3.6 批量写入:三个数量级
读优化完了,写也一样。导入 2 万条任务,三种写法实测:
写入 20000 行: 逐条=1m10.421s Batch=120ms COPY=25ms (最终行数=20000)
| 写法 | 耗时 | 每次往返的行数 | 适用 |
|---|---|---|---|
逐条 Exec | 1 分 10 秒 | 1 | 几乎不要用 |
pgx.Batch | 120ms | 一批多条 | 需要返回值、有逻辑 |
CopyFrom | 25ms | 全部 | 纯导入、不需要逐行结果 |
逐条 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) 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。