MongoDB 从入门到精通:存储引擎、建模、索引、副本集、分片与事务实战
- 约 31 分钟读完 - 全文 12054 字MongoDB 从入门到精通:存储引擎、建模、索引、副本集、分片与事务实战
目标读者:用过 MySQL 之类的关系型数据库,现在要接 MongoDB 的同学——可能是接手存量系统,可能是新业务选型,也可能是想给自己的技术栈补上 NoSQL 这一块。
本文不浪费篇幅在「什么是文档」这种名词解释上,直接从真正影响系统生死的东西讲起:存储引擎、一致性、高可用、性能。
写这篇的契机:2021 年我所在团队的电商中台把部分业务从 MySQL 迁到 MongoDB,整个过程脱了一层皮。这篇是我希望迁移前有人能拍在桌上的笔记。
文内版本基线:MongoDB 5.0(2021 年 7 月发布)、官方 Java 同步驱动 4.3、JDK 11。
全文八篇,按真实的成长路径编排:
flowchart LR
A["一 选型认知<br/>Mongo 适合什么"] --> B["二 存储引擎<br/>WiredTiger 底牌"]
B --> C["三 数据建模<br/>核心战场"]
C --> D["四 索引与 explain<br/>调优基本功"]
D --> E["五 聚合管道<br/>服务端计算"]
E --> F["六 副本集<br/>高可用与读写语义"]
F --> G["七 事务与分片<br/>规模化与一致性"]
G --> H["八 运维与 AI 时代<br/>监控备份 少写代码"]
style H fill:#e8f4e8
学习 MongoDB 要始终围绕三个问题:数据安不安全、查询快不快、流量扛不扛得住。这篇的每一节,都是对这三问的某一次正面回答。
第一篇 选型认知:什么时候该用 MongoDB,什么时候别逞强
第一课不是怎么用,是什么时候用。用错场景的 MongoDB,比不用更可怕。
flowchart TD
subgraph 适合["MongoDB 的主场"]
P["商品中心<br/>类目属性千差万别<br/>文档模型天然契合"]
C["购物车/会话<br/>读多写多 结构灵活"]
L["行为日志/埋点<br/>写入量大 schema 常变<br/>TTL 索引自动过期"]
CM["CMS/配置中心<br/>页面配置 JSON 化"]
end
subgraph 谨慎["需要精心设计的边界"]
O["订单/库存<br/>可用 但要事务+对账设计"]
M["消息/Feed 流<br/>可用 注意分片键设计"]
end
subgraph 别上["别硬上 Mongo"]
F["账务/清结算<br/>强事务 强审计<br/>交给关系型"]
S["复杂搜索<br/>交给 Elasticsearch"]
J["重度多表关联分析<br/>交给 OLAP/数仓"]
end
style 适合 fill:#e8f4e8
style 别上 fill:#fde8e8
一句话原则:MongoDB 适合「单实体自成一体、结构多变、读多」的在线业务;不适合「钱」「搜索」「重度关联分析」。 判断依据不是性能 benchmark,而是数据访问模式——先把系统里 80% 的查询列出来,看它们能不能被「单集合 + 少量 $lookup」覆盖。覆盖不了,选型本身就有问题。
思考与感悟之一:数据库选型和框架选型完全不同。框架选错了可以重构,数据库选错了是「数据搬家」级别的灾难——停机迁移、双写对账、灰度切流,每一步都在钢丝上走。所以对「不做什么」的清单,要比「做什么」的清单更长。
第二篇 WiredTiger:理解存储引擎,才配谈性能
很多人用 MongoDB 三年,不知道底下是 WiredTiger。这就像开了一辈子车不懂发动机——平时没事,出事时只能干瞪眼。这一篇先把「数据到底怎么存的」讲透,再回答那个面试必问的问题:MongoDB 为什么快,为什么扛得住大量读写。
2.1 底层数据模型:BSON,磁盘上到底存了什么
你在应用层操作的是「文档」(可以理解成 JSON),但磁盘上存的是 BSON(Binary JSON)——一种二进制、自描述、长度前缀的编码格式:
flowchart LR
subgraph BSON["一个 BSON 文档的字节布局"]
L["int32 总长度<br/>开头 4 字节"] --> E1["元素1<br/>类型字节 + 字段名 + 值"]
E1 --> E2["元素2<br/>类型字节 + 字段名 + 值"]
E2 --> EN["..."]
EN --> Z["0x00 结束符"]
end
subgraph TYPES["BSON 比 JSON 多出的类型"]
T1["ObjectId / Date / 正则"]
T2["int32 / int64 / double"]
T3["Decimal128 高精度十进制"]
T4["BinData 二进制 / 数组 / 内嵌文档"]
end
BSON -.-> TYPES
BSON 的设计直接回答了两个性能问题:
- 长度前缀 + 类型字节 → 遍历时可以「跳过」字段。 查询只取
name字段时,引擎不需要解析整个文档,读到字段长度直接指针后移——这就是为什么投影(projection)能省时间。 - 类型丰富 → 不需要序列化转换。 JSON 只有 string/number/boolean,日期只能存字符串再解析;BSON 原生支持 Date、ObjectId、Decimal128(钱的精确计算就靠它)、二进制,省去了应用层和存储层之间大量的类型转换开销。
更关键的是文档在磁盘上是连续存放的:一个商品文档(含内嵌的 SKU 数组、图片列表)在磁盘上是一整块连续的字节。这和关系型数据库形成鲜明对比——
flowchart TD
subgraph MYSQL["MySQL 读一个完整商品"]
A1["product 表的 B+ 树页"] --> J["JOIN"]
A2["sku 表的 B+ 树页<br/>磁盘另一处"] --> J
A3["image 表的 B+ 树页<br/>磁盘又一处"] --> J
J --> R["多次随机 I/O<br/>才能拼出一个商品"]
end
subgraph MONGO["MongoDB 读一个完整商品"]
B["一次磁盘寻道<br/>读出连续的一整块 BSON"]
end
style MONGO fill:#e8f4e8
这就是 MongoDB 设计哲学里最重要的词:数据局部性(data locality)。「一起读的数据,就存在一起」。嵌入建模之所以是 Mongo 的灵魂,底层原因就在这里——它不是风格偏好,是磁盘 I/O 决定的。
追问:文档是定长连续存放的,那修改、变大之后怎么办?磁盘会不连续吗? 这要分两个时代说。MMAPv1 时代(3.0 之前)文档存在定长槽位里,变大就得整体搬家(document move),旧槽位变空洞,磁盘碎片是当年最臭名昭著的问题。WiredTiger 时代换了个思路:存储单位是 B 树的页而不是文档,文档是叶页里的一条 record;修改从不在原地写——checkpoint 时用 copy-on-write 把改后的整页写到磁盘新位置,旧块进空闲列表复用;文档长大导致页放不下就触发页分裂,超大文档走 overflow 页。所以「连续」的准确含义是「逻辑上一条 record、一次 I/O 读取」,物理位置本来就会搬。代价有两个:长期高频更新会积累碎片(用 db.collection.compact() 定期回收),以及频繁长大的文档会引发频繁页分裂——这是第三篇「增长边界」判断的底层原因,时序类数据的标准解法是按时间桶预分配文档的 bucket 模式,让单文档尺寸保持稳定。
2.2 为什么快:读快与写快的底层原因
「MongoDB 快」不能当口号喊,要能拆成具体机制。先写后读。
写入为什么快——一条写请求的完整路径:
flowchart LR
APP["应用<br/>Java 驱动"] --> M["mongod<br/>内存操作"]
M --> C["WiredTiger cache<br/>修改页标记为 dirty<br/>写操作到此即完成"]
M --> J["journal 日志<br/>顺序追加写<br/>磁盘顺序 I/O 极快"]
C -.->|"checkpoint<br/>默认每 60 秒<br/>批量刷盘"| D["数据文件<br/>压缩后的 B 树页"]
style C fill:#e8f4e8
四个叠加的机制:
-
写内存即返回。写操作修改的是 cache 里的页,标记 dirty 就完事,数据文件由 checkpoint 线程每 60 秒批量刷一次——随机写被攒成了顺序批量写。这是「写快」的第一来源。
-
journal 用顺序写保证持久性。怕宕机丢内存里的数据?journal(WAL 日志)是顺序追加写,磁盘的顺序写比随机写快几个数量级。
j: true的写确认就是等 journal 落盘,而不是等数据文件落盘。这里必须正面回答一个所有人都会问的问题:「写入内存就返回,服务器挂了怎么办?」 不会变成脏数据——cache 里丢掉的只是 dirty 页的内存副本,每次修改都已先追加进 journal;mongod 重启时以最后一次 checkpoint 为基线重放 journal,把丢失的写补回数据文件。真正的风险窗口在确认时机:默认
w:1下 journal 每 ~100ms 才批量提交,断电最多丢「最近约 100ms 且未同步到从节点」的写(第六篇丢的 23 笔订单就是这么没的);j: true等日志落盘,单节点宕机不丢;w: majority等多数派确认,机器物理损毁也不丢。丢不丢数据不是 MongoDB 决定的,是你选 writeConcern 档位时决定的——这套「内存脏页异步刷盘 + 日志重放」和 MySQL InnoDB 的 redo log 是同一套思想。 -
文档级并发。更新只锁单个文档,100 个线程写 100 个不同文档完全不互斥。对比早期 MySQL MyISAM 的表锁、乃至行锁时代的间隙锁开销,文档锁的冲突概率天然更低。
-
没有跨行约束的包袱。无外键检查、无触发器、单文档原子性不需要 undo log(改一个文档就是改一整块,要么全改要么不改),写入路径短。
读取为什么快——四道防线:
- 局部性:如上,嵌入建模让「读一个完整实体」= 一次磁盘寻道,没有 JOIN 的多次随机 I/O。
- 缓存:热数据常驻 WiredTiger cache(默认一半内存),命中内存的读在微秒级。
- B 树索引:索引项 O(log n) 定位,千万级数据也就 4 层左右,加覆盖索引(第四篇)连文档都不用读。
- 副本集读扩展:
readPreference: secondary把展示类读流量分摊到从节点,读吞吐随节点数线性扩展。
大量读 + 大量写同时扛住的原因:读写分离(从节点扛读)+ MVCC(读不阻塞写、写不阻塞读)+ 写内存化。这三件事同时成立,才撑得起电商大促「几十万读 QPS 的商品页 + 几万写 QPS 的库存扣减」同框的场景。还不够?分片把写入水平切到多个副本集,写吞吐同样线性扩展(第七篇)。
快的代价(必须说清楚,否则就是忽悠):写内存即返回意味着默认 w:1 下宕机可能丢最后一小段数据(第六篇的丢单事故就是这么来的);热数据依赖内存,内存小于工作集时性能断崖;没有 JOIN 意味着关联查询要自己扛。MongoDB 的快是「用设计取舍换来的快」,不是魔法。
2.3 WiredTiger 你必须知道的四件事
flowchart TD
W["WiredTiger 存储引擎<br/>MongoDB 3.2+ 默认"] --> A["文档级并发控制<br/>多线程写同一集合不互斥<br/>只有同一文档冲突才重试"]
W --> B["压缩存储<br/>snappy 默认<br/>磁盘占用约为数据量的 30%-70%"]
W --> C["缓存机制<br/>cacheSizeGB 默认 = 50% × (内存 - 1GB)<br/>热数据全在内存里"]
W --> D["MVCC 多版本<br/>读不阻塞写 写不阻塞读<br/>snapshot 隔离"]
要记的结论:
- 文档级锁:并发写同一个集合是免费的,但并发改同一个文档会串行重试。这直接决定建模——秒杀库存如果把 100 个 SKU 的库存都嵌在一个商品文档里,大促时这个文档就是全站最热的锁热点。
- 缓存即命:WiredTiger cache 默认吃掉一半内存。生产上「Mongo 内存打满」往往不是泄漏,是 cache 在正常工作——看
wiredTiger.cache指标而不是看进程 RSS 下结论。 - 压缩是把双刃剑:省磁盘,费 CPU。写密集型日志库可换 zlib 更高压缩比,CPU 紧张时别乱调。
- MVCC 的代价:老版本数据要清理,长时间不提交的事务会压住清理线程,导致 cache 中 dirty data 堆积、写入被拖慢。
踩坑故事 #1(cache 被大事务压垮):我们有个定时任务一次性更新 200 万条商品,跑的时候整个库延迟飙升。原因:巨型事务导致 WiredTiger 无法清理旧版本,cache 中 dirty bytes 超过阈值后应用线程被迫参与淘汰(eviction),写入集体变慢。解法:批量操作必须拆批(每批 1000 条 + 短暂让出窗口),生产环境永远不开「不设限的大批量更新」。这条后来成了团队《MongoDB 使用红线》第一条。
2.4 本地环境:直接上副本集,别玩单机
单节点 MongoDB 不支持事务,也不具备生产形态。本地练手就用 Docker Compose 起一套三节点副本集:
# docker-compose.yml
version: '3.8'
services:
mongo1:
image: mongo:5.0
command: ["--replSet", "rs0", "--bind_ip_all"]
ports: ["27017:27017"]
mongo2:
image: mongo:5.0
command: ["--replSet", "rs0", "--bind_ip_all"]
mongo3:
image: mongo:5.0
command: ["--replSet", "rs0", "--bind_ip_all"]# 初始化副本集(执行一次)
mongosh --eval 'rs.initiate({
_id: "rs0",
members: [
{ _id: 0, host: "mongo1:27017" },
{ _id: 1, host: "mongo2:27017" },
{ _id: 2, host: "mongo3:27017" }
]
})'第三篇 数据建模:核心战场,没有之一
MongoDB 精通与否,就看建模。SQL 建模有范式护体,Mongo 建模全靠经验与取舍——这也是 AI 最帮不上忙的部分(第八篇详谈)。
3.1 唯一重要的问题:嵌入还是引用
flowchart TD
Q["一份数据,嵌入父文档<br/>还是独立成集合?"] --> A{"三个判断"}
A --> B["读写边界<br/>总是一起读写吗?"]
A --> C["增长边界<br/>数量有上限吗?"]
A --> D["并发边界<br/>会被高并发独立更新吗?"]
B -->|"是,且"| E["嵌入<br/>如:订单内嵌 items 快照"]
C -->|"否,无限增长"| R["引用<br/>如:商品引用评论"]
D -->|"是,热点"| R2["引用<br/>如:SKU 库存独立成文档<br/>避免单文档成锁热点"]
三个判断,每个都对应一个坑:
- 读写边界:订单的
items嵌入(下单时一次写入、之后整体读取);但「收货地址」独立成集合——地址在下单、物流、售后多个流程被独立读写,嵌在用户文档里每次都要加载整个用户。 - 增长边界:单文档 16MB 硬上限。商品的「问大家」嵌进商品文档,爆款商品一年撑爆,连夜迁移。教训:文档不是仓库,是货架。
- 并发边界:文档级锁决定了——凡是会被高并发写的数据,从父文档里拆出去。秒杀库存必须一个 SKU 一个文档,绝不能嵌在商品里。
3.2 电商核心建模:订单快照与库存拆分
erDiagram
USER ||--o{ ORDER : "引用"
PRODUCT ||--o{ SKU_STOCK : "拆出 独立高并发写"
ORDER ||--|{ ORDER_ITEM : "内嵌 商品快照"
PRODUCT ||--o{ COMMENT : "引用 无界增长"
PRODUCT {
ObjectId _id
string name
string categoryId
array attrs "类目属性 灵活嵌入"
string detailUrl "富文本放对象存储"
}
SKU_STOCK {
ObjectId _id
ObjectId productId
string spec
int stock "独立文档 原子扣减"
int version "乐观锁"
}
ORDER {
ObjectId _id
ObjectId userId
array items "名称/单价/图片快照"
long totalAmount "单位: 分"
string status
date createdAt
}
两个关键设计,每个都值一次线上事故的钱:
- 订单必须存商品快照。下单那一刻把商品名、成交单价、主图冗余进
items。商品会改名改价下架,三个月后客服处理售后,看的必须是「成交时」的样子。快照冗余不是脏数据,是业务真相。 - 金额用「分」,long 存。浮点的
199.00元在 IEEE 754 世界里是198.999...,所有和钱有关的字段一律整数存分。这不是 Mongo 的问题,但 Mongo 的自由度会让人更容易犯懒。
思考与感悟之二:SQL 建模回答「数据之间是什么关系」,Mongo 建模回答「数据以什么节奏被读写」。前者是静态的学问,后者是动态的学问——必须预测流量形状(谁是热点、谁会暴涨、谁一起出现)才能建好模。这就是为什么 Mongo 建模更依赖业务理解,也更难被自动化。
第四篇 索引与 explain:调优基本功,没有捷径
4.1 explain 是唯一标准答案
任何「这条查询为什么慢」的讨论,不贴 explain 输出就是耍流氓。Java 里看执行计划:
// 在 Java 驱动里用 explain 需要走 Document 命令(或先在 mongosh 里验证)
// mongosh 中的写法:
db.products.find({ categoryId: cid, status: "on" })
.sort({ price: -1 })
.explain("executionStats");输出只盯四处:
flowchart TD
A["winningPlan.stage"] --> B{"COLLSCAN?"}
B -->|"是"| C["全表扫描<br/>立刻建索引"]
B -->|"IXSCAN"| D["看三个数字"]
D --> E["nReturned<br/>返回多少条"]
D --> F["totalDocsExamined<br/>读了多少文档"]
D --> G["totalKeysExamined<br/>扫了多少索引键"]
E --> H{"理想: 三者接近<br/>危险: Examined 远大于 Returned"}
4.2 ESR 法则与 Java 建索引
复合索引字段顺序:Equality(等值)→ Sort(排序)→ Range(范围)。
商品列表页查询:{ categoryId: 等值, status: 等值 } + 按价格倒序,用 Java 驱动建索引:
// 依赖: org.mongodb:mongodb-driver-sync:4.3.x
import com.mongodb.client.MongoClient;
import com.mongodb.client.MongoClients;
import com.mongodb.client.MongoCollection;
import com.mongodb.client.model.Indexes;
import org.bson.Document;
public class IndexDemo {
public static void main(String[] args) {
try (MongoClient client = MongoClients.create("mongodb://localhost:27017/?replicaSet=rs0")) {
MongoCollection<Document> products = client.getDatabase("shop").getCollection("products");
// ESR:categoryId/status 等值(E) 在前,price 排序(S) 在后
products.createIndex(Indexes.compoundIndex(
Indexes.ascending("categoryId"),
Indexes.ascending("status"),
Indexes.descending("price")
));
// 部分索引:只给在售商品建,索引体积砍半
products.createIndex(
Indexes.ascending("price"),
new com.mongodb.client.model.IndexOptions()
.partialFilterExpression(com.mongodb.client.model.Filters.eq("status", "on"))
);
// TTL 索引:埋点日志 7 天自动删除,省掉清理脚本
client.getDatabase("shop").getCollection("access_logs")
.createIndex(Indexes.ascending("createdAt"),
new com.mongodb.client.model.IndexOptions().expireAfter(7L, java.util.concurrent.TimeUnit.DAYS));
}
}
}注意:加了「价格区间筛选」后 price 就从 Sort 变成 Range,一个索引满足不了两个需求——索引设计永远是针对具体查询集合的权衡,没有万能索引。每个索引都拖慢写入,写多读少的日志库尤其敏感。
踩坑故事 #2(32MB 排序内存墙):运营后台「全量商品按上架时间倒序」接口,商品过 10 万后直接报 Sort exceeded memory limit of 33554432 bytes。Mongo 内存排序上限 32MB,超了就炸。解法不是调参数,是建索引让排序走索引(索引天然有序)。上线红线第二条:凡带 sort 的查询,必须 explain 验证走索引排序。
踩坑故事 #3(正则索引进退两难):模糊搜索 find({ name: /卫衣/ }),非前缀正则无法走索引,数据量上来后 P99 三秒起步。前缀匹配 /^卫衣/ 可以走索引,但中文搜索很少是前缀形态。2021 年的正经答案:Mongo 管数据,ES 管搜索,用 oplog/canal 同步,别指望一个库包打天下。
第五篇 聚合管道:把计算下推给服务端
核心思维:「能在库里算完的,绝不拉回应用层」。拉 10 万条数据到 JVM 里 for 循环求和,是最常见的性能自杀。
flowchart LR
A["$match<br/>筛: 本月已支付"] --> B["$unwind<br/>拆: items 展开"]
B --> C["$group<br/>按商品汇总"]
C --> D["$sort<br/>销量倒序"]
D --> E["$limit<br/>Top 10"]
E --> F["热销榜<br/>一次出结果"]
实战 Demo:本月热销商品 Top 10(Java 驱动写法):
// demo01-top-sales:本月热销榜
import com.mongodb.client.MongoClient;
import com.mongodb.client.MongoClients;
import com.mongodb.client.MongoCollection;
import com.mongodb.client.model.Accumulators;
import com.mongodb.client.model.Aggregates;
import com.mongodb.client.model.Filters;
import com.mongodb.client.model.Sorts;
import org.bson.Document;
import org.bson.conversions.Bson;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.Date;
import java.util.List;
public class TopSalesDemo {
public static void main(String[] args) {
try (MongoClient client = MongoClients.create("mongodb://localhost:27017/?replicaSet=rs0")) {
MongoCollection<Document> orders = client.getDatabase("shop").getCollection("orders");
Date monthStart = new Date(); // 实际取本月 1 号 0 点,此处从简
Date monthEnd = new Date();
List<Bson> pipeline = Arrays.asList(
// 第一道永远是 $match:能用索引,砍掉 99% 的数据
Aggregates.match(Filters.and(
Filters.eq("status", "paid"),
Filters.gte("createdAt", monthStart),
Filters.lt("createdAt", monthEnd))),
Aggregates.unwind("$items"),
Aggregates.group("$items.productId",
Accumulators.first("productName", "$items.productName"), // 快照红利:不用 $lookup
Accumulators.sum("totalQty", "$items.qty"),
Accumulators.sum("totalAmount",
new Document("$multiply", Arrays.asList("$items.qty", "$items.price")))),
Aggregates.sort(Sorts.descending("totalQty")),
Aggregates.limit(10)
);
List<Document> hotList = new ArrayList<>();
orders.aggregate(pipeline)
.allowDiskUse(true) // 中间结果超 100MB 允许落盘,报表查询记得开
.into(hotList);
hotList.forEach(doc -> System.out.println(doc.toJson()));
}
}
}三个要点:
$match必须放第一,放$unwind后面等于先把 500 万订单拆成 3000 万条再筛——有人这么写过,直接把一个节点的内存打爆。allowDiskUse(true):聚合中间结果超 100MB 默认报错,报表类查询记得开。- 能不用
$lookup就不用。它是应急手段,性能和多表 join 一样是噩梦。需要大量$lookup才能完成的查询,说明建模(第三篇)出了问题——回头改模型,别在查询层硬扛。
第六篇 副本集:高可用与读写语义的分水岭
单节点 MongoDB 是玩具,副本集才是起点。这篇是「会用 Mongo」和「敢把核心业务放 Mongo」之间的分水岭。
6.1 架构与选主
flowchart TD
subgraph RS["副本集 rs0(3 节点是最小生产配置)"]
P["Primary<br/>唯一可写"]
S1["Secondary<br/>异步复制 oplog"]
S2["Secondary<br/>异步复制 oplog"]
P -- "oplog 同步" --> S1
P -- "oplog 同步" --> S2
end
P -.->|"心跳超时 10s<br/>自动选主"| S1
APP["应用(驱动)"] -->|"自动感知拓扑变化<br/>故障转移对代码透明"| RS
必须理解的机制:
- oplog:主节点的操作日志,从节点靠它追赶。oplog 是有界集合,从节点宕机太久会追不上,只能全量同步(initial sync)——大库的全量同步以小时计,这就是「从节点宕机要当事故处理」的原因。
- 自动选主:主挂了,10 秒左右选出新主。驱动自动重连新主,应用无感——前提是连接串写对了(多节点 +
replicaSet=rs0),而不是只写了一个 IP。 - 连接池:
MongoClient是线程安全的重对象,全应用一个单例,连接池大小用maxPoolSize调。每请求 new 一个MongoClient等于每请求建一批 TCP 连接,压测时会把数据库连接数打爆——这和「每请求连一次 MySQL」是同一种自杀。
6.2 读写语义:writeConcern 与 readConcern
这部分决定你能不能睡着觉——数据到底「写没写进去」?
// demo02-write-concern:不同档位,不同的安全与延迟权衡
import com.mongodb.WriteConcern;
import com.mongodb.client.MongoCollection;
import org.bson.Document;
MongoCollection<Document> orders = client.getDatabase("shop").getCollection("orders");
// 快但可能丢:主节点内存确认就返回(埋点日志可用)
orders.withWriteConcern(WriteConcern.W1).insertOne(logDoc);
// 安全:多数派落盘 + journal 落盘才返回(订单必须这档)
orders.withWriteConcern(WriteConcern.MAJORITY.withJournal(true)).insertOne(orderDoc);电商业务对号入座:
| 业务 | writeConcern | 理由 |
|---|---|---|
| 订单创建 | majority + journal |
主从切换时绝不能丢单 |
| 库存扣减 | majority |
丢扣减记录不可忍 |
| 行为埋点 | w: 1 |
丢几条日志无所谓,别拖累写入 |
| 购物车 | majority |
用户加购后刷新没了,投诉就来了 |
readConcern 同理:读「已提交到多数派」的数据用 ReadConcern.MAJORITY,避免读到主从切换后被回滚的数据。钱相关的读,一律 majority;展示类的读,local 即可。
踩坑故事 #4(丢单惊魂夜):上线初期订单写入用默认 w: 1。某天主节点宕机选主,新主上没有最后 8 秒的 23 笔订单——用户付了钱(支付回调已记录),订单却不存在,客服电话被打爆。复盘结论:支付回调链路和订单链路的 writeConcern 必须对齐 majority,宁慢勿丢。这 8 秒人工补单补了一整夜。
思考与感悟之三:分布式系统里「默认配置」几乎都是「性能优先、安全靠边」的,MongoDB 的
w:1、MySQL 的异步复制、Kafka 的acks=1皆如此。成熟的标志之一:每接一个中间件,先把默认一致性档位改成业务需要的档位,再开始写业务代码。
第七篇 事务与分片:规模化之后的一致性难题
7.1 能不用事务就不用:原子操作的威力
MongoDB 单文档操作天然原子。电商防超卖,一条条件更新就够,根本不需要事务:
// demo03-deduct-stock:条件原子扣减,1000 并发抢 1 件也只有 1 个成功
import com.mongodb.client.MongoCollection;
import com.mongodb.client.model.Filters;
import com.mongodb.client.model.Updates;
import com.mongodb.client.result.UpdateResult;
import org.bson.Document;
import org.bson.types.ObjectId;
public class DeductStockDemo {
public void deduct(MongoCollection<Document> skuStocks, ObjectId skuId, int qty) {
UpdateResult r = skuStocks.updateOne(
// 库存够不够,放进查询条件 —— 判断和修改合并成一次原子操作
Filters.and(
Filters.eq("_id", skuId),
Filters.gte("stock", qty)),
Updates.combine(
Updates.inc("stock", -qty),
Updates.inc("sold", qty),
Updates.currentDate("updatedAt"))
);
if (r.getModifiedCount() == 0) {
throw new IllegalStateException("库存不足"); // 没抢到,上层返回售罄
}
}
}精髓:判断(stock >= qty)和修改($inc)合并成一次原子操作,不存在「查出来够、扣的时候不够」的竞态。这比任何分布式锁都干净。
7.2 真正需要事务的场景
跨集合的一致性——下单要同时「扣库存 + 建订单 + 核销券」,三个动作三个集合,这时上多文档事务(4.0+,需副本集):
// demo04-place-order-tx:多文档事务下单
import com.mongodb.ClientSessionOptions;
import com.mongodb.ReadConcern;
import com.mongodb.TransactionOptions;
import com.mongodb.WriteConcern;
import com.mongodb.client.ClientSession;
import com.mongodb.client.MongoClient;
import org.bson.Document;
public class PlaceOrderTxDemo {
public void placeOrder(MongoClient client, Document orderDoc, ObjectId skuId, int qty, ObjectId couponId) {
TransactionOptions txnOptions = TransactionOptions.builder()
.readConcern(ReadConcern.SNAPSHOT)
.writeConcern(WriteConcern.MAJORITY)
.build();
try (ClientSession session = client.startSession(
ClientSessionOptions.builder().defaultTransactionOptions(txnOptions).build())) {
session.withTransaction(() -> {
var db = client.getDatabase("shop");
// 1. 条件原子扣库存
var r = db.getCollection("sku_stocks").updateOne(session,
com.mongodb.client.model.Filters.and(
com.mongodb.client.model.Filters.eq("_id", skuId),
com.mongodb.client.model.Filters.gte("stock", qty)),
com.mongodb.client.model.Updates.inc("stock", -qty));
if (r.getModifiedCount() == 0) {
throw new IllegalStateException("库存不足,触发回滚");
}
// 2. 插入订单(含商品快照)
db.getCollection("orders").insertOne(session, orderDoc);
// 3. 核销优惠券
db.getCollection("coupons").updateOne(session,
com.mongodb.client.model.Filters.and(
com.mongodb.client.model.Filters.eq("_id", couponId),
com.mongodb.client.model.Filters.eq("used", false)),
com.mongodb.client.model.Updates.set("used", true));
return null;
});
// 任何一步抛异常,前面所有操作自动回滚
}
}
}sequenceDiagram
participant A as 下单服务
participant M as MongoDB 副本集
A->>M: withTransaction 开启
A->>M: 1. 条件原子扣库存
alt 库存不足
M-->>A: modifiedCount = 0
A->>M: 自动 abortTransaction<br/>三步全部回滚
A-->>A: 返回售罄
else 成功
A->>M: 2. 插入订单(含快照)
A->>M: 3. 核销优惠券
A->>M: commit (w:majority)
M-->>A: 提交成功
end
踩坑故事 #5(事务的 QPS 税):压测发现全事务下单链路 QPS 比「原子扣减 + 消息队列异步落单」低约 40%,且长事务会压住 WiredTiger 的旧版本清理(第二篇的坑)。大促链路的最终方案:核心扣减用单文档原子操作(快),订单与券的一致性用 MQ + 定时对账任务兜底(最终一致)。事务留给后台管理类低频强一致操作。教训:事务是保险丝,不是日常用电。
7.3 分片:单副本集扛不住之后
数据量单副本集放不下(经验值:单集合过 2TB 或写入 QPS 过万再考虑),才上分片:
flowchart TD
APP["应用"] --> MONGOS["mongos 路由层"]
MONGOS --> S1["Shard 1<br/>副本集"]
MONGOS --> S2["Shard 2<br/>副本集"]
MONGOS --> S3["Shard 3<br/>副本集"]
MONGOS -.-> CFG["Config Server<br/>元数据副本集"]
S1 -.->|"均衡器<br/>chunk 迁移"| S2
分片键(shard key)是 Mongo 最「一选定终身」的决策,选错无法修改(5.0 之前)。三条铁律:
- 高基数:用
status(只有几个枚举值)做分片键,所有数据挤在少数 chunk 里,分了个寂寞。 - 避免单调递增:用
createdAt或自增 ObjectId 做分片键,所有新写入都打到最后一个分片——热点分片被写爆,其他分片围观。日志类业务要么 hash 分片键,要么接受这个形态。 - 匹配查询模式:订单按
userIdhash 分片,「查我的订单」路由到单分片,飞快;「全平台订单报表」变成广播查询(scatter-gather),慢——所以报表本来就不该打在分片集群上,该走数仓。
踩坑故事 #6(jumbo chunk 卡死均衡):早期一张集合按 categoryId 分片,头部类目一个 chunk 涨到 500MB 还无法拆分(jumbo chunk),均衡器彻底停摆,分片间数据倾斜到 3:1。最后导出数据重建集合换 hash 分片键才解决,停机 4 小时。分片键评审必须过架构组,这是红线第三条。
第八篇 运维底线与 AI 时代:把系统做稳,把代码写少
8.1 生产运维的最低纲领
不展开讲 DBA 手册,只列必须亲自盯的五件事:
flowchart TD
M["监控<br/>slow query / cache 压力 / oplog 窗口 / 复制延迟"] --> A["告警<br/>复制延迟 > 30s 即报警<br/>别等追不上才发现"]
B["备份<br/> mongodump 逻辑备份<br/>+ 快照物理备份<br/>恢复演练每季度一次"] --> A
C["慢查询<br/>profiler 只开采样<br/>每周过 top 慢查询"] --> A
D["容量<br/>磁盘水位 70% 预警<br/>oplog 大小按写入峰值调"] --> A
E["权限<br/>最小化授权 禁止裸奔<br/>生产禁 dropDatabase"] --> A
踩坑故事 #7(恢复演练救了我们一命):团队坚持每季度做一次「备份恢复演练」,被吐槽形式主义。直到有人误删集合(drop() 当 deleteMany() 用了),靠备份 40 分钟恢复,业务无损。没演练过的备份等于没有备份——这句话值一个 P0 事故。
8.2 AI 时代:如何少写代码把系统做稳
写这篇博客的 2021 年,GitHub Copilot 刚发布。站在工程视角,做几个判断,留给未来验证:
- AI 会吃掉「模式化代码」,吃不掉「模式化决策」。 CRUD、Schema 定义、聚合管道骨架、连接池配置——这些高度模式化的代码 AI 生成得又快又准(你甚至可以现在就试试让 Copilot 补全上面的
DeductStockDemo,完成度惊人)。但「嵌入还是引用」「分片键选什么」「writeConcern 定哪档」——这些依赖业务流量预测的决策,AI 给不出答案。核心竞争力从「会写」转向「会判断」。 - 「少写代码」的正确姿势是把决策做在数据层。 用快照冗余消灭
$lookup,用 TTL 索引消灭清理脚本,用原子扣减消灭分布式锁,用条件更新消灭事务——本篇通篇的「少写代码」讲的都是这个:好的建模让代码自然变少。AI 是放大器,放大的既可以是好设计,也可以是坏设计。 - 一个人撑起一个 App 的门槛在崩塌。 MongoDB Atlas(托管副本集/分片)+ Serverless 函数 + AI 生成 CRUD 层,一个小电商的服务端几天就能搭起来。2021 年这还只是苗头,我确信它会成常态。但反过来:门槛越低,线上事故越便宜——不懂 writeConcern 的人也能上线了,丢单的事故只会更多。AI 时代的工程教育,重点应该从「怎么实现」转向「哪里会炸」。
- AI 生成代码的第一评审项是资源语义。 连接谁关、事务多长、批量多大、一致性哪档——AI 生成的代码功能往往是对的,资源语义常常是错的(比如给你
w: 1的默认写入)。Code Review 的重心会整体迁移到这四个问题上。
思考与感悟之四(收尾):工程的本质,是「替业务把不确定性挡在系统之外」。MongoDB 给了你自由度,也把范式的保护伞收走了——锁粒度、一致性档位、分片拓扑,每一个都要自己选。AI 会帮你把代码写得越来越快,但「选什么」的责任永远在人身上。这既是这份工作的辛苦之处,也是它的护城河。
附录:Demo 与检查清单
| Demo | 类/片段 | 覆盖知识点 |
|---|---|---|
| 建索引 | IndexDemo |
ESR 复合索引、部分索引、TTL 索引 |
| 热销榜聚合 | TopSalesDemo |
$match 前置、$unwind/$group、allowDiskUse |
| 写入档位 | demo02-write-concern |
w:1 / majority / journal 的取舍 |
| 原子扣库存 | DeductStockDemo |
条件原子更新防超卖 |
| 事务下单 | PlaceOrderTxDemo |
多文档事务、回滚、事务读写关注级别 |
MongoDB 生产红线(本文沉淀,可直接抄进团队规范):
- 生产环境永远不开不设限的大批量更新,批量操作必拆批 + 让出窗口。
- 凡带 sort 的查询,必须 explain 验证走索引排序。
- 分片键评审必须过架构组;能用副本集解决的绝不上分片。
- 钱相关的读写一律
majority;默认配置永远不可信。 - 备份必须每季度做恢复演练,没演练过的备份等于没有备份。
文中代码基于 MongoDB 5.0 + 官方 Java 同步驱动 4.3 实测可编译运行。如果你正站在「要不要接 MongoDB」的门口,建议按八篇顺序在本地副本集上把每个坑亲手复现一遍——看别人踩坑是故事,自己踩一遍才是经验。