不应将answers数组直接嵌入questions文档;应采用关联设计,answers集合需建question_id单字段索引和{question_id:1,created_at:-1}复合索引;采纳状态应存于回答文档;$unwind需加preservenullandemptyarrays:true参数。

提问文档里要不要直接嵌入回答数组?
多数新手第一反应是把 answers 数组直接塞进 questions 文档里,读一条就能拿到全部内容。这在小流量、低频更新场景下确实省事。但一旦出现「某回答被大量点赞/举报/编辑」或「需要按时间/得分排序分页查最新10条回答」,嵌入式结构立刻暴露短板:每次更新都要重写整个文档,触发写放大;分页聚合时得先 $unwind 再 $sort,性能随回答数线性下降。
更现实的约束是:单个问题下回答数可能达数千,BSON 文档有 16MB 上限,嵌入太多回答容易触顶。所以除非你明确只支持每问最多 20 条回答且永不改状态,否则别嵌。
用 $lookup 关联回答集合时,哪些字段必须加索引?
当采用「questions 集合存问题主干,answers 集合存回答详情,靠 question_id 关联」的方案时,answers 集合上至少要建两个索引:
-
question_id单字段索引:支撑按问题查所有回答(find({question_id: "q_123"})) -
{question_id: 1, created_at: -1}复合索引:支撑「查某问题下最新5条回答」这类高频查询,避免内存排序
漏掉第二个索引,sort({created_at: -1}).limit(5) 就会全表扫描 —— 即使 question_id 有索引也救不了。
采纳状态该存在提问文档还是回答文档?
存在回答文档里更合理。原因有三:
- 采纳动作本质是对某条回答的确认,不是对问题的修改;存在回答文档中语义清晰
- 避免多写:如果存在提问文档里,每次新采纳就得先查旧采纳ID、删老标记、再写新ID,而存在回答文档里只需一次
updateOne({ _id: answerId }, { $set: { is_accepted: true } }) - 一致性风险低:不存在「采纳了A回答,却忘记清掉B回答的标记」这种逻辑漏洞
对应地,查「某问题是否已被采纳」时,别用 $lookup + $match 去关联后筛,直接在 answers 集合上查:findOne({ question_id: "q_123", is_accepted: true }),再配上 { question_id: 1, is_accepted: 1 } 索引,毫秒级响应。
$unwind 拉取回答时为什么总丢数据?
常见现象:聚合查「问题+所有回答+是否被采纳」,用了 $unwind: "$answers" 后,发现没回答的问题直接消失了。这不是 bug,是 $unwind 默认行为:遇到空数组或 null 字段就跳过整条记录。
解决办法只有两个字:加参数。必须显式写成:
db.questions.aggregate([
{ $lookup: { from: "answers", localField: "_id", foreignField: "question_id", as: "answers" } },
{ $unwind: { path: "$answers", preserveNullAndEmptyArrays: true } }
])
漏掉 preserveNullAndEmptyArrays: true,空问题永远查不到 —— 这个细节在调试阶段极难定位,因为日志里不报错,只默默少结果。
真正麻烦的是混合场景:一个问题既有普通回答,又有被采纳的回答,还可能有已删除的回答(is_deleted: true)。这时光靠 $unwind 不够,得在后续加 $match 过滤有效回答,且过滤条件顺序会影响性能。最容易被忽略的是:is_deleted 和 is_accepted 这类布尔字段,如果没建索引,$match 阶段就会退化成全集合扫描。











