首页 / 资讯中心 / 文章详情

MongoDB多表关联实战:MongoTemplate $lookup性能优化与避坑指南

MongoDB多表关联实战:MongoTemplate $lookup性能优化与避坑指南 ★ FEATURED ARTICLE
1. 为什么“多表关联”在 MongoDB 里是个伪命题1.1 先搞清楚 MongoDB 的文档模型到底怎么回事很多从 MySQL 转过来的兄弟第一次用 MongoDB 都会下意识地找JOIN关键字。我当初也一样脑子里全是LEFT JOIN、INNER JOIN那套东西结果翻遍文档发现 MongoDB 压根没有这玩意儿。这不是 MongoDB 偷懒没做而是它的数据模型从根上就跟关系型数据库走了两条路。关系型数据库讲究的是范式化把数据拆散到不同的表里通过外键关联用JOIN在查询时拼回来。这样做的好处是数据冗余少、更新一致性强。但代价是什么每次查询都要做磁盘随机 I/O表越大、关联越多性能衰减越厉害。我见过一个六表关联的查询在千万级数据量下跑了几十秒加了各种索引都救不回来。MongoDB 的思路完全反过来。它鼓励你把经常一起访问的数据放在同一个文档里用嵌套文档或者数组来组织。这样一次查询就能拿到所有需要的数据不需要跨集合拼装。你可以把它想象成MySQL 是把一本书拆成很多页散落在不同地方每次读都要去不同地方取MongoDB 是把一个章节装订在一起翻到那一页就全有了。但现实情况是我们做业务系统数据之间的关系天然就是复杂的。用户、订单、商品、评论这些实体之间有多对多、一对多的关系不可能全部塞进一个文档里。所以 MongoDB 提供了$lookup聚合操作符来模拟关联查询Spring Data MongoDB 里的MongoTemplate也封装了对应的 API。1.2 $lookup 到底能做什么不能做什么$lookup是 MongoDB 3.2 引入的聚合阶段操作符语法大概长这样{ $lookup: { from: orders, localField: _id, foreignField: userId, as: userOrders } }它的作用是把当前集合的某个字段和另一个集合的某个字段做匹配把匹配到的文档以数组形式塞进一个新字段里。看起来跟LEFT JOIN很像对吧但有几个关键区别你得心里有数第一$lookup 只支持左外连接。没有INNER JOIN的等价写法你得在后续的$match阶段过滤掉空数组。这个设计其实挺合理的因为 MongoDB 的聚合管道本身就是流式的你可以在$lookup之后接各种过滤、排序、分组操作。第二$lookup 的性能跟索引强相关。如果foreignField上没有索引每次匹配都要全集合扫描。我实测过一个场景主集合 10 万条数据从集合 50 万条数据foreignField没建索引查询跑了 40 多秒。建了索引之后降到 200 毫秒以内。这个差距是致命的所以用 $lookup 之前必须先确认 foreignField 有索引。第三$lookup 的结果是数组。即使匹配到的只有一条记录它也会包在数组里。如果你确定是一对一关系可以用$unwind展开但要注意$unwind对空数组的处理——默认会丢弃空数组的文档加preserveNullAndEmptyArrays: true才能保留。第四$lookup 不能跨数据库。from字段只能是同一个数据库下的集合。如果你分库了那就只能应用层拼装。1.3 什么场景该用 $lookup什么场景不该用我个人的经验法则是这样的场景推荐方案理由一对少量如订单关联用户$lookup数据量小一次查询搞定一对大量如用户关联所有订单应用层分两次查询$lookup 会产生巨大数组内存爆炸多对多如文章关联标签嵌入文档 ID 数组 应用层拼装$lookup 嵌套太深性能差频繁访问的关联数据冗余嵌入用空间换时间避免关联报表统计类关联$lookup $group聚合管道擅长这类操作这个表格不是死的具体还得看你的数据量、访问模式和一致性要求。但核心原则就一条能嵌入就别关联能应用层拼装就别用 $lookup。$lookup 是最后的手段不是首选方案。2. MongoTemplate 操作 $lookup 的完整姿势2.1 环境准备和基础配置先假设你用的是 Spring Boot 项目pom.xml 里加上依赖dependency groupIdorg.springframework.boot/groupId artifactIdspring-boot-starter-data-mongodb/artifactId /dependencyapplication.yml 配置spring: data: mongodb: uri: mongodb://localhost:27017/mydb auto-index-creation: true这里有个坑要注意auto-index-creation默认是 false如果你用Indexed注解声明索引必须显式开启。但生产环境我建议关掉这个选项手动管理索引避免应用启动时意外创建索引导致性能抖动。实体类定义假设我们有两个集合user和order。Document(collection user) Data public class User { Id private String id; private String name; private String email; Indexed private String city; } Document(collection order) Data public class Order { Id private String id; private String userId; private BigDecimal amount; private Date createTime; Indexed private String status; }注意Order里的userId加了Indexed这是后面$lookup性能的关键。User的city也加了索引因为后面要按城市过滤。2.2 最基础的 $lookup查用户及其订单假设需求是查所有用户并带上他们的订单列表。用MongoTemplate这样写public ListUserWithOrders getUserWithOrders() { LookupOperation lookupOperation LookupOperation.newLookup() .from(order) .localField(_id) .foreignField(userId) .as(orders); Aggregation aggregation Aggregation.newAggregation(lookupOperation); AggregationResultsUserWithOrders results mongoTemplate.aggregate( aggregation, user, UserWithOrders.class); return results.getMappedResults(); }对应的结果类Data public class UserWithOrders { private String id; private String name; private String email; private String city; private ListOrder orders; }这段代码跑起来没问题但有几个细节值得说第一localField和foreignField的类型必须一致。如果user._id是 ObjectId而order.userId是 String那匹配不上。我踩过这个坑数据明明存在查出来orders永远是空数组。解决办法是在写入时就统一类型或者用$toString/$toObjectId在聚合管道里转换。第二as字段名不要跟已有字段冲突。如果你写.as(name)会把原来的name字段覆盖掉结果就是用户名字变成了订单数组。这个错误很隐蔽因为不会报错只是数据不对。第三结果类里的orders字段类型要跟实际匹配结果匹配。如果Order类里有些字段在数据库里不存在反序列化时会忽略但如果数据库里有Order类没有的字段也会被忽略。反过来如果Order类里有Field注解映射了不存在的字段会得到 null。2.3 带过滤条件的 $lookup只查已完成的订单实际业务中不可能把所有订单都拉出来通常要加过滤条件。比如只查状态为 COMPLETED 的订单public ListUserWithOrders getUserWithCompletedOrders() { LookupOperation lookupOperation LookupOperation.newLookup() .from(order) .localField(_id) .foreignField(userId) .as(orders); // 注意这里的过滤是在 lookup 之后过滤的是 user 文档 // 如果要过滤 order需要用 pipeline 形式的 lookup MatchOperation matchOperation Aggregation.match( Criteria.where(city).is(Beijing)); Aggregation aggregation Aggregation.newAggregation( matchOperation, lookupOperation); return mongoTemplate.aggregate(aggregation, user, UserWithOrders.class) .getMappedResults(); }但这样写有个问题matchOperation过滤的是user集合orders数组里仍然包含所有状态的订单。如果你只想保留已完成的订单有两种做法做法一在应用层过滤。查出结果后用 Java 8 Stream 过滤results.forEach(u - u.setOrders( u.getOrders().stream() .filter(o - COMPLETED.equals(o.getStatus())) .collect(Collectors.toList()) ));这种做法简单直接适合订单量不大的场景。但如果每个用户有几千个订单全查出来再过滤就是浪费。做法二用 pipeline 形式的 $lookup。MongoDB 3.6 之后支持在$lookup里写子管道LookupOperation lookupOperation LookupOperation.newLookup() .from(order) .localField(_id) .foreignField(userId) .as(orders) .pipeline(Aggregation.match( Criteria.where(status).is(COMPLETED)));但 Spring Data MongoDB 的LookupOperationAPI 对 pipeline 的支持在不同版本里不太一样。我用的 3.x 版本里newLookup()返回的 builder 没有直接的.pipeline()方法。这时候就得用Aggregation的底层 API 或者直接写DocumentAggregation aggregation Aggregation.newAggregation( Aggregation.match(Criteria.where(city).is(Beijing)), context - new Document($lookup, new Document(from, order) .append(let, new Document(userId, $_id)) .append(pipeline, Arrays.asList( new Document($match, new Document($expr, new Document($and, Arrays.asList( new Document($eq, Arrays.asList($userId, $$userId)), new Document($eq, Arrays.asList($status, COMPLETED)) )) ) ) )) .append(as, orders) ) );这段代码看起来有点吓人但拆开看就清楚了let定义变量pipeline里用$expr做条件匹配。这种写法灵活度最高但可读性差建议封装成工具方法。2.4 嵌套 $lookup订单关联商品如果订单里还有商品 ID需要再关联商品集合就涉及嵌套$lookupLookupOperation userOrderLookup LookupOperation.newLookup() .from(order) .localField(_id) .foreignField(userId) .as(orders); LookupOperation orderProductLookup LookupOperation.newLookup() .from(product) .localField(orders.productId) .foreignField(_id) .as(products); Aggregation aggregation Aggregation.newAggregation( userOrderLookup, Aggregation.unwind(orders), orderProductLookup, Aggregation.group(_id) .first(name).as(name) .first(email).as(email) .push(new Document(order, $orders) .append(products, $products)) .as(orderDetails) );这里有几个关键点第一localField可以是数组字段。orders.productId表示取orders数组里每个元素的productIdMongoDB 会自动展开匹配。这个特性很好用但要注意如果orders是空数组$lookup结果也是空数组。第二$unwind的位置很重要。必须在第二个$lookup之前展开orders否则orders.productId没法正确匹配。$unwind之后文档数量会膨胀每个订单变成一条独立文档。第三$group重新组装。展开后再分组把订单和商品重新塞回数组。这里的$push里用了new Document构造嵌套结构实际项目中建议定义专门的 DTO 类。第四性能警告。嵌套$lookup加上$unwind如果数据量大内存消耗会非常恐怖。MongoDB 聚合管道有 100MB 的内存限制超过会报错。解决办法是加allowDiskUse(true)Aggregation aggregation Aggregation.newAggregation(...) .withOptions(AggregationOptions.builder() .allowDiskUse(true) .build());但允许磁盘使用只是不报错了性能还是会很差。这种场景我建议重新设计数据模型把常用字段冗余到订单里避免深层关联。3. 性能优化让 $lookup 跑得跟单表查询一样快3.1 索引是命根子没有索引一切白搭前面反复强调了索引的重要性这里展开说下具体怎么建。对于$lookup来说关键索引在从集合的 foreignField 上。以上面的例子就是order.userId// 方式一注解 Indexed private String userId; // 方式二手动创建 mongoTemplate.indexOps(order).ensureIndex( new Index().on(userId, Sort.Direction.ASC) );但光有单字段索引还不够。如果你的查询里还有过滤条件比如status COMPLETED那最好建复合索引mongoTemplate.indexOps(order).ensureIndex( new Index().on(userId, Sort.Direction.ASC) .on(status, Sort.Direction.ASC) );复合索引的字段顺序很关键等值匹配的字段放前面范围匹配的放后面。userId是等值匹配$eqstatus也是等值匹配所以顺序影响不大。但如果有个createTime范围查询那createTime必须放最后。怎么验证索引生效了用explainAggregation aggregation Aggregation.newAggregation(lookupOperation); Document explainResult mongoTemplate.aggregate(aggregation, user, Document.class) .getRawResults();更直接的方式是在 MongoDB shell 里跑db.user.aggregate([...]).explain(executionStats)看executionStats.executionStages里的indexName字段如果是COLLSCAN就说明没走索引得检查索引建对没有。3.2 限制返回字段别把整个文档拖出来$lookup默认会把从集合的整个文档塞进数组。如果order文档很大比如有几十个字段而前端只需要amount和createTime那就是巨大的浪费。用$project限制字段Aggregation aggregation Aggregation.newAggregation( lookupOperation, Aggregation.project(name, email) .and(orders.amount).as(orderAmounts) .and(orders.createTime).as(orderTimes) );但注意$project对$lookup产生的数组字段做投影时语法有点特殊。orders.amount会提取所有订单的amount组成新数组。如果你想要完整的订单对象但只保留部分字段得在$lookup的 pipeline 里做投影context - new Document($lookup, new Document(from, order) .append(let, new Document(userId, $_id)) .append(pipeline, Arrays.asList( new Document($match, new Document($expr, new Document($eq, Arrays.asList($userId, $$userId)))), new Document($project, new Document(amount, 1) .append(createTime, 1) .append(_id, 0)) )) .append(as, orders) )这样从集合只返回需要的字段网络传输和内存占用都能大幅降低。我实测过一个场景order文档有 30 多个字段用$project只取 3 个字段后查询耗时从 1.2 秒降到 300 毫秒。3.3 分页的正确打开方式分页是个大坑。很多人直接在聚合管道最后加$skip和$limitAggregation aggregation Aggregation.newAggregation( lookupOperation, Aggregation.skip((long) page * size), Aggregation.limit(size) );这样写在小数据量下没问题但数据量大了之后$skip会扫描并丢弃前面所有文档页码越大越慢。第 100 页可能要跑几十秒。正确的做法是基于游标的分页cursor-based pagination用上一页最后一条记录的_id或时间戳作为起点Aggregation aggregation Aggregation.newAggregation( Aggregation.match(Criteria.where(_id).gt(lastId)), Aggregation.sort(Sort.Direction.ASC, _id), Aggregation.limit(size), lookupOperation );注意$match和$sort要放在$lookup之前先用索引快速定位到需要的文档再做关联。这样每页查询时间基本恒定不会随页码增长而变慢。如果业务非要支持跳页那至少把$skip和$limit放在$lookup之前Aggregation aggregation Aggregation.newAggregation( Aggregation.match(filterCriteria), Aggregation.skip((long) page * size), Aggregation.limit(size), lookupOperation );这样先分页再关联关联的数据量从全量变成了一页的量性能提升非常明显。3.4 什么时候该放弃 $lookup我做了这么多年总结出一个判断标准如果 $lookup 之后的文档数量超过原文档的 10 倍或者单个文档的数组长度超过 1000就该考虑换方案了。替代方案有这么几种方案一应用层两次查询。先查主集合拿到 ID 列表再用$in查从集合最后在 Java 里用 Map 拼装。这种方式把压力从数据库转移到了应用层适合从集合数据量不大的场景。ListUser users mongoTemplate.find(query, User.class); ListString userIds users.stream().map(User::getId).collect(Collectors.toList()); ListOrder orders mongoTemplate.find( Query.query(Criteria.where(userId).in(userIds)), Order.class); MapString, ListOrder orderMap orders.stream() .collect(Collectors.groupingBy(Order::getUserId)); users.forEach(u - u.setOrders(orderMap.getOrDefault(u.getId(), Collections.emptyList())));方案二冗余嵌入。把常用的关联数据直接嵌入主文档。比如用户文档里直接存最近 10 条订单的摘要。这样查询用户时一次就能拿到不需要关联。代价是更新订单时要同步更新用户文档一致性维护复杂一些。方案三物化视图。用定时任务或变更流Change Stream预先计算好关联结果存到一个专门的集合里。查询时直接查这个集合速度极快。适合报表、看板这类对实时性要求不高的场景。4. 踩坑实录那些年我被 $lookup 坑过的瞬间4.1 类型不匹配导致永远查不到数据这是最常见的坑没有之一。MongoDB 是弱类型的_id默认是ObjectId但如果你在 Java 里用 String 存userId那$lookup就匹配不上。// user 集合 { _id: ObjectId(...), name: 张三 } // order 集合 { _id: ObjectId(...), userId: 60a1b2c3d4e5f6a7b8c9d0e1 } // String 类型这时候localField: _id和foreignField: userId类型不一致匹配结果永远是空数组。解决办法有两个办法一写入时统一类型。在 Java 里把userId也定义成ObjectIdprivate ObjectId userId;办法二聚合时转换类型。用$toString或$toObjectIdcontext - new Document($lookup, new Document(from, order) .append(let, new Document(userId, new Document($toString, $_id))) .append(pipeline, Arrays.asList( new Document($match, new Document($expr, new Document($eq, Arrays.asList($userId, $$userId)))) )) .append(as, orders) )我建议用办法一在数据写入时就保证类型一致。办法二虽然能解决问题但每次查询都要做类型转换性能有损耗。4.2 数组字段匹配的陷阱如果localField是数组$lookup的行为可能跟你想的不一样。比如// user 集合 { _id: 1, tagIds: [100, 200, 300] } // tag 集合 { _id: 100, name: 技术 } { _id: 200, name: 生活 }用localField: tagIds和foreignField: _id做$lookup结果是{ _id: 1, tagIds: [100, 200, 300], tags: [ { _id: 100, name: 技术 }, { _id: 200, name: 生活 } ]}看起来没问题对吧但如果tagIds里有重复值比如[100, 100, 200]那tags数组里也会出现重复的标签文档。这个行为在 MongoDB 文档里没有明确说明我是踩了坑才发现的。解决办法是在写入时去重或者在聚合后用$setIntersection去重。但更根本的是设计数据模型时就避免数组里出现重复值。4.3 内存超限Exceeded memory limit for $group这个错误在嵌套$lookup$group的场景下特别常见。MongoDB 聚合管道默认限制 100MB 内存超过就报错Exceeded memory limit for $group, but didnt allow external sort. Pass allowDiskUse:true to opt in.解决办法前面提过加allowDiskUse(true)。但我要说的是加了这个选项只是不报错了性能问题依然存在。数据量大到需要落盘查询时间可能是秒级甚至十秒级。我的经验是如果聚合管道需要落盘那这个查询就不适合在线业务。要么优化数据模型要么改成离线任务预计算。4.4 排序失效$lookup 之后的 $sort 不走索引在$lookup之后做$sortMongoDB 无法利用索引只能内存排序。如果结果集很大又会触发内存限制。正确的做法是把$sort放在$lookup之前Aggregation aggregation Aggregation.newAggregation( Aggregation.sort(Sort.Direction.DESC, createTime), // 先排序 Aggregation.limit(100), // 再限制 lookupOperation // 最后关联 );这样先用索引排序并限制数量再对少量文档做关联性能最好。4.5 常见问题速查表问题现象可能原因排查方法解决方案orders 数组永远为空字段类型不匹配检查 localField 和 foreignField 类型统一类型或用 $toString 转换查询超时foreignField 无索引explain 看是否 COLLSCAN在 foreignField 上建索引内存超限报错聚合结果集过大看日志里的 memory limit 错误加 allowDiskUse 或优化数据模型排序很慢$sort 在 $lookup 之后explain 看是否用了索引把 $sort 移到 $lookup 之前结果有重复localField 数组有重复值检查源数据写入时去重字段被覆盖as 字段名与已有字段冲突检查 as 名称换个不冲突的字段名5. 进阶玩法让关联查询更优雅的几个技巧5.1 封装通用的 Lookup 工具类每次写$lookup都要拼一堆Document代码又长又容易出错。我习惯封装一个工具类public class LookupBuilder { public static AggregationOperation lookup(String from, String localField, String foreignField, String as) { return LookupOperation.newLookup() .from(from) .localField(localField) .foreignField(foreignField) .as(as); } public static AggregationOperation lookupWithPipeline(String from, String localField, String foreignField, String as, ListDocument pipeline) { return context - new Document($lookup, new Document(from, from) .append(let, new Document(localField, $ localField)) .append(pipeline, pipeline) .append(as, as)); } }用的时候Aggregation aggregation Aggregation.newAggregation( LookupBuilder.lookup(order, _id, userId, orders), Aggregation.match(Criteria.where(orders).ne(Collections.emptyList())) );这样代码清爽很多也方便统一加日志和监控。5.2 用 $graphLookup 处理树形结构如果你的数据是树形结构比如分类、组织架构$graphLookup比$lookup更合适。它能递归查询所有后代节点Aggregation aggregation Aggregation.newAggregation( Aggregation.graphLookup(category) .startWith($_id) .connectFrom(_id) .connectTo(parentId) .maxDepth(5) .as(descendants) );这个操作符在处理无限级分类时特别好用一次查询就能拿到整棵子树。但要注意maxDepth必须设置否则可能无限递归。5.3 变更流 物化视图彻底告别实时关联对于报表类查询我现在的做法是用 Change Stream 监听源集合的变化实时更新一个物化视图集合。查询时直接查物化视图不需要任何关联。Bean public CommandLineRunner watchOrders() { return args - { MongoCollectionDocument orders mongoTemplate.getCollection(order); orders.watch(Arrays.asList( Aggregation.match(Criteria.where(operationType) .in(insert, update, delete)) )).forEach(change - { // 更新物化视图集合 Document fullDocument change.getFullDocument(); if (fullDocument ! null) { mongoTemplate.save(buildViewDocument(fullDocument), order_view); } }); }; }这种方式把关联的计算成本从查询时转移到了写入时查询性能极好。代价是数据有轻微延迟通常毫秒级以及需要额外存储空间。5.4 读写分离把 $lookup 压力转移到从节点如果 MongoDB 是副本集部署可以把$lookup这类重查询路由到从节点Configuration public class MongoConfig { Bean public MongoTemplate secondaryMongoTemplate(MongoClient mongoClient) { return new MongoTemplate(mongoClient, mydb) { Override protected MongoClient getMongoClient() { return mongoClient; } }; } }更简单的做法是在查询时指定读偏好Query query new Query().withReadPreference(ReadPreference.secondaryPreferred());这样主节点只处理写入和轻量查询重查询走从节点整体吞吐量能提升不少。6. 我的实战心得从踩坑到形成方法论6.1 数据模型设计永远优先于查询优化我做了这么多项目最大的体会是MongoDB 的性能问题90% 都能通过合理的数据模型设计避免。如果你发现某个查询需要三层嵌套$lookup才能搞定那大概率是数据模型设计有问题。我的设计原则是一对少嵌入文档。比如订单里嵌入用户的基本信息姓名、电话。一对多在主文档里存从文档的 ID 数组查询时用$in批量查。多对多两边都存对方的 ID 数组或者用中间集合。频繁聚合预计算物化视图。这个原则不是绝对的但能覆盖 80% 的场景。剩下的 20% 再考虑用$lookup。6.2 监控和慢查询日志必须配齐MongoDB 的慢查询日志默认是关闭的生产环境一定要打开db.setProfilingLevel(1, { slowms: 100 })这样超过 100 毫秒的查询都会记录到system.profile集合里。定期分析这个集合找出需要优化的查询db.system.profile.find({ millis: { $gt: 500 } }).sort({ ts: -1 }).limit(10)我靠这个功能发现过好几个隐藏的性能问题包括一个没建索引的$lookup查询优化后 QPS 提升了 10 倍。6.3 版本差异要注意MongoDB 不同版本对$lookup的支持差异很大3.2引入$lookup只支持等值匹配3.6支持 pipeline 形式的$lookup可以在子管道里写复杂条件4.0支持$lookup里的$expr4.4支持$lookup里的let变量Spring Data MongoDB 的 API 也跟着版本走。如果你用的 Spring Boot 2.x默认的 MongoDB 驱动可能不支持某些新特性。升级前一定要看兼容性矩阵。6.4 最后分享几个实用小技巧技巧一用$ifNull处理空数组。$lookup没匹配到时返回空数组但有时候你希望返回 null 或者默认值Aggregation.project(name) .and(new Document($ifNull, Arrays.asList($orders, Collections.emptyList()))) .as(orders)技巧二用$size过滤空数组。如果你只想要有订单的用户Aggregation.match(new Document($expr, new Document($gt, Arrays.asList( new Document($size, $orders), 0))))技巧三用$slice限制数组长度。如果每个用户有几千个订单但你只需要最近 5 个Aggregation.project(name) .and(new Document($slice, Arrays.asList($orders, 5))) .as(recentOrders)技巧四批量查询用$in代替$lookup。如果主集合只有几十条数据从集合有几十万条那用$in批量查从集合再在应用层拼装性能比$lookup好很多。因为$lookup是对每条主文档都执行一次从集合查询而$in是一次查询搞定。这些技巧都是我在实际项目中反复验证过的你可以直接拿去用。但记住没有银弹具体场景具体分析多测多调优才是正道。
阅读完成 · 觉得有帮助?
咨询建站