1. 为什么你总在 MongoDB 里查不到嵌套字段?Dot Notation 不是语法糖,而是数据建模的照妖镜
刚接手一个电商后台项目时,我翻了三天日志才定位到一个“订单状态更新失败”的诡异问题。前端传来的order.status明明是"shipped",但数据库里查出来还是"processing"。最后发现,开发同事把状态字段存成了嵌套结构:{ "status": { "value": "shipped", "updated_at": "2024-06-15T08:23:41Z" } },而查询语句却写的是{"status": "shipped"}——这根本就匹配不上。这种“看着像、实际错”的坑,在 MongoDB 里太常见了。Dot Notation(点号表示法)不是可有可无的查询技巧,它是你和嵌套文档之间唯一的、精确的对话协议。它解决的从来不是“怎么写”,而是“你怎么理解数据结构”这个根本问题。如果你还在用JSON.stringify(obj).includes("shipped")这种暴力方式查嵌套字段,或者靠find().forEach()在应用层遍历过滤,那说明你还没真正进入 MongoDB 的世界。这篇内容专为两类人准备:一类是刚从 SQL 转来、被JOIN养成“扁平思维”的开发者;另一类是已经用上嵌套文档、却总在$elemMatch和普通点号之间反复横跳的实战派。它不讲抽象概念,只拆解你在真实业务中每天会写的 7 类嵌套查询:从最基础的user.profile.name到最烧脑的items.0.price、tags.$、comments.$.author,再到聚合管道里的"$user.address.city"。所有示例都基于真实电商、SaaS 和内容平台的数据模型,参数、索引建议、性能陷阱全部来自我们线上集群的慢查询日志分析。你不需要记住所有语法,但必须清楚:每一次点号的出现,都在暴露你对数据关系的理解深度。
2. Dot Notation 的底层逻辑:MongoDB 如何把“点”翻译成内存地址
2.1 点号不是字符串拼接,而是路径解析器的指令集
很多人以为address.city就是把两个字符串用点连起来,然后 MongoDB 去“搜索”这个组合名。这是最大的误解。Dot Notation 是 MongoDB 查询引擎内置的路径解析器(Path Resolver)所执行的一套确定性指令。它的工作流程分三步,每一步都决定着查询能否命中:
- Tokenization(分词):将
address.city拆成["address", "city"]两个 token。注意:这里不支持任何转义字符,"address\.city"会被当作字面量字段名处理,而非嵌套路径。 - Traversal(遍历):从文档根节点开始,按顺序查找每个 token 对应的键。先找
address字段,如果存在且是对象(Object),再在这个对象里找city字段;如果address是数组,则进入第三步。 - Projection & Matching(投影与匹配):如果最终找到的值是标量(string/number/boolean),则直接用于比较;如果是数组,则根据上下文决定是“匹配任意元素”(如
{ "tags.0": "mongodb" })还是“匹配所有元素”(如{ "tags": { "$all": ["mongodb", "tutorial"] } })。
提示:
{ "address.city": "Beijing" }和{ "address": { "city": "Beijing" } }在功能上等价,但底层实现完全不同。前者触发路径解析器,后者是标准的嵌套文档匹配。前者能利用address.city单独索引,后者只能利用address复合索引或全表扫描。
2.2 为什么user.profile.name能查,user.profile却不能用点号?
关键在于“点号操作的对象必须是对象或数组”。user.profile.name中,profile必须是对象,name才是其子键。但如果profile字段本身是null、undefined或字符串"{}",那么user.profile.name的遍历就会在第二步中断,整个表达式返回null,导致查询不匹配。这就是为什么线上经常出现“数据明明存在,查询却为空”的情况——不是数据丢了,而是数据类型错了。
我们曾在线上遇到一个典型案例:用户注册时,profile字段被错误地初始化为""(空字符串)。后续所有profile.avatar的更新都失败,因为""不是对象,无法添加子键。修复方案不是改查询,而是用$setOnInsert在插入时强制确保profile是空对象:{ "$setOnInsert": { "profile": {} } }。
2.3 数组索引的隐式规则:.0、.1并非魔法,而是确定性访问
当路径中包含数组时,Dot Notation 的行为会发生质变。items.0.price表示“取items数组的第一个元素(索引 0),然后取它的price字段”。这里的.0不是通配符,而是精确索引。它要求items数组长度至少为 1,且第 0 个元素必须是对象并包含price键。
更关键的是,MongoDB 对数组的点号访问默认是“位置无关”的。也就是说,{ "items.price": 99 }并不意味着“第一个 item 的 price 是 99”,而是“items数组中任意一个元素的price字段等于 99”。这正是$elemMatch存在的意义——当你需要“同一个数组元素同时满足多个条件”时,就必须用$elemMatch,而不是点号。
例如,要查“包含一个价格为 99 且状态为in_stock的商品”,必须写:
{ "items": { "$elemMatch": { "price": 99, "status": "in_stock" } } }如果写成{ "items.price": 99, "items.status": "in_stock" },它会匹配到items[0].price == 99且items[1].status == "in_stock"的文档,这显然不符合业务逻辑。
3. 7 类高频嵌套查询场景:从入门到避坑的完整实操指南
3.1 场景一:精准定位单层嵌套字段(user.profile.email)
这是最基础也最容易出错的场景。假设用户集合结构如下:
{ "_id": ObjectId("..."), "username": "alice", "profile": { "email": "alice@example.com", "age": 28, "preferences": { "theme": "dark", "notifications": true } } }正确查询:
// 查 email 为 alice@example.com 的用户 db.users.find({ "profile.email": "alice@example.com" }) // 查 age > 25 且 theme 为 dark 的用户(注意:profile.preferences.theme) db.users.find({ "profile.age": { "$gt": 25 }, "profile.preferences.theme": "dark" })实操要点:
- 索引必须匹配路径:为加速查询,需创建
profile.email字段的单独索引:db.users.createIndex({ "profile.email": 1 })。创建profile整体索引{"profile": 1}效果极差,因为 MongoDB 无法高效地从整个嵌套对象中提取email。 - 空值陷阱:如果部分用户没有
profile字段,或profile为null,"profile.email"查询会自动跳过这些文档。若需包含它们,必须显式检查:{ "$or": [ { "profile.email": "alice@example.com" }, { "profile": null } ] }。 - 大小写敏感:MongoDB 默认区分大小写。
"profile.email"匹配"Alice@Example.COM"会失败。生产环境务必统一存储格式,或使用正则{$regex: "^alice@example.com$", $options: "i"}(但会牺牲索引效率)。
注意:不要试图用
{"profile": {"email": "alice@example.com"}}替代点号。前者是嵌套文档的完全匹配,要求profile字段必须且仅包含profile还有age和preferences。这会导致 99% 的查询失败。
3.2 场景二:数组元素的精确索引访问(comments.0.author)
适用于需要操作数组首个元素的场景,如置顶评论、最新回复等。假设文章集合结构:
{ "_id": ObjectId("..."), "title": "MongoDB Dot Notation", "comments": [ { "author": "bob", "text": "Great tutorial!", "likes": 5 }, { "author": "carol", "text": "What about arrays in arrays?", "likes": 2 } ] }正确查询与更新:
// 查第一条评论作者为 bob 的文章 db.posts.find({ "comments.0.author": "bob" }) // 将第一条评论的 likes 加 1(原子操作) db.posts.updateOne( { "_id": ObjectId("...") }, { "$inc": { "comments.0.likes": 1 } } ) // 为第一条评论添加一个新字段(如果不存在则创建) db.posts.updateOne( { "_id": ObjectId("...") }, { "$set": { "comments.0.is_top": true } } )实操要点:
- 索引失效警告:
comments.0.author无法利用comments.author的索引。必须创建专门的索引:db.posts.createIndex({ "comments.0.author": 1 })。但此索引仅对索引为 0 的元素有效。如果想查“任意位置的 author”,必须用comments.author索引。 - 越界安全:如果
comments数组为空,comments.0.author访问不会报错,而是返回null,因此{ "comments.0.author": "bob" }查询结果为空。这是安全的设计,无需额外 try-catch。 - 更新限制:
$set可以创建新字段,但$push、$pop等数组操作符不能用于带索引的路径(如comments.0)。它们只作用于整个数组字段。
3.3 场景三:匹配数组中任意一个元素(tags.name)
这是最常用也最容易被滥用的场景。tags通常是一个字符串数组或对象数组。继续用文章集合:
{ "_id": ObjectId("..."), "title": "MongoDB Dot Notation", "tags": ["mongodb", "database", "nosql"] }正确查询:
// 查包含 "mongodb" 标签的文章(最简写法) db.posts.find({ "tags": "mongodb" }) // 查包含 "mongodb" 或 "nosql" 的文章 db.posts.find({ "tags": { "$in": ["mongodb", "nosql"] } }) // 查同时包含 "mongodb" 和 "database" 的文章(顺序无关) db.posts.find({ "tags": { "$all": ["mongodb", "database"] } })为什么不用点号?因为tags是字符串数组,tags.name会尝试访问每个字符串的name属性(字符串没有该属性),结果永远是null。所以,对于一维字符串/数字数组,直接查询字段名即可,点号是多余的。
进阶:对象数组的点号匹配如果tags是对象数组:
"tags": [ { "name": "mongodb", "category": "tech" }, { "name": "tutorial", "category": "learning" } ]此时必须用点号:
// 查 name 为 "mongodb" 的标签 db.posts.find({ "tags.name": "mongodb" }) // 查 category 为 "tech" 的标签(注意:这会匹配到任意一个 category 为 tech 的元素) db.posts.find({ "tags.category": "tech" })实操要点:
- 索引策略:对
tags.name创建多键索引(Multikey Index):db.posts.createIndex({ "tags.name": 1 })。MongoDB 会自动识别tags是数组,并为每个元素的name值建立索引条目。 - 性能陷阱:
{ "tags.name": "mongodb" }会匹配到tags[0].name == "mongodb"或tags[1].name == "mongodb"的文档。如果你只想查tags[0].name,必须用tags.0.name并创建对应索引。
3.4 场景四:定位并更新匹配的数组元素(comments.$.text)
当需要更新“某个条件满足的数组元素”时,$位置操作符是唯一选择。它代表第一个匹配查询条件的数组元素的索引。
实操步骤:
// 步骤1:查询并定位。目标:将作者为 "bob" 的评论文本改为 "Updated!" db.posts.find({ "comments.author": "bob" }) // 步骤2:使用 $ 操作符更新。注意:查询条件中必须包含数组字段(comments.author),更新路径中用 $.text db.posts.updateOne( { "comments.author": "bob" }, // 查询条件,必须包含 comments.xxx { "$set": { "comments.$.text": "Updated!" } } )实操要点:
- 严格匹配规则:
$只替换第一个匹配的元素。如果一个文档有两条author: "bob"的评论,只有第一条会被更新。要更新所有,需用$[](全数组操作符)或$[<identifier>](带条件的过滤操作符)。 - 查询与更新耦合:
$的工作依赖于查询条件中的数组字段。updateOne({ "comments.author": "bob" }, { "$set": { "comments.$.text": "x" } })是合法的;但updateOne({ "_id": id }, { "$set": { "comments.$.text": "x" } })会报错,因为查询条件中没有提供comments的匹配信息,MongoDB 不知道该替换哪个元素。 - 嵌套数组的 $:如果数组内还有数组(如
comments.replies),$只作用于最外层数组。要更新嵌套数组,需用$[<identifier>]配合arrayFilters。
3.5 场景五:聚合管道中的 Dot Notation($user.address.city)
在$project、$match、$group等阶段,点号用于引用上游输出的嵌套字段。这是构建复杂报表的核心能力。
实操案例:统计各城市用户的平均年龄
db.users.aggregate([ // 阶段1:筛选出有完整地址的用户(避免 null 引发错误) { "$match": { "address.city": { "$exists": true, "$ne": null } } }, // 阶段2:投影出 city 和 age,便于后续分组 { "$project": { "city": "$address.city", // 引用 address.city 字段 "age": "$profile.age", // 引用 profile.age 字段 "_id": 0 } }, // 阶段3:按 city 分组,计算平均 age { "$group": { "_id": "$city", "avg_age": { "$avg": "$age" } } }, // 阶段4:美化输出 { "$project": { "city": "$_id", "average_age": { "$round": ["$avg_age", 1] }, "_id": 0 } } ])实操要点:
$前缀是必须的:在聚合中,所有字段引用都必须加$前缀,如"$address.city"。这与 find 查询中的"address.city"语法不同,切勿混淆。- 空值处理:
$address.city在address为null或address.city不存在时,返回null。$group阶段会将所有null归为一组。因此,强烈建议在$match阶段提前过滤掉无效数据,如示例中的{ "$address.city": { "$exists": true, "$ne": null } }。 - 性能优化:聚合管道是顺序执行的。把
$match放在最前面,可以尽早减少文档流,极大提升性能。把$project放在$match后面,可以减少传输到后续阶段的字段数量。
3.6 场景六:动态字段名的点号访问($getField与$concat)
当字段名本身是变量时(如多语言内容content.en.title、content.zh.title),静态点号无法满足需求。MongoDB 5.0+ 引入了$getField表达式。
实操案例:根据请求的语言参数动态返回标题
// 假设文档结构 { "content": { "en": { "title": "MongoDB Tutorial", "body": "..." }, "zh": { "title": "MongoDB 教程", "body": "..." } } } // 聚合管道:输入语言参数 lang = "zh" db.articles.aggregate([ { "$project": { "title": { "$getField": { "field": { "$concat": ["content.", "$$lang", ".title"] }, // 动态拼接字段路径 "input": "$$ROOT" // 从当前文档根节点开始查找 } } } } ])实操要点:
$$lang是变量:$$表示聚合变量,$lang是字段引用。此处$$lang应通过let参数传入:db.articles.aggregate([ ... ], { "let": { "lang": "zh" } })。$concat的局限:它只能拼接字符串,不能拼接对象或数组。因此,$getField适合“字段名动态”的场景,不适合“路径深度动态”的场景(如level1.level2.level3的层数不确定)。- 替代方案:对于简单场景,可以用
$switch+ 多个$cond实现分支判断,但代码冗长。$getField是更优雅的解决方案。
3.7 场景七:嵌套文档的原子性更新与条件校验($set+$cond)
点号不仅是查询工具,更是构建强一致性业务逻辑的基石。例如,一个库存扣减操作,必须保证“扣减前库存大于 0”。
实操案例:安全扣减商品库存
// 文档结构 { "_id": ObjectId("..."), "name": "Laptop", "inventory": { "total": 10, "reserved": 2, "available": 8 } } // 原子性更新:仅当 available >= 1 时,才扣减 1 db.products.updateOne( { "_id": ObjectId("..."), "inventory.available": { "$gte": 1 } // 条件校验放在查询部分 }, { "$inc": { "inventory.available": -1, "inventory.reserved": 1 } } )实操要点:
- 条件与更新分离:MongoDB 的原子性保证的是“查询条件满足”和“更新操作执行”这两个动作的不可分割。因此,所有业务规则(如库存是否充足)都必须作为查询条件的一部分,而不是在应用层判断。
$inc的安全性:$inc是原子操作,即使并发请求同时执行,也不会出现超卖。$inc: { "inventory.available": -1 }比find -> modify in app -> save安全一万倍。- 返回结果验证:
updateOne的返回结果中matchedCount和modifiedCount都为 1,表示成功扣减;若matchedCount为 1 但modifiedCount为 0,说明查询条件满足但更新未执行(如$inc值为 0);若matchedCount为 0,说明库存已不足。应用层必须检查这些返回值。
4. 索引、性能与避坑:Dot Notation 的黑暗森林指南
4.1 索引设计的黄金法则:路径即索引名
Dot Notation 的威力,90% 取决于索引。没有正确的索引,再精妙的查询也是全表扫描。
核心原则:索引字段名必须与查询路径完全一致。
| 查询语句 | 是否能用{"user.profile.email": 1}索引 | 原因 |
|---|---|---|
{"user.profile.email": "a@b.com"} | ✅ 是 | 路径完全匹配 |
{"user.profile": {"email": "a@b.com"}} | ❌ 否 | 这是嵌套文档匹配,索引不生效 |
{"user": {"profile": {"email": "a@b.com"}}} | ❌ 否 | 同上,且层级更深 |
{"user.profile.email": {"$regex": "^a"}} | ⚠️ 部分 | 前缀正则可利用索引,但$options: "i"会失效 |
多字段复合索引的陷阱:
创建{"user.profile.email": 1, "user.profile.age": 1}索引后,以下查询能用索引:
{"user.profile.email": "a", "user.profile.age": 25}{"user.profile.email": "a"}(前缀索引)
但以下查询不能用索引:
{"user.profile.age": 25}(缺少前缀字段){"user.profile.age": 25, "user.profile.email": "a"}(字段顺序与索引定义不一致)
提示:使用
explain("executionStats")是检验索引是否生效的唯一真理。关注executionStats.nReturned(返回文档数)和executionStats.totalDocsExamined(扫描文档数)。理想情况下,两者应相等且远小于集合总数。
4.2 性能杀手排行榜:那些让你的查询慢如蜗牛的操作
根据我们线上 200+ 个 MongoDB 集群的慢查询日志分析,以下是 Dot Notation 相关的 Top 5 性能杀手:
| 排名 | 操作 | 问题描述 | 解决方案 |
|---|---|---|---|
| 1 | 在大型数组上使用array.field | comments.text查询会扫描comments数组的每一个元素,数组越大越慢 | 用comments.0.text(如果只需首条)或为comments.text创建多键索引 |
| 2 | 对未索引的嵌套字段进行$regex | { "profile.bio": { "$regex": "mongodb" } }无法利用索引,必全表扫描 | 预处理:将关键词提取为独立数组字段profile.keywords: ["mongodb", "database"],然后用{ "profile.keywords": "mongodb" }查询 |
| 3 | 在$or中混合使用点号和非点号字段 | { "$or": [ {"user.profile.email": "a"}, {"status": "active"} ] }无法使用任一索引 | 拆分为两个独立查询,应用层合并结果;或创建覆盖所有字段的复合索引(谨慎评估) |
| 4 | 使用$where或 JavaScript 函数 | { "$where": "this.user.profile.email.indexOf('gmail') > -1" }强制全表扫描 | 重构为原生操作符,如{"user.profile.email": { "$regex": "@gmail\\.com$" } } |
| 5 | 在聚合$lookup后对关联字段使用点号 | $lookup结果是数组,$unwind后再用joined_user.profile.email查询,性能极差 | 在$lookup的pipeline中就完成过滤,让数据库在关联时就剪枝 |
4.3 数据建模反模式:为什么你的嵌套结构正在杀死性能
Dot Notation 的强大,反而容易掩盖数据建模的缺陷。以下是三个高发的反模式:
反模式一:“过度嵌套”的万能对象
// ❌ 错误示范:把所有用户信息塞进一个 profile 对象 "profile": { "personal": { "name": "...", "dob": "..." }, "contact": { "email": "...", "phone": "..." }, "address": { "home": { ... }, "work": { ... } }, "preferences": { ... } }问题:每次只更新profile.contact.email,却要序列化/反序列化整个profile对象,网络和 CPU 开销巨大;profile.contact.email索引无法利用profile的其他字段。
正解:扁平化关键查询字段,保留嵌套用于逻辑分组:
// ✅ 正确示范 "email": "a@b.com", // 关键查询字段,单独索引 "phone": "+86...", "profile": { // 仅存放低频访问、大体积数据 "bio": "...", "avatar_url": "..." }反模式二:“数组爆炸”的标签系统
// ❌ 错误示范:每个用户都有 1000+ 个标签 "tags": ["tag1", "tag2", ..., "tag1000"]问题:tags数组过大,导致文档体积膨胀,影响内存缓存效率;tags多键索引会生成海量索引条目,拖慢写入。
正解:对高频标签建模为独立集合,用引用代替内嵌:
// ✅ 正确示范:用户文档只存 tag_id 数组 "tag_ids": [ObjectId("..."), ObjectId("...")] // tags 集合 { "_id": ObjectId("..."), "name": "mongodb", "category": "tech" }反模式三:“假嵌套”的字符串模拟
// ❌ 错误示范:用字符串存储 JSON,逃避嵌套设计 "metadata": "{\"city\":\"Beijing\",\"country\":\"China\"}"问题:metadata是字符串,metadata.city查询永远返回null;无法创建索引;无法用$set原子更新city字段。
正解:直接存储为对象:
// ✅ 正确示范 "metadata": { "city": "Beijing", "country": "China" }4.4 生产环境避坑清单:那些文档里不会写的血泪教训
坑一:
$unset删除嵌套字段的副作用db.coll.updateOne({ _id: 1 }, { "$unset": { "user.profile.phone": "" } })会删除phone字段,但user.profile对象依然存在。如果profile变成空对象{},后续user.profile.email查询会失败(因为profile是对象,但没有email键)。解决方案:删除后检查,必要时用$exists清理空对象。坑二:
$push与点号的冲突db.coll.updateOne({ _id: 1 }, { "$push": { "comments.0.replies": { "text": "hi" } } })是非法的。$push只能作用于整个数组字段,不能作用于数组的某个索引。正确写法:{ "$push": { "comments.0.replies": { "text": "hi" } } }是错的;应先用$set确保comments.0.replies是数组,再用$push作用于comments.0.replies字段。坑三:时区与日期嵌套的陷阱
{"created_at": { "$date": "2024-01-01T00:00:00Z" }}是标准 ISODate。但如果嵌套为{"meta": { "created_at": "2024-01-01T00:00:00Z" }},meta.created_at就是字符串,{ "meta.created_at": { "$gt": ISODate("2023-01-01") } }会进行字符串比较,结果错误。必须确保嵌套的日期字段是ISODate类型,而非字符串。坑四:
$addToSet在嵌套数组中的行为{"tags": ["a", "b"]},执行{ "$addToSet": { "tags": "a" } },tags不变;但执行{ "$addToSet": { "tags": ["a", "b"] } },会向tags数组添加一个新元素["a", "b"],变成["a", "b", ["a", "b"]]。$addToSet对数组的“相等性”判断是浅比较,不是深比较。
5. 常见问题速查表与终极排查技巧
5.1 “查不到”问题的 5 分钟诊断流程
当你的 Dot Notation 查询返回空结果,请按此顺序快速排查:
| 步骤 | 操作 | 预期结果 | 说明 |
|---|---|---|---|
| 1. 检查数据是否存在 | db.coll.findOne({ _id: ObjectId("...") }) | 返回完整文档 | 确认_id正确,文档真实存在 |
| 2. 检查字段路径与类型 | db.coll.findOne({ _id: ObjectId("...") }, { "projection": { "user.profile.email": 1, "user.profile": 1 } }) | 查看user.profile是对象,且user.profile.email有值 | 确认profile不是null、""或数组 |
| 3. 检查查询语法 | db.coll.find({ "user.profile.email": { "$eq": "a@b.com" } }) | 与{"user.profile.email": "a@b.com"}结果一致 | $eq是显式写法,排除语法歧义 |
| 4. 检查索引与执行计划 | db.coll.find({ "user.profile.email": "a@b.com" }).explain("executionStats") | executionStats.executionStages.stage为IXSCAN | 确认索引被使用;若为COLLSCAN,立即创建索引 |
| 5. 检查权限与视图 | db.runCommand({ "collStats": "coll" }) | 返回集合统计信息 | 确认当前用户有find权限,且未在只读视图中操作 |
5.2 “更新不生效”问题的根因分析
| 现象 | 最可能原因 | 验证命令 | 解决方案 |
|---|---|---|---|
updateOne返回matchedCount: 0 | 查询条件无匹配文档 | db.coll.countDocuments({ your_query }) | 检查查询条件,确认数据存在 |
updateOne返回matchedCount: 1, modifiedCount: 0 | 更新操作未改变字段值(如$set设为相同值,$inc值为 0) | db.coll.findOne({ your_query }) | 检查更新内容是否与当前值相同 |
updateOne报错The positional operator did not find the match needed from the query. | $操作符的查询条件中,未包含对应的数组字段 | db.coll.find({ "comments.author": "bob" }) | 确保查询条件中包含comments.author,而非comments.text |
updateOne报错Cannot create field 'xxx' in element {xxx: null} | 尝试对null字段使用点号(如profile为null,却执行profile.email) | db.coll.findOne({ "profile": null }) | 在更新前,用$exists和$ne过滤掉null文档,或用$setOnInsert初始化 |
5.3 高级调试技巧:用$expr揭开点号的神秘面纱
$expr允许在查询条件中使用聚合表达式,是调试 Dot Notation 的终极武器。
技巧一:查看字段是否存在且非空
// 查所有 user.profile.email 存在且不为 null 的文档 db.users.find({ "$expr": { "$and": [ { "$ne": [{ "$type": "$user.profile.email" }, "missing"] }, { "$ne": ["$user.profile.email", null] } ] } })技巧二:动态计算嵌套字段长度
// 查 comments 数组长度大于 5 的文章 db.posts.find({ "$expr": { "$gt": [{ "$size": "$comments" }, 5] } }) // 查 profile.preferences 数组中 "theme" 字段的值 db.users.find({ "$expr": { "$eq": [{ "$ifNull": ["$user.profile.preferences.theme", "light"] }, "dark"] } })技巧三:安全的嵌套字段访问(避免 null 报错)
// 即使 user 或 profile 为 null,也能安全返回 email 或默认值 db.users.aggregate([ { "$project": { "safe_email": { "$ifNull": [ "$user.profile.email", "no-email@default.com" ] } }