news 2026/10/1 10:42:11

MongoDB高级查询实战:聚合优化、地理检索与慢查询排查

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
MongoDB高级查询实战:聚合优化、地理检索与慢查询排查

如果你是一个刚写完CRUD接口、觉得MongoDB高级查询不过如此的Java开发者,那我劝你先别这么想。上周我接手一个订单统计接口,用户只是点了“导出月度报表”,一个聚合查询直接跑了八秒多,监控里全是扫描几十万文档的告警。后来我只是把筛选链路里$group前置的条件改成$match先过滤,再拆掉一个多余的$lookup,耗时就从八秒掉到了三百毫秒。这件事让我意识到,高级查询不是说你会用几个操作符就行,关键在文档模型、管道顺序、索引和内存边界之间的配合。

这篇文章围绕MongoDB高级查询,重点拆解Java开发者最常用的几个场景:聚合优化、List嵌套List的展开与统计、地理空间检索、慢查询排查、事务一致性,最后附上我踩过的几个高频坑。每段都有可以直接抄的Java驱动代码和参数说明,适合已经会基础CRUD、想进一步把查询写快写稳的人。

1. 文档模型决定高级查询的边界,别拿关系库的SQL思维硬套

1.1 数据建模先于查询设计:数组、嵌套文档和反范式

我在排查慢查询时,第一件事不是看Java代码,而是先打开集合看文档结构。MongoDB最容易被用废的地方,就是开发者拿关系库思维去设计它。关系库习惯把实体拆成一张张表,然后用JOIN拼回来;MongoDB鼓励你把一个聚合根直接落成一份文档,该嵌入就嵌入,该引用再引用。

举个例子,订单系统最常见的文档结构是这样:

{ "_id": "order_10086", "shopId": "S10001", "createTime": ISODate("2025-06-01T10:30:00Z"), "customer": { "userId": "U001", "name": "老张" }, "items": [ { "name": "空调", "amount": 2599, "tags": ["上门安装", "以旧换新"] }, { "name": "风扇", "amount": 199, "tags": ["包邮"] } ], "location": { "type": "Point", "coordinates": [116.397, 39.908] } }

这里customer是嵌套文档,items是对象数组,location是GeoJSON点,tags又是字符串数组,属于“List嵌套List”的典型场景。为什么这样设计?因为订单页面展示时,订单头、商品明细、配送坐标几乎总是一起出现,嵌入一份文档就能一次取回,不用像关系库那样现拼多张表。

但嵌入的代价是查询变复杂:你要按数组元素过滤、按嵌套字段分组、展开两层数组做统计。这正是后续聚合管道要处理的活儿。你必须在建模阶段就想清楚:哪些数据是“一起读”的,哪些是需要单独维护的。高频一起读的放里面,低频大字段放外面,特别大的附件或日志甚至可以用引用单独集合存放。

1.2 Java驱动里Document与Bson的自由度

Java开发者在MongoDB这边的核心API就是MongoCollection<Document>。很多人用起来很不习惯,因为Document没有强类型约束,写错了字段名不会在编译期报错,只在运行期看到null。这个自由度是把双刃剑:它让JSON文档直接映射到对象,但又逼你养成“每个查询字段都要对着真实文档核对”的习惯。

连接驱动的代码可以非常干净:

MongoClient client = MongoClients.create("mongodb://localhost:27017"); MongoDatabase db = client.getDatabase("order_db"); MongoCollection<Document> orders = db.getCollection("orders");

注意Document只是一个Map<String, Object>的包装,所以你可以随心所欲地构造查询条件。真正推荐的查询用法不是手拼字符串,而是用静态导入的Filters、Aggregates、Updates、Sorts。比如查某个店铺当天的订单:

Bson filter = Filters.and( Filters.eq("shopId", "S10001"), Filters.gte("createTime", todayStart), Filters.lt("createTime", tomorrowStart) ); orders.find(filter).sort(Sorts.descending("createTime")).into(new ArrayList<>());

不要小看这些工具类。它们生成的是规范化Bson对象,不会出现字符串拼接导致的注入问题,也能让聚合Pipeline的层级关系在代码里一眼看清。后面所有示例都会保持这种写法。

1.3 查询入口选错,优化无从谈起

很多性能问题不是在管道里翻车,而是第一步就用错了入口。find适合精确条件、排序、分页;distinct适合取去重后的字段值;aggregate适合分组、计数、关联、地理计算这类需要“多阶段加工”的需求。如果只是查几十条明细,你非要用聚合管道先$unwind再$group,等于把简单事复杂化。

还有一个高频错误:想在数组里做条件过滤时,不知道$elemMatch和普通点路径的区别。比如订单items里要找“单价大于500”的订单,你直接写Filters.gt("items.price", 500),结果是只要数组里任意一项满足就返回整份订单,而且无法知道到底哪一项满足。而elemMatch能把数组元素当成一个整体来做条件组合:

Bson filter = Filters.elemMatch("items", Filters.and(Filters.gt("price", 500), Filters.lt("price", 2000)) );

这两种查询数据语义完全不同,explain拉出来看也会走不同的索引分支。所以高级查询的第一步,不是学更多函数,而是把“我要对文档整体操作,还是对数组元素操作”这件事想清楚。选错入口,后面聚合优化做得再漂亮,也救不回来。

2. 聚合管线实战:从List嵌套List到月度报表的一路展开

2.1 $match一定放最前面

聚合管道是一段一段顺序执行的流水线,不是SQL那种由优化器帮你自动重排的查询计划。每一阶段都会把上一阶段输出的文档逐个处理后再交给下一阶段,所以第一个阶段能砍掉多少数据,直接决定后面所有阶段的成本。

线上最常见的问题,就是有人把$match放在$group后面。比如要统计 S10001 店铺6月的订单金额,正确写法是先用$match把shopId和createTime范围筛掉,再进入$group。如果先$group全集合,再$match,等于先把几十万文档分组完才发现其实只要其中一小部分,内存和时间都白烧了。

Java驱动里这一段长这样:

Bson match = Aggregates.match(Filters.and( Filters.eq("shopId", "S10001"), Filters.gte("createTime", Date.from(start.toInstant())), Filters.lt("createTime", Date.from(end.toInstant())) ));

要注意$match里的条件同样可以走索引。聚合管道的第一个$match,和find的过滤条件享受一样的索引优化待遇,所以把高频过滤字段建好索引,能让管道第一关就明显提速。

2.2 嵌套数组双层展开:统计每个标签的销售额

回到“List嵌套List”这个热门问题。比如你想统计每个商品标签对应多少销售额,也就是items.tags这个数组元素,怎么办?不能直接对items.tags做$group,因为tags是嵌套在两层的数组里。你要先展开一层items,再展开一层items.tags。

管道阶段如下:

pipeline.add(Aggregates.unwind("$items")); pipeline.add(Aggregates.unwind("$items.tags"));

第一次$unwind会把一份订单拆成多份,每份只带一个items元素;第二次$unwind再把每个items元素按其tags数组继续拆。这时候管道里的文档大概长这样:

{ "_id": "order_10086", "shopId": "S10001", "items": { "name": "空调", "amount": 2599, "tags": "上门安装" } } { "_id": "order_10086", "shopId": "S10001", "items": { "name": "空调", "amount": 2599, "tags": "以旧换新" } }

展开之后,$group按items.tags分组累加items.amount,每个标签的销售额就出来了。需要注意的是,如果某条文档里的数组为空,$unwind默认会直接丢弃这条文档。如果希望空数组也保留下来做统计占位,要加preserveNullAndEmptyArrays(true):

pipeline.add(Aggregates.unwind("$items.tags", new UnwindOptions().preserveNullAndEmptyArrays(true)));

这个参数在业务上非常关键。统计“零增长场景”时,你往往希望没有标签的订单也计入总数,而不是静默消失。

2.3 多维分组与字段整形:$group + $project

做月度报表,经常要按“月份+店铺”两个维度分组。$group的_id可以是一个复合结构,Java里直接构造Bson文档:

Bson group = Aggregates.group( new Document("$dateToString", new Document("format", "%Y-%m") .append("date", "$createTime")) .append("shopId", "$shopId"), Accumulators.sum("totalAmount", "$items.amount"), Accumulators.sum("orderCount", 1), Accumulators.avg("avgPerOrder", "$items.amount") ); pipeline.add(group);

这里我用$dateToString先格式化时间,让_id变成{ month: "2025-06", shopId: "S10001" },然后累加总额、单量和均价。需要提醒的是,$dateToString的默认时区是UTC,如果你的业务时区在UTC+8,月份边界就会偏掉几个小时,后面踩坑清单里会专门说。

$group之后,管道里只剩汇总结果。如果你还嫌字段名丑、层级深,可以用$project做最后一层整形:

Bson project = Aggregates.project(Projections.fields( Projections.include("totalAmount", "orderCount"), Projections.computed("month", "$_id.month"), Projections.computed("shopId", "$_id.shopId"), Projections.excludeId() ));

$project不止能选字段,还能用$convert做类型转换、用$substr切字符串。但注意别在$project里做太多计算,能前置到$match和$unwind的尽量前置。聚合管道的哲学是:每个阶段只做一件事,尽快减少文档体积和字段体积。

2.4 少用$lookup,要用就带管道条件

MongoDB本身不是关系库,$lookup虽然能“连表”,但通常意味着你要把右集合的所有匹配文档搬进内存,开销比SQL的JOIN大得多。我的习惯是:只要数据允许,就把关联字段直接嵌入主文档;实在要关联,也尽量用pipeline形式的$lookup限定匹配范围,而不是全量拉进来再过滤。

Java驱动支持两种写法。简单写法是按本地字段和外部字段等值关联:

pipeline.add(Aggregates.lookup("users", "customer.userId", "_id", "userInfo"));

更稳的写法是管道式$lookup,先在外部集合过滤,再关联:

Bson lookup = Aggregates.lookup( "users", new Document("$match", Filters.and( Filters.eq("status", "active"), Filters.expr(Filters.eq("$_id", "$$uid")) )), "userInfo" ); // 上面是示意图,实际管道式lookup需要更完整的let声明

实际上管道式$lookup要配合let声明变量,Java里写起来会稍微啰嗦,但语义更强:先只留下需要的active用户,再关联进来。这样做的好处是右侧集合的扫描和传输量明显减少。

我还见过一种反面例子:订单规模只有一千条,却每单都$lookup一个百万级用户集合,结果接口直接超时。这种场景的正确做法是把用户昵称冗余到订单里,通过更新时同步或异步补齐,而不是查询时现拼。数据一致性完全可以用下文的事务或最终一致性方案兜住。

2.5 allowDiskUse:大数据量聚合的内存兜底

聚合操作符默认有100MB内存限制,超出后会直接报错,报错信息类似Exceeded memory limit for $group, but didn't allow external sort。特别是$sort和$group同时出现、输入数据又是几十万条时,很容易触顶。

Java驱动里开启磁盘兜底是一行代码:

List<Document> result = new ArrayList<>(); orders.aggregate(pipeline) .allowDiskUse(true) .into(result);

允许落盘以后,MongoDB可以把临时数据溢写到磁盘,避免内存被打爆。但这不等于可以放纵:临时文件在分片集群和大并发场景下会吃IO和磁盘,正确姿势是先用索引和$match把数据压下去,allowDiskUse只是最后的保险,不是优化方案。

3. 地理空间查询:从“附近的人”到电子围栏圈选

3.1 GeoJSON的经纬度顺序,错了就查询失败

地理空间查询第一个坑不是查询语句,而是存储格式。MongoDB使用GeoJSON时,坐标数组的顺序一定是[经度, 纬度],也就是[lng, lat],不是国内常见的“先纬度后经度”。很多做过地图开发的人习惯[39.908, 116.397],存进去后$nearSphere不是查不到,就是索引校验直接失败。

正确的点格式:

{ "name": "银泰店", "location": { "type": "Point", "coordinates": [116.397, 39.908] } }

字段名可以随便取,但type必须是GeoJSON类型,坐标必须是数组。如果是范围查询,比如多边形,注意外环的坐标数组必须首尾闭合,也就是说最后一个点和第一个点要相同,否则$geoWithin可能报Loop is not closed之类的错误。

3.2 三种地理查询怎么选:$nearSphere、$geoNear、$geoWithin

我整理过一张对比表,几乎每次见人就发:

场景推荐方法说明
查询附近N米内的门店$nearSphere或$near语义直观,返回结果会自动按距离由近到远排序
想同时得到距离并做聚合统计聚合阶段$geoNear能输出distanceField,还能和其他聚合阶段配合
圈定一个多边形/矩形范围$geoWithin不做距离排序,也不返回距离,只判断是否在范围内
判断点是否落在某个区域$geoIntersects反过来判断几何相交,适合围栏命中

最容易被忽略的是:$nearSphere走球面距离,单位是米;$near默认走平面距离,精度在很大范围上不够。如果门店跨城市、跨经纬度较大,直接用$near会算出失真结果。正确做法是统一用球面计算,并在创建索引时用2dsphere,而不是老式的2d平面索引。

3.3 Java驱动里的坐标查询代码

先建索引,Java里这样写:

MongoCollection<Document> shops = db.getCollection("shops"); shops.createIndex(Indexes.geo2dsphere("location"));

然后查“距离 [116.397, 39.908] 五公里内的门店”:

Bson nearFilter = Filters.nearSphere( "location", new Point(new Position(116.397, 39.908)), 5000.0, // maxDistance,单位米 0.0 // minDistance ); List<Document> nearby = shops.find(nearFilter) .limit(20) .into(new ArrayList<>());

注意maxDistance和minDistance在这里都是双精度米数。返回结果只包含匹配文档,并不会自动给你算距离。如果想拿到“这家店离我多少米”,要用$geoNear管道:

Bson geoNearStage = new Document("$geoNear", new Document() .append("near", new Document("type", "Point") .append("coordinates", Arrays.asList(116.397, 39.908))) .append("distanceField", "distance") .append("maxDistance", 5000.0) .append("spherical", true) ); List<Document> result = new ArrayList<>(); shops.aggregate(Collections.singletonList(geoNearStage)) .limit(20) .into(result);

执行完以后,每条文档都会多一个distance字段,单位是米,可以直接拿来在Java后端返回给前端。

3.4 范围圈选、精度和索引冷知识

做电子围栏时,比如“圈出某个区域的配送范围”,用$geoWithin最合适。Java里可以直接传一个Document形式的GeoJSON多边形:

Document polygon = new Document("type", "Polygon") .append("coordinates", Arrays.asList( Arrays.asList(116.35, 39.85), Arrays.asList(116.45, 39.85), Arrays.asList(116.45, 39.95), Arrays.asList(116.35, 39.95), Arrays.asList(116.35, 39.85) )); List<Document> inZone = shops.find(Filters.geoWithin("location", polygon)) .into(new ArrayList<>());

$geoWithin不会自动按距离排序,也不会返回距离字段,所以它适合做“是否在内”的判断,而不是“谁更近”。精度方面,2dsphere索引对GeoJSON默认支持到大约米级精度,已经满足绝大多数业务。如果你要处理高精度测绘,可能要配合$geoIntersects和更严格的多边形细分。

最后说一个冷知识:2dsphere索引是可以建立在复合索引里的。比如门店查询经常同时过滤“营业状态”和“坐标范围”,那就把status和location建成复合索引:

shops.createIndex(Indexes.compoundIndex( Indexes.ascending("status"), Indexes.geo2dsphere("location") ));

复合地理索引里,地理字段前的普通字段用来快速过滤,地理字段本身负责空间检索。这样比分别建两个索引更省空间,查询计划也更容易命中。

4. 慢查询排查与索引优化:explain输出到底看哪里

4.1 索引不是越多越好,而是要对准查询形状

很多Java同学遇到聚合慢,第一反应是“把所有可能过滤的字段都建上索引”。我见过一个集合建了二三十个单字段索引,结果写放大严重,每次insert要同时维护几十棵B树,最后写入比查询还慢。

索引设计必须对着实际查询形状来。所谓查询形状,就是过滤条件、排序条件、分组条件组合起来的长相。比如报表接口的核心是shopId + createTime过滤,再按createTime排序,那就建一个复合索引:

orders.createIndex(Indexes.compoundIndex( Indexes.ascending("shopId"), Indexes.descending("createTime") ));

这里有个经验法则:等值条件放前面,排序和范围条件放后面。原因是MongoDB在复合索引中如果对第一字段精确匹配,之后用的仍然是索引有序性,能省掉一次内存排序。如果第一字段是范围条件,排序能力会被削弱。所以别小看字段顺序,它决定了sort阶段是走索引还是走内存。

4.2 explain三个核心字段要会读

Java驱动里可以直接拿执行计划:

Document explain = orders.find(Filters.eq("shopId", "S10001")) .sort(Sorts.descending("createTime")) .explain();

这段拿到的是默认查询计划。更推荐看executionStats执行统计,在shell里用:

db.orders.find({ shopId: "S10001" }).sort({ createTime: -1 }).explain("executionStats")

重点看三块。第一是winningPlan,里面会暴露stage是IXSCAN(索引扫描)、COLLSCAN(全表扫描)还是SORT。出现COLLSCAN基本等于索引没生效。第二是executionStats.totalDocsExamined,也就是实际看了多少文档。这个数字越小越好。第三是totalKeysExamined,这个是索引键扫描次数。理想情况是totalKeysExamined和totalDocsExamined都接近返回条数,说明索引精准命中了目标数据。

有个经典比喻:全表扫描等于你在一个没有目录的图书馆从第一本翻到最后一本;索引扫描等于先查目录卡再直奔书架;而sort出现在执行计划里,等于虽然找到了书,但还得按出版日期重新排一遍,多花一道工。

4.3 深分页和内存排序是隐形杀手

聚合排序默认吃100MB内存,前面提过一次。find排序虽然没有这个硬限制,但一旦sort不能走索引,所有匹配文档都会被塞进内存排序。数据量一大,要么慢,要么直接报内存错误。

深分页问题同样隐蔽。find().skip(100000).limit(10)看起来只是跳十万条,但MongoDB实际是把前面十万条全部扫出来丢掉,再取后面十条。skip越大越慢。对这种场景,有几种缓解方案。最常用的是“基于上次结果的游标翻页”:用_id或createTime记住上次位置,下一页直接用$gt或$lt条件继续取:

Bson pageFilter = Filters.and( Filters.gt("_id", lastId), Filters.eq("shopId", "S10001") ); List<Document> page = orders.find(pageFilter) .limit(20) .sort(Sorts.ascending("_id")) .into(new ArrayList<>());

这种翻页方式的时间复杂度稳定在“索引查找 + 取一页”,不会随页数增加而线性恶化。代价是不能再随意跳页,但绝大多数B端列表系统其实不需要任意跳页,下拉加载就够了。

另外,批量拉取大结果集时,into(new ArrayList<>())会把所有数据一次装进内存。如果你要导出十万条,最好流式处理:

MongoCursor<Document> cursor = orders.find(filter).iterator(); while (cursor.hasNext()) { Document doc = cursor.next(); // 逐条写入文件或队列 } cursor.close();

这样内存占用稳定,不会因为导出量一大就把JVM堆撑爆。很多“接口偶尔OOM”的问题,根子其实都在这。

5. 事务与一致性:Java里的分布式文档库最后一公里

5.1 单文档原子性和多文档事务怎么取舍

MongoDB的高性能很大程度来自“单文档原子操作”。只要一次更新只影响一份文档,updateOne的原子性就是天然保证。比如扣减某个商品的库存,用$inc就能安全完成:

products.updateOne(Filters.eq("_id", "SKU001"), Updates.inc("stock", -1));

这里不需要事务,也不需要先查再改。但实际业务经常是“扣库存 + 记流水 + 更新订单状态”三个动作,跨了多份文档,就需要多文档事务。MongoDB支持多文档事务,但普及度不如关系库,很多人不知道它早在4.0就开始支持副本集内事务了。需要注意的前提是:事务只在副本集和分片集群上可用,单机standalone不支持。

多文档事务的代价是并发性能下降,因为事务期间要持有锁、写oplog、做冲突检测。所以设计取舍是:能用单文档原子操作解决的就不要开事务;必须保证强一致多文档更新时,才把相关操作放进同一个事务。

5.2 Java会话事务标准写法

Java驱动里多文档事务要用ClientSession包起来,正确姿势是这样:

MongoClient client = MongoClients.create("mongodb://localhost:27017"); MongoDatabase db = client.getDatabase("shop_db"); MongoCollection<Document> accounts = db.getCollection("accounts"); try (ClientSession session = client.startSession()) { session.startTransaction(); try { accounts.updateOne(session, Filters.eq("_id", "A"), Updates.inc("balance", -100)); accounts.updateOne(session, Filters.eq("_id", "B"), Updates.inc("balance", 100)); session.commitTransaction(); } catch (Exception e) { session.abortTransaction(); throw e; } }

关键点有三个。第一:所有参与事务的写操作必须把session作为第一个参数传进去,否则不会进入同一事务上下文。第二:事务默认可能有事务超时时间,长事务容易踩到TransactionTimeout,所以事务里面别做远程调用、别等锁、别写日志文件。第三:事务提交失败时不一定真的失败,可能是不确定状态,需要查重或重试才能判断。

5.3 重试、幂等和余额扣减的经典坑

分布式场景下“先查余额,再扣款”是天坑。你查出余额是1000,另一个请求同时把它改成了800,你再基于1000去扣,结果就错了。正确做法是把判断条件写进更新操作里,让数据库自己保证原子性:

UpdateResult result = accounts.updateOne( Filters.and( Filters.eq("_id", "A"), Filters.gte("balance", 100) ), Updates.inc("balance", -100) ); if (result.getModifiedCount() == 0) { // 余额不足或账户不存在 }

这一招不用事务也能解决大部分余额扣减问题,因为updateOne是原子操作。如果还要同时给另一个账户加钱,才需要引入前面的事务。事务提交后如果返回网络异常,客户端可能不知道是否成功,业界通用方案是引入幂等键,比如为每笔扣款生成唯一流水号,在事务里先查流水号是否已存在,存在就不再执行。这个习惯能帮你避免很多“重复扣款”的线上事故。

6. 高频踩坑清单:这些Java代码看着对其实坑很大

6.1 用ObjectId排序并不等于按创建时间排序

ObjectId是MongoDB默认主键,前4字节是时间戳,后面是随机值加计数器。很多人就直接Sorts.ascending("_id")来模拟按时间排序,在低并发、单进程环境下偶尔能对上,但严格来说并不可靠。因为同一秒内创建的文档,排序顺序取决于随机加计数器的组合,跨进程时顺序更不可控。

如果业务强依赖“插入顺序”,正确的做法是单独存一个createTime字段,并且建立createTime索引,查询时显式按它排序。不要为了省一个字段去赌_id的顺序。遇到过手滑复用ObjectId做分页游标,结果下一页跳空了好几条,因为_id并不是严格单调的。

6.2 时区问题会让“月度报表”少一天数据

线上报表按月分组时,我吃过一次大亏。业务在UTC+8,凌晨零点到八点的订单,如果按UTC日期分组,会被算进前一天。一个月下来,每天和前一天的边界都斜着切一刀,报表对不上账。解决办法是在$dateToString里显式指定timezone:

new Document("$dateToString", new Document("format", "%Y-%m-%d") .append("date", "$createTime") .append("timezone", "Asia/Shanghai"))

Java驱动里还可以直接传ZoneId,比如.append("timezone", ZoneId.of("Asia/Shanghai"))。不是所有聚合操作符都支持时区字段,所以提前在文档里约定:所有createTime统一存UTC毫秒或ISO Date,只在展示和分组时转时区。这样既规避服务器时区漂移,也避免各端算法不一致。

6.3 正则查询没转义,慢查询小则乱大则误伤

Java里用户输入关键词,很多人直接拼Pattern.compile(".*" + keyword + ".*")传给MongoDB做模糊搜索。这种写法有两个问题:第一,包含前导通配符的正则基本无法用索引,数据量一大就是全表扫描;第二,用户输入里的.、*、?会被当成正则元字符,语义完全变了。

如果确实需要前缀模糊匹配,应该用^前缀正则,并且对关键字做转义:

String escaped = Pattern.quote(keyword); Pattern regex = Pattern.compile("^" + escaped, Pattern.CASE_INSENSITIVE); collection.find(Filters.regex("name", regex));

这样做至少有机会命中普通索引的前缀范围。如果非要“包含匹配”,且数据量很大,建议改用专门的文本索引或外置搜索能力,而不是硬扛正则扫全表。

6.4 null、空数组和字段缺失的语义并不一样

查询{ tags: null }时,MongoDB会把“字段不存在”和“字段值就是null”都匹配进来。这个行为容易让你在统计“没有标签的订单”时误伤那些压根没有tags字段的历史文档。如果你只想查“有该字段且值为null”的文档,要用$type配合$exists:

Bson nullFilter = Filters.and( Filters.exists("tags", true), Filters.type("tags", "null") );

空数组同理。db.orders.find({ tags: [] })能查到tags为空数组的文档,但如果你用$size: 0,语义其实更明确、也能被索引优化更好地覆盖。三条路看起来差不多,实际统计口径差很多。上线前建议先在测试数据集对比count,把口径确定下来再写Java逻辑。


最后说一点我个人的体会:高级查询真的不是堆操作符,而是理解数据在磁盘上怎么存、管道在内存里怎么流转、索引在查询计划里怎么走。我吃过好几次亏之后才把习惯改过来:先拿真实数据量跑explain,再调管道和索引,最后才把代码交上去。如果你也想少踩坑,遇到慢查询别急着抄网上的写法,先打开MongoDB的explain看一眼,再决定要不要加索引、要不要拆阶段。这一步做完,很多问题其实自己就浮现了。

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/10/1 10:41:23

金属加工车间油雾治理:从选型误区到2026年市场机会全解析

干这行久了&#xff0c;每次走进机加工车间&#xff0c;鼻子比眼睛先提醒你——空气中那股混着切削液气味和细微油性颗粒的"雾糊感"&#xff0c;几乎成了金属加工车间的标配气息。我见过太多老板觉得这不就是"味道大一点"的常态&#xff0c;直到机床电柜里…

作者头像 李华
网站建设 2026/10/1 10:40:16

大模型协同生成可运行3D游戏:Unity工程级实践指南

1. 这不是“跑个Demo”&#xff1a;一次真实工程级3D游戏生成的全链路复现我上周在实验室里把三台机器并排摆开&#xff0c;一台装着刚拉下来的 Step 5 Preview 镜像&#xff0c;一台跑着 DeepSeek V4 Pro 的量化推理服务&#xff0c;第三台是本地编译的 GLM5.3 vLLM 0.6.4 镜…

作者头像 李华
网站建设 2026/10/1 10:38:51

U-Net+CBAM裂缝分割实战:从工地图像到像素级检测

简介&#xff1a;本资源是一套基于深度学习的裂缝检测技术完整实现方案&#xff0c;面向计算机、人工智能、土木工程及自动化等专业的在校学生、教师与初级工程师&#xff0c;适用于课程设计、毕业设计、科研入门与工程实践参考。压缩包共3个文件&#xff0c;含2个核心Python脚…

作者头像 李华
网站建设 2026/10/1 10:38:41

NLP端到端工程骨架:可复现、可部署的中文文本分类与对话实践

简介&#xff1a;这是一份面向NLP初学者与进阶学习者的综合性实践代码包&#xff0c;覆盖文本分类、对话机器人、Transformer架构实现、GPT语言模型微调、图神经网络&#xff08;GNN&#xff09;在NLP中的应用、对抗训练、摘要抽取、知识蒸馏、VAE文本生成及中文医疗问答等11大…

作者头像 李华
网站建设 2026/10/1 10:37:09

OpenCV+LBPH实现轻量级人脸考勤系统

简介&#xff1a;本资源是一套基于OpenCV与Python实现的轻量级人脸识别考勤系统源码&#xff0c;面向计算机专业本科生、人工智能初学者及课程设计实践者&#xff0c;解决高校课堂/实验室场景下传统考勤效率低、易代签、数据难统计等实际问题。压缩包共28个文件&#xff0c;含1…

作者头像 李华