你没有真正用起来的 ORM 关联查询姿势
用了很久 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 关联,数据库里不建真实外键约束 (或仅建索引)。理由不外乎:
- 迁移、分库、导入数据更自由;
- 高并发写库时避免外键带来的额外锁/校验开销;
- 缺的关联数据(孤儿)可以在代码里容错(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 关联 + 数据库索引 是多数项目稳妥的组合,外键约束按团队口风统一。
下一篇,我们把视角从 数据 移回 工具 ,看看那些散落在各处的重复代码 - 日期格式化、随机串、加密、签名排序……怎么抽成一个天天在用、又不至于过度设计的工具函数库。






