最佳实践

目录

  1. 避免 N+1 查询问题
    1. 问题
    2. 解决方案:预加载
  2. 只查询需要的列
  3. 大表的批量处理
  4. 连接池管理
    1. 配置空闲超时
    2. 关闭时断开连接
  5. 事务最佳实践
    1. 保持事务简短
    2. 使用生成器函数简化代码
  6. 索引策略
    1. 为常见查询模式使用复合索引
    2. 需要时使用索引提示
  7. 模型组织
    1. 使用目录方式加载模型
    2. 在 initialize() 中定义关联
    3. 尽可能使用 TypeScript 装饰器
  8. 安全
    1. 永远不要在原始 SQL 中使用用户输入
    2. 谨慎使用 raw()

避免 N+1 查询问题

当你加载一组记录,然后为每条记录的关联分别发起查询时,就会产生 N+1 查询问题。

问题

// 差:1 次查询文章 + N 次查询评论
const posts = await Post.find({ authorId: 1 });
for (const post of posts) {
  post.comments = await Comment.find({ postId: post.id });
}

解决方案:预加载

使用 .with() 或 .include() 在单次查询中加载关联:

// 好:1 次 JOIN 查询
const posts = await Post.find({ authorId: 1 }).with('comments');
for (const post of posts) {
  console.log(post.comments); // 已加载
}

可以同时加载多个关联:

const posts = await Post.find().with('author', 'comments');

只查询需要的列

默认情况下,Leoric 会查询所有列(SELECT *)。当只需要特定列时,使用 .select():

// 差:加载所有列,包括大文本字段
const posts = await Post.find();

// 好:只加载需要的
const posts = await Post.find().select('id', 'title', 'createdAt');

对于有大 TEXT 或 BLOB 列的表尤其重要。

大表的批量处理

处理大表时,避免一次性加载所有记录到内存。应使用带索引且唯一的游标,而不是不断增大的 offset:

// 差:全部加载到内存
const allPosts = await Post.find();

// 好:不带额外过滤条件时,按主键稳定排序并分批处理
const pageSize = 100;
let lastId;
while (true) {
  let query = Post.order('id').limit(pageSize);
  if (lastId != null) {
    query = query.where({ id: { $gt: lastId } });
  }

  const posts = await query;
  if (posts.length === 0) break;

  for (const post of posts) {
    // 处理每条记录
  }
  lastId = posts.at(-1).id;
}

这个例子会遍历整张表且没有额外的过滤条件,因此按主键进行范围扫描是高效的。如果批处理查询带有过滤条件,游标及排序字段应该与查询使用的复合索引相匹配:先放使用等值过滤的字段,再放有序的游标字段,最后追加主键作为唯一的排序条件。例如,按 tenantId、status 过滤并按 id 分页的查询通常应使用 (tenant_id, status, id) 索引。请通过数据库的 EXPLAIN 功能确认实际执行计划。

建议尽量使用不会变化的字段作为游标。Limit/offset、复合游标和窗口函数分页的细节请参考《分页》。

连接池管理

配置空闲超时

对于长时间运行的应用,配置空闲超时以防止过期连接:

const realm = new Realm({
  host: 'localhost',
  database: 'my_app',
  idleTimeout: 30000, // 30 秒
});

关闭时断开连接

应用关闭时务必断开连接:

process.on('SIGTERM', async () => {
  await realm.disconnect();
  process.exit(0);
});

事务最佳实践

保持事务简短

// 差:事务内调用外部 API 会长时间占用连接
await Bone.transaction(async ({ connection }) => {
  const user = await User.create({ name: 'Alice' }, { connection });
  const result = await fetch('https://api.example.com/notify'); // 慢!
  await AuditLog.create({ action: 'user_created' }, { connection });
});

// 好:将外部调用移到事务之外
const user = await Bone.transaction(async ({ connection }) => {
  const user = await User.create({ name: 'Alice' }, { connection });
  await AuditLog.create({ action: 'user_created' }, { connection });
  return user;
});
await fetch('https://api.example.com/notify');

使用生成器函数简化代码

// 生成器函数自动传递 connection
await Bone.transaction(function* () {
  const user = yield User.create({ name: 'Alice' });
  yield AuditLog.create({ action: 'user_created', userId: user.id });
});

索引策略

为常见查询模式使用复合索引

如果你经常使用多条件查询:

// 如果这是常见的查询模式:
Post.find({ authorId: 1, status: 'published' }).order('createdAt', 'desc')

考虑添加复合索引:(author_id, status, created_at)。

需要时使用索引提示

当查询优化器做出次优选择时:

Post.find({ authorId: 1 }).forceIndex('idx_author_created')

详见索引提示。

模型组织

使用目录方式加载模型

const realm = new Realm({
  models: 'app/models',  // 自动从目录加载所有模型
});

在 initialize() 中定义关联

class Post extends Bone {
  static initialize() {
    this.belongsTo('author', { Model: 'User' });
    this.hasMany('comments');
    this.hasMany('tags', { through: 'tagMaps' });
  }
}

尽可能使用 TypeScript 装饰器

class Post extends Bone {
  @Column({ primaryKey: true })
  id: bigint;

  @BelongsTo()
  author: User;

  @HasMany()
  comments: Comment[];
}

安全

永远不要在原始 SQL 中使用用户输入

// 差:SQL 注入漏洞
await realm.query(`SELECT * FROM posts WHERE title = '${userInput}'`);

// 好:参数化查询
await realm.query('SELECT * FROM posts WHERE title = ?', [userInput]);

// 好:使用 ORM 查询接口
await Post.find({ title: userInput });

谨慎使用 raw()

raw() 函数绕过转义。只用于 SQL 函数和表达式,绝不用于用户提供的值:

// 好:SQL 函数
await Post.update({ id: 1 }, { viewCount: raw('view_count + 1') });

// 差:在 raw() 中使用用户输入
await Post.find({ title: raw(userInput) }); // SQL 注入!