Node系列 · ORM:数据查询
真实业务里 90% 的查询是"跨多表 JOIN"——查用户时连带查订单、查文章时连带查作者和评论。Sequelize 的关联(association)+ 预加载(include)让这件事用 JS 方法完成,不必手写 JOIN。
一、模型关联(复习)
上一章 《Sequelize 模型》 定义了四种关联:
javascript
// 一对多:User 有多个 Post
User.hasMany(Post, { foreignKey: 'userId' });
Post.belongsTo(User, { foreignKey: 'userId' });
// 一对一:User 有一个 Profile
User.hasOne(Profile, { foreignKey: 'userId' });
Profile.belongsTo(User, { foreignKey: 'userId' });
// 多对多:Post 有多个 Tag
Post.belongsToMany(Tag, { through: 'PostTags' });
Tag.belongsToMany(Post, { through: 'PostTags' });定义关联后,Sequelize 自动给模型添加 4 个方法:
| 方法 | 含义 |
|---|---|
user.getPosts() | 查 user 的所有 post |
user.setPosts(arr) | 设置 user 的 post(多对多) |
user.addPost(p) | 加一条 post 给 user |
user.countPosts() | 统计 post 数 |
二、预加载(Include)
2.1 一层 include
javascript
// 查所有 Post,附带作者信息
const posts = await Post.findAll({
include: [{
model: User,
as: 'author', // 别名(belongsTo 时可选)
attributes: ['id', 'name'], // 只取需要的字段
}],
});
console.log(posts[0].author.name); // 直接访问关联对象生成的 SQL:
sql
SELECT
Post.*,
author.id, author.name
FROM posts AS Post
LEFT JOIN users AS author ON author.id = Post.userId;2.2 嵌套 include
javascript
// 查所有 Post,附带作者 + 作者的 Profile + 文章的评论
const posts = await Post.findAll({
include: [
{ model: User, as: 'author', include: [{ model: Profile }] },
{ model: Comment, include: [{ model: User, as: 'commenter' }] },
],
});2.3 多对多 include
javascript
// 查所有 Post,附带 tags
const posts = await Post.findAll({
include: [{ model: Tag, through: { attributes: [] } }], // 不取中间表字段
});through: { attributes: [] } 排除中间表的字段,避免结果里多出冗余列。
2.4 include + where
javascript
// 查所有 Post,附带作者;只查已发布的
const posts = await Post.findAll({
where: { status: 'published' },
include: [{ model: User, as: 'author' }],
});
// include 里也可以加 where(过滤关联表)
const users = await User.findAll({
include: [{
model: Post,
where: { status: 'published' }, // 只 include 已发布的文章
required: false, // LEFT JOIN(默认 false)
}],
});required | SQL 类型 | 行为 |
|---|---|---|
false(默认) | LEFT JOIN | 即使没匹配也保留主表行 |
true | INNER JOIN | 没匹配就过滤掉主表行 |
三、复杂条件查询
3.1 关联过滤(嵌套 where)
javascript
// 找出"至少有一篇已发布文章"的用户
const users = await User.findAll({
include: [{
model: Post,
where: { status: 'published' },
required: true,
}],
});3.2 关联排序(order 含关联)
javascript
// 按最新文章排序用户
const users = await User.findAll({
order: [[{ model: Post, as: 'posts' }, 'createdAt', 'DESC']],
include: [{ model: Post, as: 'posts' }],
});四、聚合与分组
4.1 计数
javascript
// 查 Post 列表,附带评论数
const posts = await Post.findAll({
attributes: {
include: [[Sequelize.fn('COUNT', Sequelize.col('comments.id')), 'commentCount']],
},
include: [{ model: Comment, attributes: [] }], // 不返回 comments 数据
group: ['Post.id'],
order: [[Sequelize.literal('commentCount'), 'DESC']],
});include + attributes: [] 是"只 JOIN 不取数据"的写法——只为了拿到关联表做聚合。
4.2 findAndCountAll 分页
javascript
const { count, rows } = await Post.findAndCountAll({
where: { status: 'published' },
include: [{ model: User, as: 'author', attributes: ['id', 'name'] }],
distinct: true, // count 时去重,避免 JOIN 重复计算
limit: 20,
offset: 0,
order: [['createdAt', 'DESC']],
});
console.log(`共 ${count} 条,当前页 ${rows.length} 条`);WARNING
findAndCountAll + include 时必须 distinct: true,否则 JOIN 出来的行数会虚高(比如一篇文章 5 条评论,会被算成 5 篇文章)。
五、原生 SQL(Raw Queries)
ORM 不万能。复杂报表查询用 sequelize.query():
javascript
const [results, metadata] = await sequelize.query(
`SELECT
u.id, u.name,
COUNT(DISTINCT p.id) AS post_count,
COUNT(DISTINCT c.id) AS comment_count
FROM users u
LEFT JOIN posts p ON p.user_id = u.id
LEFT JOIN comments c ON c.post_id = p.id
WHERE u.created_at > ?
GROUP BY u.id
ORDER BY post_count DESC
LIMIT ?`,
{
replacements: [oneMonthAgo, 10],
type: sequelize.QueryTypes.SELECT,
}
);type | 含义 |
|---|---|
SELECT | 返回数组 |
INSERT / UPDATE / DELETE | 返回受影响行数 |
BULKUPDATE / BULKDELETE | 批量操作 |
RAW | 默认,返回原始数据 |
replacements 是占位符(?),自动转义——比手拼 SQL 安全。
六、查询优化清单
| 优化点 | 做法 |
|---|---|
避免 SELECT * | 显式 attributes: ['id', 'name', ...] |
| 限制关联深度 | include 不要超过 3 层;太多就拆查询 |
分页必备 distinct | findAndCountAll + include 必须 distinct: true |
| N+1 查询 | 用 include 一次性预加载;不要 for user of users { await user.getPosts() } |
| 大结果集 | limit / offset 限制;用游标分页替代 offset |
| 复杂报表 | sequelize.query() 跑 raw SQL |
6.1 N+1 反例 vs 正例
javascript
// ❌ N+1:1 次查 users,N 次查 posts
const users = await User.findAll();
for (const user of users) {
user.posts = await user.getPosts(); // 每次都查数据库
}
const usersWithPosts = await User.findAll({
include: [{ model: Post }], // 1 次查询拿全
});七、完整案例
javascript
async function getPublishedPosts({ page = 1, pageSize = 20, keyword }) {
const { count, rows } = await Post.findAndCountAll({
where: {
status: 'published',
...(keyword && { title: { [Op.like]: `%${keyword}%` } }),
},
include: [
{ model: User, as: 'author', attributes: ['id', 'name', 'avatar'] },
{ model: Tag, through: { attributes: [] } },
],
attributes: {
include: [[Sequelize.fn('COUNT', Sequelize.col('comments.id')), 'commentCount']],
},
group: ['Post.id', 'author.id', 'Tags.id'],
distinct: true,
limit: pageSize,
offset: (page - 1) * pageSize,
order: [['createdAt', 'DESC']],
});
return {
list: rows,
total: count.length, // COUNT(DISTINCT Post.id)
page,
pageSize,
};
}八、最佳实践
| 场景 | 推荐 |
|---|---|
| 一层 / 二层关联 | include + attributes 精准控制 |
| 深度 > 3 层 | 拆查询后应用层拼装 |
| 列表分页 | findAndCountAll + distinct: true |
| 关联计数 | attributes.include + fn('COUNT') |
| 复杂报表 | sequelize.query() 跑 raw SQL |
| N+1 | 永远用 include 预加载 |
| 大字段 | attributes.exclude 排除 TEXT / JSON 字段 |
九、小结
- 关联查询三步:定义
hasMany/belongsTo等 →include预加载 →attributes精准控制字段 findAndCountAll+include必须distinct: true,避免 JOIN 重复计算- 关联排序:
order: [[{ model: Post, as: 'posts' }, 'createdAt', 'DESC']] - 关联计数:
attributes.include + fn('COUNT', col('comments.id')) - 复杂报表用
sequelize.query()跑 raw SQL,replacements占位符防注入 - 永远避免 N+1:用
include一次拿全,不要循环调user.getPosts()
