Последняя статья серии закрывает практическую эксплуатацию и отвечает на вопрос, который стоял за сквозной PG-нитью с самого начала: MongoDB или Postgres? Здесь — драйверы на Go и Java (пул соединений, retryable writes, таймауты), change streams как встроенный источник CDC, варианты бэкапа — mongodump, снапшоты, PBM — и метрики, за которыми стоит следить в проде. А в конце — карта выбора между MongoDB, PostgreSQL и ScyllaDB по осям модель данных, масштабирование, консистентность и эксплуатация, без вкусовщины, с опорой на всё, что было измерено в серии.
В статье
- Драйверы Go и Java: пул, retryable writes, таймауты
- Пул соединений вживую — maxPoolSize как жёсткий потолок
- Retryable writes переживают step-down primary
- Change streams — встроенный источник CDC, и
resumeAfter - Бэкап:
mongodump, снапшоты, PBM — когда что уместно - Мониторинг: ключевые метрики MongoDB в проде
- Карта выбора: MongoDB, PostgreSQL или ScyllaDB
- Оси сравнения: модель, масштаб, консистентность, эксплуатация
- Итог серии и PG-нити
Предыдущая статья серии — Sharding вживую — доводила MongoDB до горизонтального масштаба: mongos, config servers, чанки, hashed против ranged shard key, resharding под нагрузкой. Здесь кластер уже не растёт вширь — он эксплуатируется: как к нему подключается приложение (пул соединений, retryable writes, таймауты драйвера — на Go и на Java), как из него читать поток изменений (change streams как источник CDC), как его резервировать и за чем следить в проде. А в конце — то, ради чего в серии с самого начала шла сквозная PG-нить: карта выбора между MongoDB, PostgreSQL и ScyllaDB, без вкусовщины, по осям, с опорой на числа, реально измеренные в предыдущих статьях.
Всё измеренное ниже снято на живом 3-узловом replica set mongo:8.2.11 (rs0) поверх сквозного датасета серии (seed=42: 50 000 пользователей, 5000 товаров, 200 000 заказов), стенд mongodb/ops-stand на Go (драйвер go.mongodb.org/mongo-driver/v2 v2.4.2) и Java-зеркало change streams mongodb/java/ops (org.mongodb:mongodb-driver-sync 5.5.1).
Драйверы Go и Java: пул, retryable writes, таймауты
Первое, что отделяет «прочитал документацию» от «держит прод», — как именно приложение обращается к кластеру. Драйвер MongoDB — не тонкая обёртка над сокетом, а компонент, который держит пул соединений, сам умеет переустанавливать их при смене primary и повторять записи. Три настройки решают почти всё поведение под нагрузкой и при сбоях:
- Пул соединений (
maxPoolSize) — верхний потолок одновременных TCP-соединений одного клиента к одному узлу. По умолчанию 100. Пул — не «желательный размер», а жёсткий предел: когда все соединения заняты, новые операции встают в очередь и ждут, а не открывают ещё одно соединение. - Retryable writes (
retryWrites=true) — включены по умолчанию в строке подключения обоих драйверов. Драйвер один раз повторяет неудавшуюся запись, если причина неудачи — смена primary или сетевой сбой, а не логическая ошибка. Идемпотентность гарантируется номером записи (txnNumber) внутри сессии. - Таймауты —
serverSelectionTimeoutMS(сколько ждать доступного узла, по умолчанию 30 s),connectTimeoutMS, а в v2-драйвере — единыйtimeoutна всю операцию (Go:options.Client().SetTimeout(...)илиcontext.WithTimeout). Без явного таймаута операция при недоступном primary «висит» доserverSelectionTimeoutMS.
Ниже эти три механизма показаны не в теории, а вживую: пул — как потолок конкурентности, retryable write — как реальный повтор через смену primary.
Пул соединений вживую — maxPoolSize как жёсткий потолок
Стенд запускает 9 конкурентных FindOne через клиента с maxPoolSize=3 и наблюдает пул напрямую через *event.PoolMonitor (события ConnectionCheckedOut / ConnectionCheckedIn), а не по косвенным признакам тайминга.
Честная методологическая оговорка (важно для воспроизведения). Классический способ смоделировать «медленный сервер» в тестах драйверов — configureFailPoint / failCommand. Но публичный образ mongo:8.2.11 с Docker Hub собран без enableTestCommands=1 — прямой вызов db.adminCommand({configureFailPoint:...}) на нём отвечает MongoServerError: no such command: 'configureFailPoint' (проверено живьём до реализации стенда). Поэтому вместо синтетического сбоя стенд создаёт реальную серверную задержку: $where с sleep(200) на коллекции ровно из одного документа — JS-предикат выполняется один раз за запрос, давая детерминированные 200 мс блокировки на каждый FindOne. Это не fault injection, а настоящая латентность; направление вывода от подмены не меняется.
var peak, inUse int64
poolMon := &event.PoolMonitor{
Event: func(e *event.PoolEvent) {
switch e.Type {
case event.ConnectionCheckedOut:
n := atomic.AddInt64(&inUse, 1)
if n > atomic.LoadInt64(&peak) {
atomic.StoreInt64(&peak, n)
}
case event.ConnectionCheckedIn:
atomic.AddInt64(&inUse, -1)
}
},
}
opts := options.Client().ApplyURI(uri).SetMaxPoolSize(3).SetPoolMonitor(poolMon)
// 9 горутин, каждая делает FindOne с серверным $where+sleep(200мс)| Метрика | Значение |
|---|---|
maxPoolSize |
3 |
| воркеров | 9 |
| серверная задержка на запрос | 200 мс |
| суммарное время пачки | 623.9 мс |
| пик одновременно занятых соединений | 3 |
Оба ассерта — жёсткие и оба прошли. Первый: пик занятых соединений никогда не превысил maxPoolSize=3 — это доказано напрямую событиями монитора, а не выведено из тайминга. Второй: суммарное время пачки (623.9 мс) втрое больше одного раунда блокировки (200 мс). Девять воркеров через пул из трёх проходят минимум за ⌈9/3⌉ = 3 последовательных раунда очереди; при полном параллелизме пачка заняла бы ~200 мс, а заняла ~624 мс — прямое наблюдаемое доказательство, что пул реально ограничивает конкурентность, а не просто ведёт счётчик. Практический вывод для прода: maxPoolSize — это ваш потолок параллелизма к каждому узлу. Занизите — операции встают в очередь под нагрузкой (ровно то, что видно здесь); завысите — рискуете исчерпать соединения/память на стороне сервера. Настраивается под реальную конкурентность приложения, а не «по умолчанию 100 и забыли».
Retryable writes переживают step-down primary
Самое сильное доказательство стенда. Пока идёт серия из 20 InsertOne (при retryWrites=true по умолчанию), стенд конкурентно выполняет реальный replSetStepDown (force:true, secondaryCatchUpPeriodSecs:0) текущего primary mongo1:27017 — то есть посреди записи вырывает из-под неё узел, на который она шла.
Слабое доказательство было бы «все 20 прошли» — но это могло быть везением тайминга (соединение переустановилось до первой попытки). Поэтому стенд перехватывает команды через *event.CommandMonitor и ищет строгий признак повтора: один и тот же txnNumber (одна логическая запись) в двух CommandStartedEvent для insert — значит попытка реально провалилась на старом primary и была повторена драйвером на новом.
| Метрика | Значение |
|---|---|
| записей всего | 20 |
| успешных (итог) | 20 / 20 |
| суммарное время | 8.746 s |
| остановленный primary | mongo1:27017 |
Started-попыток insert всего |
21 (одна запись потребовала 2 попытки) |
Succeeded insert |
20 |
записей с доказанным повтором (тот же txnNumber в >1 Started) |
1 |
| макс. попыток на одну запись | 2 |
Честная оговорка (раскрываем прямо). Строгое доказательство «повтор одной и той же операции» через CommandMonitor получено ровно для 1 записи из 20. Остальные 19 либо успели пройти до step-down, либо застали кластер уже без primary и дождались нового через обычный server selection (до serverSelectionTimeoutMS) без формального повтора команды. Это тоже поведение устойчивости к недоступности primary — driver-level retry не единственный её механизм, — но именно строгий пруф «та же операция повторена» пойман для одной записи. Этого достаточно, чтобы утверждать: механизм воспроизведён вживую, а не гипотетический. Гоняться за большей долей повторов — значит подгонять тайминг гонки; честнее зафиксировать один железный случай и не выдавать race-статистику за закономерность.
Что это даёт в проде: при штатной смене primary (плановый rolling-рестарт, перевыборы после сбоя узла — см. Replica sets, oplog и concerns) приложение с retryWrites=true не роняет запись — драйвер сам повторяет её на новом primary, идемпотентно по txnNumber. Кластер после форсированного step-down в этом прогоне сам переизбрал primary обратно на mongo1:27017.
Java-зеркало: те же гарантии, другой синтаксис
Java-драйвер (mongodb-driver-sync 5.5.1) даёт ровно те же гарантии — retryable writes включены по умолчанию строкой подключения, пул настраивается через MongoClientSettings. Меняется синтаксис, не семантика:
MongoClientSettings settings = MongoClientSettings.builder()
.applyConnectionString(new ConnectionString(uri)) // retryWrites=true по умолчанию
.applyToConnectionPoolSettings(b -> b.maxSize(3))
.applyToClusterSettings(b ->
b.serverSelectionTimeout(30, TimeUnit.SECONDS))
.build();
try (MongoClient client = MongoClients.create(settings)) {
// InsertOne переживёт step-down так же, как в Go: идемпотентность по txnNumber
}Change streams — встроенный источник CDC, и resumeAfter
Change streams — встроенный в MongoDB поток изменений: приложение подписывается на коллекцию (watch) и получает события insert / update / delete в порядке их применения, с resume-токеном на каждом. Это готовый источник CDC — не надо читать oplog вручную или ставить внешний коннектор.
Стенд проверяет не «получили три события», а устойчивость к простою консьюмера через resumeAfter: после insert-события консьюмер закрывается намеренно; пока никто не слушает, происходит update; переоткрытие потока с resumeAfter(token) обязано вернуть именно это пропущенное update-событие первым — не дубль insert и не потерю. Проверено независимо на Go и на Java, каждый на своей коллекции (cs_demo_go / cs_demo_java), против одного живого кластера.
// 1. открыли поток, получили insert-событие и его resume-токен
stream, _ := coll.Watch(ctx, mongo.Pipeline{})
stream.Next(ctx)
token := stream.ResumeToken() // токен сразу после insert
stream.Close(ctx) // консьюмер намеренно "ушёл"
// 2. update произошёл, пока никто не слушал (имитация простоя)
coll.UpdateOne(ctx, filter, update)
// 3. переоткрыли с resumeAfter — первым обязан прийти именно тот update
opts := options.ChangeStream().SetResumeAfter(token)
stream2, _ := coll.Watch(ctx, mongo.Pipeline{}, opts)
stream2.Next(ctx) // operationType == "update", не потеряно| Событие | operationType | латентность (генерация → доставка), Go / Java |
|---|---|---|
| insert | insert |
25.3 мс / 107.1 мс |
update (после resumeAfter, после простоя) |
update |
1.4 мс / 3.5 мс |
| delete | delete |
6.0 мс / 15.3 мс |
Ассерт (прошёл на обоих клиентах): все три operationType пришли в правильном порядке, а первое событие после resumeAfter — именно update, произошедший во время простоя. Resume-токен пережил закрытие потока и вернул точную позицию.
Почему Java по латентности медленнее. 107.1 мс против 25.3 мс на первый insert — вероятно, значительная часть этого — JVM warm-up: класс-загрузка и JIT первого запроса (на одном прогоне это правдоподобное объяснение, а не строго доказанная единственная причина). После прогрева абсолютный разрыв схлопывается — оба клиента уходят в единицы миллисекунд (resume: 3.5 мс Java против 1.4 мс Go; удаление: 15.3 против 6.0 мс), — но по соотношению Java остаётся примерно в 2.5 раза медленнее и после первого события, а не выходит на паритет (та же картина в aggregation из статьи про пайплайн, где Java стабильно медленнее Go на всех пяти операциях). Важно другое: количественные результаты Go/Java-зеркал — число документов, коды ошибок, содержимое событий — совпадают между клиентами байт-в-байт; расходится только латентность, и она у Java стабильно чуть выше.
Практический угол: change streams закрывают классические CDC-задачи без внешней инфраструктуры — реакция на изменения (инвалидация кеша, отправка в поисковый индекс), репликация в аналитическое хранилище, аудит. resumeAfter (и его родственник startAfter для инвалидированных потоков) делает консьюмер устойчивым к рестарту: сохранил токен — продолжил ровно с того места, не потеряв и не задублировав события. Это прямой контраст с логической репликацией PostgreSQL, которую надо явно включать (wal_level=logical) — к этому вернёмся в карте выбора.
Бэкап: mongodump, снапшоты, PBM — когда что уместно
Три уровня резервирования, каждый под свой масштаб:
mongodump/mongorestore— логический дамп: обходит коллекции и пишет BSON. Прост, портируем между версиями, позволяет восстановить отдельную базу/коллекцию с переименованием namespace. Минус — не масштабируется на терабайты: это полный проход по данным.- Снапшот тома (filesystem / volume snapshot) — физическая копия файлов WiredTiger на уровне диска (LVM, EBS, ZFS). Быстр и не зависит от объёма логически, но требует консистентности: снимать при остановленных записях либо через
db.fsyncLock(), и снапшот привязан к версии/формату хранения. - PBM (Percona Backup for MongoDB) — инструмент для шардированных кластеров: консистентный по времени бэкап всех шардов сразу, point-in-time recovery, инкременты. То, чем закрывают продовый шардированный кластер, где ни логический дамп, ни одиночный снапшот тома не дают согласованного среза по всем шардам.
Стенд демонстрирует базовый уровень — round-trip mongodump → mongorestore с переименованием namespace (cookbook.* → cookbook_restored.*) на том же кластере, с построчной сверкой числа документов до и после:
mongodump --uri="$RS_URI" --db=cookbook --out=/backup
mongorestore --uri="$RS_URI" \
--nsFrom="cookbook.*" --nsTo="cookbook_restored.*" /backup| Метрика | Значение |
|---|---|
mongodump |
1 s (255 021 документ, ~120 МБ) |
mongorestore |
5 s |
| users (orig / restored) | 50 000 / 50 000 |
| products (orig / restored) | 5000 / 5000 |
| orders (orig / restored) | 200 000 / 200 000 |
Ассерт двойной и оба прошли: исходная база совпадает с эталоном dataset/manifest.json до бэкапа, а восстановленная совпадает с исходной по всем трём коллекциям — round-trip не потерял и не задублировал ни одного документа. Числа маленькие (1 s / 5 s на ~120 МБ), потому что датасет учебный; урок в точности round-trip, а не в скорости. На проде выбор уровня диктует объём и топология: mongodump — для небольших/отдельных баз и переносов, снапшот тома — для быстрого физического бэкапа одного узла, PBM — для согласованного бэкапа шардированного кластера с PITR.
Стенд показывает механику, но в проде решает не сам факт бэкапа, а то, что вокруг него:
- RPO/RTO — первичны. Сколько данных допустимо потерять (recovery point) и за сколько надо подняться (recovery time) — из этих двух чисел выводятся уровень и частота бэкапа, а не наоборот. «Бэкап раз в сутки» без заданного RPO — это необоснованные 24 часа потенциальной потери.
mongodump— это НЕ point-in-time recovery. Он снимает состояние на момент прохода, без непрерывного лога операций между бэкапами. Для PITR на серьёзном проде нужен PBM (или oplog-tailing поверх снапшотов), позволяющий откатиться на произвольный момент, а не только на время последнего полного дампа.- Restore-drills. Бэкап, который ни разу не восстанавливали, — это гипотеза, а не бэкап. Регулярный тестовый restore на отдельный кластер — единственная проверка, что RTO реален, а дамп не битый и совместим с текущей версией.
- Шифрование и хранение. Бэкапы шифруют at-rest, держат вне того же отказового домена, что и кластер (другой регион/провайдер), и задают retention. Иначе резервная копия становится и точкой утечки, и точкой единого отказа вместе с продом.
Мониторинг: ключевые метрики MongoDB в проде
Что смотреть, чтобы поймать проблему до того, как её заметят пользователи. Метрики берутся из db.serverStatus(), rs.status(), db.currentOp() и штатного экспортёра (mongodb_exporter → Prometheus):
- Replication lag (
rs.status(),optimeDatesecondary против primary) — насколько отстают реплики. Растущий lag ломает causal-чтения с secondary и удлиняет окно потери данных при сбое primary. - Пул соединений и очередь (
connections.current/available, а на стороне драйвера — те самыеPoolMonitor-события из раздела про пул). Упор вmaxPoolSizeвиден как очередь и рост латентности — ровно эффект, воспроизведённый выше. - WiredTiger cache —
bytes currently in the cache,tracked dirty bytes, эвикция. Dirty bytes, стабильно упирающиеся в порог, и активная эвикция под нагрузкой — сигнал, что рабочий набор не влезает в кеш (детально разобрано в статье про WiredTiger этой серии). opcountersи latency команд — темп insert/query/update/delete иopLatencies. Всплеск latency без роста нагрузки — время искать медленный запрос.- Медленные запросы — профайлер (
db.setProfilingLevel) иCOLLSCANв планах: запрос без индекса виден как полный скан коллекции. - Oplog window — на сколько времени назад хватает oplog (
rs.printReplicationInfo()). Короткое окно означает, что медленная реплика может не догнать и потребовать полной ресинхронизации.
Change streams, показанные выше, — это ещё и способ построить бизнес-мониторинг поверх данных: подписка на изменения коллекции даёт поток доменных событий (заказ создан, статус сменился) без опроса БД.
Карта выбора: MongoDB, PostgreSQL или ScyllaDB
Финал серии и сквозной PG-нити. Вопрос «Mongo или Postgres?» (а с недавних пор — «…или Scylla?») не имеет ответа «всегда X»: у каждой из трёх систем есть режим, в котором она — правильный выбор, и режим, в котором она — дорогая ошибка. Ниже — карта по осям, опирающаяся на то, что реально измерено в этой серии и в соседних флагманах, а не на маркетинг.
ACID-транзакции и богатый SQL?"} Q1 -->|"Да"| PG["PostgreSQL
ACID + jsonb-документы
рядом с таблицами"] Q1 -->|"Нет"| Q2{"Модель данных — прежде всего
документ: вложенность,
гибкая схема, агрегация?"} Q2 -->|"Нет: запрос по ключу партиции,
предсказуемая latency
при огромной записи"| SCY["ScyllaDB
wide-column, shard-per-core"] Q2 -->|"Да"| Q3{"Нужен встроенный горизонтальный
масштаб — sharding,
change streams из коробки?"} Q3 -->|"Да"| MG["MongoDB
документ + встроенный sharding"] Q3 -->|"Скорее вертикальный масштаб,
документ как часть схемы"| PG style PG fill:#c9e4c5,stroke:#5b8a5e style MG fill:#f4d9c6,stroke:#c67a4a style SCY fill:#e8c9a0,stroke:#c67a4a
flowchart TD
Q0["Выбор хранилища под нагрузку"] --> Q1{"Нужны сильные multi-row
ACID-транзакции и богатый SQL?"}
Q1 -->|"Да"| PG["PostgreSQL
ACID + jsonb-документы
рядом с таблицами"]
Q1 -->|"Нет"| Q2{"Модель данных — прежде всего
документ: вложенность,
гибкая схема, агрегация?"}
Q2 -->|"Нет: запрос по ключу партиции,
предсказуемая latency
при огромной записи"| SCY["ScyllaDB
wide-column, shard-per-core"]
Q2 -->|"Да"| Q3{"Нужен встроенный горизонтальный
масштаб — sharding,
change streams из коробки?"}
Q3 -->|"Да"| MG["MongoDB
документ + встроенный sharding"]
Q3 -->|"Скорее вертикальный масштаб,
документ как часть схемы"| PG
style PG fill:#c9e4c5,stroke:#5b8a5e
style MG fill:#f4d9c6,stroke:#c67a4a
style SCY fill:#e8c9a0,stroke:#c67a4a
Три системы стоят на трёх разных фундаментах:
- PostgreSQL — реляционная БД с сильными транзакциями (ACID, снимки, внешние ключи), богатым SQL и — важно для сравнения с Mongo — типом
jsonb, который умеет хранить документы прямо в реляционной таблице. Масштаб — вертикальный плюс потоковая репликация; горизонтальный шардинг — надстройка (Citus, партиционирование, ручной шардинг), не встроенная модель. - MongoDB — документная БД: гибкая схема, богатый язык запросов и агрегация над вложенными документами, встроенные replica sets и sharding, change streams «из коробки». Транзакции есть (multi-document), но модель данных подталкивает к денормализации, а не к join.
- ScyllaDB — wide-column БД в духе Cassandra: масштабирование записи через consistent hashing и tunable consistency, предсказуемая latency на огромных объёмах — ценой ограниченной модели запросов (запрос обязан идти по ключу партиции) и отсутствия join/транзакций общего вида. Подробнее — ScyllaDB: когда и как моделировать и обзорная карта хранилищ Карта хранилищ данныхготовится, с 22 сентября.
Оси сравнения: модель, масштаб, консистентность, эксплуатация
Модель данных
Здесь сходятся два измерения из статьи #1 этой серии — Документная модель и схема-дизайн. Тот же документ заказа, сохранённый как BSON в Mongo и как jsonb в PostgreSQL, дал 380 байт против 575 байт среднего размера — но это разные единицы измерения ($bsonSize — логический размер BSON; pg_column_size — реальный размер колонки на диске с TOAST-оверхедом), а не вывод «Mongo компактнее». Оба — живые числа с одного стенда, и вывод из них честный: PostgreSQL умеет документы через jsonb, и если документная модель нужна как один аспект схемы рядом с реляционными таблицами и транзакциями — это весомый аргумент остаться на Postgres, а не заводить вторую БД. MongoDB выигрывает, когда документная модель — основная: глубокая вложенность, гибкая схема, агрегация над массивами внутри документа как первичный паттерн доступа. ScyllaDB — когда модель сводится к «запрос по ключу партиции», а гибкость документа не нужна вовсе.
Масштабирование
MongoDB несёт горизонтальный масштаб встроенно: sharding — часть системы, mongos + config servers + resharding вживую (см. Sharding вживую). PostgreSQL масштабируется прежде всего вертикально и через потоковую репликацию с read-репликами; горизонтальный шардинг записи — надстройка, а высокая доступность строится отдельными паттернами (см. PostgreSQL HA: Patroni и репликацияготовится, с 29 сентября). ScyllaDB здесь сильнее всех по чистому масштабу записи: shared-nothing, линейное добавление узлов, отсутствие единого роутера-узкого места — цена в том, что модель запросов заранее подчинена ключу партиции.
Консистентность
PostgreSQL — сильные ACID-транзакции и снимки по умолчанию: это его центр тяжести. MongoDB даёт настраиваемую консистентность — write concern (w:1 против w:majority) и read concern / причинную согласованность; в статье #5 серии w:majority стоил ×7.46 к w:1 даже на одном хосте (549.7 µs → 4.10 ms), а causal-чтение с secondary доказано детерминированным признаком afterClusterTime в команде. Multi-document транзакции в Mongo есть, но это не режим по умолчанию. ScyllaDB — tunable consistency (ONE/QUORUM/ALL) без транзакций общего вида: вы торгуете согласованность на доступность и latency явным выбором уровня на каждый запрос.
Эксплуатация
Ось, которой посвящена эта статья. У MongoDB встроенное сильно: replica sets с автоматическими перевыборами, retryable writes (пережили step-down вживую — 20/20, повтор доказан по txnNumber), change streams как готовый CDC с resumeAfter, mongodump/PBM. Ключевой контраст с PostgreSQL из статьи #5 серии: oplog в MongoDB пишется безусловно на любом узле replica set сразу после rs.initiate() — поток изменений доступен всегда; логическая репликация PostgreSQL требует явного wal_level=logical и перезапуска (образ postgres:18 поднимается с wal_level=replica по умолчанию, max_wal_senders=10). «Встроено всегда» против «включается явно» — это не «лучше/хуже», а разная стоимость входа в CDC/репликацию. ScyllaDB эксплуатационно требователен иначе: тюнинг под железо, compaction-стратегии, repair — своя операционная культура.
Сводка осей
| Ось | PostgreSQL | MongoDB | ScyllaDB |
|---|---|---|---|
| Модель данных | реляционная + jsonb (документы как аспект) |
документная (гибкая схема — основа) | wide-column (запрос по ключу партиции) |
| Масштабирование | вертикаль + read-реплики; шардинг — надстройка | встроенный sharding + resharding | shared-nothing, линейный масштаб записи |
| Консистентность | сильные ACID по умолчанию | настраиваемая (write/read concern), txn не по умолчанию | tunable (ONE/QUORUM/ALL), без общих txn |
| Эксплуатация / CDC | логическая репликация — явный wal_level=logical |
oplog безусловно, change streams + retryable writes | своя культура: compaction, repair, tuning |
Как этим пользоваться на практике:
- Нужны сильные транзакции, реляционные связи, а документы — лишь часть схемы → PostgreSQL (
jsonbзакрывает документную потребность рядом с ACID). - Документная модель — основная, нужен встроенный горизонтальный масштаб и CDC из коробки → MongoDB.
- Гигантский поток записи по простой модели «ключ → значение/строка», предсказуемая latency важнее гибкости запросов → ScyllaDB.
- Смешанная нагрузка — нормально держать две системы (например, Postgres как source of truth + Mongo/Scylla под конкретный профиль доступа), но каждая новая БД — это отдельная эксплуатация, бэкап и мониторинг. Второе хранилище оправдано, когда профиль доступа действительно не ложится на первое, а не «на всякий случай».
Итог серии и PG-нити
Что осталось в руках после семи статей. MongoDB — это документная модель с честной ценой (рост документа упирается в лимит 16 МиБ; денормализация экономит round-trips против наивного referenced-паттерна, но $lookup перекладывает join на сервер), индексы с правилом ESR и подводными камнями partial/covered-планов, aggregation с настоящим лимитом памяти blocking-стадий, replica sets с кворумными выборами и настраиваемой консистентностью, sharding с самым дорогим и трудно-отменяемым решением — выбором shard key, и наконец эксплуатация: пул как жёсткий потолок, retryable writes, change streams как CDC, бэкап с точным round-trip.
Сквозная PG-нить вела не к «Mongo лучше Postgres» и не к обратному. Она вела к тому, что видно только на числах: jsonb в PostgreSQL реально хранит документы (и на нашей выборке был даже больше по размеру колонки); единичный key-read на одном хосте PostgreSQL прошёл быстрее Mongo (175.6 µs против 264.7 µs) — не потому что «Postgres быстрее вообще», а потому что single-host стенд стирает сетевые различия и single-row lookup по PK в PG предельно оптимизирован; oplog в Mongo встроен безусловно, а логическая репликация PG включается явно. Каждый из этих фактов — довод не «за» и не «против», а в пользу осознанного выбора по оси, которая важна именно вашей нагрузке.
Ответ на «Mongo или Postgres?» — «зависит, и вот от чего именно»: от того, документная ли у вас модель или реляционная с документами-аспектами; нужен ли встроенный горизонтальный масштаб или хватает вертикали с репликами; где центр тяжести консистентности; и во что обходится эксплуатация с CDC. А если в кадр входит ScyllaDB — добавляется ось чистого масштаба записи ценой гибкости запросов. Карта хранилищ целиком, с другими семействами БД, — Карта хранилищ данныхготовится, с 22 сентября.
Живые стенды к этой статье — mongodb/ops-stand (Go) и mongodb/java/ops (Java-зеркало change streams). Все числа выше сняты на mongo:8.2.11 и postgres:18, драйверы mongo-driver/v2 v2.4.2 и mongodb-driver-sync 5.5.1, датасет seed=42.
Комментарии