优化 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慢的现实,或者前端做缓存