$expr查询几乎都不走索引,因其强制在文档层面运行时计算,无法下推至索引扫描阶段;即使字段有索引,含$expr的查询仍触发全表扫描,仅常量比较等极简场景可能命中索引。

为什么 $expr 查询几乎都不走索引
因为 $expr 强制 MongoDB 在文档层面执行运行时计算,绕过了索引的预排序结构。即使字段上有完美匹配的索引,只要查询里出现 $expr,优化器就无法将条件下推到索引扫描阶段。
常见错误现象:db.orders.find({$expr: {$gt: ["$total", {$multiply: ["$quantity", "$price"]}]}}) —— 即使 total、quantity、price 全都有索引,explain() 仍显示 "stage": "COLLSCAN"。
根本原因不是语法写错,而是索引只存原始字段值,不存表达式结果;MongoDB 无法把 {$multiply: [...]} 这类动态计算提前“翻译”成索引可比的范围。
- 所有聚合操作符(
$add、$substr、$concat等)在$expr中都会触发全表扫描 -
$expr+ 字段比较(如{$expr: {$eq: ["$a", "$b"]}})也无法利用{a: 1, b: 1}复合索引,因为索引不提供跨字段等值判定能力 - 唯一例外是极简场景:仅用
$expr做常量比较,如{$expr: {$eq: ["$status", "done"]}},此时可能命中{status: 1}索引,但本质是退化为普通查询,$expr成了冗余包装
把计算逻辑前置:什么时候该在应用层算
只要计算结果能转成确定的常量值,就该从 $expr 挪到查询条件外。这不是妥协,而是对索引机制的尊重。
使用场景举例:查“下单时间距今超7天的订单”,别写 {$expr: {$lt: ["$createdAt", {$subtract: [new Date(), 7*24*60*60*1000]}]}};而应在代码里先算出截止时间点:
const cutoff = new Date(Date.now() - 7 * 24 * 60 * 60 * 1000);
db.orders.find({ createdAt: { $lt: cutoff } });
这样 createdAt 上的索引就能直接生效。
- 时间运算(
$dateAdd、$subtract)、数值四则($multiply、$add)基本都能前置 - 字符串拼接(
$concat)若用于生成查询关键词,应提前拼好再传入{field: "value"} - 注意时区:Node.js 的
new Date()是本地时区,MongoDB 存的是 UTC,务必统一转换
哪些计算真没法前置?考虑冗余字段 + 单独索引
当计算依赖其他字段且必须在服务端做(比如实时校验库存是否充足:stock - sold >= required),又不能接受全表扫描时,唯一可靠路径是把结果固化为一个字段。
例如,在写入或更新时同步计算并保存 availableStock:
// 写入时
db.products.insertOne({
name: "widget",
stock: 100,
sold: 20,
availableStock: 80 // ← 应用层算好写入
});
<p>// 查询时直接走索引
db.products.find({ availableStock: { $gte: 50 } }).hint({ availableStock: 1 });</p>
- 冗余字段需配合应用逻辑保证一致性,不能靠数据库自动维护
- 索引建在冗余字段上(如
{availableStock: 1}),而非原始字段组合 - 避免用
$set+$expr在 update 中动态更新该字段——那又回到无索引问题
别信“复合索引能救 $expr”这种传言
有人试过给所有参与 $expr 的字段建复合索引,比如 {quantity: 1, price: 1, total: 1},然后查 {$expr: {$gt: ["$total", {$multiply: ["$quantity", "$price"]}]}},结果仍是 COLLSCAN。
因为复合索引只是把多个字段值按顺序拼起来排序,它不存储任何中间计算结果,更不支持运行时表达式求值。
-
$expr不是“用索引加速计算”,而是“在扫描文档后做计算” - 哪怕你建了
{"$**": 1}通配符索引,对$expr查询也完全无效 - 真正需要表达式能力的场景(如多字段联动过滤),应评估是否该迁移到聚合管道的
$match阶段——但注意,$match在管道开头且只含普通条件时才可能走索引
最易被忽略的一点:开发时用小数据集测试 $expr 查询看不出慢,一旦集合涨到百万级,CPU 和磁盘 I/O 会突然飙升,而 explain() 里 totalDocsExamined 会暴露真实代价。











