Una transacció és una seqüència d'operacions de base de dades que només tindran èxit si totes les operacions de la transacció s'han executat correctament.

Introducció

Les transaccions han estat una característica important de les bases de dades relacionals durant molts anys, però han estat majoritàriament absents de les bases de dades orientades a documents fins fa poc.

La naturalesa de les bases de dades orientades a documents, on un únic document pot ser una estructura robusta i imbricada, que conté documents i matrius incrustats en lloc de només valors simples, racionalitza l'emmagatzematge de dades relacionades dins d'un sol document. Com a tal, modificar diversos documents com a part d'una única operació lògica sovint no és necessari, limitant la necessitat de transaccions en moltes aplicacions.

Hi ha, però, aplicacions per a les quals es requereix accedir i modificar diversos documents en una sola operació amb integritat garantida fins i tot amb bases de dades orientades a documents.

Entorn de treball

A causa de la forma en què s'implementen a MongoDB, les transaccions només es poden realitzar en instàncies de MongoDB que s'executen com a part d'un clúster més gran. Pot ser un clúster de bases de dades fragmentades o un conjunt de rèpliques.

Un conjunt de rèpliques de MongoDB és un grup d'instàncies separades interconnectades de MongoDB que funcionen conjuntament per oferir una alta disponibilitat i tolerància a errors. En un conjunt de rèpliques, s'escull un node com a principal per gestionar les operacions d'escriptura, mentre que la resta serveixen com a secundaris i reprodueixen les seves dades. En cas de fallada d'un node primari, un node secundari s'encarrega de prendre el relleu com a nou primari, garantint una alta disponibilitat.

flowchart TB
  n1[(Primary)]
  n2[(Secondary)]
  n3[(Secondary)]
  n1 == Replication ==> n2
  n1 == Replication ==> n3
  n1 <-. hearbeat .-> n2
  n1 <-. hearbeat .-> n3
  n2 <-. hearbeat .-> n3
  style n1 fill:#00f
  style n2 fill:#080
  style n3 fill:#080

Crea una màquina Ubuntu amb Windows Subsytem for Linux (WSL):

> connect-wsl mongodb -new

Instal.la Docker:

$ install-docker

Instal.la un conjunt de rèpliques:

$ git clone https://gitlab.com/xtec/data/mongo
$ cd mongodb
$ ./install.sh

Ja et pots connectar al servidor:

> mongosh -u root
...
rs0 [direct: primary] test> 

[direct: primary] vol dir que estas connectat al membre principal del conjunt de rèpliques.

Fonaments

A continuació veurem com funcionen les transaccions.

Base de dades

Insereix un conjunt de ciutats a la base de dades test:

rs0 [direct: primary] test> db.cities.insertMany([
... {"name": "Tokyo"}, {"name": "Delhi"}, {"name": "Seoul" }
... ])

Verifica que els documents s'han inserit correctament executant el mètode find() sense arguments, que recupera tots els documents de la col.lecció cities:

rs0 [direct: primary] test> db.cities.find()
[
  { _id: ObjectId('678a547f5bb4be8077cb0ce5'), name: 'Tokyo' },
  { _id: ObjectId('678a547f5bb4be8077cb0ce6'), name: 'Delhi' },
  { _id: ObjectId('678a547f5bb4be8077cb0ce7'), name: 'Seoul' }
]

Utilitza el mètode createIndex() per crear un índex que garanteixi que cada document de la col·lecció tingui un valor únic en la propietat name per provar més endavant els requisits de coherència quan executis transaccions:

rs0 [direct: primary] test> db.cities.createIndex({"name":1},{"unique":true})
name_1

Transacció

Per garantir que les transaccions que executes siguin atòmiques, coherents, aïllades i duradores, has d'iniciar una sessió per executar un conjunt d'operacions com a transacció.

rs0 [direct: primary] test> var session = db.getMongo().startSession()

Amb aquest objecte de sessió disponible, pots iniciar la transacció invocant el mètode startTransaction:

rs0 [direct: primary] test> session.startTransaction({
... "writeConcern": { "w": "majority"},
... "readConcern": { "level": "snapshot"},
... })

Fixa't que el mètode startTransaction s'invoca a la variable session i no a db.

El mètode startTransaction() accepta dues opcions:

La configuració writeConcern pot acceptar algunes opcions, però aquest exemple només inclou l'opció w que sol·licita que el clúster reconegui quan s'han acceptat les operacions d'escriptura de la transacció en un nombre especificat de nodes del clúster. En lloc d'un sol número, aquest exemple especifica que la transacció només es considerarà que s'ha desat correctament quan un majoria (majority) dels nodes reconeix l'operació d'escriptura.

flowchart TB
  n1[(Primary)]
  n2[(Secondary)]
  n3[(Secondary)]
  n1 == Replication ==> n2
  n1 == Replication ==> n3

  style n1 fill:#800
  style n2 fill:#800

La configuració readConcern permet especificar quines dades ha de llegir la transacció quan fas un "commit" de la transacció.

Imagina't que, després d'iniciar una transacció, un altre usuari afegeix un document a la col·lecció que fas servir en un altre node del clúster. La teva transacció ha de llegir aquestes dades noves o només les dades escrites al node on es va iniciar la transacció?

Establir el nivell de readConcern a nivell snapshot significa que la transacció llegirà una instantània de dades que han estat compromeses per la majoria de nodes del clúster.

En aquest enllaç tens més informació: Read Concern/Write Concern/Read Preference

Una transacció no pot durar més de 60 segons

Per defecte, MongoDB avorta automàticament qualsevol transacció que s'executi durant més de 60 segons. La raó d'això és que les transaccions no estan dissenyades per construir-se de manera interactiva al shell, sinó que s'utilitzen en aplicacions del món real.

Per això, és possible que trobis errors inesperats mentre fas aquesta activitat si no executes cada ordre dins del límit de temps de 60 segons.

Si trobes un error com el següent, vol dir que MongoDB ha avortat la transacció perquè s'ha superat el límit de temps:

rs0 [direct: primary] test> session.commitTransaction()
MongoServerError[NoSuchTransaction]: Transaction with { txnNumber: 1 } has been aborted.

Si això passa, has de marcar la transacció com a finalitzada executant el mètode abortTransaction(), de la següent manera:

rs0 [direct: primary] test> session.abortTransaction()

Aleshores, has de reiniciar la transacció amb el mateix mètode startTransaction() que vas executar anteriorment:

rs0 [direct: primary] test> session.startTransaction({
... "writeConcern": { "w": "majority"},
... "readConcern": { "level": "snapshost"},
... })

Crea un variable cities que representi la col·lecció cities en el context de la sessió en execució:

rs0 [direct: primary] test> var cities = session.getDatabase("test").getCollection("cities")

Comprova que l'objecte es pot utilitzar per trobar documents de la col·lecció:

rs0 [direct: primary] test> cities.find()
[
  { _id: ObjectId('678a547f5bb4be8077cb0ce5'), name: 'Tokyo' },
  { _id: ObjectId('678a547f5bb4be8077cb0ce6'), name: 'Delhi' },
  { _id: ObjectId('678a547f5bb4be8077cb0ce7'), name: 'Seoul' }
]

Insereix un document nou que representi la ciutat de Nova York a la col·lecció com a part de la transacció en curs:

rs0 [direct: primary] test> session.startTransaction()

rs0 [direct: primary] test> cities.insertOne({ name: "New York"})
{
  acknowledged: true,
  insertedId: ObjectId('678a55d45bb4be8077cb0ce9')
}

Si tornes a executar cities.find() notaràs que el nou document inserit és immediatament visible a la mateixa sessió:

rs0 [direct: primary] test> cities.find()
[
  { _id: ObjectId('678a547f5bb4be8077cb0ce5'), name: 'Tokyo' },
  { _id: ObjectId('678a547f5bb4be8077cb0ce6'), name: 'Delhi' },
  { _id: ObjectId('678a547f5bb4be8077cb0ce7'), name: 'Seoul' },
  { _id: ObjectId('678a55d45bb4be8077cb0ce9'), name: 'New York' }
]

Però fora de la sessió no està la ciutat de Nova York:

rs0 [direct: primary] test> db.cities.find()
[
  { _id: ObjectId('678a547f5bb4be8077cb0ce5'), name: 'Tokyo' },
  { _id: ObjectId('678a547f5bb4be8077cb0ce6'), name: 'Delhi' },
  { _id: ObjectId('678a547f5bb4be8077cb0ce7'), name: 'Seoul' },
]

El motiu d'això és que la instrucció d'inserció s'ha executat dins de la transacció en execució, però la transacció en si encara no s'ha compromès. En aquest punt, la transacció encara pot tenir èxit i conservar les dades, o podria fallar, cosa que desferia tots els canvis i deixaria la base de dades en el mateix estat que abans d'iniciar la transacció.

Per confirmar la transacció i desar el document inserit permanentment a la base de dades, executa el mètode commitTransaction a l'objecte de sessió:

rs0 [direct: primary] test> session.commitTransaction()
MongoServerError[NoSuchTransaction]: Transaction with { txnNumber: 3 } has been aborted.

Com era d'esperar, que MongoDB ha avortat la transacció perquè s'ha superat el límit de temps 🫡.

Amb la tecla fletxa cap amunt pots recuperar les ordres que has escrit i executar-les en menys de 60 segons: 👌

rs0 [direct: primary] test> session.startTransaction({ "writeConcern": { "w": "majority"}, "readConcern": { "level": "snapshot"}})

rs0 [direct: primary] test> cities.insertOne({ name: "New York"})
{
  acknowledged: true,
  insertedId: ObjectId('678a56ac5bb4be8077cb0cea')
}
rs0 [direct: primary] test> session.commitTransaction()

A continuació, consulta la col·lecció cities al segon shell que s'executa fora de la transacció:

rs0 [direct: secondary] test> db.cities.find()
[
  { _id: ObjectId('678a547f5bb4be8077cb0ce6'), name: 'Delhi' },
  { _id: ObjectId('678a547f5bb4be8077cb0ce5'), name: 'Tokyo' },
  { _id: ObjectId('678a547f5bb4be8077cb0ce7'), name: 'Seoul' },
  { _id: ObjectId('678a56ac5bb4be8077cb0cea'), name: 'New York' }
]

Aquesta vegada, el nou document inserit és visible tant dins de la sessió com fora d'ella. La transacció s'ha compromès i s'ha finalitzat amb èxit, mantenint els canvis fets a la base de dades. Ara podeu accedir a l' New Yorkobjecte tant a les transaccions externes com a les transaccions posteriors.

Avortar una transacció

En cancel·lar una transacció en comptes de confirmar els canvis, tots els canvis introduïts per la transacció es reverteixen, tornant la base de dades al seu estat anterior com si la transacció no hagués passat mai.

Inseriu un altre document nou a aquesta col·lecció com a part d'una nova transacció en curs.

Recorda que no cal que tornis a escriure de nou la transacció (la pots recuperar amb la tecla )

rs0 [direct: primary] test> session.startTransaction({ "readConcern": { "level": "snapshot"}, "writeConcern": { "w": "majority"}})

rs0 [direct: primary] test> cities.insertOne({name: "Barcelona"})
{
  acknowledged: true,
  insertedId: ObjectId('678a7baf5bb4be8077cb0ced')
}

Verifica que l'ha ciutat s'ha inserit dins de la sessió:

rs0 [direct: primary] test> cities.find()
[
  { _id: ObjectId('678a547f5bb4be8077cb0ce5'), name: 'Tokyo' },
  { _id: ObjectId('678a547f5bb4be8077cb0ce6'), name: 'Delhi' },
  { _id: ObjectId('678a547f5bb4be8077cb0ce7'), name: 'Seoul' },
  { _id: ObjectId('678a56ac5bb4be8077cb0cea'), name: 'New York' },
  { _id: ObjectId('678a7c8f5bb4be8077cb0cee'), name: 'Barcelona' }
]

Si vols descartar tots els canvis introduïts a la transacció i que la base de dades a l'estat anterior, pots avortar la transacció:

rs0 [direct: primary] test> session.abortTransaction()

Pots veure que la ciutat de Barcelona ja no consta en la base de dades:

rs0 [direct: primary] test> cities.find()
[
  { _id: ObjectId('678a547f5bb4be8077cb0ce5'), name: 'Tokyo' },
  { _id: ObjectId('678a547f5bb4be8077cb0ce6'), name: 'Delhi' },
  { _id: ObjectId('678a547f5bb4be8077cb0ce7'), name: 'Seoul' },
  { _id: ObjectId('678a56ac5bb4be8077cb0cea'), name: 'New York' }
]

Avortament de transaccions per errors

Una transacció es pot avortar perquè ha excedit del temps permès per executar-se, perquè tu has decidit que ha d'avortar o perquè el que intentes fer la base de dades no ho permet.

Crea una nova transacció, inserta la ciutat de "Los Angeles" i inserta de nou la ciutat de "Tokyo" (recorda que tens menys de 60 segons 🫡):

rs0 [direct: primary] test> session.startTransaction({ "readConcern": { "level": "snapshot"}, "writeConcern": { "w": "majority"}})

rs0 [direct: primary] test> cities.insertOne({name:"Los Angeles"})
{
  acknowledged: true,
  insertedId: ObjectId('678a825c5bb4be8077cb0cf4')
}
rs0 [direct: primary] test> cities.insertOne({name:"Tokyo"})
MongoServerError: E11000 duplicate key error collection: test.cities index: name_1 dup key: { name: "Tokyo" }

rs0 [direct: primary] test> cities.find()
MongoServerError[NoSuchTransaction]: Transaction with { txnNumber: 15 } has been aborted.

rs0 [direct: primary] test> session.abortTransaction()

Com que "New York" ja estava a la col.lecció és produeix un error dins la transacció i MongoDB avorta automàticament la transacció.

A més, com que les transaccions s'executen d'una manera de tot o res, MongoDB descarta el document de "Los Angeles":

rs0 [direct: primary] test> cities.find()
[
  { _id: ObjectId('678a547f5bb4be8077cb0ce5'), name: 'Tokyo' },
  { _id: ObjectId('678a547f5bb4be8077cb0ce6'), name: 'Delhi' },
  { _id: ObjectId('678a547f5bb4be8077cb0ce7'), name: 'Seoul' },
  { _id: ObjectId('678a56ac5bb4be8077cb0cea'), name: 'New York' }
]

TODO