MongoDB:文档模型、索引竞争与复制恢复
本文固定 MongoDB Server
r7.0.14,手册按 7.0 引用。源码中的 WiredTiger 是该 Server tag 所带版本;实验客户端为mongosh,服务实际版本须在运行时记录。这里的教学基线不代表最新或生产推荐版本。
一次“扣库存成功”至少有三个独立问题:库存条件是否在实际写入时成立,写入确认覆盖了哪些故障,以及客户端是否知道最终结果。文档原子性回答第一个问题的一部分;唯一索引、复制关注和业务幂等各自补上不同边界。
下图上半部沿着索引取得文档,下半部沿着写入等待复制确认。两条路径通过 RecordId 和 OpTime 连接到不同层次的状态,不能把“扫描到候选”“已经执行”“已多数确认”“客户端收到成功”混成一个状态。
从订单不变量确定文档边界
假设一笔订单最多 50 条明细,总额、状态和版本必须一起变化。嵌入明细让一次文档更新可以维护这些字段的不变量,并减少关联读取;代价是整个聚合的写竞争和文档增长集中在一起。BSON 单文档上限为 16 MiB,数年增长的评论或审计数组还会带来复制、读取和更新成本,不应只因“能嵌套”就无限追加。
引用拆分允许实体独立增长和管理生命周期,却把跨文档一致性、查询关联和故障恢复交回应用或多文档事务。选择依据应是“一起读写、一起维护什么不变量”,不是先把关系表全部变成一棵树。跨系统扣款、发信仍在 MongoDB 文档事务之外。依据:文档建模、单文档原子性。
灵活 schema 仍需要契约:金额用整数分,字段缺省值和类型要明确,配合 schemaVersion、validator 与应用校验。假设把 amount:100 的含义从“100 分”改成“100 元”,而字段名和版本不变,旧程序会算错,索引比较和回滚读取也无从判断。演进顺序是先让读端兼容两种格式,再升级写入、回填、验证、切换,最后收紧约束;回滚程序必须能读升级期间的新写入。
RecordId、KeyString 与查询的取数路径
普通 WiredTiger 集合将文档放进 record store,以 RecordId 定位;二级索引保存有序键与记录标识。普通集合的 _id 索引也不等于 InnoDB 聚簇主键叶子上的整行文档,显式 clustered collection 是另一种布局。KeyString 按 BSON 比较规则编码索引键,类型信息需要配合 TypeBits 还原,不能把所有 BSON 值先转成 UTF-8 字符串排序。
固定实现的入口关系是:索引访问方法生成 KeyString,WiredTigerIndex::insert 分派至对应索引 _insert;查询的 sorted cursor 取得键和 RecordId,未被索引覆盖的字段再按 RecordId 取文档。以下 record store 原码显示 makeCursorKey(id, _keyFormat) → WT cursor search → _getData,并区分找不到记录与其他存储错误。oplog 在入口就转到保证其可见性规则的基类路径,因此不能把普通记录的快速查找随意用于复制日志。
原码连续摘录:wiredtiger_record_store.cpp,r7.0.14,L719–L749。完整源文件 SHA-256 与范围记录在摘录标记中。
bool WiredTigerRecordStore::findRecord(OperationContext* opCtx,
const RecordId& id,
RecordData* out) const {
if (_isOplog) {
// This optimized findRecord implementation does not apply oplog visibility rules. Use the
// base class implementation which uses a cursor that guarantees the proper semantics.
return RecordStore::findRecord(opCtx, id, out);
}
dassert(opCtx->lockState()->isReadLocked());
WiredTigerCursor curwrap(_uri, _tableId, true, opCtx);
WT_CURSOR* c = curwrap.get();
invariant(c);
CursorKey key = makeCursorKey(id, _keyFormat);
setKey(c, &key);
int ret = wiredTigerPrepareConflictRetry(opCtx, [&] { return c->search(c); });
if (ret == WT_NOTFOUND) {
return false;
}
invariantWTOK(ret, c->session);
auto& metricsCollector = ResourceConsumption::MetricsCollector::get(opCtx);
metricsCollector.incrementOneCursorSeek(_uri);
*out = _getData(curwrap);
auto keyLength = computeRecordIdSize(id);
metricsCollector.incrementOneDocRead(_uri, out->size() + keyLength);
return true;
}复合索引按字段顺序比较。以 {tenant:1,status:1,createdAt:-1} 为例,tenant 与 status 等值后,可按时间顺序取该租户该状态的记录;如果只给 status,就不能凭“字段在索引里”断言存在同样窄的连续区间。ESR 是设计启发:当范围本身极窄、剩余排序很小,范围优先也可能更省,要比较实际扫描与排序成本。
覆盖查询也不是“用了索引就不取文档”。投影须只需要可由该索引满足的字段,并注意默认返回 _id;实验里显式 _id:0。加入未覆盖的 payload 后,应观察取文档阶段与 totalDocsExamined 的变化。multikey 的覆盖还有限制,尤其不能从索引条目直接还原原始数组。
MongoDB 7.0 有 classic 与 SBE 等执行路径。下面只解释 classic IndexScan::doWork:初始化或跳区间时 seek,区间内 next,之后交给 bounds checker。它不是所有 MongoDB 7.0 查询都必经的调用栈。
原码连续摘录:index_scan.cpp,r7.0.14,L213–L234。
if (kv && _checker) {
switch (_checker->checkKey(kv->key, &_seekPoint)) {
case IndexBoundsChecker::VALID:
break;
case IndexBoundsChecker::DONE:
kv = boost::none;
break;
case IndexBoundsChecker::MUST_ADVANCE:
_scanState = NEED_SEEK;
return PlanStage::NEED_TIME;
}
}
if (!kv) {
_scanState = HIT_END;
_commonStats.isEOF = true;
_indexCursor.reset();
_priority.reset();
return PlanStage::IS_EOF;
}VALID 保留候选;MUST_ADVANCE 将状态设为 NEED_SEEK,下一轮跳到新的有效区间,而非逐键扫过无效区域;DONE 清空候选并结束扫描。若删掉边界检查,范围外键可能被当成结果;若不做跳转,多个离散区间会多扫无效键。多个 multikey 条目可能指向同一 RecordId,执行器还须按语义去重;需要非覆盖字段时再取文档,classic explain 常见 FETCH 包裹 IXSCAN,SBE 则以其实际计划形态解释。
排障记录 winningPlan、是否有 slotBasedPlan、nReturned、totalKeysExamined、totalDocsExamined 与排序/溢写。范围很宽的 IXSCAN 可能接近全索引扫描,数组多条目还可能使 keys examined 远大于文档数。explain("executionStats") 不是生产压测,计划缓存使用也与普通执行有区别;比较时要固定输入、数据分布、冷热和并发。依据:复合索引、explain 结果。
唯一索引把并发查重放进写边界
两个请求同时执行 find({requestId:"R1"}),都可能看到不存在,随后都尝试插入。因此预查只能改善提示,不能承担唯一性。requestId 的唯一索引让查重与插入共享存储事务的并发边界;第二个请求应处理 duplicate key,再核对既有记录是否属于同一业务请求,而不是盲目换一个随机 ID 重做副作用。
基线锁定到 Server 所带 WiredTiger:src/third_party/wiredtiger/import.data 指向 mongodb-7.0 分支 commit eb70c851b7d807e7fb56dd08eba609f5dc02f6cf。下面解析的是 Server 存储适配层的 wiredtiger_index.cpp,不会拼接独立 WT 最新版代码。
原码连续摘录:import.data,r7.0.14,L1–L6。
{
"vendor": "wiredtiger",
"github": "wiredtiger/wiredtiger.git",
"branch": "mongodb-7.0",
"commit": "eb70c851b7d807e7fb56dd08eba609f5dc02f6cf"
}WiredTigerIndexUnique::_insert 在不允许重复的路径调用 _checkDups。后者先插入不含 RecordId 的索引键前缀,建立同键并发事务冲突,再删除前缀;删除不会消除当前事务的冲突保护,提交后才不再阻挡后来的事务。第二阶段使用有界游标寻找已存在的同键记录。
原码连续摘录:wiredtiger_index.cpp,r7.0.14,L634–L696。SHA-256 覆盖整个官方源文件,未把中间行删掉后伪装连续原码。
StatusWith<bool> WiredTigerIndex::_checkDups(OperationContext* opCtx,
WT_CURSOR* c,
const KeyString::Value& keyString,
IncludeDuplicateRecordId includeDuplicateRecordId) {
int ret;
// A prefix key is KeyString of index key. It is the component of the index entry that
// should be unique.
auto sizeWithoutRecordId = (_rsKeyFormat == KeyFormat::Long)
? KeyString::sizeWithoutRecordIdLongAtEnd(keyString.getBuffer(), keyString.getSize())
: KeyString::sizeWithoutRecordIdStrAtEnd(keyString.getBuffer(), keyString.getSize());
WiredTigerItem prefixKeyItem(keyString.getBuffer(), sizeWithoutRecordId);
// First phase inserts the prefix key to prohibit concurrent insertions of same key
setKey(c, prefixKeyItem.Get());
c->set_value(c, emptyItem.Get());
ret = WT_OP_CHECK(wiredTigerCursorInsert(opCtx, c));
// An entry with prefix key already exists. This can happen only during rolling upgrade when
// both timestamp unsafe and timestamp safe index format keys could be present.
if (ret == WT_DUPLICATE_KEY) {
auto key = KeyString::toBson(
keyString.getBuffer(), sizeWithoutRecordId, _ordering, keyString.getTypeBits());
return buildDupKeyErrorStatus(
key, getCollectionNamespace(opCtx), _indexName, _keyPattern, _collation);
}
invariantWTOK(ret,
c->session,
fmt::format("WiredTigerIndex::_insert: insert: {}; uri: {}", _indexName, _uri));
// Remove the prefix key, our entry will continue to conflict with any concurrent
// transactions, but will not conflict with any transaction that begins after this
// operation commits.
setKey(c, prefixKeyItem.Get());
ret = WT_OP_CHECK(wiredTigerCursorRemove(opCtx, c));
invariantWTOK(ret,
c->session,
fmt::format("WiredTigerIndex::_insert: remove: {}; uri: {}", _indexName, _uri));
// The second phase looks for the key to avoid insertion of a duplicate key. The range bounded
// cursor API restricts the key range we search within. This makes the search significantly
// faster.
auto rid = _keyExistsBounded(opCtx, c, keyString, sizeWithoutRecordId);
if (!rid) {
return false;
} else if (*rid == _decodeRecordIdAtEnd(keyString.getBuffer(), keyString.getSize())) {
return true;
}
boost::optional<RecordId> foundRecordId = boost::none;
if (rid && includeDuplicateRecordId == IncludeDuplicateRecordId::kOn) {
foundRecordId = *rid;
}
return buildDupKeyErrorStatus(
KeyString::toBson(
keyString.getBuffer(), sizeWithoutRecordId, _ordering, keyString.getTypeBits()),
getCollectionNamespace(opCtx),
_indexName,
_keyPattern,
_collation,
stdx::monostate(),
foundRecordId);
}三个返回分支含义不同:没有同键记录返回 false,由调用方继续真正插入;已有同一 RecordId 返回 true,调用方可认作该记录已在索引;同键却不同 RecordId 则返回 duplicate key 状态。不能把这个 bool 直接讲成“业务成功/失败”。临时前缀也不是永久锁行;索引格式、后台构建、允许重复的内部路径还有专门分支。
若只做普通查询再插入,两个事务之间没有这层写冲突;若唯一性检查和实际插入脱离同一并发边界,检查通过后仍能产生重复。唯一索引提供数据库范围内可强制的约束,业务仍负责确定幂等键、校验同键负载是否一致,并处理真正的约束冲突。依据:唯一索引。
条件更新必须重检候选
初始文档 {_id:1,stock:5,version:1},两个请求都要扣 3。把“余额够”和“版本未变”放进更新谓词,并由数据库原子完成判断和变更:
<!-- code-cue: 实验代码,单文档条件更新 -->
const result = db.stock.updateOne(
{_id: 1, stock: {$gte: 3}, version: 1},
{$inc: {stock: -3, version: 1}}
);
// matchedCount === 1:本次取得条件;=== 0:未取得,须重读原因。两个相同谓词依次执行,第一次得到 {stock:2,version:2},第二次匹配 0 条。若两个请求先各自读取 5,再都 replaceOne({_id:1}, {stock:2,...}),单次替换虽原子,应用却可能把两次扣减错误地写成一次,或者覆盖对方更新。只把 _id 放在更新条件里也不能保护“读到的 version=1”。
classic 更新路径不是“扫描到候选就一定能写”。执行过程中可能 yield、发生写冲突,文档已删除或已不再满足谓词;UpdateStage::doWork 在实际变换前通过 ensureStillMatches 重检。以下连续片段同时包含重检调用、yield 后重试准备和“不再匹配”的分支。
原码连续摘录:update_stage.cpp,r7.0.14,L418–L447。
bool docStillMatches;
const auto ensureStillMatchesRet = handlePlanStageYield(
expCtx(),
"UpdateStage ensureStillMatches",
collection()->ns().ns(),
[&] {
docStillMatches = write_stage_common::ensureStillMatches(
collection(), opCtx(), _ws, id, _params.canonicalQuery);
return PlanStage::NEED_TIME;
},
[&] {
// yieldHandler
// There was a problem trying to detect if the document still
// exists, so retry.
memberFreer.dismiss();
prepareToRetryWSM(id, out);
});
if (ensureStillMatchesRet != PlanStage::NEED_TIME) {
return ensureStillMatchesRet;
}
if (!docStillMatches) {
// Either the document has been deleted, or it has been updated such that it no longer
// matches the predicate.
if (shouldRestartUpdateIfNoLongerMatches(_params)) {
throwWriteConflictException("Document no longer matches the predicate.");
}
return PlanStage::NEED_TIME;
}普通路径返回 NEED_TIME 跳过失效候选,某些必须重新开始的更新形态抛 WriteConflict。若删去重检,旧候选可能绕过库存或版本条件。这个执行器内部的重试/跳过不等于自动重放整个业务工作流;SBE 等路径也不能直接冒称调用了这段 classic 原码。
WiredTigerRecoveryUnit 管理存储事务和时间戳读源;MVCC 让读写可以在特定快照上工作,但长事务和长快照仍要保留历史、占用资源。写冲突要按单次操作或多文档事务的语义处理,不能将“有 MVCC”理解为长事务无成本。来源:WiredTiger、更新阶段。
数组查询先澄清同元素语义
输入只有一份文档:
<!-- code-cue: 实验代码,数组反例 -->
{_id: 1, items: [{sku: "A", qty: 1}, {sku: "B", qty: 5}]}{"items.sku":"A","items.qty":{$gte:5}} 可以匹配:A 满足第一个条件,B 满足第二个条件。若业务要的是“A 这条明细至少 5 个”,应写 items:{$elemMatch:{sku:"A",qty:{$gte:5}}},这个文档不匹配。两者差异首先是查询语义,不能靠索引优化修正错误的业务条件。
multikey 为数组元素产生多个索引条目,影响 bounds 能否组合、去重和扫描成本。复合 multikey 不允许在同一文档上任意索引两个独立数组形成笛卡尔积;同一数组的嵌套路径应按具体形状核验。unique multikey 主要约束不同文档之间的重复键,不能据此保证单文档数组内部的值互不重复。来源:multikey、$elemMatch。
因此解释一次慢查询应先确认要求的是同元素还是跨元素匹配,再检查索引形状和统计。否则“优化”可能只是更快地返回错误结果。
三组关注分别约束路由、可见性与确认
| 机制 | 回答的问题 | 不能单独推出的结论 |
|---|---|---|
| read preference | 这次读可路由到哪些成员,如 primary 或 secondary | 路由到 secondary 不保证已应用刚才的写 |
| read concern | 这次读允许看哪类状态,如 local、majority、snapshot | majority 不自动建立该读与本客户端先前写的因果联系 |
| write concern | 写入要等待哪些确认,如 w:1、w:"majority"、j:true | majority 不保证每个 secondary 此刻都已应用;j:true 不等于全员读可见 |
例如 primary 接受写入,足够的数据投票成员确认后,另一个较慢 secondary 仍可能落后。此时 primary 的 majority 写成功与 secondary 读到旧值可以同时成立。需要读己之写时,在同一因果一致 session 中正确传递操作时间,并使用满足保证的 read/write concern;仅把读改成 majority 还缺少“这次读必须在那次写之后”的依赖关系。
Server 7.0 的 majority/journal 语义受 writeConcernMajorityJournalDefault、存储能力与拓扑影响。固定 ReplicationCoordinatorImpl::awaitReplication 为目标 OpTime 等待,_doneWaitingForReplication_inlock 按 numeric/tagged/majority 等模式检查成员进度,使用 JOURNAL 时关注 durable 进度,majority 与已提交快照还有额外条件。其他版本的确认优化不能反向套回本基线。
三成员 PSA(primary、secondary、arbiter)只有两个数据成员;arbiter 参与选举却不存数据,不能当第三份数据确认。显式写关注让故障预算更清楚,也避免依赖可能随拓扑变化的默认值。线性化读另有 primary、read concern 和超时等要求;不要把所有强一致语义都压成一个 majority 开关。依据:读偏好、读关注、写关注、因果保证表。
超时之后先确认结果身份
wtimeout 到期表示在期限内没有满足写关注,不表示写入没执行,更不会自动回滚已执行的文档变更。请求可能已在 primary 生效,甚至稍后满足多数确认,只是客户端先拿到了确认错误;网络断线也可能丢失成功响应。
原码连续摘录:replication_coordinator_impl.cpp,r7.0.14,L2147–L2163。
const auto timeoutError = opCtx->getTimeoutError();
auto future = [&] {
stdx::lock_guard lock(_mutex);
WriteConcernOptions fixedWriteConcern =
_populateUnsetWriteConcernOptionsSyncMode(lock, writeConcern);
return _startWaitingForReplication(lock, opTime, fixedWriteConcern);
}();
auto status = futureGetNoThrowWithDeadline(opCtx, future, wTimeoutDate, timeoutError);
// If we get a timeout error and the opCtx deadline is >= the writeConcern wtimeout, then we
// know the timeout was due to wtimeout (not opCtx deadline) and thus we return
// ErrorCodes::WriteConcernFailed.
if (status.code() == timeoutError && opCtxDeadline >= wTimeoutDate) {
status = Status{ErrorCodes::WriteConcernFailed, "waiting for replication timed out"};
}这里先构造复制等待 future,再按 wTimeoutDate 等待;能确定来自 wtimeout 的超时被转换为 WriteConcernFailed。片段没有撤销文档的路径。应用要区分命令层失败、逐条 writeErrors 与 writeConcernError,并按驱动的实际返回形态处理,不能只看 HTTP/RPC 是否超时。
以请求 R1 扣 3 为例,超时后直接换 R2 再扣一次,可能扣 6。应保留稳定的业务请求身份,查询或恢复同一个操作的状态;若重试则让同一身份落在可强制的幂等约束中,并检查请求负载一致。retryable writes 在支持条件下解决特定写操作的重复提交,不等于跨服务扣款、发信和任意业务工作流自动幂等。若证据仍不足,状态就是 UNKNOWN,不能擅自写成“失败未执行”。
journal、oplog 与 rollback 各修复什么
| 材料或过程 | 主要职责 | 不代表什么 |
|---|---|---|
| journal 与 checkpoint | 单节点按存储引擎规则恢复耐久状态,checkpoint 提供持久基线,journal 补其后变化 | checkpoint 不是跨副本提交证明 |
| oplog | 保存复制操作历史,供复制与相应恢复过程使用 | 有限窗口的复制历史不是永久备份或业务事件仓库 |
| rollback | 旧成员与合法新历史分叉后,恢复到一致历史再继续复制 | 不是按文档简单删除“新主没有的记录” |
| 备份与恢复演练 | 面向误删、逻辑损坏、足够副本丢失等灾难重建 | 副本仍在不等于能恢复到正确历史时间点 |
故障时序可以这样推:P 在隔离前接受一条仅本地确认的写 X,另一侧选出新主并形成历史 Y;P 重新加入后发现分叉,X 可能回滚。majority 保护依赖合法选举、配置和足够的数据副本;强制重配置、同时丢失足够成员或存储损坏要单独建模,不能用“有三台”掩盖。
固定 RollbackImpl::runRollback 先找 common point,再进入恢复阶段;随后 _recoverToStableTimestamp 恢复数据到稳定时间戳。下面原码强调:存储引擎恢复数据不会自动恢复 oplog,必须在稳定恢复成功后持久记录截断点,再启动 oplog recovery。若先截断日志而数据恢复失败,两者就可能不一致。
原码连续摘录:rollback_impl.cpp,r7.0.14,L640–L661。
// Persist the truncate point to the 'oplogTruncateAfterPoint' document. We save this value so
// that the replication recovery logic knows where to truncate the oplog. We save this value
// durably to match the behavior during startup recovery. This must occur after we successfully
// recover to a stable timestamp. If recovering to a stable timestamp fails and we still
// truncate the oplog then the oplog will not match the data files. If we crash at any earlier
// point, we will recover, find a new sync source, and restart roll back (if necessary on the
// new sync source). This is safe because a crash before this point would recover to a stable
// checkpoint anyways at or earlier than the stable timestamp.
//
// Note that storage engine timestamp recovery only restores the database *data* to a stable
// timestamp, but does not revert the oplog, which must be done as part of the rollback process.
_replicationProcess->getConsistencyMarkers()->setOplogTruncateAfterPoint(opCtx, truncatePoint);
_rollbackStats.truncateTimestamp = truncatePoint;
_listener->onSetOplogTruncateAfterPoint(truncatePoint);
// Align the drop pending reaper state with what's on disk. Oplog recovery depends on those
// being consistent.
_resetDropPendingState(opCtx);
// Run the recovery process.
_replicationProcess->getReplicationRecovery()->recoverFromOplog(opCtx, stableTimestamp);
_listener->onRecoverFromOplog();共同历史找不到、所需 oplog 已回收,不能靠无限重试续传;应评估 initial sync/resync 等重新建立副本基线的路径,并监控网络、磁盘和追赶能力。恢复期间历史窗口还会继续推进,扩容和 chunk 迁移也与在线流量争资源。来源:rollback、oplog。
事务错误标签决定重试范围
多文档事务用于维护合理文档边界之外的跨文档不变量,代价包括更长的快照生命周期、冲突重试和资源占用;跨分片还增加协调。事务提交响应丢失同样可能结果未知,不能把数据库原子提交等同客户端必知结果。
| 错误标签 | 应恢复的范围 | 回调中的外部副作用风险 |
|---|---|---|
TransientTransactionError | 对可重试情形重跑整个事务体,建立新的事务尝试 | 同一事务体可能再次执行,直接扣款或发信可能重复 |
UnknownTransactionCommitResult | 继续确认或重试同一次提交,不因未知就重新执行事务体 | 重跑事务体可能把一个已提交的操作再做一遍 |
具体重试须以所用驱动版本及错误标签为准,设置预算并处理不可重试错误。方便事务 API 的回调可能被驱动重跑,回调应保持可重复执行;外部动作通常需要可靠业务状态、outbox 等协议及消费幂等,不因使用事务而自动被纳入原子边界。来源:事务应用与重试。
change stream 的恢复窗口
resume token 是恢复位置,只有所需历史仍可恢复时才有用。假设消费者最后处理到 t0,oplog 最早历史已经推进到 t1 且 t1 > t0;保留 token 本身不能重造已回收事件。此时应显式进入“不能续传”的恢复状态,以有水位的一致性基线重新校准,再衔接增量并去重;丢 token 后直接从“现在”开始会静默漏数据。
重建方案要定义快照/扫描期间写入怎样衔接、何时能声明追平、重复事件怎样识别。fullDocument:"updateLookup" 返回的全文档可能是 lookup 时更晚的版本,而非每条事件发生瞬间的完整状态;消费者若需要逐版本审计,就不能用这个选项替代不可变业务事件或所需的前后映像配置。
监控应包含消费 lag、oplog 可用窗口、恢复失败和重建所需时间。窗口按实际写入速率和容量变化,不能把一次测得的小时数当永久承诺。来源:change streams。
分片键改变唯一性与恢复成本
范围分片保留原值局部性,但单调增长键容易把新写集中到边界 chunk;hashed 有助于分散原值,却弱化原值范围查询的局部性。低基数、单个过热值或超大租户依然可能热点,增加 shard 不会自动拆开同一个热键。能带合适 shard key 的请求有机会 targeted,缺路由谓词则常需 scatter-gather。
分片集合的唯一约束必须按键形核验:对范围分片,唯一索引通常要求 shard key 是索引前缀;hashed 索引不能声明 unique。若 _id 不是 shard key,默认 _id 索引不自动提供跨 shard 的 _id 唯一性,应用必须保证。比如按 tenantId 分片,唯一 {tenantId:1,requestId:1} 可约束每租户的请求 ID;它不等于全平台 requestId 唯一。
若业务坚持全局幂等键,应设计可强制的归属路由、合适的键模型或独立裁决路径,不能靠跨 shard 先查一次再插入代替约束。选 shard key 时还要说明 targeted 查询比例、热点迁移、balancer 资源、扩容与灾难恢复成本。来源:shard key、分片唯一约束。
可运行实验与证据范围
实验说明与完整输入下载 和可下载运行脚本提供两个明确入口。以下命令从仓库根目录执行,--model 只需 Python 3;--service 需要 mongosh 和可连接的 replica set,保留 mongo:7.0.14 教学基线并记录服务实际版本,不自动安装或改动用户服务。
<!-- code-cue: 实验命令 -->
bash labs/data-senior/mongodb/run.sh --model
bash labs/data-senior/mongodb/run.sh --service| 入口与输入 | 要核对的预期 | 证据能覆盖的范围 |
|---|---|---|
| 模型:cross 为 A/1、B/5,same 为 A/5;库存 100,两个版本 1 的请求各扣 80 | 点路径命中 cross/same,$elemMatch 仅命中 same;匹配数为 [1,0],最终 stock=20、version=2 | 有限 Python 状态模型,不执行 MongoDB 查询或 WT 并发 |
| 模型:已应用但 ACK 丢失的超时反例,两个事务错误标签 | 客户端结果保留 UNKNOWN;分别选择 retry-body / retry-commit,无标签转 investigate | 只选择了一种未知结果轨迹,不能推断所有超时都已应用,不注入真实网络故障 |
| 模型:窗口 15…40,token 20 与 12 | 20 在窗口内可续,12 早于最早历史不能续 | 有限序号窗口判断,不模拟真实 oplog、token 编码或重建协议 |
| 服务:唯一 tenant/request_id、同一数组/库存输入,10000 订单和 10 租户 | 同租户重复 pay-1 被拒绝;数组结果为 2/1;扣减仅一次;复合 tenant/createdAt/total 对照覆盖与 payload 取文档 | MongoDB 实际单节点语义;以脚本断言、实际 explain 引擎和扫描量为准 |
| 服务:三组读写配置、事务、change stream 基础 | 明确请求的 read preference / read concern / write concern;事务提交与流事件可观察 | 单节点 replica set 基础行为,不能证明副本滞后、跨机房多数或真实切主 |
服务脚本以新建唯一实验数据库隔离,并通过 finally 只清理自己创建的资源;不要复用共用 study 库或清空用户集合。每次实验先看实际版本与拓扑,再核对输入、断言、清理结果。
正文的“库存 5 扣 3”是最小纸面反例;公开 fixture 采用“库存 100 扣 80”并保存具体期望,二者都只允许一个相同版本的请求取得条件。
本轮已执行 --model:输出点路径命中 cross/same、elemMatch 仅 same,库存匹配 [1,0] 且最终 20/2,唯一键第二次 duplicate-key,事务动作依次为 retry-body / retry-commit / investigate,窗口内可续而过期不可续。7 段原码也已逐段与固定 tag 官方源文件和完整文件摘要核对;这些结果只覆盖上述模型与来源一致性。
T18 首次探测时两个端口均拒绝连接;T37 于 2026-10-24 在本机已有 127.0.0.1:27017 的 MongoDB 7.0.45 standalone 实跑 --server-version 7.0.45 --standalone 子集。10000订单覆盖查询 docsExamined=0、取payload=1000;唯一键重复11000;数组2/1;两次CAS匹配[1,0],库存20/版本2;local读与w1/j写通过,自己的UUID库已清理。脚本修正了mongosh不支持旧assert.eq的问题;源码仍固定r7.0.14。当前拓扑不能运行snapshot多文档事务与change stream,majority/因果session对照也明确跳过,没有改用户服务为复制集。未验证多副本丢失、rollback、网络分区、掉电或oplog窗口耗尽。源码摘录核验、有限模型和真实服务实验是三种不同证据,不能互相冒充。
三轮追问与条件变化
主问题是“怎样防止库存超卖”。合格回答先定义库存文档的不变量,把 stock 与 version 条件放进原子更新谓词,检查未匹配结果,并设计稳定请求身份来恢复超时未知;跨文档才论证事务或协议。主答 4 分:原子边界、谓词、结果分支、业务幂等各 1 分。
第一轮追问:为什么应用查不到重复却插入报错?两个预查可能同时通过,唯一索引把同键检查与插入放入存储冲突边界,KeyString 的键前缀与 RecordId 分别代表唯一值与记录身份。2 分:竞争时间线 1 分、存储兜底 1 分。
第二轮追问:majority 写完读 secondary 为什么仍旧?足够成员确认不等于任意读成员已应用,还需要正确的路由、读关注与 session 因果依赖。2 分:成员进度 1 分、三组配置与 session 1 分。
第三轮追问:resume token 不能恢复时怎么办?显式报告历史缺口,建立有水位的基线并衔接增量,不能跳到现在伪装连续。2 分:窗口条件 1 分、重建与去重 1 分。总分 10 分,至少 8 分且不把 wtimeout 说成回滚,才算完成本轮表达;自评不等于生产能力证明。
架构师条件变化需要给出新的方案约束:
- 明细从最多 50 条变为数年无界增长:拆分明细/审计,重新定义总额和状态维护方式,比较事务、异步投影与允许陈旧程度,不能继续无限嵌套。
- 部署从单机变为三机房:多数确认跨 RTT,先计算延迟和失去一个故障域后的数据投票能力,再比较可用性、RPO/RTO 与成本;备份仍需灾难重建演练。
- 租户从均匀变为单一大户占一半写:比较 tenant 归属、复合/hashed shard key 和大户拆分,量化定向查询比例及迁移资源;不能只回答“再加 shard”。
专家可选继续深入 WT 内部页结构、历史存储和时间戳恢复。本页的实现承诺是 Server 存储适配层、classic 执行分支及复制恢复入口,并未逐行分析整个 WT 引擎。
闭卷验收
本页 12 道闭卷题收进了下方自测面板:先回忆,再展开参考答案与评分点,最后给三档自评。