RE:NODE

Transakce MongoDB na jednom uzlu: replica set, limity a řešení

Text: Tým RE:NODE
15 min čtení


MongoDB umí spouštět ACID transakce nad více dokumenty, ale jen na replica setu nebo na shardovaném clusteru. Běžný standalone mongod je odmítne chybou Transaction numbers are only allowed on a replica set member or mongos. Na jednom stroji je řešením replica set s jediným členem: stojí to jeden řádek v konfiguraci a jeden jednorázový příkaz a dostaneš transakce, change streams i stejnou cestu kódu, kterou později použiješ v produkci. Druhá odpověď, často lepší, je navrhnout data tak, aby transakci vůbec nebylo potřeba: zápis do jednoho dokumentu je vždy atomický a dobře utvarovaný dokument drží na jednom místě všechno, co se musí měnit společně.

Tento článek popisuje, co transakce potřebuje, co místo ní dělá standalone server, jak z jednoho uzlu udělat replica set s jedním členem, jaké limity tě zastaví (výchozí životnost 60 sekund, konflikty zápisů, co uvnitř transakce nejde) a jaké existují vzory pro případy, kdy transakci použít nemůžeš nebo nechceš. Verze uvádíme tam, kde na nich záleží; podívej se do manuálu pro svou verzi serveru, protože chování transakcí se mezi 4.0, 4.2, 4.4 a 5.0 měnilo.

Co transakce v MongoDB potřebuje#

Transakce je skupina čtení a zápisů, které se buď provedou všechny, nebo žádný, a ostatní klienti je nikdy neuvidí rozpracované. Pro jeden dokument tuto záruku MongoDB měla vždy. Transakce nad více dokumenty přišly ve verzi 4.0 pro replica sety a ve 4.2 pro shardované clustery. Aby transakce začala, musí platit tři věci:

  • Replica set nebo shardovaný cluster. Mechanismus transakcí zapisuje do oplogu, replikačního logu, a využívá jeho časové značky, aby každé transakci dal konzistentní snímek. Standalone server oplog nemá.
  • Session. Každá transakce žije uvnitř klientské session. Ovladače to většinou schovají, ale startSession() uvidíš v každém příkladu.
  • Rozumné časování. Transakce musí skončit v rámci limitu životnosti, ve výchozím stavu 60 sekund, a v praxi by měla trvat milisekundy.

Obvyklý tvar je krátká funkce, která změní související data najednou: odepsat z jednoho účtu a připsat na druhý, snížit zásobu a vložit objednávku, označit úlohu jako převzatou a zapsat auditní záznam. Pokud se dvě kolekce musí shodovat, je transakce nejpřímější nástroj. Cenou je další latence, další paměť držená pro snímek a logika opakování v aplikaci.

Transakce přinášejí i slovník, který stojí za to znát:

PojemVýznam
readConcern: "snapshot"Všechna čtení uvnitř vidí jeden konzistentní bod v čase
writeConcern: "majority"Commit čeká, až je zápis trvale uložen na většině členů
TransientTransactionErrorCelou transakci lze zopakovat od začátku

Proč standalone uzel říká ne#

Standalone mongod je server spuštěný bez jakéhokoli nastavení replikace. Ukládá data a žurnáluje zápisy, a to je vše. Nevede oplog, nezná pojmy primary a secondary, takže na něj není co pověsit transakci nad více dokumenty. Zkus nějakou začít a dostaneš tohle:

code
MongoServerError: Transaction numbers are only allowed on a replica set member or mongos

Chyba přichází od první operace uvnitř transakce, ne od samotného startTransaction(), a proto se často objeví na nečekaném místě v zásobníku aplikace. Je to kód 20, IllegalOperation. Stejné omezení platí pro change streams: collection.watch() také potřebuje oplog a na standalone selže s podobnou zprávou.

Dvě věci je dobré mít jasně. Za prvé, zápisy do jednoho dokumentu jsou na standalone serveru pořád atomické. updateOne, který změní pět polí jednoho dokumentu, změní buď všech pět, nebo žádné; čtenář nikdy neuvidí dokument, kde jsou změněna jen tři. Za druhé, na standalone není nic rozbitého. Je to jednodušší režim serveru, který vyměňuje funkce replikace za méně věcí k provozu. Spousta produkčních zátěží ho používá, obvykle spolu s pravidelnými dumpy, jak popisuje článek o mongodump a mongorestore.

Připojovací řetězce ten rozdíl odrážejí: standalone je jeden hostitel bez volby replicaSet, replica set pojmenuje svou sadu. Podrobnosti jsou v článcích o připojovacích řetězcích MongoDB a v referenci formátu URI.

Replica set s jedním uzlem#

Replica set s jedním členem je plnohodnotná konfigurace. Zvolí sám sebe za primary, vede oplog, podporuje transakce i change streams a chová se jako větší sada, která ještě nevyrostla. Redundanci ti ale nedá: s jedním členem není failover ani druhá kopie. w: "majority" na sadě s jedním členem znamená „tento uzel zápis potvrdil“, tedy stejnou odolnost jako standalone se žurnálem, a nic víc.

Převod je stejný pro nový server i pro existující standalone a datové soubory zůstanou, jak jsou. Na stroji, který spravuješ, nastav název replica setu v mongod.conf:

mongod.conf
replication:  replSetName: rs0

Nebo ho pro rychlý lokální test předej na příkazové řádce:

bash
$ mongod --dbpath ./data --replSet rs0 --bind_ip localhost --port 27017

Restartuj server, připoj se přes mongosh a sadu jednou inicializuj:

javascript
rs.initiate({  _id: "rs0",  members: [{ _id: 0, host: "localhost:27017" }]})

Do host dej jméno, které tvoji klienti umí přeložit a na které se dostanou, protože ovladač členy objevuje podle jmen uložených v konfiguraci sady, ne podle adresy, kterou jsi zadal. Sada inicializovaná s localhost funguje pro aplikaci na stejném stroji a pro všechny ostatní selže. Za pár sekund by rs.status() měl hlásit člena jako PRIMARY:

javascript
rs.status().members.map(m => ({ name: m.name, state: m.stateStr }))

Je potřeba počítat s několika důsledky:

  • Autentizace. Replica set se zapnutou kontrolou přístupu potřebuje mezi členy interní autentizační mechanismus, obvykle soubor s klíčem odkazovaný přes security.keyFile. I s jedním členem se řiď postupem z manuálu pro autentizaci replica setu a nehádej.
  • Disk. Oplog je capped kolekce a zabírá skutečné místo. Jeho výchozí velikost se odvozuje z volného místa na disku, proto ji zkontroluj přes db.getReplicationInfo() a na malém disku ji nastav výslovně.
  • Připojovací řetězce. Přidej replicaSet=rs0, aby ovladač mluvil protokolem replica setu, nebo directConnection=true, aby přeskočil objevování a mluvil s jedním pojmenovaným hostitelem.
  • Restarty. Po restartu se sada vrátí jako primary bez dalších příkazů. Inicializuje se jen jednou.
startTransactionwritescommit entryAplikacesession ovladačemongod rs0jediný PRIMARYOplogreplikační logKolekceWiredTiger
Čeho se transakce dotkne v sadě s jedním členem

Je to dostupné v databázovém hostingu RE:NODE?#

Databázový hosting RE:NODE nabízí MongoDB, s přihlašovacími údaji vygenerovanými pro každý server a s připojením přímo na hostitele a port uvedené u tarifu. Na stránce tarifu netvrdíme, že tento server běží jako replica set, a ty to také nepředpokládej. MongoDB server, který si pronajmeš jako tarif, ti dává připojení, ne mongod.conf k úpravě, takže otázka, zda transakce fungují, je vlastnost serveru, ne tvého kódu.

Zjistit to jde tak, že se zeptáš a pak to vyzkoušíš:

  1. Otevři z panelu tiket a zeptej se, zda tvůj server MongoDB běží jako replica set a zda jsou podporované transakce nad více dokumenty. Neřiď se tímto článkem jako odpovědí.
  2. Spusť na svém připojení krátký test z další části. Standalone selže výše uvedenou chybou hned u prvního zápisu; replica set provede commit.
  3. Pokud je odpověď záporná, použij vzory atomicity jednoho dokumentu a dvoufázového commitu později v tomto článku, které fungují na jakékoli MongoDB. Pokud potřebuješ skutečné transakce, spusť vlastní mongod jako replica set s jedním členem na stroji, který spravuješ, například na VDS, a část výše platí přímo pro tebe.

V části o databázích v návodech je vysvětleno, jak panel zachází s přihlašovacími údaji k databázím. Podívej se tam dřív, než začneš ladit připojení, které je ve skutečnosti problém s autentizací; běžné pasti pokrývá článek o authentication failed a authSource.

Jak napsat transakci#

Doporučená podoba je callback API ovladače. Začne transakci, spustí tvoji funkci, provede commit a při přechodné chybě celé zopakuje, takže to tvůj kód dělat nemusí. Tento příklad v Node.js převádí peníze mezi dvěma účty a zapisuje řádek do účetní knihy, tedy tři zápisy, které se musí shodovat:

javascript
const { MongoClient } = require("mongodb");const client = new MongoClient(process.env.MONGODB_URI);await client.connect();const db = client.db("bank");async function transfer(from, to, amount) {  const session = client.startSession();  try {    await session.withTransaction(async () => {      const debit = await db.collection("accounts").updateOne(        { _id: from, balance: { $gte: amount } },        { $inc: { balance: -amount } },        { session }      );      if (debit.modifiedCount !== 1) throw new Error("insufficient funds");      await db.collection("accounts").updateOne(        { _id: to }, { $inc: { balance: amount } }, { session });      await db.collection("ledger").insertOne(        { from, to, amount, at: new Date() }, { session });    }, {      readConcern: { level: "snapshot" },      writeConcern: { w: "majority" }    });  } finally {    await session.endSession();  }}

Záleží na třech návycích. Předávej { session } každé operaci uvnitř, protože operace bez něj běží mimo transakci a potvrdí se sama. Drž callback bez vedlejších účinků, jako je odesílání e-mailu nebo volání API, protože se může spustit víckrát. A abort vyvolej výjimkou: výjimka z callbacku transakci zruší a withTransaction ji předá dál.

Rychlá kontrola z mongosh, která zároveň slouží jako test tvého serveru:

javascript
const session = db.getMongo().startSession();const coll = session.getDatabase("test").getCollection("t");session.startTransaction();coll.insertOne({ probe: 1 });session.commitTransaction();session.endSession();

Na standalone insertOne vyhodí chybu Transaction numbers are only allowed. Na replica setu se provede a zkušební dokument pak můžeš smazat.

Limity, na které narazíš#

Transakce mají být krátké a malé. Níže jsou limity, které v praxi záleží, a několik z nich jde na serveru, který spravuješ, nastavit.

LimitVýchozí hodnota
Životnost transakce60 sekund
Čekání na zámek při konfliktu5 milisekund
Velikost jednoho dokumentu16 MB

První je transactionLifetimeLimitSeconds, výchozích 60. Proces na pozadí zruší každou transakci, která trvá déle, a další operace v ní selže s NoSuchTransaction. Druhý je maxTransactionLockRequestTimeoutMillis, tedy jak dlouho transakce čeká na získání zámku, než to vzdá. Na serveru, který nespravuješ, nezměníš ani jedno, takže s nimi počítej už v návrhu.

Další chování, které je dobré znát:

  • Konflikty zápisů selžou rychle. Pokud dvě transakce mění stejný dokument, druhá okamžitě dostane chybu WriteConflict, místo aby čekala. Chyba nese štítek TransientTransactionError, který znamená „zopakuj celou transakci“. Callback API to udělá za tebe; s hlavním API musíš štítek zachytit sám.
  • Výsledek commitu může být nejistý. Pokud se spojení přeruší během commitu, ovladač vyhodí UnknownTransactionCommitResult a commit se mohl i nemusel stát. Zopakovat commit je bezpečné; zopakovat celou transakci bezpečné není, pokud tvoje zápisy nejsou idempotentní.
  • Velikost. Ve verzi 4.0 se celá transakce musela vejít do jednoho záznamu oplogu o velikosti 16 MB. Od 4.2 se transakce může rozdělit do několika záznamů oplogu, takže tento jediný strop už neplatí, ale 16 MB na každý dokument platí dál a velké transakce pořád stojí paměť a čas zámků. Pokud se blížíš starému limitu, používáš transakci jako dávkovou úlohu.
  • Kolekce. Před 4.4 musely kolekce a indexy existovat před začátkem transakce. Od 4.4 je lze vytvářet uvnitř ní. Transakce se nemohou dotýkat databází admin, config a local ani zapisovat do capped kolekcí.
  • Read concern. Čtení uvnitř používají snímek transakce. Nevidí zápisy potvrzené jinými po jejím začátku, což je smysl věci, a zároveň důvod, proč ji nedržet otevřenou, zatímco čekáš na uživatele.
  • Paměť. Snímek drží ve vyrovnávací paměti staré verze změněných dat, dokud transakce žije. Dlouhá transakce není jen pomalá, vytlačuje z paměti i ostatní práci.

Ať jsou přesná čísla ve tvé verzi jakákoli, pravidlo je stejné: čtení a rozhodování udělej, kde to jde, před otevřením transakce, pak ji otevři, zapiš, potvrď a skonči za milisekundy. Pokud přemýšlíš o zvýšení limitu životnosti, problém je v návrhu, ne v limitu.

Atomicita bez transakce#

Překvapivě hodně práce transakci nepotřebuje, protože zápis do jednoho dokumentu je atomický. MongoDB aplikuje všechny změny v jednom updateOne nebo findOneAndUpdate jako jeden celek, včetně změn ve vnořených dokumentech a polích uvnitř něj. Pokud zařídíš, aby data, která se musí měnit společně, žila v jednom dokumentu, získáš atomicitu na jakémkoli serveru, včetně standalone, bez logiky opakování a bez snímku.

Nástroje jsou aktualizační operátory a filtr, který kóduje podmínku:

javascript
// Reserve one seat: the filter is the check, the update is the actiondb.flights.findOneAndUpdate(  { _id: "LH100", seatsLeft: { $gt: 0 } },  { $inc: { seatsLeft: -1 }, $push: { passengers: { name: "Ada", at: new Date() } } },  { returnDocument: "after" })

Pokud žádný dokument nesedí, nic se nezmění a volání vrátí null, takže přeprodání není možné. Kontrolu a zápis nemůže rozdělit jiný klient. Stejný vzor funguje pro počítadla zásob, omezení četnosti požadavků, přebírání úloh ({ status: "queued" } se změní na { status: "running", worker: id }) a optimistické zamykání s polem version, které filtr porovnává a aktualizace zvyšuje.

Spolehlivost drží na dvou pravidlech. Podmínku dej do filtru, ne do kódu aplikace, který nejdřív čte a potom zapisuje, protože právě v té mezeře vzniká race condition. A používej aktualizační operátory jako $inc a $set místo nahrazování celého dokumentu zastaralou kopií.

Vnořování a návrh schématu#

Další otázka je, kde mají data žít, a rozhoduje o tom, jak často potřebuješ atomicitu napříč dokumenty. V relačním návrhu jsou objednávka, její řádky a platba tři tabulky a transakce. V MongoDB je často můžeš udělat jedním dokumentem:

json
{  "_id": "order-1042",  "customer": "c-77",  "status": "paid",  "lines": [    { "sku": "A-100", "qty": 2, "price": 1800 },    { "sku": "B-220", "qty": 1, "price": 950 }  ],  "payment": { "method": "card", "paidAt": "2026-10-22T09:14:00Z" },  "total": 4550}

Takový dokument se zapíše i přečte jednou operací a jeho změny jsou atomické. Vnořování se hodí, když se části čtou společně, patří jednomu rodiči a mají omezenou velikost. Hodí se špatně, když vnořené pole roste bez omezení (komentáře pod oblíbeným příspěvkem), když je potomek sdílený mnoha rodiči nebo když by se dokument blížil limitu 16 MB. Pak odkazuješ přes _id a smíříš se se dvěma kolekcemi.

Existuje i střední cesta: držet zdroj pravdy v jednom dokumentu a kopírovat těch pár polí, která potřebují jiné dotazy. Kopie se může rozejít, proto rozhodni, které pole je autoritativní, a kopie z něj obnovuj. Kompromisy včetně indexů, které je podporují, jsou v článku o indexech a návrhu schématu v MongoDB a otázka, zda je dokumentová databáze pro tvá data vůbec správná, v článku Postgres nebo MongoDB. Pokud jsou tvá data relační a chceš transakce jako výchozí, ne jako zvláštní režim, SQL stranu pokrývá článek o transakcích, zamykání a deadlocích v MySQL.

Dvoufázové vzory napříč dokumenty#

Když data opravdu žijí v různých dokumentech a transakce nemáš, zavedeným postupem je dvoufázový commit, který si postavíš sám. Manuál MongoDB ho popisuje na případu převodu peněz. Přidáš kolekci transactions, která zaznamenává záměr a jeho stav, a každý dotčený dokument si pamatuje, která nedokončená transakce se ho dotkla.

  1. Vlož záznam transakce se state: "initial", zdrojem, cílem a částkou.
  2. Přepni záznam na pending, pak aplikuj změnu na každý účet a přidej _id transakce do pole pendingTransactions toho účtu. Filtr zajistí, že se každá aplikace provede jen jednou.
  3. Přepni záznam na applied.
  4. Odeber _id transakce z polí pendingTransactions obou účtů.
  5. Přepni záznam na done.
javascript
// Step 2, source account: only if not already applieddb.accounts.updateOne(  { _id: "A", pendingTransactions: { $ne: tid }, balance: { $gte: 100 } },  { $inc: { balance: -100 }, $push: { pendingTransactions: tid } })

Pokud proces v kterémkoli bodě zemře, úloha obnovy projde kolekci transactions, najde záznamy uvízlé v pending nebo applied a buď je dokončí, nebo vrátí zpět pomocí týchž stavů. Vzor je idempotentní už konstrukcí: kontrola $ne: tid znamená, že dvojí spuštění kroku nic nezmění. Tuto vlastnost právě stavíš a stojí za to ji mít i tehdy, když skutečné transakce máš, protože zopakovaný požadavek nebo spadlý worker potřebují stejnou ochranu.

Je to také výrazně víc kódu a víc stavů než transakce. Použij to, když transakci získat nemůžeš, nebo když operace přesahuje systémy, které žádná databázová transakce nepokryje, třeba platební bránu. Pro všechno na jednom replica setu je callback API kratší a hůř se v něm chybuje.

Jak si vybrat#

Krátká cesta rozhodování pokryje většinu případů:

  • Všechno, co se musí měnit společně, se vejde do jednoho dokumentu: použij atomické aktualizace jednoho dokumentu. Replica set není potřeba.
  • Dva nebo tři dokumenty, krátká operace, server máš pod kontrolou: replica set s jedním členem a withTransaction.
  • Replica set zapnout nemůžeš (pronajatý server, kde podpora řekla ne): vnořuj, kde to jde, použij dvoufázový vzor, kde ne, a každý zápis udělej idempotentní.
  • Práce je dlouhá dávka nebo se dotýká tisíců dokumentů: není to transakce. Použij hromadné zápisy po částech, zaznamenávej postup a úlohu udělej restartovatelnou.

Ať zvolíš cokoli, vyzkoušej si chybové cesty: zabij proces mezi kroky a ověř, že jdou data opravit. A zálohuj, protože ani transakce, ani dvoufázový commit tě nezachrání před špatným smazáním. Jak zálohy dělat a hlavně jak z nich obnovovat, popisuje článek o zálohách a obnově databází.

FAQ#

Mohu používat transakce na standalone MongoDB?

Ne. Transakce nad více dokumenty potřebují replica set nebo shardovaný cluster. Standalone server je odmítne chybou Transaction numbers are only allowed on a replica set member or mongos. Aktualizace jednoho dokumentu jsou na standalone atomické a nad nimi si můžeš postavit dvoufázový vzor.

Jsou moje data bezpečnější s replica setem s jedním uzlem?

Ne. Zapne transakce a change streams, ale pořád je to jeden stroj s jednou kopií dat, takže není failover ani redundance. Chrání tě zálohy.

Proč moje transakce selhala zhruba po minutě?

Výchozí transactionLifetimeLimitSeconds je 60. Transakce, která je po této době pořád otevřená, se zruší a další operace selže s NoSuchTransaction. Transakci zkrať tím, že pomalou práci, například síťová volání, přesuneš mimo ni.

Existuje limit velikosti transakce?

Každý dokument je pořád omezen na 16 MB. Ve verzi 4.0 se navíc celá transakce musela vejít do jednoho záznamu oplogu o 16 MB; od 4.2 jich může zabrat několik. I tak jsou velké transakce pomalé a drží paměť, takže hromadnou práci rozděl na části.

Potřebuji transakce pro počítadlo nebo snížení zásoby?

Obvykle ne. Jeden updateOne nebo findOneAndUpdate s filtrem jako { stock: { $gte: 1 } } a $inc je atomický a nedovolí prodat víc, než máš. K transakci sáhni, jen když se musí shodovat víc než jeden dokument.

Podporuje můj server MongoDB u RE:NODE transakce?

Stránka tarifu neuvádí, že server běží jako replica set, takže to nepředpokládej. Otevři z panelu tiket a zeptej se, pak na svém připojení spusť výše uvedený test přes mongosh.

Pomohlo vám to?