Приложение получило acknowledged: true, primary упал, и после выборов документа нет ни на одном узле. В MongoDB это не сбой, а следствие того, где стоит подтверждение: PostgreSQL по WAL реплицирует физические изменения страниц, MongoDB реплицирует логические операции из oplog, и w: 1 подтверждает запись до того, как её увидел хоть один secondary. Выборы в replica set устроены по тому же принципу, что и Raft в etcd: term, голосование большинством. Ниже три узла, у которых отрезают и убивают primary, и затем кластер из двух шардов, где ключ _id типа ObjectId отправляет в один шард все вставки. Модель документа, из-за которой вообще встаёт вопрос о ключе, разобрана в статье про ClickHouse, MongoDB и Cassandra. Измерения идут на 150 000–300 000 документов; на настоящих объёмах числа будут другими.
Стенд: три узла и oplog
Узел a получает priority: 2, чтобы после опытов он возвращался в primary. Память ограничена, WiredTiger получает кэш 0,25 ГБ:
S=dba-mongodb-replica-sets-and-sharding
docker network create $S-net
for n in a b c; do
docker run -d --name $S-$n --network $S-net --memory 768m mongo:8 \
mongod --replSet rs0 --bind_ip_all --wiredTigerCacheSizeGB 0.25
done
m() { docker exec $S-$1 mongosh --quiet --eval "$2"; }
m a 'db.version()'
m a 'rs.initiate({_id:"rs0",members:[
{_id:0,host:"'$S'-a:27017",priority:2},
{_id:1,host:"'$S'-b:27017"},{_id:2,host:"'$S'-c:27017"}]}).ok'
m a 'rs.status().members.map(m=>m.name.replace("'$S'-","")+" "+m.stateStr+" "+m.optimeDate.toISOString())'
Вывод (версия, результат rs.initiate, состав через 15 секунд):
8.3.13
1
[
'a:27017 PRIMARY 2026-10-28T00:19:39.000Z',
'b:27017 SECONDARY 2026-10-28T00:19:39.000Z',
'c:27017 SECONDARY 2026-10-28T00:19:39.000Z'
]
Теперь счётчик с двумя $inc и три последние записи oplog по его коллекции. Запрос по $natural читает коллекцию local.oplog.rs в порядке вставки:
m a 'use("shop"); db.counters.insertOne({_id:"hits",n:0});
db.counters.updateOne({_id:"hits"},{$inc:{n:5}})'
m a 'use("shop"); db.counters.updateOne({_id:"hits"},{$inc:{n:2}});
db.getSiblingDB("local").oplog.rs.find({ns:"shop.counters"},{_id:0,op:1,ns:1,o:1,o2:1,t:1})
.sort({$natural:-1}).limit(3).forEach(x=>print(EJSON.stringify(x)))'
Вывод:
{"op":"u","ns":"shop.counters","o":{"$v":2,"diff":{"u":{"n":7}}},"o2":{"_id":"hits"},"t":3}
{"op":"u","ns":"shop.counters","o":{"$v":2,"diff":{"u":{"n":5}}},"o2":{"_id":"hits"},"t":1}
{"op":"i","ns":"shop.counters","o":{"_id":"hits","n":0},"o2":{"_id":"hits"},"t":1}
Запрос был $inc: 2, а в журнале лежит diff.u.n: 7: итоговое значение поля, а не приращение. Secondary применяет n = 7, и повторное применение той же записи (после рестарта или отката) даёт то же состояние. Поле t — term выборов, в которых запись сделана; t:3 у последней записи объяснён ниже. Сам журнал — capped-коллекция, у которой старые записи вытесняются новыми:
CollectionOptions options;
options.capped = true;
options.cappedSize = sz;
options.autoIndexId = CollectionOptions::NO;
Это createOplog в oplog.cpp тега r8.3.13 (строки 754–757): размер задаёт sz, и secondary, отставший дальше окна журнала, может догнать primary только полной пересинхронизацией.
Запись w:1 против w:majority при падении primary
В наборе из трёх голосующих узлов неявный write concern уже majority (документация), поэтому опасная запись здесь поставлена явно. Чтобы получить окно потери, primary сначала отрезается от сети, затем получает записи, затем убивается сигналом KILL. На отрезанном a три документа пишутся с w: 1, один с w: "majority" и таймаутом 3 секунды:
m a 'use("shop"); db.orders.insertMany([{_id:1},{_id:2},{_id:3}],{writeConcern:{w:"majority",j:true}}).acknowledged'
docker network disconnect $S-net $S-a
m a 'use("shop");
const r1=db.orders.insertMany([{_id:101},{_id:102},{_id:103}],{writeConcern:{w:1}});
print("w1 acknowledged:", r1.acknowledged);
try { db.orders.insertOne({_id:201},{writeConcern:{w:"majority",wtimeout:3000}}) }
catch(e) { print("majority:", e.code, e.codeName, e.errmsg||e.message) }
print("count on a:", db.orders.countDocuments({}), db.orders.find({},{_id:1}).toArray().map(x=>x._id).join(","))'
docker kill -s KILL $S-a
Вывод (первая строка — запись трёх документов до разрыва сети):
true
w1 acknowledged: true
majority: 64 WriteConcernTimeout waiting for replication timed out
count on a: 7 1,2,3,101,102,103,201
Клиент получил подтверждение для 101–103 и таймаут для 201, но на a есть все четыре: ошибка WriteConcernTimeout не отменяет запись, она сообщает только, что большинство её не подтвердило. Теперь выборы по логу узла b:
docker logs $S-b 2>&1 | grep -E '"(Starting an election|Election succeeded|Dry election run succeeded)'
2026-10-28T00:20:11.979+00:00 ELECTION Starting an election, since we've seen no PRIMARY in election timeout period {"electionTimeoutPeriodMillis": 10000}
2026-10-28T00:20:11.979+00:00 ELECTION Dry election run succeeded, running for election {"newTerm": 2}
2026-10-28T00:20:11.986+00:00 ELECTION Election succeeded, assuming primary role {"term": 2}
Сеть a была отрезана в 00:20:01,7 UTC (на хосте UTC+3, там 03:20:01). Таймер сработал примерно на 10,3 секунды позже, и само голосование заняло 7 мс. Десять секунд — не случайность: kDefaultElectionTimeoutPeriod в repl_set_config.cpp равен 10000 мс, и всё это время набор не принимает записи. Пробный раунд («dry run») — тот же pre-vote, что в статье про Raft.
Новый primary принимает записи с w: "majority", j: true (документы 301 и 302), затем a возвращается в сеть и стартует:
m b 'use("shop"); db.orders.insertMany([{_id:301},{_id:302}],{writeConcern:{w:"majority",j:true}}).acknowledged'
docker network connect $S-net $S-a && docker start $S-a
docker logs $S-a 2>&1 | grep -E 'Transition to ROLLBACK|Rollback common point|Preparing to write deleted|Operations reverted|Rollback complete'
2026-10-28T00:20:31.975+00:00 ROLLBACK Transition to ROLLBACK {}
2026-10-28T00:20:31.976+00:00 ROLLBACK Rollback common point {"commonPointOpTime": {"ts": {"$timestamp": {"t": 1793146801, "i": 2}}, "t": 1}}
2026-10-28T00:20:31.977+00:00 ROLLBACK Preparing to write deleted documents to a rollback file {"namespace": "shop.orders", "uuid": "e2b524c9-5f1f-4269-b30b-f39f8fce2f9a", "file": "/data/db/rollback/e2b524c9-5f1f-4269-b30b-f39f8fce2f9a/removed.2026-10-28T00-20-31.1.bson"}
2026-10-28T00:20:32.175+00:00 ROLLBACK Operations reverted by rollback {"insert": 4, "update": 0, "delete": 0}
2026-10-28T00:20:32.393+00:00 ROLLBACK Rollback complete {}
Журналы разошлись после общей точки: у a в term 1 остались четыре вставки, у b в term 2 их нет. Откат занял 418 мс (от Transition to ROLLBACK до Rollback complete), четыре вставки отменены, а сами документы сохранены в файл:
docker exec $S-a sh -c 'bsondump /data/db/rollback/*/removed.*.bson'
m a 'use("shop"); db.orders.find({},{_id:1}).sort({_id:1}).toArray().map(x=>x._id).join(",")'
{"_id":{"$numberInt":"103"}}
{"_id":{"$numberInt":"201"}}
{"_id":{"$numberInt":"102"}}
{"_id":{"$numberInt":"101"}}
2026-10-28T00:20:56.250+0000 4 objects found
1,2,3,301,302
Потеряны все четыре записи отрезанного primary: три подтверждённых w: 1 и одна, которую клиент увидел как ошибку. Записи majority (301, 302 и исходные 1–3) уцелели. Узел a (priority 2) снова стал PRIMARY, и term вырос до 3: поэтому у последней записи oplog из начала статьи t:3. Из файла отката документы возвращают вручную (документация).
Шардирование: монотонный ключ и hashed
Монотонный ключ: все новые вставки в одном шарде
Кластер минимального размера: config server и два шарда по одному узлу в своих replica set, один mongos. Узлы replica set из прошлого опыта удалены:
docker rm -f -v $S-a $S-b $S-c
docker run -d --name $S-cfg --network $S-net --memory 768m mongo:8 mongod --configsvr --replSet cfg --bind_ip_all --wiredTigerCacheSizeGB 0.25
for n in 1 2; do docker run -d --name $S-sh$n --network $S-net --memory 768m mongo:8 mongod --shardsvr --replSet sh$n --bind_ip_all --wiredTigerCacheSizeGB 0.25; done
docker exec $S-cfg mongosh --port 27019 --quiet --eval "rs.initiate({_id:'cfg',configsvr:true,members:[{_id:0,host:'$S-cfg:27019'}]}).ok"
for n in 1 2; do docker exec $S-sh$n mongosh --port 27018 --quiet --eval "rs.initiate({_id:'sh$n',members:[{_id:0,host:'$S-sh$n:27018'}]}).ok"; done
docker run -d --name $S-mongos --network $S-net --memory 768m mongo:8 mongos --configdb cfg/$S-cfg:27019 --bind_ip_all
docker exec $S-mongos mongosh --quiet --eval "sh.addShard('sh1/$S-sh1:27018'); sh.addShard('sh2/$S-sh2:27018'); db.adminCommand({listShards:1}).shards.map(s=>s._id+' '+s.host)"
[
'sh1 sh1/dba-mongodb-replica-sets-and-sharding-sh1:27018',
'sh2 sh2/dba-mongodb-replica-sets-and-sharding-sh2:27018'
]
Две коллекции: mono с ключом _id (ObjectId, растёт со временем) и hashed с тем же полем в хэшированном виде. Размер чанка понижен до 4 МБ, чтобы 70 МиБ данных делились на части (по умолчанию 128 МБ, документация). Балансировщик выключен, чтобы увидеть, куда клиент пишет сам. Загрузка — 150 000 документов по 492 байта пачками по 1000 (load.js, он копируется в контейнер mongos); счётчик opcounters.insert читается на каждом шарде до и после:
// load.js: docker cp load.js $S-mongos:/tmp/load.js
const coll = db.getSiblingDB("shop")[COLL];
const pad = "x".repeat(450);
for (let i = 0; i < 150; i++) {
const batch = [];
for (let j = 0; j < 1000; j++) batch.push({ user: Math.floor(Math.random() * 1e6), pad: pad });
coll.insertMany(batch, { ordered: false });
}
docker exec $S-mongos mongosh --quiet --eval '
db.getSiblingDB("config").settings.updateOne({_id:"chunksize"},{$set:{value:4}},{upsert:true});
sh.enableSharding("shop"); sh.stopBalancer();
sh.shardCollection("shop.mono",{_id:1}); sh.shardCollection("shop.hashed",{_id:"hashed"})'
ins() { docker exec $S-$1 mongosh --port 27018 --quiet --eval 'print(Number(db.serverStatus().opcounters.insert))'; }
for c in mono hashed; do
b1=$(ins sh1); b2=$(ins sh2)
docker exec $S-mongos mongosh --quiet --eval "const COLL='$c'" --file /tmp/load.js
echo "$c: sh1 +$(( $(ins sh1)-b1 )) sh2 +$(( $(ins sh2)-b2 ))"
done
mono: sh1 +150000 sh2 +0
hashed: sh1 +74871 sh2 +75129
Монотонный ключ отдал шарду sh1 все 150 000 вставок, хэшированный разделил почти поровну. Статистика db.hashed.getShardDistribution() подтверждает половину на каждый шард (49,91 % и 50,08 %), а у mono один чанк в sh1 держит 100 % данных (70,38 МиБ). Теперь балансировщик включается (sh.startBalancer()), и раз в 10 секунд считаются чанки shop.mono по шардам:
docker exec $S-mongos mongosh --quiet --eval 'sh.startBalancer()'
docker exec $S-mongos mongosh --quiet --eval 'const c=db.getSiblingDB("config"); const u=c.collections.findOne({_id:"shop.mono"}).uuid;
print(c.chunks.aggregate([{$match:{uuid:u}},{$group:{_id:"$shard",n:{$sum:1}}},{$sort:{_id:1}}]).toArray().map(x=>x._id+"="+x.n).join(" "))'
Вывод в 00:23:15, 00:23:26 и 00:24:07:
00:23:15 sh1=1 sh2=1
00:23:26 sh1=1 sh2=8
00:24:07 sh1=1 sh2=8
Он разрезал единственный чанк на девять и перенёс восемь самых левых в sh2; по config.changelog каждый moveChunk длился от moveChunk.start до moveChunk.commit 153–191 мс, с паузой около секунды между ними. Порядок стадий миграции задан перечислением в migration_source_manager.h, в .cpp ему соответствуют startClone, awaitToCatchUp, enterCriticalSection, commitChunkOnRecipient и commitChunkMetadataOnConfig:
enum State {
kCreated,
kCloning,
kCloneCaughtUp,
kCriticalSection,
kCloneCompleted,
kCommittingOnConfig,
kDone
};
Чанки теперь распределены (1 и 8), но вставки остаются прежними. Балансировка выключена снова, и те же 150 000 документов пишутся в mono во второй раз (sh.stopBalancer(), затем тот же цикл с mono):
mono after balancing: sh1 +150000 sh2 +0
Последний чанк [6ae14059e2151960425083e4, MaxKey) принадлежит sh1, а новый ObjectId всегда больше любой границы: балансировщик переносит готовые чанки, но не меняет направление роста ключа.
Jumbo chunk: одно значение ключа
Если у многих документов одно значение ключа, чанк нельзя разрезать между ними. В коллекции jumbo с ключом {k: 1} лежат 40 000 документов с k: 1 и по одному с k: 0 и k: 2 (вставка пачками по 1000 через mongos, sh.startBalancer()), чанк 4 МБ. Через 40 секунд config.chunks:
sh2 {"k":{}} {"k":1} jumbo=false
sh1 {"k":1} {"k":{}} jumbo=true
В moveChunk.error из config.changelog указана причина:
Cannot move chunk: the maximum number of documents for a chunk is 17188, the maximum chunk size is 4194304, average document size is 488. Found 40001 documents in chunk ns: shop.jumbo { k: 1 } -> { k: MaxKey }
Чанк на sh1 помечен jumbo=true и не переносится. С размером чанка по умолчанию (128 МБ) порог выше, а механизм тот же: низкая кардинальность ключа.
Что из этого следует
- Явный
w: 1в наборе из трёх узлов — осознанная покупка скорости ценой отката при переизбрании. Для данных, которые нельзя терять, оставляй неявныйmajorityи добавляйj: true; ошибкуWriteConcernTimeoutобрабатывай как «неизвестно», а не «не записано». - Окно без записи при потере primary — 10 секунд по умолчанию (
electionTimeoutMillis), клиент должен переживать такой простой повтором. - Ключ шарда проверяй до загрузки данных. Монотонный ключ (ObjectId, время) даёт горячий шард даже при ровных чанках; hashed убирает перекос вставок (74 871 и 75 129); ключ с малым числом значений даёт jumbo.
Чек-лист
- Выведи write concern, с которым пишет приложение, и убедись, что для важных данных он
majority; найди все явныеw: 1. - После теста падения primary (
docker kill -s KILL) проверь каталогrollbackна бывшем primary: он не должен получать документы, которые клиент считал записанными. - Перед
sh.shardCollectionпрогони тестовую вставку и сравниopcounters.insertна шардах: ноль вставок на одном из шардов — признак монотонного ключа. - Следи за
jumbo: trueвconfig.chunks: такой чанк балансировщик не переносит сам. - Убери стенд:
docker rm -f -v $S-cfg $S-sh1 $S-sh2 $S-mongos && docker network rm $S-net.