qinghuan
← 回到知识探索

后端现场 / 动手实验

每条 SQL 都很快,为什么打开列表还是慢?

先数一次页面到底查询了多少次,再把逐条补明细改为按页批量读取,同时保留排序、空明细和归属条件。

展开本文目录
  1. 下载完整练习包
  2. 先数整页,别只盯着最慢的一条
  3. 先决定这一页有哪些订单,再补这批订单的明细
  4. 批量也要有大小和归属边界
  5. 用实际执行的语句计数,再检查四个结果
  6. 少了几次调用,不等于快了几倍
  7. 最后核对两次读取之间允许发生什么
  8. 记住这三点
  9. 别把例子用过头
  10. 自己试一小步
  11. 查原始资料
开始前,会这些就够了
  • 会读 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 的万能计数器。

展开收起代码 · 小实验python · 68 行

长行可在代码区域内左右滚动,按 Tab 可把焦点移入代码区。

小实验 · python
"""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 后直接限制结果行数,却仍声称按订单分页。

自己试一小步

页面改成七张订单,每张显示明细数量;怎样给查询数定上限,同时保持空明细订单和原排序?

  1. 自建七张虚构订单,至少包括零条明细、一条明细和多条明细;另加同编号的其他租户记录。
  2. 自行选择每批上限,写下需要统计哪些 SELECT,再实现只返回数量的加载方式。
  3. 比较逐条与批量的订单编号、顺序和数量;另跑空页与非法批大小,不参考正文的固定四单输出。

做到这些,就算完成

  • 提交你自己的输入、预期结果、实测语句数与说明。
  • 查询数随批次数变化,没有为了凑目标数漏数据或隐藏额外查询。
  • 明确静态等价测试没有覆盖并发一致性,也没有证明线上加速比例。

查原始资料

想确认一个细节,先看这份资料对应的部分,不必从头读完所有文档。

  • MyBatis:关联的嵌套查询 (新标签页)↗MyBatis · 英文

    阅读 Nested Select for Association 中 N+1 Selects Problem 与 lazy loading 段落;支持逐条关联读取及访问时触发查询。

    公开英文官方资料;本文只采用列出的阅读范围。

  • SQLite:许多小查询为何可以高效 (新标签页)↗SQLite · 英文

    阅读第 2、3 节:客户端/服务器的消息往返,与 SQLite 进程内函数调用的差别;支持本实验不能代表远程耗时。

    公开英文官方资料;本文只采用列出的阅读范围。

  • Python 3.12:sqlite3 跟踪回调 (新标签页)↗Python Software Foundation · 英文

    阅读 set_trace_callback 的回调参数、实际执行语句及禁用方式;也核对内部事务与触发器可能产生额外语句的说明。

    公开英文官方资料;本文只采用列出的阅读范围。

  • MyBatis:foreach (新标签页)↗MyBatis · 英文

    阅读 foreach 示例与集合支持范围;支持生成 IN 占位结构。本篇空集合直接返回的策略是原创接口约定,不是文档自动保证。

    公开英文官方资料;本文只采用列出的阅读范围。

  • SQLite:实现限制 (新标签页)↗SQLite · 英文

    阅读第 9 项 Maximum Number Of Host Parameters In A Single SQL Statement;支持参数总数有实现与配置限制,不把教学批大小写成通用最大值。

    公开英文官方资料;本文只采用列出的阅读范围。