最佳实践

目录

  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');

对于有大 TEXTBLOB 列的表尤其重要。

大表的批量处理

处理大表时,避免一次性加载所有记录到内存。应使用带索引且唯一的游标,而不是不断增大的 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;
}

这个例子会遍历整张表且没有额外的过滤条件,因此按主键进行范围扫描是高效的。如果批处理查询带有过滤条件,游标及排序字段应该与查询使用的复合索引相匹配:先放使用等值过滤的字段,再放有序的游标字段,最后追加主键作为唯一的排序条件。例如,按 tenantIdstatus 过滤并按 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 注入!