MongoDB 的一致性不是一个开关,而是"读"与"写"两个旋钮的配合:writeConcern 决定一次写入要等多少节点确认才算成功,readConcern 决定一次读取能看到哪个时间点的数据,readPreference 决定读流量路由到哪个节点。三者的组合矩阵,直接决定了系统的可用性、一致性与性能平衡。本文从 writeConcern 各级别讲起,深入 readConcern 的隔离语义、majority 的多数据版本机制、因果一致性会话,最后给出生产场景的组合决策表。
1. 读写关注的三要素
副本集架构下,数据在多个节点间异步复制。一次写入完成后,各节点看到的版本可能不同,读取时选择哪个版本就由读写关注决定。可以把复制链路简化为三个时间点:主节点应用写入、oplog 复制到副节点、多数节点确认提交。
// 三要素在连接串与操作中的位置
// 连接串:mongodb://host:27017/?replicaSet=rs0&readPreference=secondary&w=majority
db.orders.insertOne(
{ orderNo: "A-1", amount: 100 },
{ writeConcern: { w: "majority", j: true, wtimeout: 5000 } }
)
| 要素 | 控制什么 | 取值示例 |
|---|---|---|
| writeConcern | 写入要确认到多少节点 | w: 1, w: majority, w: 3 |
| readConcern | 读取可见的已提交状态 | local, majority, linearizable |
| readPreference | 读流量路由到哪类节点 | primary, secondaryPreferred |
三者独立可配,但语义相互影响:读流量路由到副节点时,readConcern: local 可能读到稍旧的数据,readConcern: majority 保证读到多数派已确认的数据。一个完整的读写操作可以同时指定全部三要素:
// 完整指定三要素的读取(路由到副节点 + 多数派已确认 + 因果时间)
db.orders.find(
{ customerId: "u_1001" },
{ readConcern: { level: "majority" } }
).readPref("secondaryPreferred")
1.1 数据流视角
写入的生命周期:客户端发给主节点 → 主节点应用到内存并写 journal → oplog 被副节点拉取应用 → 达到指定 w 的确认数后返回成功。读的生命周期:客户端按 readPreference 路由到节点 → 按 readConcern 决定读取哪个已提交版本 → 返回结果。
| 阶段 | 触发者 | 控制参数 |
|---|---|---|
| 写入主节点内存 | 客户端 | 无(必然发生) |
| 等待确认 | 客户端 | writeConcern w |
| 是否落盘确认 | 客户端 | writeConcern j |
| 路由到副节点 | 客户端 | readPreference |
| 选择可见版本 | 客户端 | readConcern |
重要:读写关注只约束"客户端感知",不改变复制本身。即使
w: majority返回成功,未参与确认的副节点依然需要时间追赶 oplog,读取时的 readConcern 才是最终的数据可见性闸门。
2. writeConcern 级别详解
writeConcern 定义"写入何时向客户端返回成功"。级别越高,数据越安全,但确认延迟也越高。
| w 值 | 语义 | 数据安全 | 失败模式 |
|---|---|---|---|
| w: 0 | 不等待确认 | 最低 | 不报告写入错误 |
| w: 1 | 主节点写入内存即返回 | 低 | 主节点确认即可 |
| w: “majority” | 多数成员确认 | 高 | 多数派不响应则超时 |
| w: N | 指定 N 个成员确认 | 自定义 | N 大于成员数则报错 |
// w: 0:异步发送,不等确认(丢失不可感知)
db.orders.insertOne({ orderNo: "L-0" }, { writeConcern: { w: 0 } })
// w: 1:默认,主节点写入即返回
db.orders.insertOne({ orderNo: "L-1" })
// w: "majority":多数派确认,配合 j 落盘
db.orders.insertOne({ orderNo: "L-2" }, { writeConcern: { w: "majority", j: true } })
// w: N:显式指定确认节点数
db.orders.insertOne({ orderNo: "L-3" }, { writeConcern: { w: 3 } })
2.1 j 与 wtimeout
j: true 要求写入已落盘(journal)才算确认,防服务器宕机时内存数据丢失。wtimeout 是等待确认的超时上限,超时后写入不报错,但标记为"未完全确认",返回 WriteConcernError。
// wtimeout:避免写关注长期挂起
db.orders.insertOne(
{ orderNo: "T-1" },
{ writeConcern: { w: "majority", wtimeout: 3000 } }
)
// 3 秒内未获多数确认则抛 wtimeout,但写入本身可能已发生在主节点
| 组合 | 语义 | 典型场景 |
|---|---|---|
| w: 1(默认) | 主节点确认 | 普通写入 |
| w: 0 | 尽力而为 | 日志、埋点 |
| w: majority | 多数派确认 | 核心业务 |
| w: majority + j: true | 多数派确认且落盘 | 金融、账务 |
2.2 写关注的错误处理
写关注配置不当会产生两类典型错误,需要分别处理:
// 错误一:UnsatisfiableWriteConcern(配置不可满足)
db.orders.insertOne(
{ orderNo: "E-1" },
{ writeConcern: { w: 5 } } // 副本集只有 3 个成员
)
// 抛错:cannot use w: 5, 副本集成员数不足
// 错误二:WriteConcernError(等待超时或节点不可达)
db.orders.insertOne(
{ orderNo: "E-2" },
{ writeConcern: { w: "majority", wtimeout: 2000 } }
)
// 返回的 writeConcernError 表示确认超时,但写入可能已发生
| 错误类型 | 触发条件 | 处理策略 |
|---|---|---|
| UnsatisfiableWriteConcern | w 值大于成员数,或配置非法 | 修正配置,重试前确认拓扑 |
| WriteConcernError | 确认超时、成员不可达 | 业务需查证写入是否发生,再决定幂等重试 |
| NetworkError | 连接中断 | 幂等重试或人工核对 |
w: 0 的一个特殊风险是它不等待确认,因此也不会返回写错误。写入失败(如违反唯一索引)在 w: 0 下是静默的,日志型场景可以接受,业务型场景必须避免。
重要:wtimeout 超时不代表写入失败,只代表"未确认"。确认不足的写入在副本集故障切换时可能被回滚。业务上要区分"写入失败"与"确认超时"两类错误,分别处理。
3. readConcern 隔离级别
readConcern 控制读操作读取的"已确认"级别,决定了读到的是否可能是即将被回滚的数据。
| 级别 | 语义 | 可回滚风险 |
|---|---|---|
| local | 读取当前节点的最新数据,不检查多数派 | 可能读到将被回滚的数据 |
| available | 同 local,适用于分片集群 | 同上 |
| majority | 读取多数派已确认的数据 | 不会被回滚 |
| linearizable | 读取最后一次写入的值(串行化) | 最强一致 |
| snapshot | 事务与可重试读中使用 | 快照一致性 |
// local:默认,读到节点本地最新数据
db.orders.find({ orderNo: "L-1" }).readConcern("local")
// majority:读到多数派已确认的数据
db.orders.find({ orderNo: "L-1" }).readConcern("majority")
// 聚合中使用
db.orders.aggregate(
[{ $match: { status: "paid" } }, { $group: { _id: "$customerId", total: { $sum: "$amount" } } }],
{ readConcern: { level: "majority" } }
)
3.1 available 与 local 的差别
在副本集上,available 与 local 行为一致:都返回节点本地立即可见的数据。available 的差异主要体现在分片集群:它不等待任何一致性检查,能更早返回数据,适合对一致性要求极低、追求最小延迟的读取。
3.2 linearizable 线性化读取
linearizable 要求读取的是"最后一次写入"的值,实现串行化一致。它必须在主节点执行,且代价是显著更高的延迟,只用于真正的强一致需求(如"读自己的最新写入"且不能接受旧值)。
// linearizable 必须在主节点、单文档读取场景使用
db.accounts.find(
{ _id: "u_1001" },
{ readConcern: { level: "linearizable" } }
)
4. majority 与多数据版本语义
多数派读写的核心价值是"避免读到会被回滚的数据"。理解它需要先理解副本集的多数据版本机制。
4.1 多版本数据
副本集各节点 oplog 同步存在时间差,同一文档在不同节点上可能是不同版本:主节点版本最新,部分副节点滞后。readConcern: local 可能读到旧版本或即将被回滚的版本;readConcern: majority 只返回"多数派已确认"的版本。
// 场景:主节点刚写入 order X,未同步到多数派
// 此时 majority 读取看不到 X,local 读取在主节点能看到 X
// 若主节点立即宕机,X 可能被回滚,majority 读取不会读到它
| 读取时间点 | local 看到 | majority 看到 |
|---|---|---|
| 主节点写入后未同步 | 能看到最新 | 看不到(未确认) |
| 已同步到多数派 | 能看到 | 能看到 |
| 主节点宕机前 | 能看到 | 不保证 |
4.2 majority 的代价
readConcern: majority 需要检查提交点(majority committed point),读取延迟比 local 略高,尤其在副节点上。对大多数业务场景,读多写少且能容忍短暂旧数据时,local + primaryPreferred 是性价比更高的选择。
4.3 多数派提交点的推进
副本集内部维护一个"多数派提交点":oplog 中已被多数成员应用的最后位置。majority 读写都以此为准。提交点会随复制推进而不断前移,读取时返回的是提交点之前的稳定版本。
// 查看副本集当前提交点信息
db.adminCommand({ replSetGetStatus: 1 })
// lastCommittedOpTime 表示多数派提交点
// {
// "lastCommittedOpTime": { "ts": Timestamp(1800000000, 5), "t": 7 },
// ...
// }
理解提交点有两个直接收益:一是解释为什么 majority 读取偶尔比 local 旧,因为提交点滞后于主节点;二是监控时关注 lastCommittedOpTime 与主节点 oplog 尾部的差距,差距持续放大说明复制异常,读取的数据会越来越旧。
| 指标 | 含义 | 告警阈值 |
|---|---|---|
| lastCommittedOpTime | 多数派提交点 | 长期不变即告警 |
| 提交点滞后 | 提交点与主节点尾部之差 | 超过数秒需关注 |
| secondary lag | 副节点与主节点的 oplog 差 | 超过 electionTimeout 预警 |
决策铁律:majority 读写关注是"防止回滚导致的数据异常"的黄金组合。凡是写入成功后立刻要读回并依赖该值的场景(读后写、写后读校验),必须使用
w: majority写入 +readConcern: majority读取,才能保证读到不会被回滚的数据。
5. 因果一致性会话
读写关注解决"单次操作的一致性",因果一致性会话解决"多个操作之间的先后关系"。在一个因果一致的会话中,读操作能感知同一会话中先前写入的所有数据,即使读流量被路由到其他节点。
// Node.js 驱动:开启因果一致性会话
const session = client.startSession({
causalConsistency: true,
defaultTransactionOptions: { readConcern: { level: "majority" } }
})
// 会话内先写后读:读操作携带 afterClusterTime,保证读到写入结果
session.withTransaction(async () => {
await orders.insertOne({ orderNo: "C-1" }, { session })
const doc = await orders.findOne({ orderNo: "C-1" }, { session })
// doc 一定可见,即使被路由到副节点
})
5.1 operationTime 与 afterClusterTime
因果一致性通过时间戳传递实现:写操作返回 operationTime,后续读操作携带 afterClusterTime,服务端保证返回的数据不早于该时间点。会话之间的因果性也可以通过手动传递时间戳实现。
// 跨会话传递因果时间戳
const result = await orders.insertOne({ orderNo: "C-2" })
const operationTime = result.operationTime
// 另一个会话读取时带上时间戳,保持因果
const session2 = client.startSession({ causalConsistency: true })
await orders.findOne({ orderNo: "C-2" }, {
readConcern: { level: "majority", afterClusterTime: operationTime }
})
5.2 因果一致性的适用边界
因果一致性保证"会话内因果顺序",但不保证"实时性":读操作可能等待一段时间直到该时间点的数据可用。它解决的是"用户提交后刷新看不到自己数据"的体验问题,而不是分布式系统的全局强一致。对跨会话、跨应用的一致需求,仍需 majority 读写关注或事务。
| 机制 | 保证强度 | 适用场景 |
|---|---|---|
| 会话因果一致性 | 会话内因果顺序 | 用户操作后的读回 |
| majority 读写 | 多数派提交 | 防回滚的关键数据 |
| 多文档事务 | ACID 快照 | 复杂业务原子性 |
5.3 因果一致性与副本集故障切换
因果一致性在故障切换时尤其重要。用户在副本集切换前写入了数据,切换后读请求可能被路由到新主节点或副节点。若没有因果时间戳,读操作可能读到"切换后新主节点上还没有这条写入"的旧视图;携带 afterClusterTime 后,节点会等待到该时间点再返回,避免用户"写入后刷新看不到自己数据"的诡异现象。
// 会话中先写后读,切换期间依然保持因果
const result = await orders.insertOne({ orderNo: "C-3" }, { session })
await sleep(50) // 模拟主节点切换
const doc = await orders.findOne(
{ orderNo: "C-3" },
{ session, readConcern: { level: "majority" } }
) // 即使切换,因果保证让读取等待到写入可见
因果一致性的代价是"可能阻塞等待":当目标节点尚未追平到 afterClusterTime 时,读取需要等待。这个等待通常极短,但若副节点持续滞后,因果读取会不断等待,形成可见的延迟尖峰。因此因果会话通常搭配 readConcern: majority,让等待目标与复制保证一致。
6. 读写关注组合实战
不同业务对一致性要求差异巨大,用同一套读写关注服务所有业务是常见的过度配置。按业务分级的组合策略如下:
// 等级一:日志埋点,w: 0 尽力而为
db.audit_log.insertOne({ event: "click", ts: new Date() }, { writeConcern: { w: 0 } })
// 等级二:普通业务,w: 1 默认,读 secondaryPreferred
db.orders.insertOne({ orderNo: "G-1" })
db.orders.find({ orderNo: "G-1" }).readPref("secondaryPreferred")
// 等级三:核心业务,w: majority + readConcern: majority
db.payments.insertOne(
{ payId: "P-1", amount: 100 },
{ writeConcern: { w: "majority", j: true } }
)
db.payments.find({ payId: "P-1" }, { readConcern: { level: "majority" } })
// 等级四:账务一致,使用事务 + snapshot
session.withTransaction(() => {
db.accounts.updateOne({ _id: "u_1" }, { $inc: { balance: -100 } }, { session })
db.accounts.updateOne({ _id: "u_2" }, { $inc: { balance: 100 } }, { session })
})
| 业务等级 | writeConcern | readConcern | 读偏好 | 一致性体验 |
|---|---|---|---|---|
| 日志埋点 | w: 0 | local | 任意 | 最终一致,可丢 |
| 普通业务 | w: 1 | local | secondaryPreferred | 最终一致 |
| 核心业务 | w: majority | majority | primaryPreferred | 多数派一致 |
| 账务关键 | w: majority + j | snapshot / linearizable | primary | 强一致 |
重要:读写关注是全局的、按操作粒度的,不是"一套配置打天下"。把全库统一设置成
w: majority会无谓地放大所有写入延迟;把全库统一设置成w: 1又会让关键数据暴露在回滚风险下。按业务分级配置,才能兼顾可用性与成本。
7. 组合决策总结
读写关注的正确使用取决于对业务一致性的精确要求,决策可以收敛为三个问题:写入后多久必须能被读到;读到旧值会造成什么后果;能否接受读流量路由到副节点。
- 写后即读且不可接受旧值 →
w: majority+readConcern: majority,或 linearizable - 写后短时间读到即可 →
w: 1+local,容忍短暂旧数据 - 跨操作因果依赖 → 因果一致性会话 + majority 读
- 读多写少、追求扩展读能力 →
readPreference: secondaryPreferred+ local
决策铁律:一致性的成本终归要有人买单。先确定"读到旧数据"的最坏后果是什么:如果只是页面短暂显示旧值,local 足够;如果会导致重复支付、超卖、对不上账,必须 majority 甚至 linearizable。一致性等级应该由业务后果反推,而不是由技术偏好决定。
延伸阅读
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。