我的订单列表页慢得离谱,抓 SQL 一看一个请求里竟然发了一百多条几乎一样的查询,我对着 ORM 懒加载在循环里逐个触发的 N+1 查询这个坑排查了大半天的复盘

一个让我对 ORM 方便的代价彻底警醒的数据库坑,隐蔽在代码读起来非常优雅面向对象毫无破绽,我只是访问了一下每个订单的用户名,可这行无辜的属性访问背后 ORM 悄悄发了上百条数据库查询。订单列表页要显示每个订单和下单用户名字,我用 ORM 写 orders = Order.query.all() 然后 for order in orders: order.user.name。页面慢得离谱,抓 SQL 一看:1 条查所有订单 + 100 条几乎一样的 SELECT * FROM users WHERE id=? 逐个查 user,共 101 条,这就是臭名昭著的 N+1 查询。深究懒加载才明白:ORM 默认对关联对象懒加载,查订单时不查 user,等你第一次访问 order.user 时才临时发一条 SQL 查;访问属性看起来是廉价内存操作但对懒加载关联属性它背后偷偷发了一条数据库查询,这个隐藏的 IO 是核心;在循环里每个订单访问一次 user 就发一条,1+N 条;代码层面一条 SQL 都没显式写全是 ORM 替你发的,数据小感觉不到数据大才暴露;本质是 ORM 的对象映射抽象隐藏了访问关联=数据库查询这个昂贵真相,让我用访问内存属性的随意做了发数据库查询的昂贵操作。这篇从故障现场、N+1 与懒加载真相、正解(eager 加载 JOIN select_related/joinedload、批量 IN prefetch/selectinload、手动批量查+内存 map 关联、按需决定 eager 还是 lazy、监控单请求 SQL 数)、ORM 其他坑(过度 eager、SELECT* 、循环逐条写、一次 load 巨量、SQL 低效、session 关闭后访问懒加载)、懒加载 vs eager 对照表、ORM 抽象的漏给的启示、决策图与铁律,到附上一个批量查+内存关联的通用反 N+1 模板。核心领悟:抽象的价值和危险同源,它通过隐藏底层成本提供便利,当被隐藏的成本恰恰昂贵(数据库IO网络)时会让我们对成本失去感知不知不觉滥用,让昂贵操作看起来像廉价操作是抽象最危险的副作用;用抽象要警惕被抽象成廉价实则昂贵(尤其IO)的操作恢复对真实成本的感知;N+1 本质是把本可批量做的事拆成 N 次零散做,解法本质是批处理化零散为批量,这思想处处适用(批量API/合并请求/DataLoader)。

我的订单列表页慢得离谱,抓 SQL 一看一个请求里竟然发了一百多条几乎一样的查询,我对着 ORM 懒加载在循环里逐个触发的 N+1 查询这个坑排查了大半天的复盘

这是一个让我对 ORM "方便的代价"彻底警醒的数据库坑。它隐蔽在:代码读起来非常优雅、面向对象、毫无破绽——我只是"访问了一下每个订单的用户名",可这行无辜的属性访问背后,ORM 悄悄替我发了上百条数据库查询,把页面拖得奇慢。

需求很常见:一个订单列表页,要显示每个订单和下单用户的名字。我用 ORM 写得清清爽爽:

# 订单列表页(有问题的版本, 以ORM伪代码示意)
orders = Order.query.all()          # 1. 查出所有订单(假设100个)

for order in orders:
    print(order.id, order.user.name)  # 2. 访问 order.user —— 触发了懒加载!
    #                                    每次访问一个新order的user, ORM就【偷偷发一条SQL】查user

这段代码,读起来太顺了——"查出所有订单,然后逐个打印订单 ID 和它的用户名",面向对象、自然流畅。可它慢得离谱。我打开 SQL 日志/慢查询监控一看,倒吸一口凉气:

一个请求里实际发出的 SQL:

SELECT * FROM orders;                          -- 1条: 查所有订单(100个)
SELECT * FROM users WHERE id = 1;              -- 然后, 对每个订单的user...
SELECT * FROM users WHERE id = 2;              -- ...各发一条查询
SELECT * FROM users WHERE id = 3;
... (一共 100 条几乎一样的查user的SQL!) ...
SELECT * FROM users WHERE id = 100;

# 总计: 1(查订单) + 100(逐个查user) = 101 条 SQL!
# → 这就是臭名昭著的 "N+1 查询问题"
# → 100个订单要101条SQL; 1000个订单就1001条; 数据越多越慢, 数据库被打爆

我盯着这一百多条几乎一模一样SELECT * FROM users WHERE id = ?,终于明白页面为什么这么慢。我以为 order.user.name 只是"读一个已经在内存里的属性",可实际上,ORM 对每个 order.user 的访问,都悄悄地、单独地向数据库发了一条 SQL 去查那个 user!100 个订单,就是 1 条查订单 + 100 条查 user,共 101 条查询。而我的代码里一条 SQL 都没显式写——这些查询全是 ORM"替我"在我访问属性时发的,藏得严严实实。这就是数据库性能问题里臭名昭著的 N+1 查询

第一件事:看清真相——ORM 懒加载让"访问关联属性"悄悄触发查询,循环里就成了 N 次

我去深入理解了 ORM 的"懒加载(lazy loading)"机制,才彻底明白这个"一行代码发上百条 SQL"的根源——ORM 默认对关联对象是懒加载的:查主对象(订单)时不会把关联对象(user)也查出来,而是等你真正访问那个关联属性(order.user)时,才临时发一条 SQL 去查;这个"访问属性触发查询"被 ORM 隐藏得很自然,以至于在循环里,它就变成了"每个元素各触发一次查询",总共 1 + N 次

N+1 查询的真相

# 1. ORM 的懒加载(lazy loading)默认行为:
#    - 你查 orders 时, ORM 只查了 orders 表(1条SQL), 没查关联的 user;
#    - order.user 这个关联属性, 此刻是"还没加载"的;
#    - 当你【第一次访问 order.user】时, ORM 才【临时发一条SQL】去查那个user, 然后缓存。

# 2. 关键: "访问一个属性" 看起来是廉价的内存操作, 但对懒加载的关联属性,
#    它背后【偷偷发了一条数据库查询】! 这个"隐藏的IO", 是坑的核心。

# 3. 在循环里, 灾难放大:
#    for order in orders:        # 100个订单
#        order.user.name         # 每个order访问一次user → 每次发一条SQL
#    → 1条查orders + 100条查user = 101条SQL
#    → 这就是 "N+1": 1次查主列表 + N次查每个的关联

# 4. 为什么这么隐蔽:
#    - 代码层面【一条SQL都没显式写】, 全是ORM"替你"发的;
#    - 读代码完全看不出"这里会发100条查询"——它伪装成了普通的属性访问;
#    - 数据量小时(几条)感觉不到, 数据量大时(成百上千)才暴露。

# 5. 后果: 数据越多, SQL条数线性增长, 性能急剧下降; 大量小查询还会
#    打满数据库连接、增加网络往返开销, 是典型的"看似OOP优雅、实则性能灾难"。

# 6. 本质: ORM 用"把数据库行映射成对象、关联映射成属性"的抽象, 让我们能"面向对象"地
#    操作数据; 但这个抽象【隐藏了"访问关联=数据库查询"这个昂贵的真相】, 让我们
#    用"访问内存属性"的随意, 去做了"发数据库查询"的昂贵操作。

# 核心: ORM懒加载下, 访问关联属性会悄悄触发一条SQL; 在循环里逐个访问就成了N+1次查询
#   (1次查主+N次查关联); 代码看不出、数据大才暴露; 要用eager加载/批量查一次拿到关联数据。

真相大白,我恍然大悟。原来 ORM 默认对关联对象是懒加载的:查 orders 时只查了 orders 表(1 条 SQL)、没查关联的 user;order.user 此刻"还没加载";当你第一次访问 order.user 时,ORM 才临时发一条 SQL 去查那个 user。关键在于:"访问一个属性"看起来是廉价的内存操作,但对懒加载的关联属性,它背后偷偷发了一条数据库查询——这个"隐藏的 IO"是坑的核心。在循环里,灾难就放大了:100 个订单,每个访问一次 user 就发一条 SQL,1 条查 orders + 100 条查 user = 101 条,这就是 "N+1"(1 次查主列表 + N 次查每个的关联)。它之所以隐蔽:代码层面一条 SQL 都没显式写、全是 ORM"替你"发的,读代码完全看不出"这里会发 100 条查询",它伪装成了普通的属性访问;数据量小时感觉不到,数据量大时才暴露。本质是:ORM 用"把行映射成对象、关联映射成属性"的抽象让我们能面向对象地操作数据,但这个抽象隐藏了"访问关联=数据库查询"这个昂贵的真相,让我们用"访问内存属性"的随意,去做了"发数据库查询"的昂贵操作。

第二件事:正解——用 eager 加载/JOIN/批量查,一次把关联数据拿到

搞懂了原理,正解就清晰了:用 eager(预先)加载,在查主对象时就用 JOIN 或一次 IN 批量查,把关联数据一并拿到,把 N+1 条变成 1~2 条

N+1 的正解(各ORM写法不同, 原理一致)

# ====== 正解一: eager加载/JOIN, 一条SQL连关联一起查出来 ======
# SQLAlchemy:  Order.query.options(joinedload(Order.user)).all()
# Django:      Order.objects.select_related('user').all()   # 一对一/外键, JOIN
# Hibernate:   from Order o join fetch o.user              # JOIN FETCH
# → 生成: SELECT ... FROM orders JOIN users ON ...  (1条SQL搞定订单+用户)

# ====== 正解二: 批量查(IN), 适合一对多/多对多 ======
# Django:      Order.objects.prefetch_related('items')     # 先查订单, 再用IN批量查items
# SQLAlchemy:  selectinload(Order.items)
# → 生成2条: SELECT * FROM orders;  SELECT * FROM items WHERE order_id IN (1,2,...,100);
# → 从 1+N 条, 降到 2 条!

# ====== 正解三: 手动批量查(不依赖ORM特性时) ======
orders = query_orders()                          # 1条: 查订单
user_ids = [o.user_id for o in orders]           # 收集所有user_id
users = query_users_in(user_ids)                 # 1条: WHERE id IN (...) 批量查user
user_map = {u.id: u for u in users}              # 做成map
for o in orders:
    u = user_map[o.user_id]                       # 从内存map取, 不再发SQL
# → 同样把 N+1 降到 2 条; 这是"批量查+内存关联"的通用模式

# ====== 核心判断: 什么时候eager, 什么时候lazy ======
# - 确定要用关联数据(尤其在循环/列表里): eager加载(避免N+1)
# - 关联数据用不到/很少用: 保持lazy(别白查)
# → eager不是越多越好(过度eager会查回一堆用不到的数据); 按"这次到底要不要用"决定。

# ====== 排查: 监控一个请求发了多少条SQL ======
# 开SQL日志、用ORM的查询计数/profiler、APM工具看单请求SQL数;
# 一个请求发了几十上百条相似SQL, 基本就是N+1。→ CI/压测时关注SQL条数。

# 核心: N+1用eager加载解决——JOIN(select_related/joinedload)或批量IN查(prefetch/selectinload),
#   或手动"批量查+内存map关联"; 把1+N条降到1~2条; 按"是否真要用关联"决定eager还是lazy; 监控单请求SQL数。

修复的核心,是"用 eager 加载/JOIN/批量查,一次把关联数据拿到"正解一:eager 加载/JOIN——查主对象时就用 JOIN 把关联一起查出来(SQLAlchemy 的 joinedload、Django 的 select_related、Hibernate 的 join fetch),一条 SQL 搞定订单+用户正解二:批量查(IN)——适合一对多/多对多(Django 的 prefetch_related、SQLAlchemy 的 selectinload),先查订单再用 WHERE id IN (...) 批量查关联,从 1+N 降到 2 条正解三:手动批量查——不依赖 ORM 特性时,收集所有 user_id、一条 IN 批量查、做成 map,循环里从内存 map 取,这是"批量查+内存关联"的通用模式关键判断:确定要用关联数据(尤其在循环/列表里)就 eager 加载、关联数据用不到就保持 lazy;eager 不是越多越好(过度 eager 会查回一堆用不到的数据)排查靠监控单请求 SQL 数(开 SQL 日志/profiler/APM,一个请求发几十上百条相似 SQL 基本就是 N+1)。归根结底:N+1 用 eager 加载解决——JOIN 或批量 IN 查,或手动批量查+内存 map 关联,把 1+N 降到 1~2 条;按是否真要用关联决定 eager 还是 lazy;监控单请求 SQL 数。

第三件事:ORM 使用相关的其他常见坑

排查后我把 ORM 使用相关的其他常见坑也系统梳理了一遍。

ORM 使用的其他常见坑

# 1. N+1查询(本文): 懒加载在循环里逐个触发。→ eager/批量查。

# 2. 过度eager: 无脑全eager, 查回大量用不到的关联数据, 反而慢。→ 按需加载。

# 3. 查了全部列却只用几列: SELECT * 拉回大对象。→ 只查需要的字段(投影)。

# 4. 在循环里逐条insert/update: 1000次单条写。→ 批量insert/bulk操作。

# 5. 一次性load巨量数据进内存: query.all()几百万行。→ 分页/流式/游标。

# 6. ORM生成的SQL低效/没走索引: 复杂查询ORM生成的SQL可能差。→ 看实际SQL, 必要时手写。

# 7. 懒加载在session/事务关闭后访问: 抛异常(如Hibernate的LazyInitializationException)。

# 8. 不了解ORM底层就盲用: 把"对象操作"当免费, 不知背后的SQL。→ 始终关注生成的SQL。

# 共同根源: ORM提供了"用对象操作数据库"的便利抽象, 但它【隐藏了底层的SQL和IO成本】;
#   不了解"每个对象操作背后对应什么SQL", 就会在不知不觉中写出低效的数据库访问。

# 核心: ORM方便但隐藏了SQL/IO成本; 要N+1用eager、按需加载和投影、批量写、大数据分页、
#   关注ORM生成的实际SQL; 别把"对象操作"当免费, 心里要有"它对应什么SQL"的账。

排查让我把 ORM 的其他坑也梳理清了。一、N+1 查询(本文)。二、过度 eager(无脑全 eager 查回大量用不到的数据)。三、SELECT * 只用几列(只查需要的字段)。四、循环里逐条 insert/update(用批量/bulk)。五、一次性 load 巨量数据(分页/流式)。六、ORM 生成的 SQL 低效(看实际 SQL,必要时手写)。七、懒加载在 session 关闭后访问(LazyInitializationException)。八、不了解底层盲用它们的共同根源是:ORM 提供了"用对象操作数据库"的便利抽象,但它隐藏了底层的 SQL 和 IO 成本;不了解"每个对象操作背后对应什么 SQL",就会不知不觉写出低效的数据库访问核心是:ORM 方便但隐藏了 SQL/IO 成本;N+1 用 eager、按需加载和投影、批量写、大数据分页、关注 ORM 生成的实际 SQL;别把对象操作当免费下面这张图,是这次 N+1 的成因与解法:

第四件事:懒加载 vs eager 加载对照表

这次踩坑后,我把懒加载和 eager 加载的取舍整理成一张表,设计查询时对照。

维度 懒加载(lazy) eager加载(JOIN/批量)
何时查关联 访问时才查 查主对象时一并查
循环里访问关联 ✗ N+1, 慢 ✓ 1~2条, 快
关联用不到时 ✓ 不白查 ✗ 查了浪费
SQL条数 1+N(逐个) 1~2(一起/批量)
代码隐蔽性 高(看不出发了SQL) 低(显式声明要加载)
适用 关联很少用 确定要用关联(尤其列表)

这张表把懒加载和 eager 的取舍钉清了。核心是:懒加载"用时才查、不用不查"(省,但在循环里会 N+1)、eager"一次查全"(在列表场景避免 N+1,但关联用不到时浪费);选哪个,取决于"你这次到底用不用、在哪用(是不是在循环/列表里)这些关联数据"它给我的最大启发是:懒加载和 eager 加载,本质是"延迟到用时再算(省当下、但可能多次)"和"提前一次算好(费当下、但避免多次)"这对取舍在"数据加载"上的体现;没有绝对的好坏,关键是匹配你的访问模式——单次零散访问适合懒、批量列表访问适合 eager这其实呼应了一个普遍的性能优化主题:"按需(lazy)"和"预取(eager/prefetch)"的权衡,在很多地方都存在(数据加载、缓存预热、CDN 预取、预编译);而做对这个权衡的关键,是理解你的访问模式(access pattern)——你将如何、何时、以什么频率访问这些数据,决定了该按需还是预取这让我形成一个习惯:在做"懒还是急"这类加载决策时,先想清楚"这份数据,我接下来会怎么用它?"——尤其警惕"在循环/批量场景里用懒加载"这个 N+1 高发组合按访问模式权衡懒加载与 eager 加载、警惕"循环+懒加载"——是用好 ORM 的关键判断。

第五件事:ORM 这个抽象的"漏"给我的启示

这次最深的反思,是关于 ORM 这层抽象本身。

ORM 提供的便利 它隐藏(泄漏)的东西
用对象操作数据库, 不写SQL 每个操作背后的真实SQL
关联映射成属性, 自然访问 访问关联=一次数据库查询(N+1)
屏蔽不同数据库差异 具体数据库的优化/特性
自动生成SQL 生成的SQL可能低效/没走索引
对象生命周期管理 session/事务/懒加载的边界

这张表道出了 ORM 这层抽象的"两面"。核心是:ORM 用"把数据库当成对象来操作"的抽象,极大地提升了开发效率、屏蔽了 SQL 的繁琐;但它隐藏(泄漏)了底层数据库操作的真实成本和行为(每个属性访问背后的 SQL、IO 成本、SQL 的效率);用 ORM 时如果完全不了解它底下生成了什么 SQL,就极易写出 N+1 这类"对象层面优雅、数据库层面灾难"的代码它给我的深刻启发是:抽象(ORM、缓存、框架的各种自动化)是把双刃剑——它让你不必关心底层、提升了效率,但也让你看不见底层、容易对底层的成本失去感知;而当底层的成本(这里是数据库查询)真正重要时(性能场景),这种"看不见"就会变成"踩坑"这让我对"用抽象"有了更成熟的态度:享受抽象带来的便利,但不能完全'信任'抽象、对底层一无所知;尤其对那些"抽象掉了昂贵操作(IO、网络、数据库)"的抽象,要保持对底层的'透视'能力——知道"我这个对象操作,底层到底对应什么数据库操作、有多大成本";真正用好 ORM(及一切抽象)的人,是"能用对象的便利写代码,又能随时透过它看到底层 SQL"的人享受 ORM 的便利、但保持透视底层 SQL 的能力——是这个 N+1 坑,在技术之上,带给我的关于"如何与抽象相处"的更深思考。

第六件事:用 ORM 查关联数据时,我现在的判断习惯

现在每当我用 ORM 查一批数据、又要用到它们的关联,我都会按这张图先想清楚:

这张图的精髓,是"列表里要用关联就 eager 加载防 N+1,按关联类型选 JOIN 或批量 IN,并监控 SQL 数"不在循环里用关联就懒加载即可;会在列表里逐个用关联就警惕 N+1、必须 eager:一对一/外键用 JOIN(select_related),一对多/多对多用批量 IN(prefetch),不依赖 ORM 就手动批量查+内存 map关键是开发期监控单请求 SQL 数这套习惯,让我用 ORM 时,从"随手循环访问关联属性"变成了"先想会不会 N+1、要不要 eager"——核心始终是:ORM 懒加载在循环里会 N+1,列表用关联就 eager 加载,心里要有"这操作对应什么 SQL、发了几条"的账。

我立下的几条规矩

这场"一行属性访问发上百条 SQL"的事故,换来了我用 ORM 时,刻进骨子里的几条铁律:

  1. ORM 关联默认懒加载,访问才查。访问关联属性会偷偷发一条 SQL。
  2. 循环里访问关联 = N+1 查询。1 次查主 + N 次查关联,数据大就崩。
  3. 列表里要用关联,必须 eager 加载。JOIN 或批量 IN,降到 1~2 条。
  4. 一对一用 select_related,一对多用 prefetch。选对加载方式。
  5. 按需加载,别过度 eager。用不到的关联别白查。
  6. 监控单请求 SQL 数。几十上百条相似 SQL 基本就是 N+1。
  7. 心里始终有"这对象操作对应什么 SQL"的账。别把对象操作当免费。

附:一段"批量查 + 内存关联"的通用反 N+1 模板

这次踩坑后,我把"批量查 + 内存 map 关联"这个不依赖任何特定 ORM、放之四海皆准的反 N+1 模式,沉淀成了一个通用模板(以 Python 伪代码示意):

# ====== 通用反 N+1 模板: 批量查 + 内存关联 ======
def load_orders_with_users(order_ids):
    # 1. 一次查出主对象(订单)
    orders = db.query("SELECT * FROM orders WHERE id IN %s", order_ids)   # 1条SQL

    # 2. 收集所有需要的关联ID(去重)
    user_ids = list({o.user_id for o in orders})

    # 3. 一次批量查出所有关联对象(用 IN)
    users = db.query("SELECT * FROM users WHERE id IN %s", user_ids)       # 1条SQL
    user_map = {u.id: u for u in users}     # 做成 id -> user 的map

    # 4. 在内存里把关联"拼"回去(零SQL)
    for o in orders:
        o.user = user_map.get(o.user_id)    # 从内存map取, 不发任何SQL

    return orders
# → 无论多少个订单, 永远只有 2 条SQL(查订单 + 批量查user); 彻底消灭N+1

# ====== 这个模式的三步精髓 ======
# 1) 批量查主对象  2) 收集关联key, 一次IN批量查关联  3) 内存map拼接
# → "把 N 次零散的、按需的小查询, 合并成 1 次批量的大查询"

# ====== 推广: 任何"为一批东西各取一份关联数据"都适用 ======
# - 为一批订单取用户、为一批文章取作者、为一批评论取点赞数...
# - 都可以: 批量主 → 收集key → 批量查关联 → 内存拼
# → 这是"批处理(batching)"思想: 化"多次小请求"为"一次大请求"

# 核心: 反N+1的通用模式是"批量查主 + 收集key一次IN批量查关联 + 内存map拼接", 把N次小查询
#   合并成1次批量查; 它不依赖特定ORM, 是"批处理化零散请求"这一普适思想在数据加载上的应用。

这个反 N+1 模板,是我这次踩坑后最有价值的工程沉淀。它的三步精髓——批量查主对象 → 收集关联 key 一次 IN 批量查关联 → 在内存里 map 拼接——把原本"N 次零散的、逐个的小查询",合并成了"1 次批量的大查询",无论多少条数据,SQL 永远是固定的 2 条。而它最大的价值,是不依赖任何特定 ORM 的特性:无论你用什么语言、什么 ORM、甚至裸写 SQL,这个"批量+内存拼接"的模式都适用;它是一个比"记住某个 ORM 的 select_related 怎么写"更底层、更通用的解法。这正是我想分享的核心思想:N+1 的本质,是"把一个本可以批量做的事,拆成了 N 次零散地做";而它的解法本质,是"批处理(batching)"——把 N 次零散的小请求,合并成 1 次批量的大请求;这个"化零散为批量"的思想,远不止用于数据库——批量 API 调用(别循环调单个接口,用批量接口)、批量写文件、合并网络请求、REST 的 over/under-fetch。">GraphQL 的 DataLoader……处处都是它的身影因为"N 次跨边界(数据库/网络/磁盘)的小操作",其总成本(主要是 N 次往返的固定开销)往往远高于"1 次处理同样数据量的大操作";识别出"循环里在跨边界地做零散小操作"这个模式,并把它批量化,是一个适用范围极广、收益极高的性能优化通法掌握"批量查+内存拼接"的反 N+1 模板、并理解其背后"化零散为批量"的批处理通用思想——这,是我用一次 N+1 的事故,换来的、关于性能优化的、最实用也最可迁移的智慧。

写在最后

回头看,这场由"ORM 懒加载"引发的、N+1 查询拖垮页面的事故,真正教给我的,远不止"用 select_related"这一个技巧。它让我对"抽象隐藏成本"这件事的危险,有了一次刻骨铭心的认识。我栽跟头,根源是 ORM 这层抽象太成功了——它成功地让我"忘记了"自己其实是在和数据库打交道。在 ORM 的世界里,order.user.name 看起来就是一个普普通通的、廉价的、内存中的对象属性访问,和访问 order.id 没有任何区别;ORM 把"这个属性背后其实是一次昂贵的、跨网络的数据库查询"这个关键的真相,完美地隐藏了起来。于是我用"访问内存属性"的随意态度,在循环里访问了一百次 order.user,也就不知不觉地发了一百次数据库查询——而我对此毫无感知,因为代码看起来太无辜了。这让我领悟到一个深刻的认知:抽象的"价值"和"危险"是同源的——抽象通过"隐藏底层的复杂性和成本"来提供便利;但当被隐藏的那个"成本"恰恰是关键的、昂贵的(如数据库 IO、网络往返)时,这种"隐藏"就会让我们对成本失去感知、从而不知不觉地滥用;"让昂贵的操作看起来像廉价的操作",是抽象最危险的副作用之一这其实给了我一条重要的工程准则:使用任何抽象时,都要特别警惕那些"被抽象成廉价操作、实则昂贵"的东西——尤其是涉及 IO、网络、数据库、磁盘 的操作;对这些"伪装成廉价"的昂贵操作,要恢复对它真实成本的感知(知道 order.user 是一次查询),并据此谨慎地使用它(别在循环里随意触发)透过抽象的便利、恢复对底层昂贵操作(尤其 IO)的真实成本感知——这,是我用一次 N+1 的事故,换来的、关于数据库、关于 ORM、也关于如何清醒地使用一切抽象的、最朴素也最深刻的领悟。如果这篇复盘,能让你下次在循环里写下 order.user.xxx 时,心里"咯噔"一下、想起"这背后是一次数据库查询啊",那我对着那一百多条相似 SQL 排查的这大半天,就值了。

—— 别看了 · 2026
声明:本站所有文章,如无特殊说明或标注,均为本站原创发布。任何个人或组织,在未征得本站同意时,禁止复制、盗用、采集、发布本站内容到任何网站、书籍等各类媒体平台。如若本站内容侵犯了原著者的合法权益,可联系我们进行处理 邮箱1846861578@qq.com。
技术教程

我在 for-each 循环里遍历一个 List 时顺手删掉了几个元素,明明是单线程却抛了个 ConcurrentModificationException,我对着增强 for 循环底层用迭代器的 fail-fast 机制这个坑排查了大半天的复盘

2026-6-2 12:20:48

技术教程

我基于 TCP 写了个通信协议,客户端连发两条消息,服务端一次读出来却粘成了一坨,有时一条消息又被拆成两次才收全,我对着 TCP 是字节流没有消息边界这个粘包拆包的坑排查大半天的复盘

2026-6-2 12:32:52

0 条回复 A文章作者 M管理员
    暂无讨论,说说你的看法吧
个人中心
购物车
优惠劵
今日签到
有新私信 私信列表
搜索