开始前,会这些就够了
- 会读 SELECT、WHERE、ORDER BY 和问号参数。
- 了解订单与明细的一对多关系。
- 运行实验需 Python 3.10+,只用标准库。
- 先读连接查询的行数与订单数,再比较整页关联数据的查询次数。
打开一页二十张订单,程序先查出订单列表,再对每张订单各查一次明细。你检查最慢的 SQL,发现每条只用几毫秒,页面却仍然慢。这时值得查的不是某一条语句,而是完成这一页一共调用了数据库多少次。
本篇用虚构订单在内存 SQLite 中数查询、核对结果。它不连接远程数据库,也不测网络耗时。学习目标是识别“一次取列表,随后逐条补数据”的调用结构,交出查询次数与结果等价的证据。
把例子带到自己的电脑上
下载完整练习包
需要 Python 3.10 或更新版本;制作时实测 3.14.0。使用标准库,不需要安装第三方包。下载 ZIP 后选择“全部提取”,进入能看到 README.md 的文件夹。
下载 ZIP · 5 个文件包里有什么
- n_plus_one.py:完整示例
- README.md:环境、范围与独立练习
- observed.stdout.txt:制作时的观察记录
- verification.json:对应源码的验证范围
- SHA256SUMS.txt:文件内容摘要
在这个文件夹打开 PowerShell,逐行运行
python --version
python -B n_plus_one.py解压后在含示例文件的文件夹打开 PowerShell,按命令运行。先预测结果,再与当次输出对照。 SQLite 查询计数或固定并发模型,不代表远程数据库、并发压测或线上耗时。
找不到命令或文件时,先按 README 检查环境和所在目录。文中标出的阅读时间只估算文章篇幅,安装环境和动手练习可按自己的节奏进行。
先数整页,别只盯着最慢的一条
上面的结构常叫 N+1:先查列表的一次是加一,随后为 N 条记录分别查关联数据。四张订单就是五次查询,二十张就是二十一次。若一个页面还逐条查询商户和配送记录,又会形成其他重复调用;N 指这一条加载链中的对象数,并非一个固定配置值。
代码里看不到显式循环,也可能发生这种事。某些映射框架把关联字段留到访问时再加载,序列化响应或遍历页面数据时才触发查询。MyBatis 的 Nested Select 文档专门讨论了这种情况。延迟加载只是推迟到需要时执行;如果随后每一项都被访问,仍然可能全部执行。
先决定这一页有哪些订单,再补这批订单的明细
批量方案分两步:第一步按明确顺序和页大小取得订单编号;第二步只查询这些编号对应的明细,再按订单编号分组。先为每张订单建立空列表,所以没有明细的订单也会保留下来。合并时沿用第一步的订单顺序,而不是碰巧依赖第二条 SQL 返回的行顺序。
这里讨论的是读取方式,不改变页面要展示的事实。把两张表直接 JOIN 后再 LIMIT,限制的可能是订单与明细配对的行数,不能直接当成限制订单数。联接也是可选方案,但要先证明页的单位、重复数据量及对象组装规则。不能为了少执行一条语句,悄悄换掉原问题。
批量也要有大小和归属边界
实验将页大小与每批编号数都限制为一到二十,便于手算。查到四张订单时,一批装得下就执行一次主查询和一次明细查询;每批只装两个编号,则需要两次明细查询。编号增加后可以拆批,并为整页的查询数设置上限,不能把所有历史订单塞进一个巨大 IN 列表。
真正的上限要查看所用数据库、驱动与运行配置。占位符总数还包括租户等固定参数,不能把允许的参数个数全部分给订单编号。动态生成的是问号数量,订单值仍通过绑定传入。MyBatis 的 foreach 能生成这些结构,但空列表该怎样处理仍由业务定义;本例空页直接跳过明细查询。
样例用 tenant 区分两个互不共享订单的商家 A 店与 B 店。两店都可以有编号 1 的订单,所以只按订单编号查找还不够。两步 SQL 都用 tenant 限定为 A 店,防止批量化时读到 B 店的明细。程序里的 A 是可信练习上下文,不是完整认证系统。真实接口需要从经过验证的身份得到范围,不能让请求者随意指定别人的租户。
用实际执行的语句计数,再检查四个结果
将下面的完整代码保存为 n_plus_one.py,运行 python n_plus_one.py。程序只在内存里创建本次练习表,先放入固定数据,再分别执行逐条查询、整批查询、拆成两批和空页查询。每次读取期间安装 SQLite trace 回调,只统计本例以 SELECT 开头的语句,结束后移除回调。
输出中的五、二、三、一分别是这四种路径的 SELECT 数。断言还比较完整结果,检查订单一的空明细没有丢失,租户 B 的 999 号明细没有混进来。建表与插入发生在计数之前。本例没有触发器、存储过程或懒加载框架,所以这套计数方法不应被当成生产 ORM 的万能计数器。
展开收起代码 · 小实验
长行可在代码区域内左右滚动,按 Tab 可把焦点移入代码区。
"""Python 3.10+; in-memory SQLite; counts SELECTs, not network latency."""
import sqlite3
def seed(db):
db.executescript("""
CREATE TABLE orders(tenant TEXT, id INTEGER,
PRIMARY KEY(tenant, id));
CREATE TABLE items(tenant TEXT, order_id INTEGER, id INTEGER);
INSERT INTO orders VALUES ('A',1),('A',2),('A',3),('A',4),('B',1);
INSERT INTO items VALUES
('A',2,21),('A',2,22),('A',3,31),('A',4,41),('B',1,999);
CREATE INDEX item_owner ON items(tenant, order_id, id);
""")
def load_page(db, after, size, batch_size=None):
if not 1 <= size <= 20 or (batch_size is not None and not 1 <= batch_size <= 20):
raise ValueError("size and batch size must be 1..20")
tenant = "A" # Trusted fixture context, not a caller-supplied authorization decision.
statements = []
db.set_trace_callback(lambda sql: statements.append(sql)
if sql.lstrip().upper().startswith("SELECT") else None)
try:
ids = [row[0] for row in db.execute(
"SELECT id FROM orders WHERE tenant=? AND id>? ORDER BY id LIMIT ?",
(tenant, after, size))]
result = {order_id: [] for order_id in ids}
if batch_size is None:
for order_id in ids:
result[order_id] = [row[0] for row in db.execute(
"SELECT id FROM items WHERE tenant=? AND order_id=? ORDER BY id",
(tenant, order_id))]
else:
# An empty page takes zero child queries. Each batch binds values.
for start in range(0, len(ids), batch_size):
batch = ids[start:start + batch_size]
slots = ",".join("?" for _ in batch)
sql = ("SELECT order_id,id FROM items WHERE tenant=? "
f"AND order_id IN ({slots}) ORDER BY order_id,id")
for order_id, item_id in db.execute(sql, [tenant, *batch]):
result[order_id].append(item_id)
return result, len(statements)
finally:
db.set_trace_callback(None)
def main():
with sqlite3.connect(":memory:") as db:
seed(db)
expected = {1: [], 2: [21, 22], 3: [31], 4: [41]}
naive, naive_count = load_page(db, 0, 4)
batch, batch_count = load_page(db, 0, 4, 20)
split, split_count = load_page(db, 0, 4, 2)
empty, empty_count = load_page(db, 4, 4, 2)
assert naive == batch == split == expected
assert list(naive) == list(batch) == list(split) == list(expected)
assert (naive_count, batch_count, split_count) == (5, 2, 3)
assert empty == {} and empty_count == 1
print("SELECT counts: naive=5 batch=2 split=3 empty=1")
print("same parent order and child contents:", naive == batch == split)
print("empty-child order preserved:", batch[1] == [])
print("other tenant item excluded:", all(999 not in v for v in batch.values()))
print("SQLite calls only; no remote database or latency benchmark.")
if __name__ == "__main__":
main()
怎样运行:Python 3.10+;保存为 n_plus_one.py,运行 python n_plus_one.py。只使用内存 SQLite。 2026-10-13 已用 3.14.0 执行这份示例并通过自带断言;下方为当次输出。SQLite 查询计数或固定并发模型,不代表远程数据库、并发压测或线上耗时。
运行后对照结果
SELECT counts: naive=5 batch=2 split=3 empty=1
same parent order and child contents: True
empty-child order preserved: True
other tenant item excluded: True
SQLite calls only; no remote database or latency benchmark.少了几次调用,不等于快了几倍
SQLite 在当前进程里执行查询,没有向独立数据库服务器来回传消息。因此这里数到的只能叫 SQL 执行次数,不能称作已经测量的网络往返。SQLite 官方甚至专门解释了为什么很多小查询在它这里可以很高效。把本机脚本跑快几毫秒,不足以证明线上接口会同样变快。
换成远程数据库后,再用同一页面、相同数据范围测整次请求:记录语句数、返回行数、读取字节、数据库时间与接口耗时。批量可能减少等待,也可能一次搬回太多明细、占用更多内存。若只需要明细数,应考虑返回计数;若只展示少量明细,要另外定义截取规则,而不是先取完全部再扔掉。
最后核对两次读取之间允许发生什么
先读订单、后读明细,是两个观察时刻。并发更新可能让订单状态与明细来自不同版本;减少语句数量本身不承诺一致快照。本篇的固定内存数据没有并发修改,因此仅验证静态结果等价。需要同一视图时,另行选择并验证事务隔离或其他读取契约。
迁回 Java 项目时,可以先给一个代表性列表接口记录 SQL 数,再检查是否由 Mapper 循环、关联属性访问或响应转换触发。只改这一条加载链,保留分页与权限条件;用空页、空明细、同编号不同租户和大明细集合复测。这样你得到的是一项能解释取舍的优化,而不仅是“查询少了”的截图。
记住这三点
- 从整次页面加载数 SQL,才能识别 N+1。
- 先选父记录的这一页,再按有限批次取关联数据。
- 语句数、网络等待、结果大小与正确性要分别验证。
别把例子用过头
- 将本地 SQLite 调用数当作已经测出的网络延迟。
- 批量读取时遗漏租户条件,或把空编号列表解释为查全部。
- 在一对多 JOIN 后直接限制结果行数,却仍声称按订单分页。
自己试一小步
页面改成七张订单,每张显示明细数量;怎样给查询数定上限,同时保持空明细订单和原排序?
- 自建七张虚构订单,至少包括零条明细、一条明细和多条明细;另加同编号的其他租户记录。
- 自行选择每批上限,写下需要统计哪些 SELECT,再实现只返回数量的加载方式。
- 比较逐条与批量的订单编号、顺序和数量;另跑空页与非法批大小,不参考正文的固定四单输出。
做到这些,就算完成
- 提交你自己的输入、预期结果、实测语句数与说明。
- 查询数随批次数变化,没有为了凑目标数漏数据或隐藏额外查询。
- 明确静态等价测试没有覆盖并发一致性,也没有证明线上加速比例。
查原始资料
想确认一个细节,先看这份资料对应的部分,不必从头读完所有文档。
- MyBatis:关联的嵌套查询 (新标签页)↗
阅读 Nested Select for Association 中 N+1 Selects Problem 与 lazy loading 段落;支持逐条关联读取及访问时触发查询。
- SQLite:许多小查询为何可以高效 (新标签页)↗
阅读第 2、3 节:客户端/服务器的消息往返,与 SQLite 进程内函数调用的差别;支持本实验不能代表远程耗时。
- Python 3.12:sqlite3 跟踪回调 (新标签页)↗
阅读
set_trace_callback的回调参数、实际执行语句及禁用方式;也核对内部事务与触发器可能产生额外语句的说明。 - MyBatis:foreach (新标签页)↗
阅读 foreach 示例与集合支持范围;支持生成 IN 占位结构。本篇空集合直接返回的策略是原创接口约定,不是文档自动保证。
- SQLite:实现限制 (新标签页)↗
阅读第 9 项 Maximum
NumberOf Host Parameters In A Single SQL Statement;支持参数总数有实现与配置限制,不把教学批大小写成通用最大值。