跳到主要内容

优化 Cloudbase 云数据库查询性能

一句话定义:Cloudbase 的文档型数据库底层基于 MongoDB 协议,这篇按定位 → 加索引 → 调结构 → 改翻页的顺序,把常见的慢查询场景一遍过,讲清每一步的机制(为什么这么做有效)和它在数据量增长时的极限,不编具体毫秒数字。

预计耗时:35 分钟 | 难度:进阶

适用场景

  • 适用:已经能正常读写数据库(完成 add-database-wechat-miniprogram 即可),开始关心查询性能
  • 适用:数据量从几千条涨到十几万 / 百万级,查询变慢
  • 适用:做日报 / 列表 / 搜索这类典型读多写少场景
  • 不适用:数据量超过千万级、跨集合 join 复杂的强关系业务。文档型数据库不擅长这个量级的关系运算,见第七步「何时换关系数据库」
  • 不适用:写性能是瓶颈的场景(本篇重点讨论读)。索引加多了反而拖慢写,这点第二步会展开

环境要求

依赖版本
Cloudbase 环境任意,文档型数据库默认就支持
@cloudbase/js-sdk@cloudbase/node-sdk任意当前版本
控制台权限「文档型数据库 → 集合管理 / 索引管理 / 慢查询」可访问

第一步:先找慢查询,再决定优化谁

性能优化的第一原则:先证明慢,再说优化。盲目加索引经常加错地方,反而拖慢写入。

定位入口三个:

1. 控制台慢查询日志

控制台 → 环境总览 → 高级 → CLS 日志(确保已开启)→ 搜索 module:database AND eventType:MongoSlowQuery,能看到所有耗时偏高的查询条件、命中索引情况、扫描文档数。

如果偏好用 SDK 拉取:

const CloudBase = require('@cloudbase/manager-node');
const manager = CloudBase.init({ secretId, secretKey, envId });

const res = await manager.log.searchClsLog({
queryString: 'module:database AND eventType:MongoSlowQuery',
StartTime: '2024-04-01 00:00:00',
EndTime: '2024-04-01 23:59:59',
Limit: 100,
Sort: 'desc',
});

for (const log of res.Results || []) {
console.log(log.Timestamp, log.Content);
}

完整接口签名:searchClsLog

2. 看具体调用的耗时

在云函数里 Date.now() 卡一下时间窗口,把 RequestId 也打日志。返回慢了去查这个 RequestId 对应的具体执行情况。

3. 控制台「监控告警」

控制台 → 数据库 → 监控告警,有读 / 写延迟、慢查询占比、连接数等指标,出全局趋势图比单条样本更能看到问题面。

慢的判断标准:不要纠结「多少 ms 算慢」。看相对值就行 — 如果一类查询的耗时是同集合大多数查询的 5-10 倍,或者扫描文档数远大于返回文档数(比如返回 20 条扫了 10 万条),就值得看。

第二步:加索引

文档型数据库在没有索引的字段上做 where 是「全表扫描」(collection scan),时间随集合大小线性增长。建索引后能把这个降到对数级别(B-Tree / 类似结构上的查找)。

在控制台加索引

控制台 → 数据库 → 集合 → 索引管理 → 新建索引:

  • 单字段索引:针对一个字段查询 / 排序。比如 where({status: 'pending'}),给 status 加单字段索引
  • 组合索引:多字段联合查询,如 where({userId, status}).orderBy('createdAt', 'desc'),加 (userId, status, createdAt) 三字段组合索引
  • 唯一索引:除了加速,还能在写入时强制唯一性约束

完整规则见 data-index

组合索引的「最左前缀」原则

组合索引 (a, b, c) 能命中:

查询条件能否命中
where({a: x})命中
where({a: x, b: y})命中
where({a: x, b: y, c: z})命中
where({b: y})不命中
where({a: x, c: z})部分命中(只走 a)

机制:索引在底层是按字段顺序拼接的有序结构,跳过前面的字段就没法定位。

设计组合索引时把等值条件放在前,范围条件放在后(gt / lt / in):

// 推荐:userId 等值在前,createdAt 范围在后
await db.collection('orders')
.where({
userId: 'u1', // 等值
createdAt: db.command.gte(yesterday), // 范围
})
.orderBy('createdAt', 'desc')
.get();

// 索引:(userId, createdAt)

索引不是越多越好

  • 写入时每个索引都要更新,索引数量直接影响写延迟
  • 索引会占空间,内存压力大时整体性能下降
  • Cloudbase 文档建议单集合不超过 20 个索引,见 data-index 索引数量限制一节
  • 没在用的索引及时删掉。控制台「索引管理」能看每个索引的命中频率

排序方向也要对上

组合索引 (age: 升序, score: 降序) 能命中:

  • orderBy('age', 'asc').orderBy('score', 'desc')(完全一致)
  • orderBy('age', 'desc').orderBy('score', 'asc')(完全反向,索引可反向遍历)

不能命中:

  • orderBy('age', 'asc').orderBy('score', 'asc')(中间方向不一致)

详见 data-index 排序方向影响一节。

第三步:避免全表扫描

慢查询里最常见的是无意中触发的全表扫:

反例:limit 没加

// 集合有 50 万条,这个查询会把所有记录都拉出来
const res = await db.collection('logs').where({ level: 'error' }).get();

正确做法:总是 limit:

const res = await db
.collection('logs')
.where({ level: 'error' })
.orderBy('createdAt', 'desc')
.limit(50)
.get();

反例:skip 大值

// 看第 1000 页,每页 20 条
const res = await db.collection('items').skip(20000).limit(20).get();

skip 在底层依然要从头读到 20000 条,然后丢弃。skip 越大越慢。换 cursor-based 分页(第六步)。

反例:正则表达式

// 正则扫不走索引
await db.collection('users').where({ name: /^/ }).get();

模糊搜索建议直接换 PostgreSQL 全文索引,或者在写入时把首字 / 拼音作为冗余字段独立建索引。

第四步:用聚合管道替代多次查询

业务里经常出现「先查一组 A,再用 A 的结果去查 B,最后合并」这种模式:

// 反例:N+1 查询
const orders = await db.collection('orders').where({ userId }).get();
const productIds = orders.data.map((o) => o.productId);
// 每个商品再查一次,网络 RPC N 次
const products = [];
for (const id of productIds) {
const p = await db.collection('products').doc(id).get();
products.push(p.data[0]);
}

这种用一次聚合管道(aggregate)更省网络:

// 在云函数端跑(Web SDK 不支持 aggregate,小程序也不支持,要走云函数)
const cloudbase = require('@cloudbase/node-sdk');
const app = cloudbase.init();
const db = app.database();

const res = await db
.collection('orders')
.aggregate()
.match({ userId })
.lookup({
from: 'products',
localField: 'productId',
foreignField: '_id',
as: 'product',
})
.project({
_id: 1,
productId: 1,
product: { $arrayElemAt: ['$product', 0] },
amount: 1,
})
.end();

关键点:

  • aggregate 只在 Node SDK(云函数 / 服务端)的文档型数据库里支持;前端 SDK(@cloudbase/js-sdk)不支持。要在前端用,前面套一层云函数
  • match 阶段如果在管道开头,可以利用索引(和 where 等价的索引利用规则)
  • 完整管道操作符见 aggregate API
  • 聚合的执行复杂度比单次 where 高,数据量大时慎用 lookup(本质是 join)

排序内存上限是 100MB(MongoDB 默认值),数据量大了 sort 要么前面加 match / limit 缩小集合,要么在排序字段上加索引让 sort 利用索引。详见 aggregate.md 的 FAQ。

第五步:字段投影,只拉需要的

// 反例:文档有 30 个字段,只用 3 个,但全拉了
const res = await db
.collection('orders')
.where({ userId })
.limit(50)
.get();
// 推荐:用 field 只拉需要的字段
const res = await db
.collection('orders')
.where({ userId })
.field({ _id: true, status: true, amount: true, createdAt: true })
.limit(50)
.get();

机制:网络传输的字节数直接和返回字段数 / 大小成正比。如果文档里有大字段(description 上千字、images 数组、嵌套对象),不取出来能省下大块带宽和反序列化时间。

注意:field白名单模式,列出来的取,没列的不取。_id 默认会带上,不写也会返回。

第六步:大数据集用 cursor 分页

skip + limit 在大集合上慢:第 1000 页要先读 19999 条丢掉再返回 20 条。

cursor-based 分页用「上一页最后一条的某个有序字段」作为下一页起点:

async function listOrders(userId, lastCursor) {
let query = db
.collection('orders')
.where({ userId })
.orderBy('createdAt', 'desc')
.orderBy('_id', 'desc'); // 加 _id 兜底,防止 createdAt 同值时漏 / 重

if (lastCursor) {
const _ = db.command;
query = query.where({
// 这里实际要用 or 逻辑,完整写法见下面
...
});
}

return query.limit(20).get();
}

cursor 完整逻辑要处理「相同 createdAt 时按 _id 切」,有点啰嗦但不复杂:

import { db } from './cloudbase';

const _ = db.command;

async function listOrdersCursor(userId, cursor) {
const base = { userId };

// cursor = { createdAt, _id } 来自上一页最后一条
let where;
if (cursor) {
where = _.or([
{ ...base, createdAt: _.lt(cursor.createdAt) },
{ ...base, createdAt: cursor.createdAt, _id: _.lt(cursor._id) },
]);
} else {
where = base;
}

const res = await db
.collection('orders')
.where(where)
.orderBy('createdAt', 'desc')
.orderBy('_id', 'desc')
.limit(20)
.get();

const last = res.data[res.data.length - 1];
return {
items: res.data,
nextCursor: last ? { createdAt: last.createdAt, _id: last._id } : null,
};
}

要点:

  • 索引上必须有 (userId, createdAt, _id),这样上面的 where + sort 全部走索引
  • 没有「跳到第 N 页」的能力,只能下一页 / 上一页。如果业务一定要跳页,接受 skip 慢的现实,或者前端做缓存

第七步:何时该换关系型数据库

文档型数据库适合:

  • 文档自包含的场景(订单 + 嵌套 items / 用户资料 + 嵌套地址列表)
  • 字段不固定 / schema 频繁变更
  • 读多写少 + 单文档 / 单集合查询

不适合:

  • 多表 join,关联字段多。lookup 在文档型数据库里是性能黑洞,数据量大时延迟陡增
  • 强事务跨集合(给 A 转账给 B,两条记录要原子性)。文档型有事务但跨集合性能差
  • 复杂分析(group by + having + 多层 join)

这种场景 Cloudbase 提供 PostgreSQL / TDSQL 集成,在控制台「数据库 → 关系型数据库」开通,通过 PG RESTful API 或者直接 SQL 接入。

混合方案也常见:文档型存主数据,关系型做分析报表。但混合带来的复杂度也是成本,小项目先单一选型,数据量到了百万再考虑迁移。

运行验证

  1. 控制台 → CLS 日志 跑一次 module:database AND eventType:MongoSlowQuery 搜索,记下当前慢查询数
  2. 找一个慢查询,看它的 where 条件,在控制台「索引管理」加上对应组合索引
  3. 等几分钟(索引在后台构建,集合大时可能几十秒),再触发同样的查询,慢查询日志里这条应该消失
  4. aggregate 替代 N+1 查询的例子,云函数端用 console.time / console.timeEnd 卡耗时,应该有量级差异
  5. 翻页改成 cursor 形式后,翻到第 N 页耗时不再随 N 增长

常见错误

错误现象原因修复
加了索引查询还是慢索引字段顺序和查询条件不匹配,或者排序方向不一致见第二步「最左前缀」和「排序方向也要对上」
索引创建很慢甚至超时集合数据量很大(几百万)在低峰期建索引;Cloudbase 后台构建,期间集合可读可写
aggregateSort exceeded memory limit of 104857600 bytes排序字段大或没缩小数据集match / limit 提前缩集合;给排序字段加索引;减少 sort 字段数
aggregate 在前端调用报错前端 SDK 不支持 aggregate包一层云函数,前端 callFunction
skip(N) 翻页越来越慢大 skip 必然慢换 cursor-based 分页(第六步)
加了组合索引但部分查询还是慢部分查询条件不在索引前缀里看具体查询条件,可能要再加一个不同顺序的索引(总数控制在 20 内)
唯一索引创建报「重复值」现有数据中有重复先清理重复数据再创建唯一索引

相关文档

下一步