--- id: backend.mongodb.document-model title: MongoDB 基础:文档建模(嵌入还是引用)、索引与 ESR、聚合、事务和变更流 type: lesson domain: backend stack: [mongodb] tags: [MongoDB, 文档模型, 嵌入, 引用, 复合索引, ESR, 聚合管道, 事务, 变更流, 副本集] level: basic prerequisites: [backend.postgresql.index] est_hours: 3 related: [backend.mongodb.scenario.conversation-store, backend.elasticsearch.index, backend.mongodb.interview] week: [14] order: 1 --- # MongoDB 基础:文档建模(嵌入还是引用)、索引与 ESR、聚合、事务和变更流 > BOSS直聘 2026-10-23 的调研中,有岗位把 MongoDB 列为要求或加分项(常见于存储对话记录、日志、灵活结构的业务数据)。定位:**了解并能用**,主库仍以 PostgreSQL / MySQL 为主。示例基于 MongoDB 8.2 + PyMongo 4.19 实际运行通过;以官方文档为准:https://www.mongodb.com/docs/manual/ 。 ## 解释说明 ### 和关系型数据库的对应 | 关系型 | MongoDB | | --- | --- | | 数据库 / 表 / 行 | database / collection / document(BSON,类似 JSON) | | 表结构 | 默认无固定结构,可用 `$jsonSchema` 校验 | | JOIN | 优先“嵌入”,必要时用聚合的 `$lookup` | | 事务 | 单文档写入天然原子;多文档事务需要副本集或分片集群 | ### 建模:嵌入还是引用 - **嵌入**:总是一起读写、数量有上限的子数据(会话中的最近消息、订单中的商品快照)。一次读取拿到全部,单文档原子更新。 - **引用**:数量无上限或会被独立访问、多处共享的数据(用户、商品主数据、海量消息)。 - 单个文档上限 16MB,数组不能无限增长(“无限数组”是常见反模式)。 ### 索引 - 单字段、复合、多键(数组)、TTL(自动过期)、唯一、文本索引等。 - **复合索引的 ESR 规则**:等值条件字段在前(Equality),排序字段居中(Sort),范围条件字段在后(Range)。 - 用 `explain()` 看是否 `IXSCAN`(走索引)、有没有 `COLLSCAN`(全表扫描)和 `SORT`(内存排序)。 ### 聚合管道 `$match` → `$unwind` → `$group` → `$sort` → `$project` …… 像流水线一样处理;`$match` 尽量放在最前面以利用索引。 ### 副本集、事务、变更流 - **副本集**:一主多从,自动故障转移;读写关注(`readConcern` / `writeConcern`)控制一致性。 - **事务**:多文档 ACID,需要副本集;有性能成本,优先通过建模让一次写入只改一个文档。 - **变更流(Change Streams)**:订阅集合的变更,常用于同步到 ES / 缓存 / 消息队列,支持 resume token 断点续传。 ## 案例 AI 客服会话存储:会话文档嵌入最近消息(上限 200 条),结构随渠道不同而变化;按“租户 + 状态 + 时间倒序”查询待处理会话;转人工时同时改会话状态并创建工单(事务);变更流把工单同步到搜索服务。 ## 代码示例 [examples/conversations/mongo_demo.py](examples/conversations/mongo_demo.py)(需要 `MONGO_URL`,单节点副本集即可),实际输出: ```text 1. Schema 校验:非法 status 被拒绝 2. 查询走索引且无需内存排序:['FETCH', 'IXSCAN', 'LIMIT'] 3. 聚合统计 6 组,例如 {'tenant': 't0', 'channel': 'app'} 共 50 个会话,平均 22.9 tokens 4. 幂等 upsert:重复投递 3 次只有 1 条 5. 事务:成功的提交生效,失败的整体回滚 6. 变更流收到插入事件,并拿到 resume token 用于断点续传 ``` ESR 索引和 explain: ```python conv.create_index([("tenant_id", 1), ("status", 1), ("created_at", -1)]) # 等值、等值、排序 plan = conv.find({"tenant_id": "t1", "status": "handoff"}).sort("created_at", -1).limit(10).explain() ``` 幂等 upsert: ```python events.create_index("event_id", unique=True) events.update_one({"event_id": "evt-001"}, {"$setOnInsert": {"payload": "退款申请"}}, upsert=True) ``` 本地启动单节点副本集(无 Docker 时可用官方二进制包): ```bash mongod --dbpath ./data --replSet rs0 --bind_ip 127.0.0.1 --port 27017 mongosh --eval 'rs.initiate()' # 或用 PyMongo 执行 replSetInitiate ``` ## 易错点 - 把关系型的表结构原样搬过来,到处 `$lookup`,失去文档模型的优势。 - 无限增长的嵌入数组。 - 以为“无结构”就不需要校验:关键字段要用 Schema 校验和应用层校验。 - 复合索引顺序不对,查询仍然内存排序。 - 单机(非副本集)部署上使用事务或变更流,直接报错。 - 用 `skip` 做深分页:越往后越慢,改用“基于上一页最后一个值”的范围查询。 ## 面试常问 - 什么时候选 MongoDB,什么时候选 PostgreSQL? - 嵌入和引用怎么选? - 复合索引的设计原则? - MongoDB 支持事务吗?有什么限制?