HDFS desa els esdeveniments massius i MinIO serveix les fotos, però cap dels dos no pot respondre en pocs mil·lisegons a "les últimes comandes de l'Anna" ni descomptar dues unitats de formatge-curat sense vendre la mateixa peça dues vegades. Per a això hi ha les bases de dades, i quan una sola màquina ja no n'hi ha prou, les bases de dades distribuïdes. Aquesta lliçó reuneix tot el que el Mòdul 3 i les lliçons anteriors han preparat: el particionament i el hashing consistent de 04-01, la replicació i els quòrums de 03-04, els models de consistència i la taula CAP de 03-02, i les transaccions de 03-05. Veurem els tres camins que existeixen per distribuir una base de dades (relacional escalada, NoSQL d'estil Dynamo i documental), els compararem, i prendrem la decisió més important del mòdul per a Quilòmetre Zero: comandes migra a Cassandra i inventari es queda a PostgreSQL. Després ho farem real: Cassandra a docker-compose.yml, el keyspace km0_comandes, les taules comandes_per_client i comandes_per_id, i un repositori Python que tria el nivell de consistència a cada operació.
Contingut
- Què és una base de dades distribuïda i les seves transparències
- Fragmentació horitzontal i vertical
- Camí (a): la base de dades relacional escalada i NewSQL
- Camí (b): NoSQL d'estil Dynamo amb Cassandra
- Modelatge orientat a consultes a Cassandra
- Camí (c): bases de dades documentals amb MongoDB
- Taula comparativa
- La decisió de Quilòmetre Zero
- Pràctica: Cassandra a
docker-compose.ymlirepositori_cassandra.py - Errors Comuns i Consells
- Exercicis
- Conclusió
- Què és una base de dades distribuïda i les seves transparències
Una base de dades distribuïda és una col·lecció de dades lògicament relacionades, físicament repartides entre diversos nodes connectats per xarxa, que es presenta a les aplicacions com una sola base de dades. La definició té dues meitats: el repartiment físic (que ja sabem fer amb particions i rèpliques) i la il·lusió d'unitat, que és de nou la llista de transparències de 01-01 aplicada a les dades:
| Transparència | Què vol dir | Fins a quin punt l'ofereix cada camí |
|---|---|---|
| De fragmentació | L'aplicació consulta la taula comandes, no la partició 7 |
Total a Cassandra i Spanner; parcial en sharding per aplicació (l'aplicació tria el shard) |
| De replicació | L'aplicació no sap quantes còpies hi ha ni quina llegeix | Total, encara que el nivell de consistència el pot triar l'aplicació |
| D'ubicació | Cap consulta no esmenta un node | Total amb un router o un driver intel·ligent (04-01, apartat 9) |
| De fallada | La caiguda d'un node no interromp el servei | Depèn del factor de replicació i del nivell de consistència |
| De transacció | Una operació sobre diversos nodes és atòmica | Total a NewSQL (amb cost); limitada a una partició a Cassandra i MongoDB (o amb transaccions multidocument més lentes) |
L'última fila és la que separa els camins. Mantenir la transparència de transacció entre nodes exigeix 2PC o consens (03-03, 03-05) a cada escriptura que creui particions, i cada camí decideix fins a quin punt ho permet.
- Fragmentació horitzontal i vertical
En el vocabulari clàssic de les bases de dades distribuïdes, particionar una taula s'anomena fragmentar:
- Fragmentació horitzontal: cada fragment té un subconjunt de les files, amb totes les columnes. És exactament el particionament de 04-01 (per rang, hash o compost) aplicat a una taula, i en l'argot NoSQL s'anomena sharding. Les comandes de l'Anna en un node, les d'en Marc en un altre.
- Fragmentació vertical: cada fragment té un subconjunt de les columnes (amb la clau primària repetida). Les columnes de facturació d'una comanda en un node, les de repartiment en un altre. És poc freqüent entre nodes d'una mateixa base de dades, però és precisament el que va fer Quilòmetre Zero en dividir el monòlit:
comandes,inventari,pagamentsirepartimentsón fragments verticals de l'antic esquema, cadascun propietari de les seves columnes, ambcomanda_idcom a clau compartida i sense joins entre serveis (01-06). - Fragmentació mixta: vertical entre serveis, horitzontal dins de cadascun. És la situació real de la plataforma:
km0_comandesés un fragment vertical que al seu torn es particiona horitzontalment perclient_id.
Tres regles d'una bona fragmentació (Özsu i Valduriez): completesa (tota fila o columna és en algun fragment), reconstrucció (la taula original es pot recompondre amb unions) i disjunció (una fila és en un sol fragment, llevat de la clau a la vertical). La replicació relaxa la tercera a propòsit.
- Camí (a): la base de dades relacional escalada i NewSQL
El primer camí conserva SQL, el model relacional i les transaccions ACID, i hi afegeix distribució per capes:
Rèpliques de lectura. Ho vam muntar a 03-04: comandes-db-primari rep totes les escriptures i comandes-db-replica (i les que s'afegeixin) serveixen lectures amb replicació en streaming. Escala les lectures, no les escriptures ni la mida, i porta amb si la consistència eventual a les rèpliques (llegir la teva pròpia escriptura exigeix anar al primari o esperar l'LSN).
Sharding per aplicació. Quan les escriptures o el volum desborden un primari, l'aplicació reparteix les files entre diverses instàncies PostgreSQL independents amb la lògica de 04-01: shard = anell.node_per(client_id), una connexió per shard, i l'aplicació sap a quin shard és cada dada. És el que van fer durant anys Instagram, Notion o Shopify. Funciona, però la transparència de fragmentació desapareix: els joins entre shards es fan a l'aplicació, les transaccions entre shards no existeixen (o són sagues, 03-05), reequilibrar shards és un projecte en si mateix, i cada nou cas d'ús ha de respectar la clau de partició.
Citus. Extensió de PostgreSQL que automatitza aquest sharding: un node coordinador rep les consultes SQL normals, i les taules "distribuïdes" (SELECT create_distributed_table('comandes', 'client_id')) es reparteixen per hash en nodes treballadors. El coordinador reescriu cada consulta en subconsultes per shard i combina resultats; les transaccions que toquen un sol shard són locals, i les que en toquen diversos fan servir 2PC entre treballadors (03-05, amb les seves latències). És el punt mitjà: SQL i PostgreSQL amb repartiment automàtic, ideal per a càrregues multiinquilí on gairebé totes les consultes inclouen la clau de distribució.
NewSQL. Bases de dades dissenyades des de zero per ser distribuïdes sense perdre SQL ni ACID:
| Sistema | Idea central | Consistència | Cost |
|---|---|---|---|
| Google Spanner | Particions replicades amb Paxos; transaccions globals amb 2PC sobre Paxos; TrueTime (rellotges atòmics i GPS amb incertesa acotada) per ordenar commits globalment (reprèn 01-05) | Serialitzabilitat externa (linealitzable i serialitzable) | Latència de commit = quòrum entre regions (desenes de ms); maquinari especial |
| CockroachDB | Rangs de claus replicats amb Raft (03-03); transaccions distribuïdes amb 2PC optimitzat; rellotges híbrids (HLC) sense maquinari especial | Serialitzable | Escriptures amb latència de consens; les lectures poden requerir esperes per incertesa de rellotge |
| YugabyteDB | Tablets replicats amb Raft; capa SQL compatible amb PostgreSQL (en reutilitza el parser) | Snapshot isolation o serialitzable | Similar a CockroachDB |
NewSQL és CP amb transaccions (03-02: tria consistència a la partició i consistència sobre latència en operació normal). El que es paga és latència per escriptura (un consens per rang, més 2PC si n'hi ha diversos) i complexitat operativa. És la resposta correcta quan calen transaccions entre particions i SQL complet a escala; no és necessària per a la majoria de serveis de Quilòmetre Zero, que ja han renunciat a les transaccions entre dominis amb les sagues.
- Camí (b): NoSQL d'estil Dynamo amb Cassandra
El 2007 Amazon va publicar el disseny de Dynamo, el seu magatzem clau-valor per al cistell de la compra, que optimitzava per disponibilitat i latència d'escriptura: sense líder, hashing consistent, quòrums configurables, vectors de versió i reparació en lectura. Cassandra (Facebook, 2008; avui Apache) va combinar l'arquitectura de Dynamo amb el model de dades de famílies de columnes de Bigtable. Riak, Voldemort i ScyllaDB pertanyen a la mateixa família. Tot el que segueix és la posada en pràctica de 04-01 i 03-04:
Anell amb hashing consistent. Cada node posseeix rangs de tokens de l'anell de Murmur3 (−2⁶³ a 2⁶³−1); amb num_tokens: 16 (Cassandra 4) cada node té 16 vnodes. La clau de partició es hasheja i el token resultant determina el node coordinador d'aquesta partició i les seves rèpliques: les següents N−1 posicions diferents de l'anell (saltant vnodes del mateix node i, amb NetworkTopologyStrategy, repartint entre racks i centres de dades).
flowchart TB
subgraph anell["Anell km0_comandes · RF=3 · 6 nodes"]
direction LR
n1(("node1<br/>rack1"))
n2(("node2<br/>rack2"))
n3(("node3<br/>rack1"))
n4(("node4<br/>rack2"))
n5(("node5<br/>rack1"))
n6(("node6<br/>rack2"))
n1 --> n2 --> n3 --> n4 --> n5 --> n6 --> n1
end
k["partició client_id = 'anna'<br/>token = 0x3F… → cau entre node2 i node3"] -.-> n3
n3 -. "rèplica 1" .- r1[" "]
n4 -. "rèplica 2 (altre rack)" .- r2[" "]
n5 -. "rèplica 3" .- r3[" "]
style r1 fill:none,stroke:none
style r2 fill:none,stroke:none
style r3 fill:none,stroke:none
Sense líder. Qualsevol node accepta qualsevol petició (opció "qualsevol node" de 04-01): el node que la rep actua com a coordinador, reenvia l'escriptura a les N rèpliques de la partició i espera les confirmacions que exigeixi el nivell de consistència. No hi ha elecció de líder ni failover: si una rèplica està caiguda, les altres continuen acceptant escriptures i el coordinador desa un hinted handoff (03-04) per lliurar-l'hi quan torni.
Factor de replicació i estratègia. Es fixen per keyspace: SimpleStrategy (rèpliques als nodes següents de l'anell, només per a proves) o NetworkTopologyStrategy amb un factor per centre de dades ({'dc-bcn': 3, 'dc-vlc': 3}), que a més reparteix les rèpliques entre racks. Canviar el factor exigeix nodetool repair perquè les noves rèpliques s'omplin.
Nivells de consistència per operació. És l'aportació més elegant de Cassandra: cada lectura i cada escriptura tria quantes rèpliques han de respondre. Amb N = 3:
| Nivell | Escriptura: confirma quan… | Lectura: respon amb… | W+R>N amb RF=3 |
|---|---|---|---|
ONE |
1 rèplica ha escrit (al commit log i la memtable) | La primera rèplica que respon | ONE + ONE = 2 ≤ 3: pot llegir dades velles |
QUORUM |
⌊N/2⌋+1 = 2 rèpliques | 2 rèpliques, es retorna la més recent (per marca de temps) | QUORUM + QUORUM = 4 > 3: lectura forta |
ALL |
Les 3 | Les 3 | Màxima consistència, disponibilitat mínima: un node caigut bloqueja |
LOCAL_QUORUM |
Quòrum dins del centre de dades local | Ídem | Forta dins del DC, sense esperar l'altra regió |
ANY |
Qualsevol node, fins i tot només un hint | — | Màxima disponibilitat d'escriptura, sense garantia de lectura |
SERIAL / LOCAL_SERIAL |
Transaccions lleugeres (IF NOT EXISTS) amb Paxos |
Llegeix l'estat després de l'últim Paxos | Linealitzable per partició, molt més lent |
És exactament el W + R > N de 03-04 amb quorum_wr.py, però decidit a cada operació: la posició de furgoneta-3 s'escriu amb ONE (ràpid, AP), la confirmació d'una comanda amb QUORUM (consistent, CP), i la lectura que mostra a l'Anna la seva comanda acabada de confirmar amb QUORUM perquè vegi el que acaba d'escriure. Les lectures fan read repair quan les rèpliques discrepen, i nodetool repair (antientropia amb arbres de Merkle) reconcilia en segon pla.
Escriptura a disc. Una escriptura va al commit log (seqüencial, durador) i a la memtable en memòria; quan la memtable s'omple, s'aboca a un SSTable immutable a disc. Les SSTables es compacten periòdicament fusionant versions. Aquest disseny (LSM-tree) fa que les escriptures siguin seqüencials i baratíssimes, que és la raó per la qual Cassandra és l'elecció clàssica per a càrregues d'escriptura intensiva; les lectures poden haver de consultar diverses SSTables (mitigat amb filtres de Bloom i memòries cau).
Tombstones. Com que les SSTables són immutables, esborrar no esborra: escriu una làpida (tombstone) amb marca de temps que oculta el valor. La làpida s'elimina a la compactació després de gc_grace_seconds (10 dies per defecte), temps que ha de superar la reparació de qualsevol rèplica que estigués caiguda; si no, la rèplica caiguda "ressuscitaria" la dada esborrada en reconciliar. Moltes làpides en una partició (esborrats massius, cues implementades a Cassandra) degraden greument les lectures: és l'antipatró més famós de Cassandra.
- Modelatge orientat a consultes a Cassandra
En SQL es modela el domini (normalitzat) i després s'escriuen les consultes que calguin, amb joins i índexs. A Cassandra no hi ha joins ni consultes ad hoc eficients, així que s'inverteix l'ordre: primer les consultes, després una taula per consulta, desnormalitzant el que calgui. La clau primària té dues parts:
CREATE TABLE comandes_per_client (
client_id text,
data timestamp,
comanda_id text,
estat text,
total decimal,
linies list<frozen<linia>>,
PRIMARY KEY ((client_id), data, comanda_id)
) WITH CLUSTERING ORDER BY (data DESC, comanda_id ASC);- La clau de partició
(client_id)(els parèntesis interiors) decideix el token i per tant el node: totes les comandes de l'Anna són juntes, al mateix node (i les seves rèpliques). És la decisió de 04-01, apartat 10, feta esquema. - Les clustering columns
data, comanda_idordenen les files dins de la partició, a disc.CLUSTERING ORDER BY (data DESC)desa les més recents primer, així que "les últimes 20 comandes de l'Anna" és llegir les primeres 20 files de la partició: una sola cerca, un sol node. - Les consultes eficients són les que fixen la clau de partició completa i, opcionalment, un rang sobre les clustering columns en ordre:
WHERE client_id = 'anna',WHERE client_id = 'anna' AND data > '2026-09-01'. Una consulta sense clau de partició (WHERE estat = 'PENDENT') exigeixALLOW FILTERINGi recorre tot el clúster: és el scatter/gather de 04-01 en la seva pitjor forma, i es prohibeix en producció. - Per a "comanda per id" (la confirmació, l'enllaç del correu) cal una altra taula,
comandes_per_id, ambPRIMARY KEY ((comanda_id)), i l'aplicació escriu a totes dues en unBATCHregistrat (atòmic entre les dues taules, encara que no aïllat). És el preu de la desnormalització, i és barat perquè les escriptures ho són. - Les particions s'han de mantenir acotades (recomanació: < 100 MB i < 100 000 files): un client restaurant amb desenes de milers de comandes trencaria la regla, i per això 04-01 va proposar el cub mensual
(client_id, any_mes)com a clau de partició composta. Ho aplicarem com a exercici.
- Camí (c): bases de dades documentals amb MongoDB
Les bases de dades documentals desen documents JSON/BSON amb esquema flexible i consultes riques sobre qualsevol camp, amb índexs secundaris. MongoDB les distribueix en dos nivells:
- Rèplica set: un primari i diversos secundaris amb replicació asíncrona per oplog i elecció automàtica de primari (un protocol derivat de Raft, 03-03). L'aplicació tria el write concern (
w: 1,w: "majority") i la read preference (primary,secondaryPreferred), que són de nouWiRamb altres noms. És líder-seguidor amb failover (03-04). - Sharding: les col·leccions es reparteixen en chunks per rang o hash d'una shard key, cada chunk viu en un rèplica set, els routers
mongosencaminen les consultes (opció "capa d'encaminament" de 04-01), i els config servers (un rèplica set) desen el mapa de chunks, que un balancejador mou per reequilibrar. Les consultes amb la shard key van a un shard; sense ella, a tots. - Transaccions: ACID en un document sempre (una comanda amb les seves línies incrustades s'escriu atòmicament); des de la versió 4.0/4.2, transaccions multidocument i multishard amb 2PC intern, més lentes i amb límits.
Encaixa quan el domini s'expressa bé com a documents autocontinguts amb consultes variades (catàlegs, perfils, contingut). El tractem breument perquè, per a Quilòmetre Zero, el catàleg (document per producte amb variants i fotos) seria un candidat natural, però l'equip va decidir mantenir-lo a PostgreSQL amb columnes jsonb (que cobreixen el 90 % del cas documental) més la memòria cau de Redis (04-05): una base de dades menys a operar.
- Taula comparativa
| PostgreSQL + rèpliques | Citus / sharding | NewSQL (CockroachDB, Spanner) | Cassandra | MongoDB | |
|---|---|---|---|---|---|
| Model de dades | Relacional | Relacional | Relacional | Família de columnes (taules amples, particions) | Documents |
| Consistència (03-02) | CP al primari; rèpliques eventuals | CP per shard | CP, serialitzable global | Ajustable per operació (ONE…ALL); AP per defecte, CP amb QUORUM | CP al primari; ajustable amb write/read concern |
| Escalat d'escriptures | No (un primari) | Sí, per shard | Sí, per rang | Sí, lineal (sense líder) | Sí, per shard |
| Escalat de lectures | Sí (rèpliques) | Sí | Sí | Sí | Sí (secundaris) |
| Consultes | SQL complet, joins | SQL; joins eficients només col·localitzats | SQL complet | Només per clau de partició; sense joins; una taula per consulta | Riques sobre qualsevol camp; agregacions |
| Transaccions | ACID complet | ACID en un shard; 2PC entre shards | ACID distribuït | Atomicitat per partició; batch entre taules; LWT amb Paxos | ACID per document; multidocument amb cost |
| Índexs secundaris | Sí | Sí | Sí | Locals (scatter/gather) o vistes materialitzades | Sí, inclosos globals per shard |
| Latència d'escriptura | Baixa (1 node) | Baixa/mitjana | Mitjana (consens) | Molt baixa (LSM, sense líder) | Baixa |
| Punt fort | Tot el que cap en una màquina gran; comptadors i restriccions | Multiinquilí amb SQL | Transaccions globals | Escriptura massiva, sèries temporals, disponibilitat | Flexibilitat d'esquema |
| Punt feble | Escalat d'escriptura | Consultes sense la clau de distribució | Latència i complexitat | Modelatge rígid, tombstones, sense agregacions | Memòria, sharding difícil de canviar |
- La decisió de Quilòmetre Zero
Amb la taula al davant i la taula de decisions CAP de 03-02, l'equip decideix per servei:
comandes migra a Cassandra. Raons:
- Volum i escriptura intensiva: 40 000 comandes/dia en campanya amb 6 esdeveniments per comanda, més l'historial complet (la llei obliga a conservar-lo anys) i la taula
sagas(03-05) amb una transició per pas. És una càrrega d'escriptura seqüencial i de lectura per clau coneguda, el punt fort de l'LSM-tree i de l'anell sense líder. - Consultes conegudes i estables: "les meves comandes" (per client, ordenades per data), "comanda per id", i els índexs per mercat i productor materialitzats des de
comandes.esdeveniments. Cap no necessita joins ni agregacions en línia (aquestes van al llac de dades i al Mòdul 5). - Disponibilitat: crear una comanda ha de funcionar encara que un node o fins i tot un centre de dades falli; amb
LOCAL_QUORUMi RF=3 per DC, l'escriptura continua amb un node caigut. La comanda ja no necessita transaccions entre serveis (sagues) ni entre taules fora d'un batch, així que l'atomicitat per partició és suficient. - Escala lineal: afegir nodes afegeix capacitat proporcional, i 04-01 va mostrar que l'anell amb vnodes mou només l'imprescindible.
inventari es queda a PostgreSQL (primari + rèplica de 03-04, particionat per producte_slug quan calgui). Raons:
- Comptadors CP: l'estoc és una restricció (
CHECK (disponible >= 0)) que cal fer complir a cada reserva, ambUPDATE … WHERE disponible >= 2bloquejant la fila. Cassandra no té restriccions, els seus comptadors no són idempotents ni transaccionals, i les transaccions lleugeres (Paxos per operació) serien molt més lentes que unUPDATEa PostgreSQL. - Volum modest: 4 200 productes per 4 mercats són unes 17 000 files d'estoc; el problema no és la mida sinó la correcció i la contenció a les claus calentes, que s'atacarà amb la memòria cau i amb cues (04-05), no amb més nodes.
- Transaccions locals: reservar estoc i registrar la reserva idempotent (
missatges_processats, 02-05) a la mateixa transacció, i la taula outbox (02-05) a la mateixa base: PostgreSQL ho fa en unCOMMIT.
pagaments també es queda a PostgreSQL (CP, baix volum, auditoria); cataleg a PostgreSQL amb jsonb i Redis al davant; repartiment (posicions de furgoneta-3, 2,4 milions al dia, AP) és candidat a Cassandra amb particions diàries per repartidor, com va plantejar l'exercici 2 de 04-01; analitica al llac (04-02) i en un magatzem analític que el Mòdul 5 triarà.
- Pràctica: Cassandra a
docker-compose.yml i repositori_cassandra.py
docker-compose.yml i repositori_cassandra.pyTres nodes Cassandra en un sol centre de dades (dc-bcn), amb dos racks simulats, per poder fer servir NetworkTopologyStrategy i veure el repartiment de rèpliques:
# km0/docker-compose.yml (fragment)
x-cassandra-env: &cassandra-env
CASSANDRA_CLUSTER_NAME: km0
CASSANDRA_SEEDS: cassandra-1
CASSANDRA_DC: dc-bcn
CASSANDRA_ENDPOINT_SNITCH: GossipingPropertyFileSnitch # necessari per a NetworkTopologyStrategy
CASSANDRA_NUM_TOKENS: "16"
MAX_HEAP_SIZE: 1G
HEAP_NEWSIZE: 200M
services:
cassandra-1:
image: cassandra:5.0
hostname: cassandra-1
environment:
<<: *cassandra-env
CASSANDRA_RACK: rack1
ports: ["9042:9042"]
volumes: [cassandra-1-data:/var/lib/cassandra]
healthcheck:
test: ["CMD-SHELL", "nodetool status | grep -q '^UN'"]
interval: 15s
retries: 20
cassandra-2:
image: cassandra:5.0
hostname: cassandra-2
environment:
<<: *cassandra-env
CASSANDRA_RACK: rack2
volumes: [cassandra-2-data:/var/lib/cassandra]
depends_on:
cassandra-1: { condition: service_healthy }
cassandra-3:
image: cassandra:5.0
hostname: cassandra-3
environment:
<<: *cassandra-env
CASSANDRA_RACK: rack1
volumes: [cassandra-3-data:/var/lib/cassandra]
depends_on:
cassandra-2: { condition: service_started }
volumes:
cassandra-1-data:
cassandra-2-data:
cassandra-3-data:Els nodes han d'arrencar d'un en un (per això les dependències): dos nodes unint-se a l'anell alhora amb el mateix seed és una de les causes clàssiques d'un clúster mal format. Arrencar i comprovar l'anell:
docker compose up -d cassandra-1 cassandra-2 cassandra-3 # triga 2-3 minuts
docker compose exec cassandra-1 nodetool statusDatacenter: dc-bcn ================== Status=Up/Down |/ State=Normal/Leaving/Joining/Moving -- Address Load Tokens Owns (effective) Host ID Rack UN 172.21.0.3 104.51 KiB 16 100.0% 5f2c… rack1 UN 172.21.0.4 109.87 KiB 16 100.0% 8a1d… rack2 UN 172.21.0.5 98.22 KiB 16 100.0% c73e… rack1
UN = Up, Normal. Owns (effective) és 100 % a cada node perquè, amb RF=3 i 3 nodes, cada node té una rèplica de tot; amb 6 nodes veuríem ~50 %. nodetool ring mostra els 48 tokens (16 per node) de l'anell, i nodetool getendpoints km0_comandes comandes_per_client anna dirà quins tres nodes desen la partició de l'Anna.
L'esquema, amb cqlsh:
-- docker compose exec cassandra-1 cqlsh
CREATE KEYSPACE IF NOT EXISTS km0_comandes
WITH replication = {'class': 'NetworkTopologyStrategy', 'dc-bcn': 3};
USE km0_comandes;
CREATE TYPE IF NOT EXISTS linia (
producte text,
productor text,
quantitat int,
preu decimal
);
CREATE TABLE IF NOT EXISTS comandes_per_client (
client_id text,
data timestamp,
comanda_id text,
mercat text,
estat text,
total decimal,
linies list<frozen<linia>>,
PRIMARY KEY ((client_id), data, comanda_id)
) WITH CLUSTERING ORDER BY (data DESC, comanda_id ASC)
AND comment = 'Consulta: les meves comandes, més recents primer';
CREATE TABLE IF NOT EXISTS comandes_per_id (
comanda_id text PRIMARY KEY,
client_id text,
data timestamp,
mercat text,
estat text,
total decimal,
linies list<frozen<linia>>
) WITH comment = 'Consulta: comanda per id (confirmació, correu, saga)';El tipus linia és un UDT (user-defined type) i frozen indica que la llista es desa com un sol valor serialitzat (no es poden modificar elements solts, però es llegeix i s'escriu d'una vegada, que és el que volem). comandes_per_client i comandes_per_id contenen les mateixes dades amb clau diferent: és la desnormalització de l'apartat 5.
El repositori Python amb cassandra-driver (el driver oficial de DataStax; pip install cassandra-driver):
# km0/serveis/comandes/repositori_cassandra.py
"""Accés a km0_comandes a Cassandra amb nivell de consistència per operació."""
from datetime import datetime, timedelta, timezone
from decimal import Decimal
from cassandra.cluster import Cluster, ExecutionProfile, EXEC_PROFILE_DEFAULT
from cassandra.policies import DCAwareRoundRobinPolicy, TokenAwarePolicy
from cassandra.query import BatchStatement, BatchType, ConsistencyLevel
class RepositoriComandes:
def __init__(self, contactes=("localhost",), dc_local="dc-bcn"):
perfil = ExecutionProfile(
load_balancing_policy=TokenAwarePolicy(DCAwareRoundRobinPolicy(local_dc=dc_local)),
consistency_level=ConsistencyLevel.LOCAL_QUORUM, # valor per defecte: fort dins del DC
request_timeout=5.0,
)
self.cluster = Cluster(contact_points=list(contactes),
execution_profiles={EXEC_PROFILE_DEFAULT: perfil})
self.session = self.cluster.connect("km0_comandes")
self.session.cluster.register_user_type("km0_comandes", "linia", Linia)
# Sentències preparades: s'analitzen un cop al servidor i es reutilitzen (més ràpid i segur)
self._ins_client = self.session.prepare(
"INSERT INTO comandes_per_client (client_id, data, comanda_id, mercat, estat, total, linies) "
"VALUES (?, ?, ?, ?, ?, ?, ?)")
self._ins_id = self.session.prepare(
"INSERT INTO comandes_per_id (comanda_id, client_id, data, mercat, estat, total, linies) "
"VALUES (?, ?, ?, ?, ?, ?, ?)")
self._sel_client = self.session.prepare(
"SELECT comanda_id, data, mercat, estat, total FROM comandes_per_client "
"WHERE client_id = ? LIMIT ?")
self._sel_id = self.session.prepare("SELECT * FROM comandes_per_id WHERE comanda_id = ?")
self._upd_estat_client = self.session.prepare(
"UPDATE comandes_per_client SET estat = ? WHERE client_id = ? AND data = ? AND comanda_id = ?")
self._upd_estat_id = self.session.prepare(
"UPDATE comandes_per_id SET estat = ? WHERE comanda_id = ?")
# --- escriptures ------------------------------------------------------------------------
def crear_comanda(self, comanda_id: str, client_id: str, mercat: str, linies: list, total: Decimal,
data: datetime | None = None) -> None:
"""Escriu a les dues taules dins d'un batch registrat: o entren les dues files o cap."""
data = data or datetime.now(timezone.utc)
lot = BatchStatement(batch_type=BatchType.LOGGED, consistency_level=ConsistencyLevel.LOCAL_QUORUM)
lot.add(self._ins_client, (client_id, data, comanda_id, mercat, "CREADA", total, linies))
lot.add(self._ins_id, (comanda_id, client_id, data, mercat, "CREADA", total, linies))
self.session.execute(lot)
def canviar_estat(self, comanda_id: str, nou_estat: str) -> None:
"""Necessita la clau completa de comandes_per_client: l'obté de comandes_per_id."""
fila = self.session.execute(self._sel_id, (comanda_id,)).one()
if fila is None:
raise KeyError(comanda_id)
lot = BatchStatement(batch_type=BatchType.LOGGED, consistency_level=ConsistencyLevel.LOCAL_QUORUM)
lot.add(self._upd_estat_client, (nou_estat, fila.client_id, fila.data, comanda_id))
lot.add(self._upd_estat_id, (nou_estat, comanda_id))
self.session.execute(lot)
# --- lectures ---------------------------------------------------------------------------
def ultimes_comandes(self, client_id: str, limit: int = 20, fort: bool = False) -> list:
"""Llistat de 'les meves comandes'. Per defecte ONE (ràpid); `fort=True` fa servir LOCAL_QUORUM
just després de crear una comanda, perquè el client vegi la seva pròpia escriptura."""
consulta = self._sel_client.bind((client_id, limit))
consulta.consistency_level = ConsistencyLevel.LOCAL_QUORUM if fort else ConsistencyLevel.ONE
return list(self.session.execute(consulta))
def comanda(self, comanda_id: str, nivell=ConsistencyLevel.LOCAL_QUORUM):
consulta = self._sel_id.bind((comanda_id,))
consulta.consistency_level = nivell
return self.session.execute(consulta).one()
def tancar(self):
self.cluster.shutdown()
class Linia:
def __init__(self, producte, productor, quantitat, preu):
self.producte, self.productor, self.quantitat, self.preu = producte, productor, quantitat, preu
if __name__ == "__main__":
repo = RepositoriComandes()
repo.crear_comanda("P-2026-000123", "anna", "Girona",
[Linia("formatge-curat", "formatgeria-montblanc", 2, Decimal("14.50")),
Linia("tomaquet-rosa", "horta-la-vega", 3, Decimal("3.20"))], Decimal("38.60"))
repo.crear_comanda("P-2026-000124", "anna", "Girona",
[Linia("vi-crianca", "celler-roure-alt", 1, Decimal("15.90"))], Decimal("15.90"))
repo.crear_comanda("P-2026-000125", "marc", "València",
[Linia("formatge-fresc", "formatgeria-montblanc", 1, Decimal("6.10"))], Decimal("6.10"))
print("Comandes de l'Anna (lectura forta després d'escriure):")
for c in repo.ultimes_comandes("anna", fort=True):
print(" ", c.comanda_id, c.data, c.mercat, c.estat, c.total)
repo.canviar_estat("P-2026-000123", "PAGADA")
print("P-2026-000123:", repo.comanda("P-2026-000123").estat)
repo.tancar()El que convé entendre del codi:
TokenAwarePolicy(DCAwareRoundRobinPolicy): el driver descarrega el mapa de tokens de l'anell i envia cada petició directament a una rèplica de la partició (opció "client informat" de 04-01), evitant el salt extra del coordinador; i prefereix els nodes del centre de dades local.ExecutionProfilefixaLOCAL_QUORUMper defecte, i cada sentència pot sobreescriure'l ambconsistency_level. Aquest és el punt central de la pràctica: la consistència és una propietat de l'operació, no de la base de dades.ultimes_comandesfa servirONEa la ruta normal (la llista de comandes tolera uns mil·lisegons de retard) iLOCAL_QUORUMquan la web la crida just després decrear_comanda, que també ha estatLOCAL_QUORUM: 2 + 2 > 3, així que la lectura veu l'escriptura. És el model read-your-writes de 03-01 aconseguit amb quòrums.BatchStatement(LOGGED): Cassandra escriu primer el lot en un batchlog replicat i garanteix que les dues insercions acabin aplicant-se (atomicitat eventual, sense aïllament: un lector pot veure una taula actualitzada i l'altra no durant mil·lisegons). Els batches serveixen per mantenir taules desnormalitzades coherents, no per "anar més ràpid": un batch amb centenars de particions diferents és un antipatró.- Sentències preparades amb
?: s'envien al servidor un cop, es reutilitzen amb paràmetres, i el driver sap quin paràmetre és la clau de partició per a l'encaminament per token. canviar_estatmostra el cost de la desnormalització: per actualitzarcomandes_per_clientcal la clau completa (client_id,data,comanda_id), que s'obté decomandes_per_id. A la saga de 03-05, l'orquestrador ja coneix aquestes dades i s'estalvia la lectura.
Executa l'script, i després prova els nivells amb el clúster degradat:
python -m serveis.comandes.repositori_cassandra
docker compose stop cassandra-3
docker compose exec cassandra-1 nodetool status # cassandra-3 apareix com a DN (Down, Normal)
python - <<'EOF'
from cassandra.query import ConsistencyLevel
from serveis.comandes.repositori_cassandra import RepositoriComandes
repo = RepositoriComandes()
print("QUORUM amb 2 de 3:", repo.comanda("P-2026-000123", ConsistencyLevel.QUORUM).estat) # funciona
try:
repo.comanda("P-2026-000123", ConsistencyLevel.ALL) # falla
except Exception as e:
print("ALL amb 2 de 3:", type(e).__name__) # Unavailable: no hi ha 3 rèpliques vives
EOF
docker compose start cassandra-3Amb un node caigut, QUORUM continua llegint i escrivint (2 rèpliques vives de 3) i ALL respon Unavailable immediatament: la disponibilitat i la consistència triades operació a operació, com prometia 03-02. En tornar cassandra-3, els hints acumulats per cassandra-1 i cassandra-2 se li lliuren i nodetool repair km0_comandes reconcilia qualsevol diferència restant.
Errors Comuns i Consells
- Modelar Cassandra com SQL. Una taula
comandesnormalitzada amb índexs secundaris per a cada consulta acaba enALLOW FILTERINGi scatter/gather. Llista les consultes, una taula per consulta, desnormalitza amb batches. - Particions sense límit. Un client, un repartidor o un producte amb milions de files a la mateixa partició degrada compactacions i lectures. Afegeix un cub temporal a la clau de partició.
- Fer servir Cassandra per a cues o per a esborrats massius. Les làpides s'acumulen i les lectures moren. Si necessites una cua, ja tens Kafka (02-04).
SimpleStrategyen producció. No entén de racks ni de centres de dades: tres rèpliques al mateix rack. SempreNetworkTopologyStrategy, fins i tot amb un sol DC.gc_grace_secondsmés petit que el temps màxim de reparació. Un node que torna després ressuscita dades esborrades. Repara cada node dins d'aquesta finestra (nodetool repairprogramat).- Triar NewSQL o Cassandra "perquè escala" amb 20 GB de dades. Un PostgreSQL ben indexat en una màquina gran serveix desenes de milers de transaccions per segon. Distribueix quan el volum, les escriptures o la disponibilitat ho exigeixin, i pagaràs el modelatge rígid o la latència de consens només llavors.
- Comptadors de negoci a Cassandra. Els
counterno són idempotents (un reintent duplica l'increment) i no admeten condicions. L'estoc, els saldos i tot el que tingui una restricció van a PostgreSQL o a NewSQL. - Ignorar el nivell de consistència per defecte del driver. A
cassandra-driverésLOCAL_ONE. Fixa'l explícitament a l'ExecutionProfilei decideix per operació.
Exercicis
Exercici 1. Aplica la recomanació de particions acotades: redefineix comandes_per_client amb clau de partició composta ((client_id, any_mes), data, comanda_id) on any_mes és un text com '2026-09'. Escriu el CREATE TABLE, adapta crear_comanda per calcular any_mes a partir de data, i reescriu ultimes_comandes(client_id, limit) perquè retorni les últimes 20 comandes encara que estiguin repartides en diversos mesos (pista: recórrer mesos enrere fins a completar el límit o arribar a un topall). Què passa amb un client que no ha comprat en els últims 12 mesos?
Exercici 2. Per a cada operació de Quilòmetre Zero, tria el nivell de consistència de Cassandra (amb RF=3 a dc-bcn i RF=3 a dc-vlc) i justifica'l amb W + R > N i amb la taula CAP de 03-02: (a) escriure una posició de furgoneta-3; (b) llegir l'última posició per a la web del client; (c) confirmar la comanda P-2026-000126 en rebre pagament.confirmat; (d) llegir aquesta comanda des de la pàgina de confirmació 200 ms després; (e) l'informe nocturn d'analitica que llegeix totes les comandes del dia; (f) reservar el nom d'usuari d'un nou productor, que ha de ser únic.
Exercici 3. Un company proposa migrar també inventari a Cassandra "per tenir una sola base de dades", implementant l'estoc com UPDATE estoc SET disponible = disponible - 2 WHERE producte = 'formatge-curat' amb un tipus counter, i comprovant després amb un SELECT que no ha quedat negatiu. Explica amb un escenari concret (dues reserves concurrents de l'Anna i en Marc sobre les 3 últimes peces) per què falla, quina alternativa ofereix Cassandra (transaccions lleugeres amb IF) i per què, tot i això, la decisió de l'apartat 8 es manté.
Solucions
Solució 1:
CREATE TABLE comandes_per_client_mes (
client_id text, any_mes text, data timestamp, comanda_id text,
mercat text, estat text, total decimal, linies list<frozen<linia>>,
PRIMARY KEY ((client_id, any_mes), data, comanda_id)
) WITH CLUSTERING ORDER BY (data DESC, comanda_id ASC);def _any_mes(data: datetime) -> str:
return data.strftime("%Y-%m")
# a crear_comanda: lot.add(self._ins_client, (client_id, _any_mes(data), data, comanda_id, ...))
# a __init__: self._sel_client_mes = session.prepare(
# "SELECT * FROM comandes_per_client_mes WHERE client_id = ? AND any_mes = ? LIMIT ?")
def ultimes_comandes(self, client_id: str, limit: int = 20, mesos_max: int = 12) -> list:
resultat, cursor = [], datetime.now(timezone.utc).replace(day=1)
for _ in range(mesos_max):
files = self.session.execute(self._sel_client_mes, (client_id, _any_mes(cursor), limit - len(resultat)))
resultat.extend(files)
if len(resultat) >= limit:
break
cursor = (cursor - timedelta(days=1)).replace(day=1) # mes anterior
return resultatCada iteració és una lectura d'una partició diferent (una partició per mes), així que "les últimes 20" costa entre 1 i mesos_max lectures, gairebé sempre 1 o 2 per a un client actiu. Un client sense compres en 12 mesos rep una llista buida després de 12 lectures ràpides (les particions inexistents es resolen amb filtres de Bloom sense tocar disc): el topall evita recórrer anys enrere, i si el negoci necessita l'historial complet s'afegeix una consulta explícita per rang de mesos o una taula resum mesos_amb_comandes_per_client.
Solució 2:
| Operació | Nivell | Justificació |
|---|---|---|
(a) Escriure posició de furgoneta-3 |
ONE (o ANY) |
AP a 03-02: 2,4 M escriptures/dia; perdre una posició és irrellevant; latència mínima. W=1 |
| (b) Llegir última posició | ONE |
Una dada de fa 3 s val igual; W+R = 2 ≤ 3, acceptem llegir vell |
(c) Confirmar P-2026-000126 |
LOCAL_QUORUM (W=2 al DC local) |
CP per a la comanda; no esperar l'altre DC (latència entre BCN i VLC); un node caigut no bloqueja |
| (d) Llegir la confirmació 200 ms després | LOCAL_QUORUM (R=2) |
W+R = 4 > 3 dins del DC: read-your-writes garantit si la lectura va al mateix DC (el driver ho assegura amb DCAwareRoundRobinPolicy); si la web pogués llegir des de l'altre DC, caldria EACH_QUORUM a l'escriptura |
| (e) Informe nocturn | ONE o LOCAL_ONE |
Lectura massiva, sense pressa ni sensibilitat a mil·lisegons de retard; i encara millor des del llac de dades (04-02) que des de Cassandra |
| (f) Nom d'usuari únic | LOCAL_SERIAL amb INSERT … IF NOT EXISTS |
És una decisió que exigeix linealitzabilitat (03-01): només una transacció lleugera (Paxos per partició) garanteix que dos productors no obtinguin el mateix nom; se n'accepta la latència perquè passa un cop per productor |
Solució 3:
Escenari: queden 3 peces. L'Anna en reserva 2 i en Marc en reserva 2 gairebé alhora. Amb counter, tots dos UPDATE s'apliquen sense condició: el comptador passa a 3 − 2 − 2 = −1. Les comprovacions posteriors amb SELECT veuen −1 totes dues, i cadascuna intenta "desfer" sumant 2: el comptador queda en 3, o en 1, o en −1 segons l'entrellaçat, i cap no sap si la seva reserva val. Pitjor: si un UPDATE de comptador es reintenta per un timeout (03-05, sagues reintentables), s'aplica dues vegades, perquè els comptadors no són idempotents. L'alternativa correcta a Cassandra és una transacció lleugera: UPDATE estoc SET disponible = 1 WHERE producte = 'formatge-curat' IF disponible = 3, que executa Paxos entre les rèpliques de la partició i retorna [applied] = false al que arriba segon, que ha de rellegir i reintentar (compare-and-set). Funciona, però cada reserva costa quatre viatges de xarxa entre rèpliques (unes desenes de mil·lisegons) enfront d'un UPDATE … WHERE disponible >= 2 a PostgreSQL amb bloqueig de fila (menys d'un mil·lisegon), i sense restriccions declaratives ni transacció amb la taula missatges_processats i l'outbox. Amb formatge-curat com a clau calenta a la Setmana del Formatge Artesà, la contenció a Paxos seria el coll d'ampolla. Per això inventari es queda a PostgreSQL: el problema és la correcció d'un comptador disputat, no el volum, i aquest és el punt fort d'una base de dades relacional CP.
Conclusió
Una base de dades distribuïda reparteix físicament les dades i ofereix les transparències de fragmentació, replicació, ubicació, fallada i, amb abast diferent, de transacció. La fragmentació horitzontal és el particionament de 04-01 sobre files, i la vertical és el que Quilòmetre Zero va fer en repartir l'esquema del monòlit entre serveis. Hi ha tres camins: la base relacional escalada amb rèpliques de lectura, sharding per aplicació o Citus, i en la seva versió més ambiciosa NewSQL (Spanner amb TrueTime, CockroachDB i YugabyteDB amb Raft) que conserva ACID global a costa de latència; el NoSQL d'estil Dynamo que Cassandra encarna amb un anell de hashing consistent, sense líder, amb factor de replicació per keyspace i amb el nivell de consistència triat a cada operació (ONE, QUORUM, ALL) com a aplicació literal de W + R > N, amb un modelatge orientat a consultes (una taula per consulta, clau de partició més clustering columns) i amb les làpides com el seu peatge; i les bases documentals com MongoDB amb rèplica sets i sharding. Quilòmetre Zero ha decidit que comandes migri a Cassandra pel seu volum, la seva escriptura intensiva i les seves consultes estables, i que inventari continuï a PostgreSQL perquè un comptador amb restricció és un problema CP de correcció, no d'escala. Ho hem muntat amb tres nodes a docker-compose.yml, el keyspace km0_comandes amb NetworkTopologyStrategy, les taules comandes_per_client i comandes_per_id, i repositori_cassandra.py escrivint en batch i llegint amb ONE o LOCAL_QUORUM segons el que cada operació necessita, comprovant amb nodetool status i un node aturat que QUORUM sobreviu i ALL no.
Les dades de Quilòmetre Zero ja tenen gairebé totes el seu lloc. Però el catàleg es consulta centenars de vegades per cada cop que canvia, i cada consulta que arriba a PostgreSQL durant la Setmana del Formatge Artesà és una consulta que la base de dades podria no haver de respondre. L'última peça del mòdul és entre l'aplicació i les bases de dades: les memòries cau distribuïdes, amb Redis, els seus patrons, els seus problemes clàssics i un Redis Cluster els 16 384 slots del qual són l'última reencarnació del particionament amb què va començar el mòdul.
Curs d'Arquitectures Distribuïdes
Mòdul 1: Introducció als Sistemes Distribuïts
- Conceptes Bàsics de Sistemes Distribuïts
- Models de Sistemes Distribuïts
- Avantatges i Desafiaments dels Sistemes Distribuïts
- Les Fal·làcies de la Computació Distribuïda
- Temps, Rellotges i Ordenació d'Esdeveniments
- Del Monòlit a la Plataforma Distribuïda: el Cas Quilòmetre Zero
Mòdul 2: Comunicació en Sistemes Distribuïts
- Protocols de Comunicació
- RPC i RMI
- gRPC i Serialització de Dades
- Missatgeria i Cues de Missatges
- Patrons de Comunicació Asíncrona
Mòdul 3: Consistència i Replicació
- Models de Consistència
- El Teorema CAP i PACELC
- Algorismes de Consens
- Replicació de Dades
- Transaccions Distribuïdes i Sagues
Mòdul 4: Emmagatzematge Distribuït
- Particionament de Dades i Hashing Consistent
- Sistemes de Fitxers Distribuïts
- Emmagatzematge d'Objectes
- Bases de Dades Distribuïdes
- Memòries Cau Distribuïdes
Mòdul 5: Computació Distribuïda
- Models de Computació Distribuïda
- MapReduce i Hadoop
- Spark i Computació en Memòria
- Processament de Fluxos de Dades
- Planificació de Treballs i Pipelines de Dades
Mòdul 6: Seguretat en Sistemes Distribuïts
- Autenticació i Autorització
- Xifratge i Protecció de Dades
- Gestió d'Identitats
- Seguretat entre Serveis: mTLS i Gestió de Secrets
- Passarel·les d'API, Limitació de Taxa i Auditoria
Mòdul 7: Monitoratge i Manteniment
- Monitoratge de Sistemes Distribuïts
- Logs Centralitzats i Traçabilitat Distribuïda
- Gestió de Fallades i Recuperació
- Patrons de Resiliència: Timeouts, Reintents i Circuit Breaker
- Automatització i Orquestració
- Proves en Sistemes Distribuïts i Enginyeria del Caos
