← [ BACK_TO_NOTES ]

BodySense 数据库第 9 课:ORM Query Shape——N+1、Preload、Batch 与 Pagination

从 GORM 关联读取和 API projection 出发学习 N+1、eager/lazy/batch loading、offset/keyset pagination,并把 HTTP response shape 与 SQL query shape 对齐。

#course #growing #backend #database #postgresql #bodysense

[!info] related notes

BodySense 数据库第 9 课:ORM Query Shape——N+1、Preload、Batch 与 Pagination

[!abstract] 本课目标 性能问题常常不是“单条 SQL 慢”,而是一次 HTTP 请求悄悄发了几十/几百条 SQL。

从零建立概念:HTTP Response Shape 会决定 Database Query Shape

假设 API 要返回:

{
  "conversations": [
    { "id": "c1", "messages": [...] },
    { "id": "c2", "messages": [...] }
  ]
}

前端看到的是一个嵌套 JSON。

数据库却是:

conversations table
messages table

ORM 的任务之一,就是把关系数据组织回对象结构。

真正的工程问题是:

为了构造这个对象图,究竟发了几条 SQL?每条 SQL 返回多少行?

Lazy Loading:什么时候访问关联,什么时候才查询

某些 ORM 支持类似:

conversation.messages
→ 第一次访问时才发 SQL

优点:

不用就不查

风险:

loop over 100 conversations
→ each access messages
→ 100 child queries

N+1 很容易悄悄出现。

GORM 的关联加载模型和 Hibernate 之类并不完全相同,所以不要机械套某个 ORM 的“lazy”术语;核心仍是追真实 SQL。

N+1 从具体时间成本理解

假设:

1 query for 100 conversations
+
100 queries for messages

每条 child SQL 只 2ms:

看起来都很快

但至少有:

101 round trips
101 pool acquisitions / usages
101 parse/plan/execute cycles

高并发下:

100 HTTP requests × 101 SQL
= 10,100 DB queries

所以 N+1 的危害是查询数量放大。

Eager Loading / Preload:先批量把关联拿回来

GORM 常见:

Preload("Messages")

可能形成:

SELECT ... FROM conversations ...;

SELECT ... FROM messages
WHERE conversation_id IN (...);

也就是:

1 + 1

而不是:

1 + N

这叫 Batch Fetch 的一种形式。

JOIN Eager Load:为什么“一条 SQL”不总是最好

另一种:

conversations
JOIN messages

确实可能只发 1 条 SQL。

但 1:N 时会产生:

conversation c1 repeated for message m1
conversation c1 repeated for message m2
conversation c1 repeated for message m3

如果:

100 parents
× 100 children each

会有 10,000 joined rows。

所以评价 query shape 不能只看:

SQL count

还要看:

rows returned
duplicate bytes
mapping memory
sort
network payload

Cartesian Explosion:多个 1:N 同时 JOIN 的危险

如果一个 parent 同时 JOIN:

10 messages
×
20 events

可能生成:

200 joined combinations

即使真实 child 总数只有 30。

这就是为什么多个关联有时分批加载反而更合理。

Batch Query 的通用模式

Step 1
SELECT parents

Step 2
collect parent IDs

Step 3
SELECT children
WHERE parent_id IN (...)

Step 4
group in application memory

GraphQL DataLoader 本质也常是在做类似“把很多单点 lookup 合并为批量查询”。

Pagination 为什么必须先定义稳定排序

分页不是:

LIMIT 20

就结束。

如果没有稳定 ORDER BY,页面之间没有可靠语义。

例如:

ORDER BY created_at DESC

如果很多行 created_at 相同:

page boundary ambiguous

更稳定:

ORDER BY created_at DESC, id DESC

形成 total order。

OFFSET Pagination 的工作模型

LIMIT 20 OFFSET 100000

数据库通常不能瞬移到“第 100001 个业务结果”。

它仍需:

produce/order enough prior rows
→ skip 100000
→ return next 20

越往后,成本可能越高。

并发插入/删除还会造成:

duplicate across pages
missing rows between pages

Keyset / Cursor Pagination

使用上一页最后一个排序 key:

WHERE (created_at, id) < ($last_time, $last_id)
ORDER BY created_at DESC, id DESC
LIMIT 20

Index (created_at DESC, id DESC) 可以从 cursor 位置继续 range scan。

优势:

deep page cost 更稳定
并发变化下边界更自然

代价:

不能直接跳到任意 page number
cursor 与排序 contract 强绑定

Query Budget:ORM 性能需要可观察

对 endpoint 可以设:

queries/request <= N
DB total time
slowest query
rows scanned
rows returned
pool wait
response bytes

这样 N+1 不需要等用户投诉才发现。

1. N+1

SELECT N parents
→ for each parent: SELECT children
→ 1 + N queries

单条 child query 都很快,整体仍因 round trip、pool contention 和重复 planner/execution 成本变慢。

2. Eager / Preload / Join / Batch

不同手段:

Preload → 常是额外批量查询
JOIN    → 一条 SQL 组合结果,但可能重复 parent columns
Batch   → collect IDs → WHERE id IN (...)
Lazy    → 用到才查,最容易无意 N+1

最佳方式由 response shape、关系基数和数据量决定。

3. Pagination

Offset:

ORDER BY created_at DESC
LIMIT 20 OFFSET 100000

大 offset 可能需要扫描/跳过大量记录。

Keyset/Cursor:

WHERE (created_at, id) < (?, ?)
ORDER BY created_at DESC, id DESC
LIMIT 20

更适合稳定滚动的大列表,但 cursor 设计必须与排序唯一性一致。

4. Query Count 也是指标

一个 endpoint 的 performance budget 可以包含:

DB queries/request
rows scanned/returned
SQL latency
pool wait
payload bytes

Prediction

[!question]

  1. N+1 的每条 SQL 都只要 1ms,为什么仍可能严重?
  2. JOIN 是否永远比 Preload 两条 SQL 更好?
  3. 列表排序只按 created_at,keyset cursor 为什么可能不稳定?
  4. Offset pagination 的主要成本在哪里?
  5. GraphQL N+1 与普通 REST ORM N+1 本质是否相同?

[!success]- Answer / 解析 1. 100 个 child 就是额外约 100 次 round trip/连接占用,还会放大并发下的 pool 压力。

2. 不永远。 1:N 大关系 JOIN 会重复 parent 数据并产生巨大结果集;两次批量查询可能更高效、更易映射。

3. 多行 created_at 相同会导致边界歧义;通常需要 (created_at, id) 等稳定 total order。

4. 数据库仍需定位并跳过前面大量结果,offset 越大成本通常越高。

5. 本质相同。 都是上层逐实体/逐字段访问把一个关系读取放大成 1+N 次数据库 I/O。

Lab

人为实现一个 1+N BodySense 查询,记录 query count 和总延迟;再分别用 Preload/IN batch/JOIN 消除,比较 SQL 数量、返回行数和内存。

L4 Gate

看到 API response shape 时,能预先估计它需要多少查询、可能返回多少关系行,并用 trace/log 证明。

创建于 2026/9/18 更新于 2026/9/18
Digital Biome
Access

键盘导航

j / k / ↑ / ↓
选择对象
Enter / o
打开选择
/ 或 Ctrl+K
全站搜索
i / f / 1–4
筛选当前列表
g → h / l / p / t / a
首页 / 笔记 / 项目 / 工具 / About
r → 1–9
打开所选对象的关联
u
返回上一页
Esc
关闭浮层 / 取消命令;输入框内清空文本