Skip to content

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)
  }],
});
requiredSQL 类型行为
false(默认)LEFT JOIN即使没匹配也保留主表行
trueINNER 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 层;太多就拆查询
分页必备 distinctfindAndCountAll + 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()