Il DBMS InterSystems IRIS supporta strutture interessanti per l'archiviazione dei dati: i globali. In sostanza, si tratta di chiavi multi-livello con vari vantaggi aggiuntivi come transazioni, funzioni veloci per percorrere alberi di dati, bloccaggi e il proprio linguaggio ObjectScript.
Maggiori dettagli sui globali nella serie di articoli "Globali - spade magiche per l'archiviazione dei dati":
Sono curioso di sapere come sono implementate le transazioni nei globali, quali sono le loro peculiarità. Infatti, si tratta di una struttura di archiviazione dei dati completamente diversa dalle Tabelle comunemente conosciute. È molto più a basso livello.
Come noto dalla teoria dei database relazionali, una buona implementazione delle transazioni deve soddisfare i requisiti :
A - Atomico (atomicità). Vengono registrate tutte le modifiche apportate nella transazione o nessuna.
C - Coerenza (consistenza). Dopo il completamento della transazione, lo stato logico del DB deve essere internamente coerente. Molti di questi requisiti riguardano il programmatore, ma nel caso dei database SQL riguardano anche le chiavi esterne.
I - Isolato (isolamento). Le transazioni eseguite in parallelo non devono influenzarsi a vicenda.
D - Durabile (durabilità). Dopo un completamento riuscito della transazione, problemi a livelli inferiori (come un'interruzione di corrente, per esempio) non devono influire sui dati modificati dalla transazione.
I globali sono strutture dati non relazionali. Sono stati creati per un funzionamento super veloce su hardware molto limitato. Esaminiamo l'implementazione delle transazioni nei globali utilizzando .
Per supportare le transazioni in IRIS si usano i comandi: , , .
1. Atomarità
È più facile testare l'atomicità. Verifichiamo dalla console del database.
Kill ^a
TSTART
Set ^a(1) = 1
Set ^a(2) = 2
Set ^a(3) = 3
TCOMMITPoi facciamo un'analisi:
Write ^a(1), " ", ^a(2), " ", ^a(3)Otterremo:
1 2 3Tutto in ordine. L'atomicità è rispettata: tutte le modifiche sono state registrate.
Complichiamo il compito, introduciamo un errore e vediamo come viene mantenuta la transazione, parzialmente o per niente.
Controlliamo di nuovo l'atomicità:
Kill ^A
TSTART
Set ^a(1) = 1
Set ^a(2) = 2
Set ^a(3) = 3Dopodiché fermeremo forzatamente il contenitore, lo riavvieremo e vedremo.
docker kill my-irisQuesto comando è praticamente equivalente a una interruzione forzata dell'alimentazione, poiché invia un segnale di arresto immediato del processo SIGKILL.
Potrebbe essere che la transazione sia stata salvata parzialmente?
WRITE ^a(1), ^a(2), ^a(3)
^
^a(1)— No, non è stata salvata.
Proviamo il comando di rollback:
Kill ^A
TSTART
Set ^a(1) = 1
Set ^a(2) = 2
Set ^a(3) = 3
TROLLBACK
WRITE ^a(1), ^a(2), ^a(3)
^
^a(1)Anche stavolta non è stato salvato nulla.
2. Coerenza
Poiché nelle basi sui globali le chiavi vengono create anch'esse sui globali (ricordo che un globale è una struttura a livello inferiore per memorizzare dati rispetto a una tabella relazionale), per soddisfare il requisito di coerenza è necessario includere la modifica della chiave nella stessa transazione che modifica il globale.
Ad esempio, abbiamo un globale ^person, in cui memorizziamo le personalità e come chiave utilizziamo il codice fiscale.
^person(1234567, 'firstname') = 'Sergey'
^person(1234567, 'lastname') = 'Kamenev'
^person(1234567, 'phone') = '+74995555555
...Per avere una ricerca veloce per cognome e nome abbiamo creato la chiave ^index.
^index('Kamenev', 'Sergey', 1234567) = 1Affinché la base sia coerente dobbiamo aggiungere le personalità in questo modo:
TSTART
^person(1234567, 'firstname') = 'Sergey'
^person(1234567, 'lastname') = 'Kamenev'
^person(1234567, 'phone') = '+74995555555
^index('Kamenev', 'Sergey', 1234567) = 1
TCOMMITPertanto, durante l'eliminazione dobbiamo anche utilizzare una transazione:
TSTART
Kill ^person(1234567)
ZKill ^index('Kamenev', 'Sergey', 1234567)
TCOMMITIn altre parole, il rispetto del requisito di coerenza ricade completamente sulle spalle del programmatore. Ma quando si tratta di globali, questo è normale, a causa della loro natura a livello inferiore.
3. Isolamento
Qui iniziano le complicazioni. Molti utenti lavorano contemporaneamente sulla stessa base, modificando gli stessi dati.
La situazione è paragonabile a quando molti utenti lavorano contemporaneamente su un unico repository di codice e tentano di fare commit delle modifiche in molti file allo stesso tempo.
Il database deve gestire tutto questo in tempo reale. Considerando che nelle aziende serie c'è persino una persona dedicata al controllo delle versioni (alla fusione dei rami, alla risoluzione dei conflitti, ecc.), e il DB deve fare tutto questo in tempo reale, diventa ovvio la complessità del compito e la correttezza della progettazione del database e del codice che lo gestisce.
Il database non può comprendere il significato delle azioni effettuate dagli utenti per evitare conflitti quando lavorano sugli stessi dati. Può solo annullare una transazione che contraddice un'altra o eseguirle in modo sequenziale.
Un altro problema è che durante l'esecuzione di una transazione (fino al commit), lo stato del database può essere incoerente. Pertanto, è preferibile che altre transazioni non abbiano accesso a uno stato incoerente del database, cosa che si realizza nei database relazionali in molti modi: creando snapshot, utilizzando la multiversione delle righe, ecc.
Nell'esecuzione parallela delle transazioni è importante che non si interferiscano reciprocamente. Questa è la proprietà di isolamento.
SQL definisce 4 livelli di isolamento:
- READ UNCOMMITTED
- READ COMMITTED
- REPEATABLE READ
- SERIALIZABLE
Esaminiamo ogni livello singolarmente. I costi per l'implementazione di ciascun livello aumentano quasi esponenzialmente.
READ UNCOMMITTED — è il livello più basso di isolamento, ma è anche il più veloce. Le transazioni possono leggere le modifiche apportate l'una dall'altra.
READ COMMITTED — è il livello successivo di isolamento, che rappresenta un compromesso. Le transazioni non possono leggere le modifiche apportate l'una dall'altra fino al commit, ma possono leggere qualsiasi modifica apportata dopo il commit.
Se abbiamo una lunga transazione T1, durante la quale ci sono stati commit nelle transazioni T2, T3 … Tn, che hanno lavorato con gli stessi dati di T1, all'interrogazione dei dati in T1 otterremo ogni volta risultati diversi. Questo fenomeno si chiama lettura non ripetibile.
REPEATABLE READ — a questo livello di isolamento non abbiamo il fenomeno della lettura non ripetibile grazie al fatto che per ogni richiesta di lettura dei dati viene creato uno snapshot dei risultati e quando vengono riutilizzati nella stessa transazione vengono utilizzati i dati dello snapshot. Tuttavia, a questo livello di isolamento è possibile leggere dati fantasma, intendendo la lettura di nuove righe aggiunte da transazioni parallele già fissate.
SERIALIZABLE — è il livello più alto di isolamento. È caratterizzato dal fatto che i dati utilizzati in qualche modo nella transazione (lettura o modifica) diventano disponibili per altre transazioni solo dopo il completamento della prima transazione.
Iniziamo a capire se c'è isolamento delle operazioni nella transazione rispetto al flusso principale. Apriamo 2 finestre del terminale.
Kill ^t
Write ^t(1)
2
TSTART
Set ^t(1)=2Non c'è isolamento. Un flusso vede ciò che fa l'altro che ha aperto la transazione.
Vediamo se le transazioni di flussi diversi vedono ciò che accade all'interno di esse.
Apriamo 2 finestre del terminale e apriamo 2 transazioni in parallelo.
kill ^t
TSTART
Write ^t(1)
3
TSTART
Set ^t(1)=3
Le transazioni parallele vedono i dati l'una dell'altra. Quindi abbiamo ottenuto il livello di isolamento più semplice, ma anche il più veloce, READ UNCOMMITTED.
In linea di principio, ciò era prevedibile per i globali, per i quali le prestazioni sono sempre state una priorità.
Cosa fare se abbiamo bisogno di un livello di isolamento più elevato nelle operazioni sui globali?
Qui dobbiamo riflettere sul perché abbiamo bisogno di livelli di isolamento e su come funzionano.
Il livello di isolamento più alto, SERIALIZE, significa che il risultato delle transazioni eseguite in parallelo è equivalente alla loro esecuzione sequenziale, garantendo l'assenza di collisioni.
Possiamo farlo grazie a blocchi appropriati in ObjectScript, che hanno molti modi diversi di applicazione: è possibile fare un blocco normale, incrementale o multiplo con il comando .
Livelli di isolamento inferiori sono compromessi volti ad aumentare la velocità di funzionamento del database.
Vediamo come possiamo raggiungere diversi livelli di isolamento tramite blocchi.
Questo operatore consente di avere non solo blocchi esclusivi necessari per modificare i dati, ma anche i cosiddetti blocchi condivisi, che possono essere acquisiti simultaneamente da più flussi quando devono leggere dati che non devono essere modificati da altri processi durante la lettura.
Maggiore informazioni sul metodo di blocco a due fasi in russo e inglese:
→
→
La difficoltà sta nel fatto che durante la transazione lo stato del database può essere incoerente, tuttavia questi dati incoerenti sono visibili ad altri processi. Come evitarlo?
Lo faremo con blocchi per creare finestre di visibilità in cui lo stato del database sarà coerente. E tutte le richieste a tali finestre di visibilità di stato coerente saranno controllate dai blocchi.
Le blocchi condivisi su dati identici sono riutilizzabili: possono essere acquisiti da più processi. Questi blocchi vietano ad altri processi di modificare i dati, cioè vengono utilizzati per formare finestre di stato coerente del database.
I blocchi esclusivi vengono impiegati per modificare i dati: tale blocco può essere acquisito solo da un processo. Un blocco esclusivo può essere acquisito da:
- Qualsiasi processo, se i dati sono liberi.
- Solo quel processo che ha un blocco condiviso su tali dati e ha richiesto per primo un blocco esclusivo.

Più ristretta è la finestra di visibilità, più a lungo gli altri processi devono attendere, ma così lo stato del database può risultare più coerente al suo interno.
READ_COMMITED — l'essenza di questo livello è che vediamo solo dati già confermati da altri flussi. Se i dati in un'altra transazione non sono ancora stati confermati, vediamo la loro vecchia versione.
Questo ci permette di parallelizzare il lavoro invece di attendere il rilascio del blocco.
Senza particolari accorgimenti non saremo in grado di vedere la vecchia versione dei dati in IRIS, quindi dovremo fare affidamento sui blocchi.
Di conseguenza, dovremo autorizzare la lettura dei dati solo nei momenti di coerenza tramite blocchi condivisi.
Supponiamo di avere un database di utenti ^person, che si trasferiscono denaro tra di loro.
Il momento del trasferimento da persona 123 a persona 242:
LOCK +^person(123), +^person(242)
Set ^person(123, amount) = ^person(123, amount) - amount
Set ^person(242, amount) = ^person(242, amount) + amount
LOCK -^person(123), -^person(242)Il momento in cui si richiede la quantità di denaro da persona 123 prima di un addebito deve essere accompagnato da un blocco esclusivo (per impostazione predefinita):
LOCK +^person(123)
Write ^person(123)Se è necessario mostrare lo stato del conto nel pannello personale, si può usare un blocco condiviso o addirittura non usarlo affatto:
LOCK +^person(123)#”S”
Write ^person(123)Tuttavia, ammettendo che le operazioni di interazione con il database avvengano praticamente istantaneamente (ricordo che i global sono una struttura molto più a basso livello rispetto a una tabella relazionale), la necessità di questo livello diminuisce.
REPEATABLE READ — in questo livello di isolamento è permesso che ci possano essere più letture di dati che possono essere modificate da transazioni parallele.
Di conseguenza, sarà necessario impostare un blocco condiviso per la lettura dei dati che stiamo modificando e blocchi esclusivi per i dati che stiamo cambiando.
Il vantaggio dell'operatore LOCK è che consente di elencare in modo dettagliato tutte le serrature necessarie in un unico operatore, che possono essere molte.
LOCK +^person(123, amount)#”S”
lettura ^person(123, amount)altre operazioni (in questo momento i thread paralleli stanno cercando di modificare ^person(123, amount), ma non possono)
LOCK +^person(123, amount)
modifica ^person(123, amount)
LOCK -^person(123, amount)
lettura ^person(123, amount)
LOCK -^person(123, amount)#”S”Quando si elencano le serrature con una virgola, vengono prese in sequenza, ma se si fa così:
LOCK +(^person(123),^person(242))allora vengono prese atomiche tutte insieme.
SERIALIZE — dobbiamo impostare le serrature in modo che alla fine tutte le transazioni che hanno dati comuni vengano eseguite in sequenza. Per questo approccio, la maggior parte delle serrature deve essere esclusiva e presa sulle aree più piccole del globale per prestazioni.
Se parliamo di prelievi di fondi nel globale ^person, solo il livello di isolamento SERIALIZE è accettabile, poiché il denaro deve essere speso in modo strettamente sequenziale, altrimenti è possibile spendere la stessa somma più volte.
4. Durabilità
Ho effettuato test con l'interruzione brusca del contenitore tramite
docker kill my-irisIl database ha gestito bene. Non sono stati riscontrati problemi.
Conclusione
Per i globali in InterSystems IRIS c'è il supporto per le transazioni. Sono davvero atomiche, affidabili. Per garantire la coerenza del DB nei globali sono necessari gli sforzi del programmatore e l'uso delle transazioni, poiché non ci sono strutture complesse integrate come le chiavi esterne.
Il livello di isolamento per i globali senza l'uso di serrature è READ UNCOMMITED, mentre con le serrature può essere garantito fino al livello SERIALIZE.
La correttezza e la velocità delle transazioni sui globali dipendono molto dall'abilità del programmatore: più ampiamente vengono utilizzate le serrature shared durante la lettura, maggiore è il livello di isolamento, e più ristrettamente vengono prese le serrature esclusive, maggiore è la velocità.
Fonte: habr.com
