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

  1. Què és una base de dades distribuïda i les seves transparències
  2. Fragmentació horitzontal i vertical
  3. Camí (a): la base de dades relacional escalada i NewSQL
  4. Camí (b): NoSQL d'estil Dynamo amb Cassandra
  5. Modelatge orientat a consultes a Cassandra
  6. Camí (c): bases de dades documentals amb MongoDB
  7. Taula comparativa
  8. La decisió de Quilòmetre Zero
  9. Pràctica: Cassandra a docker-compose.yml i repositori_cassandra.py
  10. Errors Comuns i Consells
  11. Exercicis
  12. Conclusió

  1. 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.

  1. 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, pagaments i repartiment són fragments verticals de l'antic esquema, cadascun propietari de les seves columnes, amb comanda_id com 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 per client_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.

  1. 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.

  1. 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.

  1. 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_id ordenen 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') exigeix ALLOW FILTERING i 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, amb PRIMARY KEY ((comanda_id)), i l'aplicació escriu a totes dues en un BATCH registrat (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.

  1. 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 nou W i R amb 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 mongos encaminen 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.

  1. 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í (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 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

  1. 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:

  1. 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.
  2. 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).
  3. Disponibilitat: crear una comanda ha de funcionar encara que un node o fins i tot un centre de dades falli; amb LOCAL_QUORUM i 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.
  4. 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:

  1. Comptadors CP: l'estoc és una restricció (CHECK (disponible >= 0)) que cal fer complir a cada reserva, amb UPDATE … WHERE disponible >= 2 bloquejant 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 un UPDATE a PostgreSQL.
  2. 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.
  3. 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 un COMMIT.

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à.

  1. Pràctica: Cassandra a docker-compose.yml i repositori_cassandra.py

Tres 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 status
Datacenter: 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.
  • ExecutionProfile fixa LOCAL_QUORUM per defecte, i cada sentència pot sobreescriure'l amb consistency_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_comandes fa servir ONE a la ruta normal (la llista de comandes tolera uns mil·lisegons de retard) i LOCAL_QUORUM quan la web la crida just després de crear_comanda, que també ha estat LOCAL_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_estat mostra el cost de la desnormalització: per actualitzar comandes_per_client cal la clau completa (client_id, data, comanda_id), que s'obté de comandes_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-3

Amb 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 comandes normalitzada amb índexs secundaris per a cada consulta acaba en ALLOW FILTERING i 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).
  • SimpleStrategy en producció. No entén de racks ni de centres de dades: tres rèpliques al mateix rack. Sempre NetworkTopologyStrategy, fins i tot amb un sol DC.
  • gc_grace_seconds mé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 repair programat).
  • 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 counter no 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 és LOCAL_ONE. Fixa'l explícitament a l'ExecutionProfile i 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 resultat

Cada 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

Mòdul 2: Comunicació en Sistemes Distribuïts

Mòdul 3: Consistència i Replicació

Mòdul 4: Emmagatzematge Distribuït

Mòdul 5: Computació Distribuïda

Mòdul 6: Seguretat en Sistemes Distribuïts

Mòdul 7: Monitoratge i Manteniment

Mòdul 8: Casos d'Estudi i Aplicacions

© Copyright 2026. Tots els drets reservats