31. MongoDB 读写关注与一致性

读写关注全解析:writeConcern 各级别(w:0/1/majority/N 与 j、wtimeout)、readConcern 隔离级别(local/available/majority/linearizable)、majority 多数据版本语义、因果一致性会话与组合实战

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 表示确认超时,但写入可能已发生
错误类型触发条件处理策略
UnsatisfiableWriteConcernw 值大于成员数,或配置非法修正配置,重试前确认拓扑
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 })
})
业务等级writeConcernreadConcern读偏好一致性体验
日志埋点w: 0local任意最终一致,可丢
普通业务w: 1localsecondaryPreferred最终一致
核心业务w: majoritymajorityprimaryPreferred多数派一致
账务关键w: majority + jsnapshot / linearizableprimary强一致

重要:读写关注是全局的、按操作粒度的,不是"一套配置打天下"。把全库统一设置成 w: majority 会无谓地放大所有写入延迟;把全库统一设置成 w: 1 又会让关键数据暴露在回滚风险下。按业务分级配置,才能兼顾可用性与成本。

7. 组合决策总结

读写关注的正确使用取决于对业务一致性的精确要求,决策可以收敛为三个问题:写入后多久必须能被读到;读到旧值会造成什么后果;能否接受读流量路由到副节点。

  • 写后即读且不可接受旧值 → w: majority + readConcern: majority,或 linearizable
  • 写后短时间读到即可 → w: 1 + local,容忍短暂旧数据
  • 跨操作因果依赖 → 因果一致性会话 + majority 读
  • 读多写少、追求扩展读能力 → readPreference: secondaryPreferred + local

决策铁律:一致性的成本终归要有人买单。先确定"读到旧数据"的最坏后果是什么:如果只是页面短暂显示旧值,local 足够;如果会导致重复支付、超卖、对不上账,必须 majority 甚至 linearizable。一致性等级应该由业务后果反推,而不是由技术偏好决定。

延伸阅读

继续阅读

探索更多技术文章

浏览归档,发现更多关于系统设计、工具链和工程实践的内容。

全部文章 返回首页

「mongodb」更多文章

  1. 33. MongoDB 地理空间与全文检索
  2. 32. MongoDB 批量写入与吞吐优化
  3. 30. MongoDB 静态加密与字段级加密