1. 项目背景

业务场景:本地生活电商的订单系统切换到了复制集,看起来一切正常——直到客服接到大量投诉:"我刚下单成功了,但打开'我的订单'页面根本看不到这笔订单!""我明明付了款,订单状态还是'待支付',过了一分钟才变。"技术团队排查发现:下单接口连接的是 Primary 写入订单,但"我的订单"页面为了分摊读压力,读的是 Secondary——而 Secondary 的复制延迟 5-8 秒,用户刚写入就查从库,自然看不到。

还有一个更严重的问题——退款流程中:先标记订单为"已退款",再调用支付宝退款,如果支付宝退款成功但 MongoDB 的订单状态更新因为从库落后没被后续流程读到,后果是财务部门统计到"已退款但支付宝实际未退款"的对账异常。

痛点:writeConcern、readConcern、readPreference 这三个参数是 MongoDB 一致性的"三驾马车",90% 的开发从来没配过,也不知道默认值是什么意思。典型问题:"readConcern: localmajority 的区别是什么?""我的写入指定了 w: majority,为什么读还是可能读到旧数据?""读 Primary 和读 Secondary 到底差了什么?""因果一致性怎么配?"

2. 项目设计

小胖(抱头):大师!我刚下的订单在列表页看不到!过 5 秒刷新又有了。这特么是在变魔术吗?

大师:不是魔术,是你在 Primary 上写(下单),在 Secondary 上读(列表页查询)。Secondary 复制需要时间——这个时间差就是你观察到的"幽灵期"。这引出 MongoDB 三个核心概念:

  • writeConcern(写关注):即你的写入要"多可靠"。w: 1 只等 Primary 确认(快但不稳),w: "majority" 等多数节点确认(稳但稍慢)。
  • readConcern(读关注):即你的读取要看到"哪个时间点之后的数据"。local 是最新的(可能还未被确认),majority 是已被多数节点确认过的(已持久化)。
  • readPreference(读偏好):即你的请求发给谁。primary 读主节点,secondary 读从节点。

小胖:那我的场景——在下单和列表页之间保证一致性,应该怎么配?

大师:这叫"读己之写"(Read Your Own Writes)。解决方案之一是因果一致性(Causal Consistency)。你只需要在 Spring Boot 中做两件事:

// 写入时(下单接口)
ClientSession session = client.startSession();
session.startTransaction();
// ... 写入订单 ...
session.commitTransaction();

// 读取时(我的订单接口)
// 通过 session 传递 afterClusterTime,确保读到本次写入之后的数据

如果写入和读取不在同一个 session 的上下文中(比如跨服务),最简单的做法是——读你的写入不要从 Secondary 读——把"我的订单"这个接口的 readPreference 强制设为 primary

技术映射:MongoDB 的因果一致性通过 afterClusterTimeoperationTime 实现——写入返回一个时间戳,后续读取带上这个时间戳,保证读到的数据不早于该时间戳。

小白(疑惑):那 readConcern: linearizable 呢?是不是最严格的?MongoDB 支持吗?

大师:支持但不推荐在生产中频繁使用。linearizable 要求读操作必须被当前 Primary 处理,并且确认 Primary 仍然是真正的 Primary——它消耗额外的一次多数确认通信。大多数业务用 majority 就够了。对账/金融的精确总账可以用 linearizable 确保不会读到已经回滚的写入。

技术映射readConcern 的严格程度排序:local (最快) < available < majority < linearizable (最严格) < snapshot(事务专用)。

小白:那不同业务场景该用什么配置?能用一张表说清楚吗?

大师:看这张业务场景配置表:

业务场景 writeConcern readPreference readConcern 理由
用户下单 majority primary local 写是要紧操作,读直接从主读
商品浏览 w: 1 secondary local 允许短暂不一致,高吞吐
订单列表(我的订单) majority primary local 读己之写,必须读主
运营报表 w: 1 secondary majority 允许延迟,但不要瞬态数据
库存扣减 majority primary snapshot(事务内) 确切减库存,不能丢
对账/财务 majority + j: true primary linearizable 精确一致,零容忍

小胖:等等,j: true 又是什么?

大师j 代表 Journal——WiredTiger 的预写日志。j: true 要求写入被刷新到磁盘的 Journal 文件后才返回成功。这避免掉电丢失已缓冲但未落盘的数据。代价是额外 IO 延迟。

技术映射j: true ≈ MySQL 的 innodb_flush_log_at_trx_commit=1。是阻止断电丢数据的最后一层保障。

大师(总结):一致性不是越严格越好——每个场景按自己的要求选配。今天的核心记三个数——读什么(readPreference),读到什么版本(readConcern),写多保险(writeConcern)。三者各自独立,组合起来才是一致性策略。

3. 项目实战

3.1 环境准备

沿用第 17 章的 3 节点复制集。

3.2 分步实现

步骤一:演示"读己之写"问题

目标:在 Primary 上写入后在 Secondary 上查询,复现查不到的问题。

// 连接 Primary
// mongosh "mongodb://localhost:27017/?replicaSet=myRS"

use local_life
const testDoc = {
  testId: "RW_TEST_" + Date.now(),
  value: "刚写入的数据",
  createdAt: new Date()
}
db.rw_test.insertOne(testDoc, { writeConcern: { w: 1 } })
print("写入完成:", testDoc.testId)

// 立即在 Secondary 上查询
// mongosh "mongodb://localhost:27018/?replicaSet=myRS&readPreference=secondary"
db.getSiblingDB("local_life").rw_test.findOne({ testId: testDoc.testId })
// 如果刚写入立即查,可能会返回 null——Secondary 还没同步到

步骤二:五种 writeConcern 实测对比

目标:通过计时对比不同 writeConcern 的性能影响。

use local_life

function testWriteConcern(w, j = false, label) {
  const start = Date.now()
  try {
    db.wc_test.insertOne(
      { label: label, ts: new Date(), w: String(w) },
      { writeConcern: { w: w, j: j, wtimeout: 5000 } }
    )
    const elapsed = Date.now() - start
    print(`${label} (w:${w}, j:${j}): ${elapsed}ms`)
    return elapsed
  } catch (e) {
    print(`${label}: FAILED - ${e.message}`)
    return -1
  }
}

testWriteConcern(0, false, "不确认")
testWriteConcern(1, false, "主节点确认")
testWriteConcern("majority", false, "多数确认")
testWriteConcern(1, true, "主节点+Journal")
testWriteConcern("majority", true, "多数+Journal")
// 期望:耗时越来越长

步骤三:四种 readConcern 对比

目标:理解不同 readConcern 返回数据的时间点差异。

// 在不同 readConcern 下读同一个文档
function testReadConcern(level, label) {
  const start = Date.now()
  const doc = db.rw_test.findOne(
    { testId: { $regex: /^RW_TEST/ } },
    {},
    { readConcern: { level: level } }
  )
  const elapsed = Date.now() - start
  print(`${label}: ${elapsed}ms - ${doc ? '有数据' : '无数据(可能还未同步)'}`)
  return doc
}

// 注意:readConcern 需要在 mongosh 中通过命令或者通过 session 指定
// 使用 runCommand 方式测试
const result = db.runCommand({
  find: "rw_test",
  filter: { testId: { $regex: /^RW_TEST/ } },
  readConcern: { level: "local" }
})
print("local 结果数:", result.cursor.firstBatch.length)

const result2 = db.runCommand({
  find: "rw_test",
  filter: { testId: { $regex: /^RW_TEST/ } },
  readConcern: { level: "majority" }
})
print("majority 结果数:", result2.cursor.firstBatch.length)
// 在从库上,majority 可能会少返回刚写入但未多数确认的文档

步骤四:因果一致性实战

目标:通过 session 传递 operationTime 实现因果一致性。

// 场景:同一个 session 内写入后立刻读取,保证读到刚写的
const session = db.getMongo().startSession()
const coll = session.getDatabase("local_life").rw_test

// 写入
const writeResult = coll.insertOne(
  { testId: "CAUSAL_TEST", value: "因果一致性测试", createdAt: new Date() },
  { writeConcern: { w: "majority" } }
)
print("写入 operationTime:", writeResult.operationTime)

// 使用同一个 session 读取(自动携带 causalConsistency)
const readResult = coll.findOne(
  { testId: "CAUSAL_TEST" },
  { readConcern: { level: "majority" } }
)
print("读取结果:", readResult ? "读到刚写入的数据" : "未读到(不应发生)")

session.endSession()

// 跨 session 的因果一致性
// 如果写入和读取不在同一 session,手动传递 operationTime
// const readResult2 = coll.findOne(
//   { testId: "CAUSAL_TEST" },
//   {
//     readConcern: {
//       level: "majority",
//       afterClusterTime: writeResult.operationTime
//     }
//   }
// )

步骤五:readPreference 组合测试

目标:验证不同 readPreference 的实际行为。

// 在 Primary 上写入
db.rp_test.insertOne({ value: "RP_TEST", createdAt: new Date() }, { writeConcern: { w: "majority" } })

// 测试 1:primary 读(默认)
const p = db.runCommand({
  find: "rp_test",
  filter: { value: "RP_TEST" },
  $readPreference: { mode: "primary" }
})
print("primary 读(主库):", p.cursor.firstBatch.length > 0 ? "PASS" : "FAIL")

// 测试 2:secondary 读
// 在 mongosh 连接时指定: mongosh ".../?readPreference=secondary"
// 或在查询中:
const s = db.runCommand({
  find: "rp_test",
  filter: { value: "RP_TEST" },
  $readPreference: { mode: "secondary" }
})
print("secondary 读(从库):", s.cursor.firstBatch.length > 0 ? "PASS" : "FAIL")

// 测试 3:nearest 读(最低延迟节点)
const n = db.runCommand({
  find: "rp_test",
  filter: { value: "RP_TEST" },
  $readPreference: { mode: "nearest" }
})
print("nearest 读:", n.cursor.firstBatch.length > 0 ? "PASS" : "FAIL")

步骤六:一致性配置对照表

目标:综合理解 writeConcern + readConcern + readPreference 的配合。

// === 场景 A:电商下单(强一致) ===
// write: { w: "majority", j: true }
// read:  { readConcern: { level: "local" }, readPreference: "primary" }
// 结果:写入需要多数确认,读从主库读最新数据

// === 场景 B:商品浏览(高性能) ===
// write: { w: 1 }
// read:  { readConcern: { level: "local" }, readPreference: "secondary" }
// 结果:写入只等 Primary,读从从库分担读压力

// === 场景 C:报表(允许延迟,不要瞬态) ===
// write: { w: 1 }
// read:  { readConcern: { level: "majority" }, readPreference: "secondary" }
// 结果:读到的是多数确认过的稳定数据,但可能有延迟

// === 场景 D:金融对账(最高一致) ===
// write: { w: "majority", j: true }
// read:  { readConcern: { level: "linearizable" }, readPreference: "primary" }
// 结果:写入需 journal 持久化 + 多数确认,读需要验证 Primary 身份

// === 验证脚本 ===
const scenarios = [
  { name: "电商下单", w: "majority", rc: "local", rp: "primary" },
  { name: "商品浏览", w: 1, rc: "local", rp: "secondary" },
  { name: "报表", w: 1, rc: "majority", rp: "secondary" },
  { name: "金融对账", w: "majority", rc: "linearizable", rp: "primary" }
]
console.table(scenarios)

3.3 完整代码清单

文件 用途
mongodb-lab/replicaset/read-write-concern.js writeConcern/readConcern 对比实验
mongodb-lab/replicaset/causal-consistency.js 因果一致性演示
mongodb-lab/replicaset/read-preference.js readPreference 行为测试
mongodb-lab/replicaset/consistency-matrix.js 业务场景一致性配置矩阵

3.4 测试验证

// 1. 验证 writeConcern: majority 的可靠性
db.test_wc.insertOne({ test: "wc_majority" }, { writeConcern: { w: "majority" } })
// 在 2 个 Secondary 上验证数据存在
// → 全部通过

// 2. 验证因果一致性
const sess = db.getMongo().startSession()
sess.getDatabase("local_life").test_causal.insertOne({ _id: "CT1" }, { writeConcern: { w: "majority" } })
const res = sess.getDatabase("local_life").test_causal.findOne({ _id: "CT1" })
print("因果一致性:", res !== null ? "PASS" : "FAIL")
sess.endSession()

// 3. 验证 readConcern: linearizable
try {
  db.runCommand({ find: "test_wc", readConcern: { level: "linearizable" } })
  print("linearizable: PASS")
} catch(e) {
  print("linearizable: 仅 Primary 支持 - " + e.message)
}

print("\n=== 一致性验证完成 ===")

4. 项目总结

4.1 一致性参数速查

参数 可选值 默认值 性能代价 安全级别
writeConcern.w 0/1/n/"majority" 1 w↑ 延迟↑ w↑ 安全↑
writeConcern.j true/false false(64位平台) j=true 延迟↑(磁盘 IO) j=true 防断电丢数据
readConcern.level local/available/majority/linearizable/snapshot local linearizable 性能↓ majority 防脏读
readPreference primary/primaryPreferred/secondary/secondaryPreferred/nearest primary 无显著差异(路由开销) primary 一致性最强

4.2 适用场景

各配置组合的典型场景已在步骤六中给出。

4.3 注意事项

注意事项 说明
readConcern: majority 在从库可能阻塞 从库需要检查 Primary 的 commit point,如果主从延迟大,读会等
不要混用 w:1linearizable 写入只等 Primary,读却检查 Primary 是否仍为 Primary——逻辑矛盾
因果一致性的 session 不能跨服务 如果下单和订单列表是不同的微服务,需要显式传递 operationTime
nearest 可能读到自己的写入失败 nearest 按网络延迟路由,可能将刚写入的请求的后续读请求发送至延迟大的从库
仲裁节点无数据 readPreference: secondary 时不会路由到 Arbiter

4.4 常见踩坑经验

故障案例一:读从库看到"幽灵订单"

某系统用 writeConcern: 1 写订单,但用 readConcern: local 从从库读——巧合读到一条刚写入但 Primary 立刻宕机后被回滚的订单。根因:从库在回滚前已经拉取了这条 Oplog 并应用了,但没来得及知道这条记录已被回滚。解决:将读升级到 readConcern: majority,多数确认的数据不会回滚。

故障案例二:w: majority 在 2 节点复制集中卡住

已在上一章中讲过此案例,本章角度不同——开发在应用代码中写死了 w: majority,运维因资源紧张只给了 2 个数据节点——系统间歇性报 waiting for replication timed out解决:要么加数据节点,要么业务分级——部分低优先级写入降级为 w:1

故障案例三:nearest + secondary 导致请求漂移

某微服务配置 readPreference: nearest,在 K8s 的多可用区部署中,客户端到不同分区的从库延迟有波动,导致同一用户的请求一会儿读北京从库、一会儿读上海从库——订单列表数据来回变动。解决:用户相关接口强制使用 readPreference: primaryprimaryPreferred

4.5 思考题

  1. 如果使用 writeConcern: majority + readConcern: majority 的组合,能保证"写后立刻读总能读到刚写的数据"吗?为什么?
  2. 在 Spring Boot 中如何为某个特定查询覆写全局的 readPreference?如何验证这个覆写真的生效了?

(答案将在第 20 章末尾揭晓)


上一章思考题答案

  1. 三节点全部宕机后,应该先启动最后一个 Secondary(即拥有最新 Oplog 的节点),让它作为恢复的基准。如果启动了最旧的节点且它被选举为 Primary,后续节点需要通过 Rollback 把多余的操作回滚掉=> 可能引发数据丢失。正确顺序:① 找到所有节点中最新的 Oplog(看 rs.status() 保存在每个节点 local 数据库的历史信息);② 先启动拥有最新 Oplog 的节点(让它成为 Primary);③ 再启动其他节点让其追赶。

  2. Hidden 节点实时拉取 Oplog,只是不对外暴露读接口(hidden: true 阻止客户端驱动的读路由)。Delayed 节点故意延迟 Oplog 的应用(secondaryDelaySecs 秒后再应用),Oplog 仍然被拉取但被缓存在本地延迟执行。区别在于:Hidden 节点的数据是准实时的(和普通 Secondary 一样),Delayed 节点的数据是故意滞后的。

延伸阅读与资源

python入门:Rquests从菜鸟脚本到企业级SDK的网络实战圣经
Milvus向量数据库实战修炼:从 0 到 1精通向量检索与生产落地
后端工程师的 AI 转型第一课:Ollama 与私有化大模型实战
10倍开发者的 Dify 魔法书:从零构建全栈 AI 应用
后端工程师转型AI第一课-Ollama 与私有化大模型实战

大型语言模型(LLM) vLLM 高性能推理落地实战

Agent开发之LlamaIndex 实战修炼与源码进阶

大语言模型Transformers 实战修炼与源码剖析

posted on 2026-07-30 21:25  一天不进步,就是退步  阅读(12)  评论(0)    收藏  举报