你没有真正用起来的 ORM 关联查询姿势

2026-09-22 68 浏览 0 评论 0 字

用了很久 ORM,但一提关联查询,很多人只会先查出 A,再循环去查 B(N+1 问题),或者干脆手写一堆 JOIN 拼字符串。这篇文章讲 ORM 关联查询里那些 看着会、一写就掉坑 的真实场景:一对多/一对一怎么建模、N+1 是什么、急切加载(include)为什么能救你、怎么控制返回字段、以及外键到底该不该在库上建。重点是讲透 为什么 ,而不只是 怎么敲 。

一、先认识四种关联

ORM 把数据库的表关系抽象成四种模型间的关系,也是关联查询的地基:

关联语义一句话描述
belongsTo属于一条孩子记录 属于 一条父记录,外键在孩子表上
hasOne拥有一个一条父记录 拥有一条 孩子记录,外键在孩子表上
hasMany拥有一堆一条父记录 拥有多条 孩子记录,外键在孩子表上
belongsToMany多对多通过中间表,两者互相拥有多个

在 Sequelize 里建关联通常是在模型的 associate 里声明:

// 订单 Order: belongsTo 客户 Customer
Order.belongsTo(Customer, { foreignKey: 'customer_id', as: 'customer' });
Customer.hasMany(Order, { foreignKey: 'customer_id', as: 'orders' });

关键点:外键 foreignKey 只存在 子表 一方 (这里指 orders.customer_id )。 as 是给关联起别名,非常重要 - 它决定了查询时 include 里用哪个名字取数据,别乱起,要统一。

二、N+1 问题:最容易犯的查询性能坑

假设我要列出每一笔订单,并附上所属客户名。最直观的 新手写法 是这样的:

// 第一步:查出所有订单
const orders = await Order.findAll();
// 第二步:对每条订单去查它的客户  -  这就 O(N) 条查询!
for (const o of orders) {
  o.customer = await Customer.findByPk(o.customerId);
}

假如订单有 100 条,就需要 1 + 100 = 101 次数据库查询。 这就是 N+1 问题 - N 条主记录,额外 N 次关联查询。数据量小无所谓,几千上万条时这就是性能噩梦、拖垮数据库。

正确姿势一:用 include 一步急切加载(Eager Loading)

const orders = await Order.findAll({
  include: [{ model: Customer, as: 'customer' }],
});

// orders[i].customer 已被一并查出(乐观,一次查询 JOIN 回来)
for (const o of orders) console.log(o.customer.name);

这里 include 就是急切加载 - 在首次查询里就把关联数据一并带出来,避免逐条回表。一次请求,数据库只发一条带 JOIN 的查询,N+1 直接消失。

三、include 的几个弹点

include 虽好,用起来有若干 弹点 要小心:

1. as 必须和关联定义里一致 :

include: [{ model: Customer, as: 'customer' }]   // 'customer' 要和 belongsTo 里的 as 一致

as 对不上,直接报 association not found 或结果为空,这是最高频的错误。

2. 只取需要的字段 - 别啥都拉出来 :

include: [{
  model: Customer,
  as: 'customer',
  attributes: ['id', 'name'],        // 只取 id 和 name
}],

attributes 能裁剪关联对象返回的字段,省内存也少暴露。很多人不写,一个 include 会把整张客户表都拉回来,明明只要个名字。

3. required 决定是 INNER 还是 LEFT JOIN :

include: [{ model: Customer, as: 'customer', required: false }]  // LEFT JOIN:没客户的订单也返回
include: [{ model: Customer, as: 'customer', required: true }]   // INNER JOIN:只返回有客户对象订单

这决定 没关联数据的记录要不要出现 。需要保留像 孤儿订单 时用 required:false ,只要有关联的用 required:true 。

4. 关联里还能再嵌套关联 (两层 include),但嵌套越深 JOIN 越重,能用一次 include 别叠三层。

四、一对一/一对多怎么选、要不要数据库外键

很多人在数据库层纠结 要不要真建外键约束 。这个要拆开看 - ORM 层面的 关联 和数据库层面的 外键约束 是两回事:

  • ORM 关联 :只是 代码里告诉 ORM 怎么 join ,不强制数据一致性,灵活。
  • 数据库外键约束 :数据库强行保证 不能删除被引用的行 等一致性,更严格,但 在分库分表、大规模写库时可能成为性能/迁移负担 。

行业里很常见的一种甲方: 应用层面用 ORM 关联,数据库里不建真实外键约束 (或仅建索引)。理由不外乎:

  1. 迁移、分库、导入数据更自由;
  2. 高并发写库时避免外键带来的额外锁/校验开销;
  3. 缺的关联数据(孤儿)可以在代码里容错(LEFT JOIN + 判空),而不会被数据库强硬拒绝。

取舍建议 : 统一 ORM 管关联、数据库管索引 。 即在查关联的字段上建好索引( customer_id 、 order_id 加索引),让 join 快;但外键约束要谨慎,团队统一口径,别一半建一半不建。

五、刻意不 resolve 的形态:关联字段没值怎么办

用 required: false (LEFT JOIN)时,会返回 没有关联记录的记录 ,此时关联对象是 null 。代码里别直接 o.customer.name ,会空指针( Cannot read properties of null )。养成习惯:

if (o.customer) {
  console.log(o.customer.name);
}

特别是在 可选关联 (比如订单可能没绑客户)的场景,判空是必须的,否则一条脏数据就能让整个列表接口崩掉。

六、用原生 SQL 兜底的场景(混用)

ORM 不是万能。当查询极其复杂(多级子查询、窗口函数、递归),很常见的做法是 混用 :大部分逻辑走 ORM+include,某些复杂统计走一小段原生 SQL。

但混用有一个大前提要记住 - 字段命名要对齐 。如果你 ORM 模型开了 underscored (字段在库里是 snake_case、代码里 camelCase),而原生 SQL 里你手写的是 snake_case 列名,返回值两边驼峰/下划线别搞混,否则拼接中间字段对不上。这是混用里最常见的 查得出来但读字段读错 的坑。

七、关联查询的自查清单

  • [ ] as 是否和关联定义里的一致;
  • [ ] 是否白白 include 出整张表的字段,用 attributes 裁剪了没;
  • [ ] 没关联数据的记录要不要保留( required: false );查了要不要判空;
  • [ ] 关联字段(外键列)上是否建了索引;
  • [ ] 有没有无意中写出 N+1(循环里 findAll/findByPk );
  • [ ] 混用原生 SQL 时,字段命名是否与 ORM 对齐。

八、小结

关联查询是 ORM 里能力最强、也最容易用错的地方:

  • belongsTo/hasOne/hasMany/belongsToMany 是建关系的地基;
  • N+1 是性能大盗 ,用 include (eager loading)一步到位避开;
  • include 里的 as 、 attributes 、 required 、判空是四个要熟的点;
  • ORM 关联 + 数据库索引 是多数项目稳妥的组合,外键约束按团队口风统一。

下一篇,我们把视角从 数据 移回 工具 ,看看那些散落在各处的重复代码 - 日期格式化、随机串、加密、签名排序……怎么抽成一个天天在用、又不至于过度设计的工具函数库。


发布评论

发布评论前请先 登录。
0 评论
点赞
收藏

评论列表 0

暂无评论