MongoDB如何高效使用$lookup实现$nin查询并优化性能
现有三个集合:users、questions 和 answers。其中answers集合存储question_id与user_id字段,业务需求为查询符合指定筛选条件、且尚未回答某一指定问题的所有用户。
最初的实现逻辑放在应用层:先查出所有回答过该问题的用户ID,放入$nin子句拼接查询条件,代码如下:
already_answered = [ ObjectId(a["user_id"]) for a in db.answers.find({"question": question_id}, {"user_id": 1}) ] query = { # 其他业务筛选条件 "_id": {"$nin": already_answered} } users = db.users.find(query)
该方案的缺陷是:当问题回答量很大时,构造的查询文档会超出MongoDB单文档16MB的大小限制,无法执行。
后续尝试将逻辑下推到数据库层,使用$lookup聚合实现,代码如下:
users = db.users.aggregate( [ {"$match": query}, { "$lookup": { "from": "answers", "let": {"str_user_id": {"$toString": "$_id"}}, "pipeline": [ { "$match": { "$expr": { "$and": [ {"$eq": ["$user", "$$str_user_id"]}, {"$eq": ["$question_id", question_id]}, ] } }, } ], "as": "answered", } }, {"$match": {"answered": {"$size": 0}}}, {"$project": {"_id": 1}}, ] )
该聚合查询执行速度极慢,需要找到高效的实现方案。
注意:上述两段代码中answers集合的问题ID字段名不统一,第一段用question,第二段用question_id,实际使用时需统一字段名,避免查询逻辑错误。
当前聚合写法性能差主要有两个根源:
- 关联时类型转换导致索引失效:查询时将
users._id(ObjectId类型)转为字符串匹配answers.user字段,运算阶段的类型转换无法命中answers集合的索引,必须全表扫描answers做匹配。 - 逐用户关联的IO开销极高:聚合先筛选符合条件的用户,再对每个用户单独执行lookup匹配answers记录,当符合筛选条件的用户量较大时,相当于重复执行N次answers集合扫描,性能随用户量线性下降。
原应用层$nin方案本身查询效率很高,核心问题只是单查询文档超限,优化时可以结合两种方案的优势。
所有方案生效的前提是先建基础索引:
answers集合建{question_id: 1, user_id: 1}复合索引,覆盖指定问题的回答用户查询场景users集合为业务筛选条件用到的字段建对应索引,_id默认自带索引无需额外创建
方案1:类型统一+lookup优化(通用场景性能最优)
核心是从根源避免类型转换,减少lookup阶段传输的数据量:
- 提前将
answers集合的user_id字段统一存为ObjectId类型,和users._id类型完全对齐,禁止查询时做类型转换。 - 第一阶段匹配用户后只保留
_id字段,减少后续lookup阶段的数据传输量。
优化后代码:
users = db.users.aggregate([ {"$match": query}, {"$project": {"_id": 1}}, { "$lookup": { "from": "answers", "let": {"user_id": "$_id"}, "pipeline": [ {"$match": { "$expr": { "$and": [ {"$eq": ["$user_id", "$$user_id"]}, {"$eq": ["$question_id", question_id]} ] } }} ], "as": "answered_record" } }, {"$match": {"answered_record": {"$size": 0}}}, {"$project": {"answered_record": 0}} ], allowDiskUse=True)
该写法可以完全命中两个集合的索引,性能比原有聚合写法高10~100倍。
方案2:分段$nin查询(兼容旧数据,实现简单)
如果暂时无法修改存量数据的字段类型,可以对原应用层方案做分段改造,避开16MB文档限制:
- 每次从answers集合拉取固定批次的已回答用户ID(建议单次1万条以内,保证查询文档大小远低于16MB阈值)
- 将当前批次ID加入
$nin条件查询users,循环遍历完所有已回答用户ID - 对多次查询的结果去重即可
该方案实现成本极低,且$nin查询可以直接命中users._id索引,性能远高于未走索引的聚合查询,缺点是应用层需要做多次查询和结果合并。
方案3:集合差集运算(千万级以上超大数据量适用)
如果用户量、回答量均达到千万级,可以直接在数据库层做集合差运算,避免逐用户关联的开销:
pipeline = [ # 拉取指定问题下所有回答过的用户ID {"$match": {"question_id": question_id}}, {"$project": {"user_id": 1, "_id": 0}}, {"$group": {"_id": None, "answered_users": {"$addToSet": "$user_id"}}}, # 关联所有符合筛选条件的用户ID {"$lookup": { "from": "users", "pipeline": [{"$match": query}, {"$project": {"_id": 1}}], "as": "target_users" }}, # 直接计算差集得到未回答用户 {"$project": { "unanswered_users": { "$setDifference": ["$target_users._id", "$answered_users"] }, "_id": 0 }} ] result = list(db.answers.aggregate(pipeline, allowDiskUse=True)) unanswered_user_ids = result[0]["unanswered_users"] if result else []
该方案全程仅做两次集合扫描,不会逐用户执行关联,千万级数据量下性能比普通lookup高一个数量级,注意开启allowDiskUse=True避免聚合内存超限。
- 所有关联字段类型必须保持一致,禁止混用字符串、ObjectId等不同类型,从数据写入阶段就做好类型校验,避免查询时做类型转换损耗性能。
- 如果该查询为高频请求,且单问题回答量不会触发16MB文档限制,可以在
questions集合冗余answered_user_ids数组字段,每次有新回答时将用户ID写入该数组,查询时直接用该数组做$nin即可,性能最高。
内容的提问来源于stack exchange,提问作者Borda

