A la lliçó anterior va quedar una lletra sense desenvolupar. La A d'atomicitat, la C de consistència i la D de durabilitat les vam obrir en canal; la I d'aïllament la vam enunciar en quatre línies i la vam ajornar. Aquest és l'ajornament.
El motiu d'ajornar-la és que l'aïllament no s'assembla a les altres tres. L'atomicitat no admet graus: una transacció és atòmica o no ho és. La durabilitat tampoc: el que s'ha confirmat sobreviu o no sobreviu. L'aïllament, en canvi, és un dial. L'estàndard SQL defineix quatre posicions, cada gestor implementa les que vol i com vol, i triar la posició equivocada produeix errors que no apareixen al portàtil de desenvolupament, no apareixen als tests, apareixen un dimarts a les onze del matí al taulell de la sucursal Centre i no hi ha manera de reproduir-los.
Perquè això és exactament el que ha passat aquesta setmana a BiblioRed. L'exemplar EJ-3081 d'"El mapa del temps" figura prestat a dos socis diferents alhora. El club de lectura de tardor de la sucursal Nord té 25 places i 26 inscrits, tot i que el sistema comprovava l'aforament abans d'inscriure. I un esdeveniment publicat s'ha quedat sense cap ponent, malgrat que l'aplicació impedeix cancel·lar l'últim.
Cap dels tres no és un error de programació en el sentit habitual. El codi que els produeix es llegeix bé, passa la revisió i funciona perfectament quan l'executa una sola persona. Els tres són fallades de concurrència, i aquesta lliçó tracta de reconèixer-les, provocar-les a voluntat i arreglar-les.
Tot el que segueix està pensat perquè ho reprodueixis. Obre dos terminals amb psql connectats a la mateixa base de dades. Al llarg de la lliçó els anomenarem Sessió A i Sessió B, i cada demostració indica en quin ordre cal executar cada instrucció. Executar-les en un altre ordre dóna un altre resultat, i aquesta és precisament l'ensenyança.
Contingut
- Per què la concurrència és imprescindible i per què és perillosa
- El laboratori: dos terminals i l'estat inicial
- Fenomen 1: actualització perduda
- Fenomen 2: lectura bruta
- Fenomen 3: lectura no repetible
- Fenomen 4: lectura fantasma i l'última plaça
- Fenomen 5: biaix d'escriptura, el que sorprèn
- Els quatre nivells d'aïllament de l'estàndard SQL
- Com s'estableix el nivell i què fa realment PostgreSQL
- Control de concurrència per bloqueig
- Bloqueigs explícits:
FOR UPDATE,FOR SHARE,LOCK TABLE,SKIP LOCKED - Control multiversió (MVCC) i per què existeix
VACUUM - Interbloqueigs: com es produeixen, com es detecten, com s'eviten
- Bloqueig optimista enfront de pessimista
- La solució completa: l'última plaça del club de lectura
- Fora de PostgreSQL: SQLite i el retorn del problema a NoSQL
- Per què la concurrència és imprescindible i per què és perillosa
Comencem per l'obvi, perquè explica per què no n'hi ha prou de "fer-ho d'un en un".
Per què és imprescindible. BiblioRed té quatre taulells, un catàleg web obert a 12.000 socis, una aplicació mòbil i diversos processos automàtics (avisos de venciment, generació d'informes). Si les operacions s'executessin estrictament una darrere l'altra, cada taulell esperaria que acabessin tots els altres. I no només això: mentre una transacció espera que el disc confirmi el seu fsync() —aquells mil·lisegons de l'apartat 12 de la lliçó anterior—, el processador estaria ociós. La concurrència és el que permet que el temps d'espera d'una operació sigui el temps de treball d'una altra.
| Sense concurrència | Amb concurrència |
|---|---|
| El servidor atén una operació alhora | N'atén desenes o centenars simultàniament |
| Els recursos (CPU, disc, xarxa) s'usen per torns | S'usen alhora i se solapen |
| El temps de resposta creix linealment amb la càrrega | Es manté estable fins a la saturació |
| Raonar sobre el codi és trivial | Raonar sobre el codi és difícil |
Per què és perillosa. Aquella última fila és tota la lliçó. Quan dues transaccions toquen les mateixes dades alhora, el resultat pot dependre de l'ordre exacte en què s'entrellacin les seves instruccions. I aquell ordre no el controles: el decideix el planificador del sistema operatiu, la latència de la xarxa i quina pàgina hi havia a la memòria cau en aquell microsegon.
La formulació clàssica del problema és aquesta:
L'objectiu del control de concurrència és que l'execució entrellaçada de diverses transaccions produeixi el mateix resultat que alguna execució en què s'haguessin executat una darrere l'altra. Aquesta propietat s'anomena serialitzabilitat.
Fixa't en l'"alguna": no s'exigeix un ordre concret. Si A i I s'executen alhora, val que el resultat sigui el d'"A i després I" o el d'"I i després A". El que no val és que sigui un resultat que cap ordre seqüencial no hauria produït. Quan això passa, tenim una anomalia.
- El laboratori: dos terminals i l'estat inicial
Abans de provocar res, cal preparar l'escenari. Obre dos terminals:
# Terminal 1 — l'anomenarem Sessió A
psql -U bibliored -d biblioredb
# Terminal 2 — l'anomenarem Sessió B
psql -U bibliored -d biblioredbUn truc molt pràctic: fes que cada sessió s'identifiqui a l'indicador i mostri sempre el número de procés, que caldrà en parlar de bloqueigs.
I l'estat de partida de les dades que maltractarem:
exemplar_id | codi | estat | sucursal_id
-------------+---------+------------+-------------
3081 | EJ-3081 | disponible | 1SELECT esdeveniment_id, titol, places_ofertes,
(SELECT coalesce(sum(places_ocupades),0) FROM inscripcions i
WHERE i.esdeveniment_id = e.esdeveniment_id AND i.estat = 'confirmada') AS ocupades
FROM esdeveniments e WHERE esdeveniment_id = 51; esdeveniment_id | titol | places_ofertes | ocupades
-----------------+--------------------------+----------------+----------
51 | Club de lectura de tardor| 25 | 24Una plaça lliure. Un exemplar disponible. Tot el que cal per trencar el sistema.
- Fenomen 1: actualització perduda
Actualització perduda (lost update). Dues transaccions llegeixen la mateixa dada, totes dues calculen un valor nou a partir del que han llegit, i totes dues escriuen. La segona escriptura trepitja la primera, que es perd sense deixar rastre ni error.
Aquesta és la fallada de l'exemplar EJ-3081. L'aplicació del taulell fa el que és natural: llegeix l'estat, comprova que està disponible, i el presta.
Provocar-la
Els dos taulells de la sucursal Centre atenen alhora. La Sessió A és el taulell 1, atenent la Marta Alsina (sòcia 14). La Sessió B és el taulell 2, atenent l'Ivan Pereda (soci 15). Executa en l'ordre de la columna "moment":
| Moment | Sessió A (taulell 1, sòcia 14) | Sessió B (taulell 2, soci 15) |
|---|---|---|
| t1 | BEGIN; |
|
| t2 | BEGIN; |
|
| t3 | SELECT estat FROM exemplars WHERE exemplar_id=3081; → disponible |
|
| t4 | SELECT estat FROM exemplars WHERE exemplar_id=3081; → disponible |
|
| t5 | L'aplicació decideix: està disponible, es pot prestar | |
| t6 | L'aplicació decideix: està disponible, es pot prestar | |
| t7 | INSERT INTO prestecs (soci_id, exemplar_id, data_prestec, data_devolucio_prevista) VALUES (14,3081,CURRENT_DATE,CURRENT_DATE+21); |
|
| t8 | UPDATE exemplars SET estat='prestat' WHERE exemplar_id=3081; |
|
| t9 | COMMIT; |
|
| t10 | INSERT INTO prestecs (...) VALUES (15,3081,...); |
|
| t11 | UPDATE exemplars SET estat='prestat' WHERE exemplar_id=3081; |
|
| t12 | COMMIT; |
Comprova el desastre:
SELECT prestec_id, soci_id, exemplar_id, data_devolucio
FROM prestecs WHERE exemplar_id = 3081 AND data_devolucio IS NULL; prestec_id | soci_id | exemplar_id | data_devolucio
------------+---------+-------------+----------------
88301 | 14 | 3081 |
88302 | 15 | 3081 |Dos préstecs oberts del mateix exemplar físic. La Marta se l'ha endut a casa i l'Ivan és al taulell preguntant on és el seu llibre. No hi ha hagut cap error, cap excepció, cap traça. Les dues transaccions han estat atòmiques, consistents i durables. I el resultat és impossible.
Per què ha passat
La comprovació d'A (t3) i l'escriptura d'A (t8) estan separades en el temps, i en aquell forat B ha llegit. B ha pres la seva decisió amb informació que va deixar de ser certa abans que B actués. És el que s'anomena una seqüència llegir-modificar-escriure sense protecció.
Fixa't en un detall important: cap ordre seqüencial no produeix aquest resultat. Si A s'hagués executat sencera i després B, B hauria llegit prestat i hauria rebutjat el préstec. A l'inrevés, igual. El resultat obtingut no correspon a cap execució en sèrie: és una anomalia en el sentit estricte de l'apartat 1.
Nota sobre l'UPDATE en solitari
És important entendre per què això passa malgrat que un UPDATE solt sí que és segur. Compara:
-- Perillós: llegir, decidir fora, escriure
SELECT estat FROM exemplars WHERE exemplar_id = 3081; -- l'aplicació decideix
UPDATE exemplars SET estat = 'prestat' WHERE exemplar_id = 3081;
-- Segur: la decisió és dins de la mateixa escriptura
UPDATE exemplars SET estat = 'prestat'
WHERE exemplar_id = 3081 AND estat = 'disponible';La segona forma és atòmica de debò: el gestor bloqueja la fila per actualitzar-la i avalua la condició sobre la versió més recent. Si una altra transacció ja la va posar en prestat, la resposta és:
I aquell UPDATE 0 és el senyal que l'aplicació ha de llegir com "algú se m'ha avançat, cancel·la l'operació". Comprovar el nombre de files afectades és la defensa més barata que existeix contra l'actualització perduda, i sorprèn quant codi la ignora.
- Fenomen 2: lectura bruta
Lectura bruta (dirty read). Una transacció llegeix dades que una altra ha modificat però encara no ha confirmat. Si l'altra fa
ROLLBACK, la primera ha treballat amb dades que mai no van existir.
Intentar provocar-la
| Moment | Sessió A (cobrament de multa) | Sessió B (informe de recaptació) |
|---|---|---|
| t1 | BEGIN; |
|
| t2 | UPDATE multes SET estat='pagada' WHERE multa_id=4102; → UPDATE 1 |
|
| t3 | BEGIN; |
|
| t4 | SELECT estat FROM multes WHERE multa_id=4102; |
|
| t5 | ROLLBACK; (el datàfon rebutja la targeta) |
En un gestor que permetés la lectura bruta, a t4 la Sessió B llegiria pagada i l'informe comptaria un cobrament que mai no va passar.
A PostgreSQL, a t4 la Sessió B llegeix pendent. Sempre. No hi ha manera de provocar una lectura bruta, ni tan sols demanant-ho explícitament:
PostgreSQL accepta la sintaxi READ UNCOMMITTED per compatibilitat amb l'estàndard, però internament la tracta com READ COMMITTED. La raó és la seva arquitectura: com veurem a l'apartat 12, el control multiversió fa que una transacció llegeixi sempre una versió confirmada de cada fila. Llegir dades brutes no és que estigui prohibit: és que no hi ha cap mecanisme amb què fer-ho.
Això no significa que el fenomen sigui una curiositat històrica. Altres gestors sí que el permeten —SQL Server amb READ UNCOMMITTED o l'infame WITH (NOLOCK), MySQL amb READ UNCOMMITTED— i hi ha qui l'activa "perquè els informes no bloquegin". És una mala idea: a més de llegir dades que poden desaparèixer, en alguns motors pot llegir files duplicades o saltar-se files si l'índex es reorganitza durant el recorregut.
- Fenomen 3: lectura no repetible
Lectura no repetible (non-repeatable read). Una transacció llegeix una fila, i en tornar-la a llegir dins de la mateixa transacció obté valors diferents, perquè una altra transacció la va modificar i confirmar pel mig.
Provocar-la (funciona a PostgreSQL amb el nivell per omissió)
La direcció de BiblioRed demana un informe que primer compta les multes pendents i després en suma l'import:
| Moment | Sessió A (informe de direcció) | Sessió B (taulell Sud) |
|---|---|---|
| t1 | BEGIN; |
|
| t2 | SELECT import FROM multes WHERE multa_id=4102; → 12.40 |
|
| t3 | UPDATE multes SET import=3.50 WHERE multa_id=4102; |
|
| t4 | (autoconfirmació: ja està confirmat) | |
| t5 | SELECT import FROM multes WHERE multa_id=4102; → 3.50 |
|
| t6 | COMMIT; |
La mateixa consulta, dins de la mateixa transacció, ha tornat dos valors diferents. L'informe que A està construint barreja xifres de dos instants: si la primera lectura va alimentar un total i la segona un desglossament, el total i el desglossament no quadren. I qui el rebi pensarà que hi ha un error de càlcul.
La solució: pujar el nivell
-- Sessió A
BEGIN ISOLATION LEVEL REPEATABLE READ;
SELECT import FROM multes WHERE multa_id = 4102; -- 12.40
-- (la Sessió B modifica i confirma)
SELECT import FROM multes WHERE multa_id = 4102; -- 12.40 ← estable
COMMIT;En REPEATABLE READ, PostgreSQL pren una foto (snapshot) de la base de dades a la primera instrucció de la transacció, i totes les lectures posteriors veuen aquella foto, ignorant el que altres confirmin després. És exactament el que un informe necessita: una fotografia coherent d'un instant.
Regla pràctica. Tot informe que executi més d'una consulta i presenti els resultats junts hauria d'anar dins d'una transacció
REPEATABLE READ. És gratis, és una línia, i elimina d'arrel la família sencera de "els números no quadren".
- Fenomen 4: lectura fantasma i l'última plaça
Lectura fantasma (phantom read). Una transacció executa una consulta amb una condició, i en repetir-la apareixen files noves que compleixen aquella condició i que una altra transacció va inserir i confirmar pel mig. La diferència amb la lectura no repetible és que allà canviaven els valors d'una fila; aquí canvia el conjunt de files.
Aquesta és la fallada del club de lectura, i és més subtil que les anteriors perquè el codi que la produeix sembla impecable.
L'aplicació d'inscripcions fa el següent: compta les places ocupades, comprova que en queden de lliures, i insereix.
-- El que fa l'aplicació en inscriure
SELECT coalesce(sum(places_ocupades), 0)
FROM inscripcions
WHERE esdeveniment_id = 51 AND estat = 'confirmada';
-- si el resultat < places_ofertes, aleshores:
INSERT INTO inscripcions (esdeveniment_id, soci_id, data_inscripcio, estat, acompanyants, places_ocupades)
VALUES (51, ..., now(), 'confirmada', 0, 1);Provocar-la
Recorda l'estat: esdeveniment 51, 25 places, 24 ocupades, una de lliure. La Marta Alsina (14) s'inscriu des del mòbil mentre l'Ivan Pereda (15) s'inscriu al taulell Nord.
| Moment | Sessió A (Marta, sòcia 14) | Sessió B (Ivan, soci 15) |
|---|---|---|
| t1 | BEGIN; |
|
| t2 | BEGIN; |
|
| t3 | SELECT sum(places_ocupades) FROM inscripcions WHERE esdeveniment_id=51 AND estat='confirmada'; → 24 |
|
| t4 | SELECT sum(places_ocupades) FROM inscripcions WHERE esdeveniment_id=51 AND estat='confirmada'; → 24 |
|
| t5 | 24 < 25 → hi ha lloc | |
| t6 | 24 < 25 → hi ha lloc | |
| t7 | INSERT INTO inscripcions VALUES (51,14,now(),'confirmada',0,1); |
|
| t8 | INSERT INTO inscripcions VALUES (51,15,now(),'confirmada',0,1); |
|
| t9 | COMMIT; |
|
| t10 | COMMIT; |
SELECT sum(places_ocupades) AS ocupades
FROM inscripcions WHERE esdeveniment_id = 51 AND estat = 'confirmada';26 persones per a 25 cadires. El dia del club de lectura, algú es queda dret.
Per què el COUNT previ no basta, mai
Aquest és el punt que cal interioritzar, perquè és contraintuïtiu:
Comptar abans d'inserir no protegeix de res. El recompte és cert en l'instant en què es fa i deixa de ser-ho immediatament després. Entre el
SELECTi l'INSERThi ha un forat, i per aquell forat hi cap una transacció sencera.
I hi ha una cosa pitjor. A la lectura no repetible, pujar a REPEATABLE READ n'hi havia prou. Aquí no n'hi ha prou amb cap foto, perquè el problema no és el que A veu: és que A i B estan decidint sobre la mateixa plaça sense saber-ho. Ni tan sols un bloqueig sobre les files llegides serviria, perquè les files conflictives —les inscripcions de l'altra— encara no existien quan es va llegir. No es pot bloquejar una fila que no existeix. D'aquí el nom "fantasma".
Les solucions reals es veuen a l'apartat 15, i són tres, amb conseqüències diferents.
Matís sobre PostgreSQL i els fantasmes
L'estàndard SQL diu que REPEATABLE READ permet lectures fantasma. PostgreSQL no les permet: el seu REPEATABLE READ és en realitat snapshot isolation, i la foto és de la base sencera, així que les files inserides després són invisibles. És més estricte que l'estàndard.
Però compte amb la conclusió: això resol el fantasma de lectura, no el problema de l'última plaça. Sota REPEATABLE READ, A no veuria la inscripció de B... i tot i així inseriria la seva, i el COMMIT de totes dues tindria èxit perquè no hi ha conflicte d'escriptura sobre una mateixa fila. El resultat continuaria sent 26. Aquest és el pont natural cap al fenomen següent.
- Fenomen 5: biaix d'escriptura, el que sorprèn
Biaix d'escriptura (write skew). Dues transaccions llegeixen un mateix conjunt de dades, cadascuna decideix alguna cosa basant-s'hi, i cadascuna escriu en files diferents. Cap no trepitja l'altra, així que no hi ha conflicte detectable, però juntes violen una regla que individualment respectaven.
És el fenomen que sorprèn fins i tot qui porta anys treballant amb bases de dades, perquè passa en REPEATABLE READ / snapshot isolation, que molta gent dóna per "el nivell segur".
El cas de BiblioRed
Regla de la casa: tot esdeveniment publicat ha de tenir almenys un ponent confirmat. L'esdeveniment 51 en té dos: la Núria Bastos i un ponent extern. L'aplicació, en cancel·lar un ponent, comprova que en quedi algun.
| Moment | Sessió A (cancel·la la ponent 7) | Sessió B (cancel·la el ponent 9) |
|---|---|---|
| t1 | BEGIN ISOLATION LEVEL REPEATABLE READ; |
|
| t2 | BEGIN ISOLATION LEVEL REPEATABLE READ; |
|
| t3 | SELECT count(*) FROM participacions WHERE esdeveniment_id=51 AND estat='confirmada'; → 2 |
|
| t4 | SELECT count(*) FROM participacions WHERE esdeveniment_id=51 AND estat='confirmada'; → 2 |
|
| t5 | 2 > 1 → en puc cancel·lar un | |
| t6 | 2 > 1 → en puc cancel·lar un | |
| t7 | UPDATE participacions SET estat='cancellada' WHERE esdeveniment_id=51 AND ponent_id=7; |
|
| t8 | UPDATE participacions SET estat='cancellada' WHERE esdeveniment_id=51 AND ponent_id=9; |
|
| t9 | COMMIT; |
|
| t10 | COMMIT; |
Zero ponents en un esdeveniment publicat. I les dues transaccions tenien raó quan van decidir.
Fixa't en per què el gestor no ha protestat: A va escriure a la fila del ponent 7, B a la del ponent 9. No hi ha cap fila en conflicte. Els mecanismes que detecten actualitzacions perdudes treballen a nivell de fila, i aquí no hi ha dues escriptures sobre la mateixa fila. El conflicte és a la premissa: totes dues van llegir un conjunt que l'altra anava a modificar.
Altres exemples del mateix patró, perquè el reconeguis quan el vegis:
| Domini | Regla | Biaix d'escriptura |
|---|---|---|
| Guàrdies mèdiques | Sempre almenys un metge de guàrdia | Dos metges es donen de baixa alhora |
| Compte conjunt | El saldo total dels dos comptes no pot ser negatiu | Dues retirades simultànies, una de cada compte |
| Reserves de sala | Dos esdeveniments no es poden solapar | Dues altes d'esdeveniments que se solapen entre si |
| BiblioRed | Cada sucursal conserva un exemplar de referència | Dos trasllats simultanis de l'últim exemplar |
Les úniques defenses contra el biaix d'escriptura són:
- Aïllament
SERIALIZABLE(amb reintents, perquè avortarà transaccions). - Materialitzar el conflicte: forçar que les dues transaccions escriguin a la mateixa fila, encara que sigui artificialment —per exemple, bloquejant la fila d'
esdevenimentsambSELECT ... FOR UPDATEabans de tocar-ne els ponents—. - Una restricció de la base de dades que expressi la regla, quan sigui possible. Aquí no ho és directament (un
CHECKno pot comptar files d'una altra taula), cosa que il·lustra el límit de les restriccions declaratives enfront de les regles que abasten diverses files.
A l'apartat 15 aplicarem exactament aquestes tres idees al problema de l'última plaça.
- Els quatre nivells d'aïllament de l'estàndard SQL
L'estàndard SQL:1992 va definir quatre nivells, precisament en funció de quins fenòmens permeten. Aquesta és la taula canònica, la que cal saber-se:
| Nivell | Lectura bruta | Lectura no repetible | Lectura fantasma | Biaix d'escriptura |
|---|---|---|---|---|
READ UNCOMMITTED |
Possible | Possible | Possible | Possible |
READ COMMITTED |
Impossible | Possible | Possible | Possible |
REPEATABLE READ |
Impossible | Impossible | Possible (segons l'estàndard) | Possible |
SERIALIZABLE |
Impossible | Impossible | Impossible | Impossible |
Les dues últimes columnes mereixen una nota: el biaix d'escriptura no apareix a l'estàndard de 1992. Es va descriure després, quan l'snapshot isolation es va popularitzar i es va veure que complia la taula de l'estàndard fins a REPEATABLE READ i tot i així permetia anomalies. L'incloem perquè a la pràctica és el que més problemes causa avui.
I ara la taula que de debò importa quan treballes amb PostgreSQL:
| Nivell demanat | El que PostgreSQL fa en realitat | Bruta | No repetible | Fantasma | Biaix escriptura |
|---|---|---|---|---|---|
READ UNCOMMITTED |
Es comporta com READ COMMITTED |
No | Sí | Sí | Sí |
READ COMMITTED (per omissió) |
Foto nova a cada instrucció | No | Sí | Sí | Sí |
REPEATABLE READ |
Snapshot isolation: una foto per a tota la transacció | No | No | No | Sí |
SERIALIZABLE |
Snapshot isolation serialitzable (SSI) | No | No | No | No |
Tres lectures d'aquesta taula:
- PostgreSQL és més estricte que l'estàndard en
REPEATABLE READ: prohibeix els fantasmes de lectura, que l'estàndard permet. - El nivell per omissió és
READ COMMITTED, que permet tres dels quatre fenòmens. No és un descuit: és un equilibri deliberat entre correcció i rendiment, i significa que el nivell d'aïllament amb què treballa la teva aplicació avui, si ningú no l'ha tocat, és el segon més feble. - El salt de garantia és entre
REPEATABLE READiSERIALIZABLE, i és el més car:SERIALIZABLEés l'únic que elimina el biaix d'escriptura, i ho fa avortant transaccions.
La diferència crucial entre READ COMMITTED i REPEATABLE READ
És en quan es pren la foto:
READ COMMITTED |
REPEATABLE READ |
|
|---|---|---|
| Moment de la foto | A l'inici de cada instrucció | A l'inici de la primera instrucció de la transacció |
Dos SELECT iguals seguits |
Poden donar resultats diferents | Donen sempre el mateix |
| Conflicte d'escriptura | Espera i reintenta sobre la versió nova | Avorta amb error de serialització |
| Necessita lògica de reintent | No | Sí |
Aquella fila de "conflicte d'escriptura" és la que sorprèn en producció. Sota REPEATABLE READ:
| Moment | Sessió A | Sessió B |
|---|---|---|
| t1 | BEGIN ISOLATION LEVEL REPEATABLE READ; |
|
| t2 | SELECT import FROM multes WHERE multa_id=4102; → 12.40 |
|
| t3 | UPDATE multes SET import=3.50 WHERE multa_id=4102; (confirmat) |
|
| t4 | UPDATE multes SET import=import-1 WHERE multa_id=4102; |
La transacció d'A queda avortada i cal reintentar-la sencera. No és una fallada: és el gestor negant-se a produir una anomalia. Però si la teva aplicació no sap reintentar, l'usuari veu un error.
- Com s'estableix el nivell i què fa realment PostgreSQL
Tres maneres, de més local a més global:
-- 1) Per a una transacció concreta (la forma preferible)
BEGIN ISOLATION LEVEL REPEATABLE READ;
-- ...
COMMIT;
-- 2) Equivalent, just després del BEGIN
BEGIN;
SET TRANSACTION ISOLATION LEVEL SERIALIZABLE;
-- ...
COMMIT;
-- 3) Per a tota la sessió (afecta les transaccions següents)
SET SESSION CHARACTERISTICS AS TRANSACTION ISOLATION LEVEL REPEATABLE READ;Consultar el nivell actual:
També es pot canviar el valor per omissió del servidor a postgresql.conf amb default_transaction_isolation, però no és recomanable: fa que el comportament de l'aplicació depengui d'un fitxer que probablement no és al mateix repositori que el codi. El nivell és una decisió del codi, i s'ha de veure al codi.
Mode de només lectura i transaccions diferibles
Dos modificadors útils per a informes:
BEGIN ISOLATION LEVEL SERIALIZABLE READ ONLY DEFERRABLE;
-- consultes llargues de l'informe mensual de direcció
COMMIT;READ ONLYimpedeix escriptures i permet al gestor optimitzacions.DEFERRABLE, combinat ambSERIALIZABLE READ ONLY, fa que la transacció esperi fins a poder prendre una foto que garanteixi que mai no avortarà per serialització. És ideal per a un informe nocturn llarg: pot trigar una mica a arrencar, però no fallarà a la meitat després de vint minuts de feina.
Com triar el nivell: guia pràctica
| Tipus d'operació a BiblioRed | Nivell recomanat |
|---|---|
| Consultes soltes del catàleg web | READ COMMITTED (per omissió) |
Registrar un préstec amb UPDATE ... WHERE estat='disponible' |
READ COMMITTED + comprovar files afectades |
| Informe multiconsulta de direcció | REPEATABLE READ READ ONLY |
| Inscripció a esdeveniment amb aforament limitat | SERIALIZABLE amb reintent, o comptador amb restricció (apartat 15) |
| Cancel·lar un ponent respectant "almenys un" | SERIALIZABLE amb reintent |
| Procés nocturn de tancament comptable | SERIALIZABLE |
- Control de concurrència per bloqueig
Històricament, la primera resposta al problema de la concurrència va ser el bloqueig: si una transacció farà servir una dada, la reserva i els altres esperen.
Bloqueigs compartits i exclusius
| Tipus | Símbol | Es pren per a | Compatible amb compartit | Compatible amb exclusiu |
|---|---|---|---|---|
Compartit (lectura, S) |
S |
Llegir | Sí | No |
Exclusiu (escriptura, X) |
X |
Modificar | No | No |
La idea és intuïtiva: molts poden llegir alhora, però escriure requereix exclusivitat. La taula de compatibilitat es llegeix així: si A té un bloqueig S sobre la fila 3081 i B en demana un altre de S, B passa. Si B demana X, B espera.
Granularitat
Un bloqueig es pot prendre sobre unitats de mida diferent, i hi ha un compromís clar:
| Granularitat | Concurrència | Cost de gestió | Qui l'usa |
|---|---|---|---|
| Fila | Màxima | Alt (molts bloqueigs per registrar) | PostgreSQL, Oracle, InnoDB |
| Pàgina (bloc de disc) | Mitjana | Mitjà | SQL Server (amb escalada) |
| Taula | Baixa | Baix | Operacions de DDL, LOCK TABLE |
| Base de dades / fitxer | Nul·la per a escriptura | Mínim | SQLite |
Alguns gestors practiquen l'escalada de bloqueigs: si una transacció acumula massa bloqueigs de fila, els substitueixen per un de taula per estalviar memòria, amb l'efecte col·lateral de bloquejar tothom. PostgreSQL no escala bloqueigs: desa la marca de bloqueig a la mateixa fila, així que en pot tenir milions sense gastar memòria del servidor. És una diferència pràctica notable.
Bloqueig en dues fases
El protocol que garanteix la serialitzabilitat mitjançant bloqueigs s'anomena bloqueig en dues fases (two-phase locking, 2PL), i la seva regla és d'una simplicitat enganyosa:
Una transacció té una fase de creixement, en què només pot adquirir bloqueigs, i una fase de decreixement, en què només els pot alliberar. Un cop ha alliberat el primer bloqueig, no en pot adquirir cap més.
A la pràctica, gairebé tots els gestors usen 2PL estricte: els bloqueigs exclusius es mantenen fins al COMMIT o el ROLLBACK. Això garanteix que ningú no llegeixi canvis no confirmats i simplifica la recuperació.
I explica la conseqüència operativa més gran de tot això: com més dura una transacció, més temps reté els seus bloqueigs i més gent espera. És la justificació tècnica de la regla "transaccions curtes" de la lliçó 06-01.
El preu del 2PL és que les transaccions s'esperen les unes a les altres, i d'aquí neixen els interbloqueigs de l'apartat 13.
Els modes de bloqueig de taula a PostgreSQL
Per completar el quadre, PostgreSQL té vuit modes de bloqueig a nivell de taula. No cal memoritzar-los, però sí saber que existeixen i que els pren tot sol:
| Mode | El pren | Conflicte principal |
|---|---|---|
ACCESS SHARE |
SELECT |
Només amb ACCESS EXCLUSIVE |
ROW SHARE |
SELECT ... FOR UPDATE |
Amb EXCLUSIVE i superiors |
ROW EXCLUSIVE |
INSERT, UPDATE, DELETE |
Amb SHARE i superiors |
SHARE UPDATE EXCLUSIVE |
VACUUM, CREATE INDEX CONCURRENTLY |
Amb si mateix i superiors |
SHARE |
CREATE INDEX (sense CONCURRENTLY) |
Amb les escriptures |
ACCESS EXCLUSIVE |
ALTER TABLE, DROP TABLE, TRUNCATE |
Amb tot, inclòs SELECT |
Aquella última fila és la causa de la meitat de les caigudes de servei durant els desplegaments: un ALTER TABLE que espera darrere d'una consulta llarga, i tota la cua de peticions esperant darrere de l'ALTER TABLE, inclosos els SELECT que abans funcionaven. Veure els bloqueigs en curs:
SELECT pid, wait_event_type, state, left(query, 60) AS consulta
FROM pg_stat_activity
WHERE datname = 'biblioredb' AND state <> 'idle';pid | wait_event_type | state | consulta -------+-----------------+--------+------------------------------------------------- 41207 | | active | ALTER TABLE prestecs ADD COLUMN observacions TE 41255 | Lock | active | SELECT count(*) FROM prestecs WHERE data_devol
El wait_event_type = Lock de la segona fila és la signatura inconfusible de "estic esperant un altre".
- Bloqueigs explícits:
FOR UPDATE, FOR SHARE, LOCK TABLE, SKIP LOCKED
FOR UPDATE, FOR SHARE, LOCK TABLE, SKIP LOCKEDA més dels bloqueigs automàtics, SQL permet demanar-los a mà. És l'eina del bloqueig pessimista (apartat 14).
SELECT ... FOR UPDATE
Bloqueja les files llegides com si s'haguessin de modificar. Qualsevol altra transacció que intenti modificar-les —o bloquejar-les— espera.
Ara la fila 3081 està reservada. Reproducció amb dues sessions:
| Moment | Sessió A | Sessió B |
|---|---|---|
| t1 | BEGIN; |
|
| t2 | SELECT estat FROM exemplars WHERE exemplar_id=3081 FOR UPDATE; → disponible |
|
| t3 | BEGIN; |
|
| t4 | SELECT estat FROM exemplars WHERE exemplar_id=3081 FOR UPDATE; → es queda esperant |
|
| t5 | UPDATE exemplars SET estat='prestat' WHERE exemplar_id=3081; |
(continua esperant) |
| t6 | COMMIT; |
→ torna prestat |
I aquí hi ha la clau: quan B finalment obté la fila, la veu amb el valor nou. La seva comprovació de "està disponible?" ara és correcta, i rebutjarà el préstec. Això resol l'actualització perduda de l'apartat 3 de manera neta.
SELECT ... FOR SHARE
Bloqueig compartit: impedeix que altres modifiquin les files, però permet que altres les llegeixin amb FOR SHARE. S'usa quan necessites garantir que una fila no canviï mentre treballes amb dades relacionades, sense pretendre modificar-la tu.
BEGIN;
-- Garantim que el soci no es doni de baixa mentre registrem el seu préstec
SELECT actiu FROM socis WHERE soci_id = 14 FOR SHARE;
INSERT INTO prestecs (...) VALUES (14, 3081, ...);
COMMIT;Existeixen a més dues variants més suaus, FOR NO KEY UPDATE i FOR KEY SHARE, que PostgreSQL usa internament per a les claus foranes i que permeten més concurrència. Saber que existeixen n'hi ha prou.
LOCK TABLE
Bloqueja la taula sencera. És contundent i gairebé sempre desproporcionat.
El seu ús legítim és el procés de manteniment nocturn que necessita una taula quieta, o el pas d'una migració que reorganitza dades. En el camí d'una operació d'usuari, mai.
NOWAIT i SKIP LOCKED
Dos modificadors que canvien què passa quan la fila està ocupada:
| Modificador | Comportament si la fila està bloquejada |
|---|---|
| (res) | Espera indefinidament |
NOWAIT |
Falla immediatament amb error |
SKIP LOCKED |
Ignora aquella fila i torna les altres |
NOWAIT serveix per donar una resposta ràpida a l'usuari en lloc de deixar-lo esperant:
L'aplicació tradueix aquell error a "un altre taulell està atenent aquest exemplar en aquest moment, torneu-ho a provar", que és infinitament millor que una pantalla congelada.
SKIP LOCKED és la base de les cues de treball. BiblioRed té un procés que envia els avisos de venciment; amb diversos treballadors en paral·lel, cadascun ha de prendre avisos diferents:
BEGIN;
SELECT avis_id, soci_id
FROM avisos_pendents
WHERE estat = 'pendent'
ORDER BY creat_el
LIMIT 10
FOR UPDATE SKIP LOCKED;
-- ...enviar els avisos, marcar-los com a enviats...
COMMIT;Cada treballador rep deu avisos que cap altre no està processant, sense esperes i sense duplicats. És un patró que substitueix una cua de missatges en molts sistemes de mida mitjana, i funciona sorprenentment bé.
- Control multiversió (MVCC) i per què existeix
VACUUM
VACUUMEl bloqueig té un defecte greu: si escriure requereix exclusivitat, els lectors destorben els escriptors i viceversa. L'informe mensual de direcció, que recorre tres milions de préstecs, bloquejaria el taulell durant tot el seu recorregut.
La solució que adopten PostgreSQL, Oracle i InnoDB és el control de concurrència multiversió.
MVCC. El gestor no sobreescriu les dades: cada modificació crea una nova versió de la fila. Cada transacció veu la versió que era visible en el moment de la seva foto. Així, els lectors mai no bloquegen els escriptors ni els escriptors els lectors.
Com funciona a PostgreSQL
Cada fila física porta dues columnes ocultes:
| Columna oculta | Significat |
|---|---|
xmin |
Identificador de la transacció que va crear aquesta versió |
xmax |
Identificador de la transacció que la va eliminar o substituir (0 si continua vigent) |
Les pots veure:
ctid | xmin | xmax | exemplar_id | estat --------+-------+------+-------------+------------ (12,7) | 90114 | 0 | 3081 | disponible
Ara un UPDATE, i tornem a mirar:
UPDATE exemplars SET estat = 'prestat' WHERE exemplar_id = 3081;
SELECT ctid, xmin, xmax, estat FROM exemplars WHERE exemplar_id = 3081;UPDATE 1 ctid | xmin | xmax | estat ---------+-------+------+---------- (12,41) | 90118 | 0 | prestat
El ctid —l'adreça física de la fila— ha canviat de (12,7) a (12,41). La fila no s'ha modificat: se n'ha escrit una de nova en un altre lloc, i l'antiga ha quedat marcada amb xmax = 90118. Un UPDATE a PostgreSQL és, físicament, un INSERT més un marcatge de la versió anterior.
Quan una transacció llegeix, aplica una regla senzilla: una versió és visible si el seu xmin correspon a una transacció confirmada abans de la meva foto i el seu xmax és zero o correspon a una transacció no confirmada a la meva foto.
La factura: versions mortes
Aquest disseny té una conseqüència inevitable. Després d'un temps de funcionament, la taula exemplars conté la versió vigent de cada fila i totes les versions antigues que ja no veu ningú. S'anomenen versions mortes (dead tuples).
Les versions mortes costen de tres maneres:
- Espai en disc, que creix sense parar.
- Temps de lectura: un recorregut de la taula llegeix també les versions mortes i les descarta una a una.
- Consum d'identificadors de transacció, que són finits.
Per veure-ho:
SELECT relname, n_live_tup, n_dead_tup, last_autovacuum
FROM pg_stat_user_tables
WHERE relname IN ('exemplars','prestecs');relname | n_live_tup | n_dead_tup | last_autovacuum ------------+------------+------------+------------------------------- exemplars | 40000 | 1842 | 2026-08-02 11:20:14.331+02 prestecs | 2841077 | 412903 | 2026-08-02 06:02:51.882+02
VACUUM: qui paga la factura
VACUUMrecorre les taules i marca com a reutilitzable l'espai de les versions mortes que ja no pot veure cap transacció.
INFO: vacuuming "biblioredb.public.exemplars" INFO: finished vacuuming: removed 1842 dead row versions in 96 pages INFO: analyzing "biblioredb.public.exemplars" VACUUM
Variants:
| Instrucció | Què fa | Bloqueja? |
|---|---|---|
VACUUM taula |
Marca l'espai mort com a reutilitzable | No |
VACUUM FULL taula |
Reescriu la taula sencera compactant-la i retorna espai al sistema | Sí, ACCESS EXCLUSIVE: bloqueja tot |
ANALYZE taula |
Recalcula estadístiques per al planificador (tema de 06-03) | No |
En condicions normals no cal executar-lo a mà: el procés autovacuum ho fa tot sol. Però cal saber què passa si no s'executa:
- Inflament (bloat): la taula ocupa diverses vegades el que hauria d'ocupar i les consultes s'alenteixen progressivament. Una taula
prestecsde 2 GB de dades útils pot arribar a ocupar 9 GB. - Estadístiques velles, i amb elles plans d'execució dolents (06-03).
- Esgotament d'identificadors de transacció: PostgreSQL usa un comptador de 32 bits. Si
VACUUMno "congela" a temps les files antigues, el servidor s'atura per complet per evitar la pèrdua de dades, amb un missatge inoblidable:database is not accepting commands to avoid wraparound data loss. És una de les poques maneres de deixar una base de dades PostgreSQL fora de servei per descuit operatiu.
L'enemic número u de VACUUM són les transaccions llargues. Una transacció oberta des de fa tres hores obliga a conservar totes les versions mortes creades des d'aleshores, perquè teòricament aquella transacció les podria necessitar. És una altra raó —la tercera ja— perquè les transaccions siguin curtes. Detectar culpables:
SELECT pid, state, now() - xact_start AS duracio, left(query,50) AS consulta
FROM pg_stat_activity
WHERE xact_start IS NOT NULL
ORDER BY xact_start
LIMIT 3;pid | state | duracio | consulta -------+---------------------+-----------------+---------------------------------------- 39104 | idle in transaction | 03:12:47.220188 | SELECT * FROM prestecs WHERE soci_id = 41207 | active | 00:00:00.003912 | SELECT pid, state, now() - xact_start
Aquell idle in transaction de tres hores és exactament el patró que la lliçó 06-01 demanava evitar amb idle_in_transaction_session_timeout.
- Interbloqueigs: com es produeixen, com es detecten, com s'eviten
Interbloqueig (deadlock). Dues o més transaccions s'esperen mútuament en un cicle: A espera un recurs que té B, i B espera un recurs que té A. Sense intervenció externa, esperarien per sempre.
Provocar-ne un
El cas clàssic: dues transaccions que toquen les mateixes dues files en ordre invers. A BiblioRed, un trasllat d'exemplars entre les sucursals Centre i Nord, executat alhora en els dos sentits.
| Moment | Sessió A (Centre → Nord) | Sessió B (Nord → Centre) |
|---|---|---|
| t1 | BEGIN; |
|
| t2 | BEGIN; |
|
| t3 | UPDATE exemplars SET sucursal_id=2 WHERE exemplar_id=3081; → UPDATE 1 |
|
| t4 | UPDATE exemplars SET sucursal_id=1 WHERE exemplar_id=3095; → UPDATE 1 |
|
| t5 | UPDATE exemplars SET sucursal_id=2 WHERE exemplar_id=3095; → espera B |
|
| t6 | UPDATE exemplars SET sucursal_id=1 WHERE exemplar_id=3081; → espera A |
|
| t7 | (al cap d'~1 segon) → ERROR | (continua normalment) |
A la Sessió A:
ERROR: deadlock detected
DETAIL: Process 41207 waits for ShareLock on transaction 90231; blocked by process 41255.
Process 41255 waits for ShareLock on transaction 90230; blocked by process 41207.
HINT: See server log for query details.
CONTEXT: while updating tuple (12,41) in relation "exemplars"El graf d'espera que el gestor ha construït:
graph LR
A["Sessio A<br/>pid 41207<br/>te: fila 3081"] -->|espera fila 3095| B["Sessio B<br/>pid 41255<br/>te: fila 3095"]
B -->|espera fila 3081| A
Un cicle. Quan el detector troba un cicle, tria una transacció víctima —normalment la que menys ha treballat— i l'avorta. L'altra continua i acaba bé.
Com el detecta PostgreSQL
No comprova el cicle a cada espera: seria caríssim. Quan una transacció porta esperant més de deadlock_timeout (1 segon per omissió), i només aleshores, construeix el graf i busca cicles.
Conseqüència: un interbloqueig costa com a mínim un segon abans de resoldre's. En un sistema amb molts interbloqueigs, això sol ja és un problema de rendiment.
Per investigar-los, convé activar el registre dels bloqueigs que triguen:
Com evitar-los
| Tècnica | En què consisteix | Eficàcia |
|---|---|---|
| Ordenar sempre igual els accessos | Si diverses transaccions toquen diverses files, que totes les toquin en el mateix ordre (per exemple, exemplar_id ascendent) |
La més eficaç amb diferència |
| Transaccions curtes | Menys temps amb bloqueigs, menys finestra de col·lisió | Alta |
| Prendre els bloqueigs al principi | Bloquejar tot el necessari en començar, no anar demanant sobre la marxa | Mitjana-alta |
| Reduir la granularitat | Bloquejar files, no taules | Mitjana |
| Reintentar | Acceptar que passaran i reintentar la transacció | Imprescindible com a xarxa de seguretat |
La primera és la fonamental, i és fàcil d'aplicar. El trasllat d'exemplars reescrit:
BEGIN;
-- Bloquejar sempre en ordre ascendent d'identificador, sigui quin sigui el sentit del trasllat
SELECT exemplar_id FROM exemplars
WHERE exemplar_id IN (3081, 3095)
ORDER BY exemplar_id
FOR UPDATE;
UPDATE exemplars SET sucursal_id = 2 WHERE exemplar_id = 3081;
UPDATE exemplars SET sucursal_id = 1 WHERE exemplar_id = 3095;
COMMIT;Amb les dues sessions prenent els bloqueigs en el mateix ordre, el cicle és impossible: la segona espera la primera i acaba després. Hi ha espera, però no hi ha interbloqueig.
I sobre els reintents: un interbloqueig s'identifica pel SQLSTATE 40P01, i una fallada de serialització pel 40001. Tots dos són transitoris i reintentables. Una aplicació seriosa els captura i reintenta amb una espera creixent i una mica d'aleatorietat, fins a un màxim de tres o cinc intents.
# Esquema del patró de reintent (pseudocodi)
for intent in range(5):
try:
with connexio.transaction():
inscriure_soci(esdeveniment_id=51, soci_id=14)
break
except SerializationFailure: # 40001
esperar(0.05 * 2**intent + aleatori(0, 0.05))
except DeadlockDetected: # 40P01
esperar(0.05 * 2**intent + aleatori(0, 0.05))
else:
registrar_incidencia("No s'ha pogut inscriure després de 5 intents")
- Bloqueig optimista enfront de pessimista
Les dues estratègies generals per protegir una seqüència llegir-modificar-escriure. La diferència és en la suposició de partida.
| Pessimista | Optimista | |
|---|---|---|
| Suposició | Hi haurà conflicte | No hi haurà conflicte |
| Mecanisme | Bloquejar en llegir (FOR UPDATE) |
Detectar el canvi en escriure |
| Cost sense conflicte | Es paga sempre (esperes, bloqueigs) | Gairebé nul |
| Cost amb conflicte | Espera | Es perd la feina i cal refer-la |
| Risc | Interbloqueigs, esperes llargues | Reintents, fam si hi ha molta contenció |
| Adequat per a | Contenció alta, transaccions curtes | Contenció baixa, o si hi ha un humà pensant pel mig |
Implementar el bloqueig optimista amb una columna de versió
És el patró estàndard. S'afegeix a la taula una columna que s'incrementa a cada modificació, i l'actualització només s'aplica si la versió continua sent la que es va llegir.
El flux, aplicat a l'edició d'un esdeveniment de BiblioRed des del panell de gestió:
-- Pas 1: llegir (SENSE transacció oberta; el gestor pot trigar minuts a decidir)
SELECT esdeveniment_id, titol, places_ofertes, versio
FROM esdeveniments WHERE esdeveniment_id = 51; esdeveniment_id | titol | places_ofertes | versio
-----------------+---------------------------+----------------+--------
51 | Club de lectura de tardor | 25 | 7-- Pas 2: desar, exigint que ningú no hagi tocat res mentrestant
UPDATE esdeveniments
SET places_ofertes = 30,
versio = versio + 1
WHERE esdeveniment_id = 51
AND versio = 7;Si ningú no ha modificat l'esdeveniment:
Si un altre gestor el va modificar mentre el nostre usuari pensava:
I aquell UPDATE 0 és tota la detecció. L'aplicació mostra "un altre usuari ha modificat aquest esdeveniment; reviseu els canvis i torneu a desar" en lloc de trepitjar silenciosament la feina aliena.
Aquest patró resol el problema de l'apartat 15.2 de la lliçó 06-01: no hi ha cap transacció oberta mentre l'humà decideix. La transacció dura el que dura un UPDATE.
Per automatitzar l'increment i que ningú no se n'oblidi, un activador —dels que vam veure breument a 05-04—:
CREATE FUNCTION incrementar_versio() RETURNS TRIGGER AS $$
BEGIN
NEW.versio := OLD.versio + 1;
RETURN NEW;
END;
$$ LANGUAGE plpgsql;
CREATE TRIGGER trg_esdeveniments_versio
BEFORE UPDATE ON esdeveniments
FOR EACH ROW EXECUTE FUNCTION incrementar_versio();Compte: amb l'activador, l'aplicació ja no ha d'escriure versio = versio + 1 al seu UPDATE, però sí que ha de continuar posant AND versio = ? al WHERE. La condició és la protecció; l'increment és només comptabilitat.
Quan triar cadascun
- Optimista si entre la lectura i l'escriptura hi ha un ésser humà, o si els conflictes són rars (edició de fitxes de socis, catalogació de materials, gestió d'esdeveniments). És el que s'usa per omissió a les aplicacions web.
- Pessimista si la contenció és alta i el conflicte és la norma (el préstec de l'últim exemplar en hora punta), o si refer la feina és car.
- La solució completa: l'última plaça del club de lectura
Tornem al problema de l'apartat 6 i resolguem-lo de debò. Estat: esdeveniment 51, 25 places, 24 ocupades, dos socis inscrivint-se alhora.
Hi ha tres enfocaments correctes. Els tres funcionen; no són equivalents.
Enfocament 1: la restricció a la base de dades
La idea: convertir l'aforament en una dada d'una sola fila, amb una restricció declarada. Així el conflicte deixa de ser un fantasma i passa a ser un xoc sobre la mateixa fila, que el gestor sap resoldre.
-- Comptador desnormalitzat (amb la disciplina de 05-04) i la seva restricció
ALTER TABLE esdeveniments ADD COLUMN places_ocupades_total INTEGER NOT NULL DEFAULT 0;
ALTER TABLE esdeveniments ADD CONSTRAINT chk_aforament
CHECK (places_ocupades_total >= 0
AND places_ocupades_total <= places_ofertes);I la inscripció:
BEGIN;
-- L'increment i la comprovació passen a la mateixa instrucció atòmica
UPDATE esdeveniments
SET places_ocupades_total = places_ocupades_total + 1
WHERE esdeveniment_id = 51;
INSERT INTO inscripcions (esdeveniment_id, soci_id, data_inscripcio, estat, acompanyants, places_ocupades)
VALUES (51, 14, now(), 'confirmada', 0, 1);
COMMIT;Amb dues sessions simultànies:
| Moment | Sessió A (Marta, 14) | Sessió B (Ivan, 15) |
|---|---|---|
| t1 | BEGIN; |
BEGIN; |
| t2 | UPDATE esdeveniments SET places_ocupades_total = places_ocupades_total + 1 WHERE esdeveniment_id=51; → UPDATE 1 |
|
| t3 | mateix UPDATE → espera (la fila està bloquejada per A) |
|
| t4 | INSERT INTO inscripcions ...; COMMIT; |
(continua esperant) |
| t5 | l'UPDATE es reavalua sobre la versió nova (24→25) i falla |
A la Sessió B:
ERROR: new row for relation "esdeveniments" violates check constraint "chk_aforament" DETAIL: Failing row contains (51, Club de lectura de tardor, ..., 25, 26).
És impossible passar de 25. No importa el nivell d'aïllament, no importa el client, no importa si demà algú escriu un script que insereix a mà: la restricció és a la base de dades i es compleix sempre.
Dos detalls tècnics que convé entendre:
- L'
UPDATE ... SET x = x + 1no és llegir-modificar-escriure de l'aplicació: el gestor bloqueja la fila, llegeix el valor vigent i escriu. SotaREAD COMMITTED, quan B es desbloqueja reavalua el seuUPDATEsobre la versió més recent, així que suma sobre 25 i no sobre 24. - Sota
REPEATABLE READ, en lloc de l'error deCHECK, B obtindriacould not serialize access due to concurrent update. També correcte, però exigeix reintent.
Cost: la fila de l'esdeveniment es converteix en un punt de serialització. Totes les inscripcions a aquell esdeveniment es posen en cua sobre ella. Per a un club de lectura de 25 places és irrellevant; per vendre 60.000 entrades en dos minuts seria un coll d'ampolla.
Enfocament 2: bloqueig pessimista explícit
La idea: bloquejar la fila de l'esdeveniment abans de comptar, de manera que només una transacció alhora pugui estar decidint sobre aquell esdeveniment.
BEGIN;
-- Bloqueig de l'esdeveniment: materialitza el conflicte en una fila concreta
SELECT places_ofertes FROM esdeveniments WHERE esdeveniment_id = 51 FOR UPDATE;
-- Ara el recompte SÍ que és fiable: ningú més no pot ser aquí
SELECT coalesce(sum(places_ocupades), 0) AS ocupades
FROM inscripcions WHERE esdeveniment_id = 51 AND estat = 'confirmada';
-- si ocupades < places_ofertes:
INSERT INTO inscripcions (esdeveniment_id, soci_id, data_inscripcio, estat, acompanyants, places_ocupades)
VALUES (51, 14, now(), 'confirmada', 0, 1);
COMMIT;La Sessió B espera al seu FOR UPDATE fins al COMMIT d'A, després compta 25, veu que no hi ha lloc i rebutja netament.
Avantatges: no requereix columnes noves ni desnormalització, i la lògica d'aforament (que pot ser complexa: acompanyants, places reservades per a escolars, llista d'espera) queda en un sol lloc.
Inconvenients: la protecció viu al codi de l'aplicació. Si un altre programa, un altre equip o un script de manteniment insereix a inscripcions sense prendre el bloqueig, la garantia desapareix sense que ningú se n'assabenti. I cal recordar bloquejar sempre la mateixa fila i en el mateix ordre respecte a altres bloqueigs, o tornen els interbloqueigs de l'apartat 13.
Enfocament 3: SERIALIZABLE amb reintent
La idea: demanar al gestor la garantia completa i deixar que ell detecti el conflicte.
BEGIN ISOLATION LEVEL SERIALIZABLE;
SELECT coalesce(sum(places_ocupades), 0)
FROM inscripcions WHERE esdeveniment_id = 51 AND estat = 'confirmada';
INSERT INTO inscripcions (esdeveniment_id, soci_id, data_inscripcio, estat, acompanyants, places_ocupades)
VALUES (51, 14, now(), 'confirmada', 0, 1);
COMMIT;Amb les dues sessions entrellaçades com a l'apartat 6, la primera a confirmar té èxit i la segona rep, al COMMIT:
ERROR: could not serialize access due to read/write dependencies among transactions DETAIL: Reason code: Canceled on identification as a pivot, during commit attempt. HINT: The transaction might succeed if retried.
El motor SSI de PostgreSQL ha detectat que B va llegir un conjunt de files que A va modificar, i que el resultat combinat no correspon a cap execució en sèrie. Avorta B.
Avantatges: és l'única solució que resol tots els fenòmens alhora, inclòs el biaix d'escriptura de l'apartat 7. El codi de l'aplicació s'escriu com si no hi hagués concurrència, cosa que és enormement més fàcil de raonar.
Inconvenients:
- Obliga a implementar reintents. Sense ells, l'usuari veu un error críptic.
- Té cost: el gestor rastreja les dependències de lectura/escriptura de cada transacció.
- L'error arriba al
COMMIT, quan ja s'ha fet tota la feina. - I, com l'enfocament 2, no protegeix d'un script que usi un altre nivell d'aïllament.
Comparació i recomanació
| Criteri | 1. Restricció a la base | 2. Bloqueig pessimista | 3. SERIALIZABLE |
|---|---|---|---|
| Protegeix davant de qualsevol client? | Sí | No | No |
| Requereix canviar l'esquema? | Sí (comptador + CHECK) |
No | No |
| Requereix reintents? | No (sota READ COMMITTED) |
No | Sí |
| Resol el biaix d'escriptura? | Només el cas modelat | Només si es bloqueja bé | Sí, en general |
| Concurrència | Serialitza sobre una fila | Serialitza sobre una fila | Màxima mentre no hi hagi conflicte |
| Cost de manteniment | Un comptador desnormalitzat per mantenir | Disciplina a tot el codi | Lògica de reintent |
| Claredat de l'error | Excel·lent (chk_aforament) |
Bona | Críptica sense traducció |
Recomanació raonada per a BiblioRed: l'enfocament 1, complementat amb el 3 on calgui.
L'argument decisiu és el de la primera fila. Una regla de negoci tan dura com "no hi caben més de 25 persones" no pot dependre que tots els programes que toquin la base de dades, avui i d'aquí a cinc anys, recordin prendre un bloqueig o demanar un nivell d'aïllament. La restricció chk_aforament és a l'esquema, es compleix sempre, i a més es documenta sola: qui llegeixi la definició d'esdeveniments veurà la regla escrita. És el mateix argument de l'apartat 21 de la lliçó 04-04 sobre quines regles van a la base de dades.
El comptador places_ocupades_total és una desnormalització —cal mantenir-lo coherent amb inscripcions, amb les cauteles de 05-04, inclosa la comprovació periòdica que quadra—, i aquell és el seu preu. Es paga de gust.
I per a les regles que no es poden expressar com a restricció d'una fila —el "almenys un ponent" de l'apartat 7, que abasta diverses files d'una altra taula— s'usa l'enfocament 3 amb reintent, perquè és l'únic que les cobreix.
- Fora de PostgreSQL: SQLite i el retorn del problema a NoSQL
SQLite
La concurrència és la diferència més gran entre SQLite i un servidor de bases de dades, i convé tenir-la clara per no triar malament.
| Aspecte | SQLite |
|---|---|
| Escriptors simultanis | Un de sol a tot el fitxer |
| Mode per omissió (rollback journal) | Un escriptor exclou també els lectors |
Mode WAL (PRAGMA journal_mode=WAL) |
Els lectors continuen treballant durant l'escriptura; continua havent-hi un sol escriptor |
| Nivells d'aïllament | A la pràctica, SERIALIZABLE: no hi ha entrellaçat d'escriptures per protegir |
| Error de contenció | SQLITE_BUSY (database is locked) |
| Mitigació | PRAGMA busy_timeout = 5000; i BEGIN IMMEDIATE |
El BEGIN IMMEDIATE mereix una nota: a SQLite, un BEGIN normal és diferit i no pren el bloqueig d'escriptura fins a la primera escriptura, cosa que produeix el clàssic "llegeixo, decideixo, escric i em diuen que la base està ocupada" amb la feina ja feta. Si la transacció escriurà, comença amb BEGIN IMMEDIATE i prendràs el bloqueig des del principi.
La conclusió pràctica: SQLite és excel·lent amb un escriptor i molts lectors. Per als quatre taulells de BiblioRed escrivint alhora, no. Per a l'aplicació d'inventari en una tauleta, perfecta.
El problema no desapareix a NoSQL: canvia de forma
Recordaràs de 03-04 que la consistència eventual és el preu de la disponibilitat i la tolerància a particions. El que aquí convé afegir és que la consistència eventual no elimina el problema de l'última plaça: l'agreuja.
| Escenari | A PostgreSQL | En un sistema distribuït eventualment consistent |
|---|---|---|
| Dues inscripcions simultànies | Una restricció o un nivell d'aïllament les ordena | Es poden aplicar en nodes diferents que encara no es coneixen |
| Detecció del conflicte | En el moment, amb error | Després, en reconciliar |
| Resolució | La transacció falla i es reintenta | "L'últim a escriure guanya", o una funció de barreja que cal programar |
Les eines canvien de nom però responen a les mateixes idees:
- El quòrum de lectura i escriptura (
w+r>n) és una manera de comprar consistència en un sistema distribuït, anàloga a pujar el nivell d'aïllament. - El
writeConcern: {w: "majority"}de MongoDB és la petició explícita d'aquella garantia. - Les operacions atòmiques de document (
$inc,findAndModifyamb condició) són l'equivalent de l'UPDATE ... SET x = x + 1 WHERE ...de l'enfocament 1: la comprovació i l'escriptura en una sola operació indivisible.
// Equivalent conceptual de l'enfocament 1 a MongoDB
db.esdeveniments.findOneAndUpdate(
{ _id: 51, places_ocupades_total: { $lt: 25 } },
{ $inc: { places_ocupades_total: 1 } }
)
// Torna null si ja no hi cabia ningú: aquí hi ha la deteccióLa lliçó de fons, que convé endur-se: la concurrència no és un problema de les bases de dades relacionals, és un problema de la realitat. Canviar de tecnologia no l'elimina; canvia les eines amb què s'afronta i, gairebé sempre, trasllada més responsabilitat al codi de l'aplicació.
Errors Habituals i Consells
Comptar abans d'inserir i creure que això protegeix. És l'error d'aquesta lliçó. Entre el SELECT count(*) i l'INSERT hi cap una transacció sencera. Si la regla és dura, expressa-la com a restricció.
No comprovar el nombre de files afectades. UPDATE ... WHERE estat='disponible' que torna UPDATE 0 està dient "algú se m'ha avançat". Ignorar-ho converteix una protecció perfecta en una decoració.
Usar REPEATABLE READ sense lògica de reintent. En READ COMMITTED un conflicte d'escriptura espera i continua; en REPEATABLE READ avorta. Pujar el nivell sense reintents canvia un error silenciós per un error sorollós, que és millor, però continua sent un error visible per a l'usuari.
Creure que SERIALIZABLE és "el nivell segur i prou". És segur i obliga a reintentar. Sense reintents no és més segur: només falla més.
Donar per fet que el nivell per omissió és el més estricte. És READ COMMITTED, el segon més feble. Comprova-ho amb SHOW transaction_isolation; abans de raonar sobre res.
Mantenir oberta una transacció mentre un humà decideix. Bloqueja files, impedeix el VACUUM i no arregla res que el bloqueig optimista no arregli millor.
Accedir a les mateixes files en ordre diferent en llocs diferents del codi. És la recepta de l'interbloqueig. Adopta un ordre canònic —per clau primària ascendent— i respecta'l a tot arreu.
Diagnosticar un interbloqueig com una fallada de la base de dades. No ho és: la base de dades ha fet exactament el que havia de fer en detectar-lo i trencar el cicle. La fallada és a l'ordre d'accés del codi.
Ignorar el VACUUM fins que fa mal. Vigila n_dead_tup i last_autovacuum a pg_stat_user_tables, i persegueix les transaccions idle in transaction llargues, que són la causa habitual que autovacuum no pugui fer la seva feina.
Usar VACUUM FULL en horari de servei. Pren un bloqueig ACCESS EXCLUSIVE: bloqueja fins i tot els SELECT. És una operació de finestra de manteniment.
Consell final: reprodueix sempre la fallada abans d'arreglar-la. Tots els fenòmens d'aquesta lliçó es provoquen amb dos terminals psql en menys d'un minut. Un error de concurrència que no saps reproduir és un error que no saps si has arreglat.
Exercicis
Exercici 1: Identificar el fenomen i proposar la defensa
Per a cadascuna d'aquestes tres situacions reals de BiblioRed, indica quin fenomen dels cinc estudiats s'està produint, per què el nivell READ COMMITTED no l'evita, i quina és la defensa més adequada.
(a) L'informe mensual de direcció mostra "312 préstecs vençuts" al titular i, tres pàgines més avall, un desglossament per sucursal que suma 315.
(b) El procés nocturn que trasllada exemplars poc usats de Centre a Sud, i un altre que els porta de Sud a Centre, es queden penjats i un dels dos mor amb un error al cap d'un segon.
(c) La sucursal Est ha de conservar sempre almenys dos exemplars del material 907 (bibliografia escolar). En té tres. Dos bibliotecaris tramiten alhora un trasllat d'un exemplar cadascun a altres sucursals; tots dos comproven que en quedarien dos i tots dos confirmen. Al final en queda un.
Exercici 2: Reproduir i arreglar l'actualització perduda
Fent servir dos terminals psql, reprodueix el préstec doble de l'exemplar EJ-3081 de l'apartat 3. Després, reescriu l'operació de préstec de manera que sigui impossible que dos taulells prestin el mateix exemplar, sense canviar el nivell d'aïllament i sense afegir columnes noves. Escriu la transacció completa i indica què ha de comprovar l'aplicació i quin missatge ha de mostrar a l'operari.
Exercici 3: Triar l'enfocament d'aforament
La biblioteca vol admetre acompanyants als esdeveniments: un soci es pot inscriure amb fins a 3 acompanyants, i la columna inscripcions.places_ocupades recull el total de places que consumeix aquella inscripció (1 + acompanyants). A més, cada esdeveniment reserva 5 de les seves places per a grups escolars, que no poden ocupar els socis.
Amb aquestes dues regles noves, decideix quin dels tres enfocaments de l'apartat 15 faries servir, justifica-ho, i escriu el SQL de la solució.
Solucions
Solució 1
(a) Lectura no repetible.
L'informe executa dues consultes diferents dins de la mateixa sessió. Sota READ COMMITTED, cada instrucció pren una foto nova, així que la segona consulta veu tres préstecs que van vèncer —o que es van registrar— entre l'una i l'altra. Cada xifra és correcta en el seu instant; juntes són incoherents.
Defensa: embolcallar l'informe en una transacció amb foto estable.
BEGIN ISOLATION LEVEL REPEATABLE READ READ ONLY;
SELECT count(*) FROM prestecs WHERE data_devolucio IS NULL
AND data_devolucio_prevista < CURRENT_DATE;
SELECT e.sucursal_id, count(*) FROM prestecs p
JOIN exemplars e ON e.exemplar_id = p.exemplar_id
WHERE p.data_devolucio IS NULL AND p.data_devolucio_prevista < CURRENT_DATE
GROUP BY e.sucursal_id;
COMMIT;Per a un informe llarg, SERIALIZABLE READ ONLY DEFERRABLE és encara millor: garanteix que no avortarà a la meitat.
(b) Interbloqueig.
Dues transaccions que actualitzen el mateix conjunt d'exemplars en ordre invers. READ COMMITTED no hi té res a veure: els interbloqueigs són independents del nivell d'aïllament, perquè sorgeixen de l'ordre d'adquisició de bloqueigs, no de la visibilitat. L'error al cap d'un segon és exactament deadlock_timeout fent la seva feina.
Defensa: ordre canònic d'accés. Que els dos processos bloquegin els exemplars afectats amb SELECT ... FOR UPDATE ... ORDER BY exemplar_id al principi de la transacció, sigui quin sigui el sentit del trasllat. I reintent davant de 40P01 com a xarxa de seguretat.
(c) Biaix d'escriptura.
Els dos bibliotecaris llegeixen el mateix conjunt (els tres exemplars del material 907 a Est), cadascun decideix que en pot moure un de diferent, i cadascun escriu en una fila diferent. No hi ha conflicte de files, així que cap mecanisme basat en files no ho detecta. Ni READ COMMITTED ni REPEATABLE READ no ho eviten: és el fenomen de l'apartat 7.
Defensa, per ordre de preferència:
- Materialitzar el conflicte: bloquejar una fila comuna abans de decidir, per exemple la del material o la de la sucursal.
BEGIN; SELECT 1 FROM materials WHERE material_id = 907 FOR UPDATE; SELECT count(*) FROM exemplars WHERE material_id = 907 AND sucursal_id = 4 AND estat <> 'baixa'; -- si count > 2, traslladar COMMIT; SERIALIZABLEamb reintent, que ho detecta en general sense haver d'anticipar el cas.
Un CHECK no serveix aquí: la regla compta files d'una altra taula, i això queda fora del que una restricció declarativa de fila pot expressar.
Solució 2
La reproducció és la taula de l'apartat 3. La solució sense canviar nivell ni esquema, amb bloqueig pessimista sobre l'exemplar:
BEGIN;
-- 1) Bloquejar l'exemplar i llegir-ne l'estat real
SELECT estat
FROM exemplars
WHERE exemplar_id = 3081
FOR UPDATE;
-- Si torna 'disponible' → continuar. Si torna una altra cosa → ROLLBACK i avisar.
-- 2) Registrar el préstec
INSERT INTO prestecs (soci_id, exemplar_id, data_prestec, data_devolucio_prevista)
VALUES (14, 3081, CURRENT_DATE, CURRENT_DATE + 21);
-- 3) Marcar l'exemplar
UPDATE exemplars SET estat = 'prestat' WHERE exemplar_id = 3081;
COMMIT;La segona sessió espera al FOR UPDATE fins al COMMIT de la primera, i aleshores llegeix prestat, així que avorta amb ROLLBACK.
Una variant encara millor, que no necessita ni tan sols que l'aplicació comprovi res abans:
BEGIN;
UPDATE exemplars
SET estat = 'prestat'
WHERE exemplar_id = 3081
AND estat = 'disponible';
-- L'aplicació comprova les files afectades: si són 0 → ROLLBACK
INSERT INTO prestecs (soci_id, exemplar_id, data_prestec, data_devolucio_prevista)
VALUES (14, 3081, CURRENT_DATE, CURRENT_DATE + 21);
COMMIT;A la sessió perdedora:
Què ha de comprovar l'aplicació: el nombre de files afectades per l'UPDATE. Si és 0, executar ROLLBACK.
Què ha de mostrar a l'operari: un missatge que reflecteixi la realitat i li digui què fer. Per exemple: "L'exemplar EJ-3081 ja no està disponible: un altre taulell l'ha prestat fa uns segons. Consulteu altres exemplars del mateix títol o creeu una reserva." Res de "Error de base de dades" ni de codis numèrics: qui és al taulell té un soci al davant.
Nota addicional: la segona variant és preferible a la primera perquè fa la comprovació i l'escriptura en una sola instrucció, sosté el bloqueig menys temps i no depèn que l'aplicació recordi comparar l'estat llegit.
Solució 3
Enfocament triat: l'1, la restricció a la base de dades, adaptat a les dues regles noves.
Justificació: les dues regles noves —places per acompanyants i reserva escolar— fan la lògica d'aforament més complexa, i per tant més fàcil d'implementar malament en algun punt del codi. Com més complicada és una regla, més arguments hi ha perquè visqui en un sol lloc i no a cada programa que insereixi inscripcions. A més, totes dues regles continuen expressant-se sobre valors d'una sola fila d'esdeveniments, que és just el que un CHECK pot comprovar.
-- Columna per a les places reservades a grups escolars
ALTER TABLE esdeveniments
ADD COLUMN places_reservades_escolars INTEGER NOT NULL DEFAULT 0;
-- Comptador de places consumides per socis
ALTER TABLE esdeveniments
ADD COLUMN places_ocupades_total INTEGER NOT NULL DEFAULT 0;
-- Regla d'aforament completa, en una sola restricció amb nom
ALTER TABLE esdeveniments ADD CONSTRAINT chk_aforament_socis
CHECK (places_ocupades_total >= 0
AND places_ocupades_total <= places_ofertes - places_reservades_escolars);
-- Coherència de la reserva escolar
ALTER TABLE esdeveniments ADD CONSTRAINT chk_reserva_escolar
CHECK (places_reservades_escolars BETWEEN 0 AND places_ofertes);
-- Límit d'acompanyants, a la mateixa inscripció
ALTER TABLE inscripcions ADD CONSTRAINT chk_acompanyants
CHECK (acompanyants BETWEEN 0 AND 3);
ALTER TABLE inscripcions ADD CONSTRAINT chk_places_ocupades
CHECK (places_ocupades = acompanyants + 1);La inscripció de la Marta amb dos acompanyants:
BEGIN;
UPDATE esdeveniments
SET places_ocupades_total = places_ocupades_total + 3 -- ella + 2 acompanyants
WHERE esdeveniment_id = 51;
INSERT INTO inscripcions (esdeveniment_id, soci_id, data_inscripcio, estat, acompanyants, places_ocupades)
VALUES (51, 14, now(), 'confirmada', 2, 3);
COMMIT;Si l'esdeveniment té 25 places, 5 reservades a escolars i ja n'hi ha 18 ocupades per socis, l'operació falla perquè 18 + 3 > 20:
ERROR: new row for relation "esdeveniments" violates check constraint "chk_aforament_socis" DETAIL: Failing row contains (51, Club de lectura de tardor, ..., 25, 5, 21).
Observacions sobre la solució:
chk_places_ocupadesgaranteix que la columna desnormalitzadaplaces_ocupadesno es pugui desviar d'acompanyants. És la disciplina de 05-04 aplicada: una columna calculada ha de portar la seva comprovació al costat. De fet, aquí encara seria millor declarar-la com a columna generada (GENERATED ALWAYS AS (acompanyants + 1) STORED), com vam veure a 04-04.- El nom de la restricció importa:
chk_aforament_socisapareix literalment al missatge d'error, i l'aplicació el pot traduir a "No queden places suficients per a vostè i els seus acompanyants". - Continua sent necessària una comprovació periòdica que
places_ocupades_totalquadri amb la suma real d'inscripcionsconfirmades, exactament com es va explicar a 05-04 per a les taules de resum. - Si en el futur aparegués una regla que abastés diverses files d'altres taules —per exemple, "un soci no pot estar inscrit a dos esdeveniments que se solapin"—, aquella ja no cabria en un
CHECK, i caldria anar a l'enfocament 3 ambSERIALIZABLEi reintent. Convé saber on és aquella frontera.
Conclusió
Aquesta lliçó ha demostrat una cosa incòmoda: el codi correcte per a un usuari pot ser codi trencat per a dos. Les tres avaries amb què vam començar —l'exemplar prestat dues vegades, les 26 persones en un club de 25 cadires, l'esdeveniment publicat sense ponents— no venien d'errors de programació en el sentit habitual. Venien d'una suposició implícita que el món real no respecta: que entre llegir i escriure no passa res.
Hem provocat els cinc fenòmens amb dos terminals i els hem entès de dins cap enfora. L'actualització perduda, la defensa més barata de la qual és posar la condició dins del mateix UPDATE i mirar quantes files s'han vist afectades. La lectura bruta, que a PostgreSQL simplement no pot passar. La lectura no repetible, que arruïna tots els informes de més d'una consulta i es cura amb una línia. La lectura fantasma, que ensenya la lliçó més important de totes: comptar abans d'inserir no protegeix de res, perquè no es pot bloquejar una fila que encara no existeix. I el biaix d'escriptura, que passa en nivells alts d'aïllament, no el detecta cap mecanisme basat en files, i només es resol amb SERIALIZABLE o materialitzant el conflicte a mà.
Hem vist la taula canònica dels quatre nivells i —més útil encara— la taula del que PostgreSQL fa en realitat: que no té READ UNCOMMITTED de debò, que el seu REPEATABLE READ és snapshot isolation i prohibeix fantasmes que l'estàndard permet, que el seu nivell per omissió és el segon més feble, i que el salt de garantia real és a SERIALIZABLE, que es paga amb transaccions avortades i, per tant, amb lògica de reintent obligatòria.
Després hem baixat a la maquinària: els bloqueigs compartits i exclusius, la granularitat i el fet que PostgreSQL bloqueja files i mai no escala; el bloqueig en dues fases, que explica per què una transacció llarga és un problema col·lectiu; els bloqueigs explícits, amb FOR UPDATE per protegir una lectura, NOWAIT per respondre ràpid en lloc de congelar la pantalla i SKIP LOCKED per repartir una cua de treball entre diversos processos. Hem obert el MVCC fins a veure el xmin, el xmax i el ctid canviant davant dels nostres ulls, i n'hem entès la factura: les versions mortes, el VACUUM que les neteja i el que passa quan no arriba a temps. Hem provocat un interbloqueig, llegit el seu missatge real, dibuixat el seu graf d'espera i après que la defensa fonamental cap en una frase: accedeix sempre a les files en el mateix ordre.
I hem resolt el problema de l'última plaça tres vegades, per descobrir que les tres solucions funcionen i només una protegeix de debò. El bloqueig pessimista i el SERIALIZABLE viuen al codi de l'aplicació, i per tant es perden tan bon punt un altre programa toqui la base de dades. La restricció chk_aforament viu a l'esquema, es compleix sempre i es documenta sola. És la mateixa conclusió a què vam arribar a 04-04 i a 05-04, i ja la podem enunciar com a principi: una regla que no es pot trencar ha d'estar on no es pugui saltar.
Amb això, el sistema de BiblioRed ja és correcte sota concurrència. Falta que sigui ràpid. Perquè hi ha un problema pendent des de la primera línia del mòdul: el llistat de préstecs vençuts per sucursal, aquell mateix que acabem de fer servir als exercicis, triga catorze segons des que la taula prestecs va superar els dos milions de files. I catorze segons en un taulell amb un soci al davant és una eternitat.
La lliçó 06-03, Índexs i Optimització de Consultes, és la que arregla això —i és la lliçó a la qual 05-04 ens va remetre explícitament quan vam dir que els índexs són el primer que cal provar abans de desnormalitzar. Veurem què és un índex i com un B-tree converteix milions de comparacions en quatre lectures; què costa un índex, perquè no són gratis i per això no s'indexa tot; els índexs únics, compostos, parcials i d'expressió, amb la regla del prefix més a l'esquerra que explica per què l'ordre de les columnes ho canvia tot; què s'indexa i què no, inclòs l'advertiment que PostgreSQL no indexa sol les claus foranes; com es llegeix un pla d'execució d'EXPLAIN ANALYZE línia a línia i què significa que les files estimades no s'assemblin a les reals; i el cas complet d'aquells catorze segons, amb el seu pla abans, el seu diagnòstic, l'índex que ho arregla i el seu pla després.
Fonaments de Bases de Dades
Mòdul 1: Introducció a les Bases de Dades
- Conceptes Bàsics de Bases de Dades
- Tipus de Bases de Dades
- Història i Evolució de les Bases de Dades
- Sistemes Gestors de Bases de Dades i Arquitectura
Mòdul 2: Bases de Dades Relacionals
- Model Relacional
- Llenguatge SQL
- Operacions Bàsiques en SQL
- Consultes Multitaula: JOIN i Subconsultes
- Agregació i Agrupació de Dades
- Integritat Referencial
Mòdul 3: Bases de Dades No Relacionals
- Introducció a NoSQL
- Tipus de Bases de Dades NoSQL
- Modelatge de Dades en NoSQL
- Comparació entre Bases de Dades Relacionals i No Relacionals
Mòdul 4: Disseny d'Esquemes
- Principis de Disseny d'Esquemes
- Diagrames Entitat-Relació (ER)
- Transformació de Diagrames ER a Esquemes Relacionals
- Tipus de Dades i Restriccions
Mòdul 5: Normalització
Mòdul 6: Transaccions, Rendiment i Seguretat
- Transaccions i Propietats ACID
- Concurrència i Nivells d'Aïllament
- Índexs i Optimització de Consultes
- Seguretat, Permisos i Còpies de Seguretat
Mòdul 7: Exercicis Pràctics
- Exercicis de SQL
- Exercicis de Disseny d'Esquemes
- Exercicis de Normalització
- Exercicis de Consultes Avançades i Transaccions
Mòdul 8: Casos d'Estudi
- Cas d'Estudi: Base de Dades Relacional
- Cas d'Estudi: Base de Dades No Relacional
- Cas d'Estudi: Persistència Poliglota
