La lliçó anterior va deixar el catàleg de Quilòmetre Zero a PostgreSQL amb jsonb i una promesa: que Redis s'hi posaria al davant. Aquesta lliçó compleix la promesa i tanca el mòdul. Una memòria cau desa una còpia de dades que costa obtenir (una consulta a la base de dades, una crida gRPC a inventari, una imatge redimensionada) en un lloc més ràpid i més proper, per no tornar a pagar aquest cost mentre la còpia continuï sent útil. Sembla simple, i en la seva forma bàsica ho és; el difícil és tot el que envolta el "mentre continuï sent útil": quan caduca la còpia, com s'invalida quan la dada canvia, què passa quan milers de peticions descobreixen alhora que ha caducat, què fer amb la clau que tothom demana, i com evitar que la memòria cau retorni alguna cosa que la base de dades ja no diu. Veurem els patrons (cache-aside, read-through, write-through, write-behind, refresh-ahead), els problemes clàssics i les seves solucions, la comparació Memcached/Redis, i Redis Cluster amb els seus 16 384 slots com a reencarnació del particionament de 04-01. La pràctica ho posa tot a serveis/cataleg/cache.py, amb invalidació pels esdeveniments estoc.actualitzat de Kafka, i mesura la diferència de latència amb i sense memòria cau durant la "Setmana del Formatge Artesà".

Contingut

  1. Per què fer servir memòria cau: latència i càrrega a la base de dades
  2. On posar la memòria cau: client, CDN, aplicació i memòria cau distribuïda
  3. Patrons de memòria cau
  4. Invalidació i TTL: els dos problemes difícils
  5. Problemes clàssics: stampede, hot keys, penetració
  6. Consistència entre memòria cau i base de dades
  7. Memcached enfront de Redis
  8. Redis Cluster: slots, rèpliques i failover
  9. Pràctica: serveis/cataleg/cache.py i Redis Cluster
  10. Errors Comuns i Consells
  11. Exercicis
  12. Conclusió

  1. Per què fer servir memòria cau: latència i càrrega a la base de dades

La memòria cau respon a dos problemes diferents que solen aparèixer junts: latència (la petició triga massa) i càrrega (l'origen no aguanta tantes peticions). Uns números per sentir la diferència:

Origen de la dada Latència típica Peticions/s per instància
Memòria local del procés (diccionari Python) 100 ns Milions
Redis / Memcached a la mateixa xarxa 0,2–1 ms 100 000–1 000 000
PostgreSQL, consulta indexada simple 1–5 ms 10 000–50 000
PostgreSQL, consulta amb joins i jsonb 5–50 ms 500–5 000
Crida gRPC a un altre servei (que al seu torn consulta la seva base) 5–20 ms Depèn del servei

El càlcul de Quilòmetre Zero per a la "Setmana del Formatge Artesà": la web serveix 6 milions de vistes de producte al dia, concentrades en 12 hores, amb un pic de 3 vegades la mitjana: unes 420 vistes/s de pic. Cada vista compon la fitxa del producte amb una consulta al catàleg (producte + productor + fotos + variants, uns 8 ms a PostgreSQL) i una crida gRPC a inventari per a l'estoc per mercat (4 ms més). Sense memòria cau: 420 consultes/s de 8 ms sobre PostgreSQL (ocupant 3,4 s de CPU per cada segon: tres o quatre nuclis només per al catàleg, i creixent amb la campanya) i 12 ms de latència mínima per fitxa. Però el catàleg canvia unes 300 vegades al dia (preus, descripcions, fotos), és a dir, una escriptura per cada 20 000 lectures. Amb una memòria cau que serveixi el 98 % de les vistes, PostgreSQL rep 8 consultes/s en lloc de 420, i la fitxa es compon en menys d'1 ms. La ràtio lectures/escriptures és el primer indicador que una dada es pot guardar en memòria cau; el segon és quanta obsolescència tolera, i el catàleg tolera segons.

  1. On posar la memòria cau: client, CDN, aplicació i memòria cau distribuïda

Una petició travessa diverses capes, i cadascuna pot tenir memòria cau:

flowchart LR
    N[Navegador de l'Anna<br/>memòria cau HTTP] --> CDN[CDN<br/>vora propera]
    CDN --> W[Web / API]
    W --> L[Memòria cau local<br/>en procés]
    L --> R[(Redis<br/>memòria cau distribuïda)]
    R --> DB[(PostgreSQL<br/>km0_cataleg)]
    W --> I[inventari gRPC]
Capa Què guarda Abast Avantatge Límit
Client (navegador, app) Respostes HTTP amb Cache-Control, ETag (04-03) Un usuari Latència zero, sense xarxa Només serveix a aquest usuari; invalidació impossible (només caducitat)
CDN (08-04) Contingut estàtic i respostes que es poden guardar per URL Tots els usuaris propers a una vora Absorbeix l'egress (04-03) i el trànsit d'imatges Només per a respostes idèntiques per a tothom; invalidació per URL, amb retard
Memòria cau d'aplicació local Objectes a la memòria del procés (functools.lru_cache, cachetools) Una instància Nanosegons; sense dependències Cada instància té la seva còpia (10 instàncies = 10 còpies fredes i desincronitzades); es perd en reiniciar; consumeix la memòria del procés
Memòria cau distribuïda (Redis, Memcached) Objectes serialitzats per clau Totes les instàncies del servei Una sola còpia compartida; sobreviu a reinicis de l'aplicació; capacitat de desenes de GB Un salt de xarxa (~0,5 ms); un altre sistema a operar

A la pràctica es combinen: una memòria cau local petita amb TTL molt curt (1–5 s) per a les claus més calentes, davant de Redis per a tot, davant de la base de dades. Aquesta lliçó se centra en la memòria cau distribuïda, que és la que resol el problema de càrrega sobre la base de dades de manera compartida entre totes les instàncies de cataleg.

  1. Patrons de memòria cau

Com es relacionen l'aplicació, la memòria cau i la base de dades defineix el patró:

Patró Lectura Escriptura Qui parla amb la BD Avantatges Inconvenients Ús típic
Cache-aside (lazy loading) L'aplicació mira la memòria cau; si falla (miss), llegeix la BD i escriu a la memòria cau L'aplicació escriu a la BD i invalida (esborra) la clau L'aplicació Simple; només es guarda el que es demana; la memòria cau pot caure sense perdre dades Cada miss costa dos viatges; la primera lectura és lenta; l'aplicació conté la lògica El més comú: catàleg, perfils
Read-through L'aplicació demana a la memòria cau; la memòria cau llegeix la BD si no ho té Igual que cache-aside La memòria cau (o una biblioteca) L'aplicació no sap de la BD; lògica centralitzada Necessita una memòria cau programable (o una capa); mateix cost de miss Biblioteques tipus Caffeine, memòries cau d'ORM
Write-through Com read-through L'aplicació escriu a la memòria cau i la memòria cau escriu síncronament a la BD La memòria cau La memòria cau sempre està actualitzada; lectures sempre calentes després d'escriure Escriptura més lenta (dos sistemes); es guarden dades que potser ningú no llegirà Dades que es llegeixen just després d'escriure's
Write-behind (write-back) Com read-through L'aplicació escriu a la memòria cau; la memòria cau escriu a la BD després, en lots La memòria cau Escriptures rapidíssimes; agrupa escriptures Si la memòria cau mor, es perden escriptures pendents; la BD va endarrerida Comptadors de visites, mètriques
Refresh-ahead La memòria cau recalcula les claus calentes abans que caduquin Qualsevol La memòria cau Sense misses a les claus calentes; latència estable Cal predir què es demanarà; càrrega extra a la BD Portades, rànquings, fitxes més vistes
sequenceDiagram
    participant A as cataleg (app)
    participant R as Redis
    participant DB as PostgreSQL
    Note over A,DB: Cache-aside: lectura amb miss
    A->>R: GET producte:formatge-curat
    R-->>A: (nil)
    A->>DB: SELECT … WHERE slug = 'formatge-curat'
    DB-->>A: fila
    A->>R: SET producte:formatge-curat <json> EX 300
    Note over A,DB: Lectura amb hit
    A->>R: GET producte:formatge-curat
    R-->>A: <json>
    Note over A,DB: Escriptura
    A->>DB: UPDATE producte SET preu = 15.20 WHERE slug = 'formatge-curat'
    A->>R: DEL producte:formatge-curat

Quilòmetre Zero fa servir cache-aside per al catàleg, amb la variant d'invalidació per esdeveniments de l'apartat 6, i refresh-ahead per a les 200 fitxes més visitades durant les campanyes. L'estoc, que canvia amb cada reserva, no es guarda de la mateixa manera: la fitxa mostra "disponible / poques unitats / esgotat" derivat d'un valor amb TTL de 10 s, i la reserva real sempre va a inventari; és un exemple que la tolerància a l'obsolescència es decideix per dada, no per servei.

  1. Invalidació i TTL: els dos problemes difícils

"Només hi ha dos problemes difícils en informàtica: la invalidació de memòries cau i posar nom a les coses" (Phil Karlton). La frase és un acudit perquè és veritat: decidir quan una còpia deixa de ser vàlida exigeix saber quan va canviar l'original, i en un sistema distribuït ningú no té aquesta informació completa i a temps. Hi ha dos mecanismes, i es fan servir junts:

  • TTL (time to live): cada clau caduca als N segons. És simple, robust (la memòria cau s'autoneteja encara que la invalidació falli), i acota l'obsolescència màxima. El seu límit: és un compromís cec. Un TTL de 5 minuts per al preu de formatge-curat vol dir que la pujada de preu pot trigar 5 minuts a veure's; un de 5 segons vol dir 12 misses per minut per clau encara que el preu no canviï en un mes.
  • Invalidació explícita: quan la dada canvia, algú esborra (o actualitza) la clau. És precisa, però exigeix que tots els camins d'escriptura l'executin (el panell del productor, l'importador massiu, l'script de correcció que un administrador executa a mà…) i que l'esborrat arribi (si Redis és inaccessible en aquell instant, la clau vella sobreviu). Amb diverses memòries cau (local + Redis + CDN), cal invalidar a totes.

La combinació pràctica: invalidació explícita com a mecanisme principal i TTL com a xarxa de seguretat, amb un TTL prou llarg perquè els misses siguin rars i prou curt perquè una fallada d'invalidació no duri hores. Per al catàleg de Quilòmetre Zero: TTL de 10 minuts i invalidació per esdeveniments.

Una regla derivada: invalidar (esborrar) és més segur que actualitzar la memòria cau en escriure. Si dues escriptures concurrents actualitzen la memòria cau en un ordre diferent que la base de dades, la memòria cau queda amb el valor vell fins al TTL; si totes dues esborren, la lectura següent recarrega el valor correcte. Ho veurem amb detall a l'apartat 6.

  1. Problemes clàssics: stampede, hot keys, penetració

Cache stampede / thundering herd. La clau producte:formatge-curat caduca a les 12:00:00 en plena campanya. En els 50 ms següents, 40 peticions descobreixen el miss alhora i les 40 llancen la mateixa consulta de 8 ms a PostgreSQL, i les 40 escriuen el mateix valor a Redis. Amb milers de claus caducant a la mateixa finestra (perquè totes es van carregar juntes en desplegar), la base de dades rep una allau que pot tombar-la, cosa que allarga les consultes, cosa que acumula més peticions en miss. Solucions, que es combinen:

Solució Com Cost
Lock (mutex) per clau El primer que detecta el miss adquireix un lock a Redis (SET lock:producte:formatge-curat <id> NX EX 5); recalcula i publica; els altres esperen (o serveixen el valor caducat si existeix) Els que esperen afegeixen latència; el lock ha de caducar per si el propietari mor
Request coalescing Dins d'un procés, les peticions concurrents de la mateixa clau s'agrupen i només una va a l'origen (single-flight) Només protegeix dins d'una instància; amb 10 instàncies, 10 consultes en lloc de 400
TTL amb jitter TTL = base ± aleatori (300 s ± 30 s) perquè les claus no caduquin en bloc Cap
Early recompute (probabilístic) En llegir una clau propera a caducar, amb una probabilitat creixent es recalcula abans que expiri (XFetch) Alguna recàrrega anticipada innecessària
Servir valor caducat mentre es recarrega (stale-while-revalidate) Es desa el valor amb TTL lògic més petit que el físic; si el lògic ha expirat, es retorna el vell i una tasca el refresca Obsolescència controlada

Hot keys. formatge-curat a la Setmana del Formatge Artesà rep 100 vegades més lectures que el producte mitjà. En una memòria cau distribuïda amb particionament (apartat 8), aquesta clau viu en un node, que es converteix en el coll d'ampolla mentre els altres resten ociosos. Solucions: memòria cau local a cada instància amb TTL curt per a les claus calentes (absorbeix el 99 % abans d'arribar a Redis), rèpliques de lectura del node que la té, o fragmentar la clau (producte:formatge-curat:0:9 amb el mateix contingut, triades a l'atzar en lectura, totes invalidades en escriptura) per repartir-la en 10 nodes.

Penetració de memòria cau. Un bot (o un enllaç trencat) demana producte:formatge-quadrat, que no existeix. Cache-aside cerca a Redis (miss), consulta PostgreSQL (no hi ha fila) i no escriu res a la memòria cau perquè no hi ha valor: cada petició d'una clau inexistent arriba a la base de dades. Amb un atac d'un milió de slugs inventats, la memòria cau no serveix de res. Solucions: guardar l'absència (SET producte:formatge-quadrat "" EX 60, un valor sentinella amb TTL curt) i, per a conjunts de claus enormes, un filtre de Bloom al davant: una estructura probabilística compacta que respon "segur que no existeix" o "potser existeix" sense falsos negatius, de manera que les claus que segur que no existeixen es rebutgen sense tocar ni la memòria cau ni la base (Redis ho ofereix amb el mòdul RedisBloom; Cassandra el fa servir internament per a les SSTables, com vam veure a 04-04).

  1. Consistència entre memòria cau i base de dades

La memòria cau és una rèplica de la base de dades (03-04) sense cap protocol de replicació: la manté l'aplicació, a mà, i per això les anomalies són responsabilitat del codi. Les dues preguntes són què fer en escriure (actualitzar o invalidar) i en quin ordre.

Considera actualitzar la memòria cau en escriure, amb dues escriptures concurrents de preu (A: 15,20; B: 15,90):

A: UPDATE preu = 15.20      B: UPDATE preu = 15.90      (BD = 15.90, B ha guanyat)
B: SET memòria cau = 15.90  A: SET memòria cau = 15.20  (memòria cau = 15.20: INCONSISTENT fins al TTL)

Amb invalidació (esborrar), el mateix entrellaçat deixa la clau esborrada i la lectura següent carrega 15,90: correcte. Queda l'ordre entre escriure a la BD i invalidar:

Ordre Anomalia possible Probabilitat
Invalidar, després escriure a la BD Entre totes dues, una lectura fa miss, carrega el valor vell de la BD i el guarda; després arriba l'escriptura: memòria cau vella fins al TTL Alta (la finestra és tota l'escriptura)
Escriure a la BD, després invalidar Una lectura fa miss abans de l'escriptura, l'escriptura i la invalidació passen, i després la lectura (lenta) escriu el valor vell a la memòria cau Baixa (la lectura de la BD hauria de ser més lenta que una escriptura completa), però possible
Escriure, invalidar, i tornar a invalidar després d'un retard (double delete) Cobreix el cas anterior esborrant de nou passat el temps d'una lectura Molt baixa; complexitat extra

La resposta habitual és "escriure a la BD, després invalidar", amb TTL com a xarxa de seguretat i, si la dada és sensible, doble esborrat. I hi ha una solució estructuralment millor, que reprèn 02-05: invalidar a partir dels esdeveniments de canvi. inventari ja publica estoc.actualitzat a comandes.esdeveniments mitjançant la taula outbox, a la mateixa transacció que el canvi, i aquest esdeveniment arriba segur (el relay ho garanteix) i arriba després del commit (per construcció). Un consumidor de cataleg esborra la clau en rebre'l. Amb això:

  • La invalidació no depèn que cada camí d'escriptura es recordi d'esborrar: qualsevol canvi que passi per l'outbox invalida.
  • Si Redis està caigut quan arriba l'esdeveniment, el consumidor no confirma l'offset i el reintenta: la invalidació és duradora.
  • L'ordre és sempre "commit, després invalidar".
  • El preu és el retard del pipeline (desenes de mil·lisegons, de vegades segons), durant el qual la memòria cau serveix el valor vell: consistència eventual amb una finestra acotada, acceptable per al catàleg i per a l'indicador d'estoc, no per a la reserva (que mai no llegeix de la memòria cau).
sequenceDiagram
    participant Inv as inventari
    participant PG as PostgreSQL km0_inventari<br/>(estoc + outbox)
    participant Rel as relay outbox
    participant K as Kafka comandes.esdeveniments
    participant Con as cataleg (consumidor)
    participant R as Redis
    participant Web as Web
    Inv->>PG: BEGIN; UPDATE estoc…; INSERT outbox(estoc.actualitzat); COMMIT
    Rel->>PG: llegeix outbox
    Rel->>K: publica estoc.actualitzat (clau = producte)
    K-->>Con: estoc.actualitzat {producte: formatge-curat, mercat: Girona, disponible: 1}
    Con->>R: DEL estoc:formatge-curat:Girona
    Con->>K: commit offset
    Web->>R: GET estoc:formatge-curat:Girona
    R-->>Web: (nil) → miss → gRPC inventari → SET amb TTL 10 s

  1. Memcached enfront de Redis

Els dos sistemes de memòria cau distribuïda més usats s'assemblen en el bàsic (clau-valor en memòria, xarxa, latència submil·lisegon) i difereixen en gairebé tota la resta:

Memcached Redis
Model de dades Cadenes de bytes Cadenes, hashes, llistes, conjunts, sorted sets, streams, HyperLogLog, bitmaps, geoespacial, JSON i Bloom amb mòduls
Fils Multifil: escala verticalment amb nuclis Un fil principal per a ordres (E/S multifil des de la 6); escala horitzontalment amb Cluster
Persistència Cap: és una memòria cau pura Opcional: RDB (instantànies) i AOF (registre d'ordres)
Replicació Cap (el client decideix, sovint amb hashing consistent) Primari-rèplica asíncrona; Sentinel per a failover; Cluster per a particionament
Operacions atòmiques incr, cas Totes les operacions són atòmiques; transaccions MULTI/EXEC; scripts Lua i funcions atòmiques
Expiració Per clau, amb desallotjament LRU Per clau, amb diverses polítiques de desallotjament (LRU, LFU, aleatòria, per TTL)
Pub/sub, cues, locks No Sí: pub/sub, streams, SET NX EX per a locks
Memòria Slab allocator molt eficient per a valors petits Més sobrecost per clau; estructures compactes per a hashes petits
Quan triar-lo Memòria cau pura de cadenes, màxima simplicitat, màxima eficiència per nucli Gairebé sempre que calgui alguna cosa més que get/set: estructures, locks, comptadors, cues lleugeres, persistència opcional

Quilòmetre Zero tria Redis: necessita el lock de l'stampede (SET NX EX), hashes per a les fitxes, sorted sets per al rànquing de productes més vistos (refresh-ahead), i opcionalment streams. I necessitarà Redis Cluster quan una sola instància no sigui suficient.

  1. Redis Cluster: slots, rèpliques i failover

Un Redis d'una sola instància serveix centenars de milers d'operacions per segon i desenes de GB; per anar més enllà, o per tolerar la caiguda d'un node, hi ha Redis Cluster, que és el particionament de 04-01 en la seva variant de nombre fix de particions:

  • L'espai de claus es divideix en 16 384 slots. La clau s'assigna amb slot = CRC16(clau) mod 16384. No hi ha anell ni vnodes: hi ha 16 384 particions fixes repartides entre els mestres (amb 3 mestres: 0–5460, 5461–10922, 10923–16383).
  • Cada mestre posseeix un rang de slots i té una o més rèpliques (replicació asíncrona, 03-04). Els nodes es coneixen per gossip i comparteixen el mapa de slots.
  • El client és informat (opció (c) de 04-01): descarrega el mapa de slots i envia cada ordre al mestre correcte. Si el mapa està desactualitzat, el node respon -MOVED 13183 172.22.0.4:6379 i el client actualitza i reintenta; durant una migració de slots respon -ASK. redis-py ho gestiona amb RedisCluster.
  • Hash tags: només la part entre claus es hasheja: {formatge-curat}:fitxa i {formatge-curat}:estoc:Girona cauen al mateix slot, cosa que permet operacions multiclau (MGET, transaccions, scripts Lua) sobre elles. Sense hash tag, un MGET de claus en slots diferents falla amb CROSSSLOT.
  • Failover: si un mestre deixa de respondre durant cluster-node-timeout (15 s per defecte), la majoria dels mestres el declara FAIL i una de les seves rèpliques es promociona (una elecció amb època, en la línia de Raft de 03-03). Les escriptures encara no replicades es perden: Redis Cluster és AP amb finestres de pèrdua, adequat per a una memòria cau, no per a dades que no es puguin reconstruir.
  • Reequilibratge: redis-cli --cluster rebalance o reshard mouen slots sencers entre mestres, clau a clau amb MIGRATE, mentre el clúster continua servint: el nombre fix de particions de 04-01 en acció.

Com a alternativa a Cluster quan no cal particionar, Redis Sentinel vigila un primari amb rèpliques i fa failover automàtic sense repartir les dades.

  1. Pràctica: serveis/cataleg/cache.py i Redis Cluster

Redis simple a docker-compose.yml per a la primera part:

# km0/docker-compose.yml (fragment)
services:
  redis:
    image: redis:7.4
    command: ["redis-server", "--maxmemory", "512mb", "--maxmemory-policy", "allkeys-lru", "--appendonly", "no"]
    ports: ["6379:6379"]

allkeys-lru fa que, en omplir-se els 512 MB, Redis desallotgi les claus menys usades recentment: comportament de memòria cau. appendonly no perquè una memòria cau no necessita persistència.

El mòdul de memòria cau (pip install redis). Implementa cache-aside amb TTL i jitter, lock contra stampede, guardat d'absències i un consumidor d'estoc.actualitzat que invalida:

# km0/serveis/cataleg/cache.py
"""Cache-aside del catàleg amb Redis: TTL amb jitter, lock anti-stampede, invalidació per esdeveniments."""
import json
import random
import time
import uuid
import redis

r = redis.Redis(host="localhost", port=6379, decode_responses=True)

TTL_PRODUCTE = 600          # 10 min: xarxa de seguretat; la invalidació real arriba per esdeveniments
TTL_ESTOC = 10              # l'indicador d'estoc tolera 10 s de retard
TTL_ABSENCIA = 60           # guardar "no existeix" contra la penetració
JITTER = 0.1                # ±10 %
SENTINELLA = "__NO_EXISTEIX__"

def _ttl_amb_jitter(base: int) -> int:
    return int(base * random.uniform(1 - JITTER, 1 + JITTER))

# --- lock anti-stampede ---------------------------------------------------------------------
def _adquirir_lock(clau: str, ttl_ms: int = 3000) -> str | None:
    token = str(uuid.uuid4())
    return token if r.set(f"lock:{clau}", token, nx=True, px=ttl_ms) else None

_ALLIBERAR = r.register_script("""
if redis.call('GET', KEYS[1]) == ARGV[1] then return redis.call('DEL', KEYS[1]) end
return 0
""")                                    # només el propietari allibera (comparar i esborrar de manera atòmica)

def _alliberar_lock(clau: str, token: str) -> None:
    _ALLIBERAR(keys=[f"lock:{clau}"], args=[token])

# --- cache-aside genèric ----------------------------------------------------------------------
def obtenir(clau: str, carregar, ttl: int, espera_max: float = 2.0):
    """Retorna el valor guardat o el carrega amb `carregar()` protegit per lock.
    `carregar` retorna None si la dada no existeix (es guarda l'absència)."""
    valor = r.get(clau)
    if valor is not None:
        return None if valor == SENTINELLA else json.loads(valor)

    inici = time.monotonic()
    while True:
        token = _adquirir_lock(clau)
        if token:
            try:
                valor = r.get(clau)                        # algú l'ha carregat mentre esperàvem el lock?
                if valor is not None:
                    return None if valor == SENTINELLA else json.loads(valor)
                dada = carregar()
                if dada is None:
                    r.set(clau, SENTINELLA, ex=TTL_ABSENCIA)
                else:
                    r.set(clau, json.dumps(dada), ex=_ttl_amb_jitter(ttl))
                return dada
            finally:
                _alliberar_lock(clau, token)
        # un altre procés està carregant: esperem una mica i reintentem la lectura
        time.sleep(0.02)
        valor = r.get(clau)
        if valor is not None:
            return None if valor == SENTINELLA else json.loads(valor)
        if time.monotonic() - inici > espera_max:
            return carregar()                              # degradació: anar a l'origen sense guardar

# --- funcions del catàleg ---------------------------------------------------------------------
def producte(slug: str, carregar_de_bd):
    return obtenir(f"producte:{slug}", lambda: carregar_de_bd(slug), TTL_PRODUCTE)

def estoc(slug: str, mercat: str, consultar_inventari):
    return obtenir(f"estoc:{slug}:{mercat}", lambda: consultar_inventari(slug, mercat), TTL_ESTOC)

def invalidar_producte(slug: str) -> None:
    r.delete(f"producte:{slug}")

def invalidar_estoc(slug: str, mercat: str | None = None) -> None:
    if mercat:
        r.delete(f"estoc:{slug}:{mercat}")
    else:
        claus = list(r.scan_iter(f"estoc:{slug}:*"))
        if claus:
            r.delete(*claus)

# --- consumidor d'estoc.actualitzat (invalidació per esdeveniments, 02-05) --------------------
def consumir_estoc_actualitzat():
    from kafka import KafkaConsumer          # pip install kafka-python
    consumidor = KafkaConsumer("comandes.esdeveniments", group_id="cataleg-cache",
                               bootstrap_servers="localhost:9092",
                               enable_auto_commit=False,
                               value_deserializer=lambda b: json.loads(b.decode()))
    for msg in consumidor:
        ev = msg.value
        if ev["tipus"] == "estoc.actualitzat":
            d = ev["dades"]
            invalidar_estoc(d["producte"], d.get("mercat"))
            print(f"invalidat estoc:{d['producte']}:{d.get('mercat', '*')} per {ev['id_esdeveniment']}")
        elif ev["tipus"] == "producte.actualitzat":
            invalidar_producte(ev["dades"]["slug"])
        consumidor.commit()                  # només després d'invalidar: si Redis falla, es reintenta

L'important de cada part:

  • obtenir és cache-aside amb lock per clau: SET lock:<clau> <token> NX PX 3000 només té èxit per al primer procés; els altres esperen 20 ms i rellegeixen. El lock caduca en 3 s per si el propietari mor. _ALLIBERAR és un script Lua perquè "si el token és meu, esborra" sigui atòmic: sense ell, un procés lent podria esborrar el lock d'un altre. (És un lock de memòria cau: si falla, el cost és una consulta duplicada; un lock de correcció, com el del relay de 03-03, es fa amb etcd.)
  • La doble comprovació després d'adquirir el lock evita que el segon a arribar recarregui el que el primer acaba d'escriure.
  • Si l'espera supera espera_max, es degrada a consultar l'origen directament sense guardar: millor una consulta extra que una petició penjada.
  • _ttl_amb_jitter desincronitza les caducitats; SENTINELLA guarda les absències contra la penetració.
  • El consumidor reutilitza el tòpic comandes.esdeveniments i l'embolcall id_esdeveniment/tipus/versio/data_ms/origen/dades de 02-05. enable_auto_commit=False i el commit() després d'invalidar fan la invalidació almenys un cop: esborrar dues vegades és inofensiu (idempotent), no esborrar és l'error que volem evitar. Fixa't que les claus de partició de Kafka són l'id de comanda, així que els estoc.actualitzat d'un mateix producte poden arribar per particions diferents i en ordre diferent; com que invalidem (no actualitzem), l'ordre no importa: és la raó de fons de la regla "esborrar, no actualitzar".

Mesura de latència (simulacions/latencia_cache.py): simulem la fitxa de formatge-curat amb una "base de dades" que triga 8 ms i comparem:

# km0/simulacions/latencia_cache.py
import statistics, time
from serveis.cataleg import cache

def carregar_de_bd(slug):
    time.sleep(0.008)                          # PostgreSQL: consulta de la fitxa, 8 ms
    return {"slug": slug, "nom": "Formatge curat d'ovella", "productor": "Formatgeria Montblanc",
            "preu": 14.50, "fotos": ["fotos/formatge-curat/miniatura-800.jpg"]}

def mesurar(f, n=2000):
    temps = []
    for _ in range(n):
        t0 = time.perf_counter(); f(); temps.append((time.perf_counter() - t0) * 1000)
    temps.sort()
    return statistics.mean(temps), temps[len(temps) // 2], temps[int(n * 0.99)]

cache.invalidar_producte("formatge-curat")
print("sense memòria cau   mitjana/p50/p99 (ms): %.2f / %.2f / %.2f" % mesurar(lambda: carregar_de_bd("formatge-curat")))
print("amb memòria cau     mitjana/p50/p99 (ms): %.2f / %.2f / %.2f" % mesurar(lambda: cache.producte("formatge-curat", carregar_de_bd)))
sense memòria cau   mitjana/p50/p99 (ms): 8.14 / 8.11 / 8.42
amb memòria cau     mitjana/p50/p99 (ms): 0.19 / 0.17 / 0.41

Quaranta vegades menys latència, i (el que importa a PostgreSQL) una consulta en lloc de 2 000. Executa després python -c "from serveis.cataleg.cache import *; [invalidar_producte('formatge-curat') for _ in range(1)]" des d'un altre terminal mentre corre la mesura amb 20 fils i veuràs a MONITOR de redis-cli un únic SET producte:formatge-curat després de cada DEL, amb els altres 19 fils llegint el valor acabat de carregar: el lock treballant.

Redis Cluster amb tres mestres i tres rèpliques, per a la segona part. Sis nodes amb cluster-enabled yes i un contenidor que els uneix:

# km0/docker-compose.yml (fragment)
x-redis-cluster: &redis-cluster
  image: redis:7.4
  command: ["redis-server", "--cluster-enabled", "yes", "--cluster-node-timeout", "5000",
            "--appendonly", "no", "--maxmemory", "256mb", "--maxmemory-policy", "allkeys-lru"]

services:
  redis-1: { <<: *redis-cluster, hostname: redis-1 }
  redis-2: { <<: *redis-cluster, hostname: redis-2 }
  redis-3: { <<: *redis-cluster, hostname: redis-3 }
  redis-4: { <<: *redis-cluster, hostname: redis-4 }
  redis-5: { <<: *redis-cluster, hostname: redis-5 }
  redis-6: { <<: *redis-cluster, hostname: redis-6 }
  redis-cluster-init:
    image: redis:7.4
    depends_on: [redis-1, redis-2, redis-3, redis-4, redis-5, redis-6]
    command: >
      sh -c "sleep 5 && redis-cli --cluster create
      redis-1:6379 redis-2:6379 redis-3:6379 redis-4:6379 redis-5:6379 redis-6:6379
      --cluster-replicas 1 --cluster-yes"

--cluster-replicas 1 assigna una rèplica a cada mestre (els tres primers són mestres, els tres últims rèpliques, aparellats evitant el mateix host quan és possible). Comprovacions:

docker compose exec redis-1 redis-cli cluster info | head -6      # cluster_state:ok, cluster_slots_assigned:16384
docker compose exec redis-1 redis-cli cluster nodes                # 3 master amb rangs de slots, 3 slave
docker compose exec redis-1 redis-cli cluster keyslot producte:formatge-curat        # p. ex. 13183
docker compose exec redis-1 redis-cli cluster keyslot "{formatge-curat}:fitxa"       # mateix slot que…
docker compose exec redis-1 redis-cli cluster keyslot "{formatge-curat}:estoc:Girona"   # …aquest
docker compose exec redis-1 redis-cli -c set producte:formatge-curat '{"preu":14.5}'  # -c: segueix MOVED
docker compose exec redis-1 redis-cli set producte:formatge-curat x   # sense -c: (error) MOVED 13183 172.22.0.4:6379
docker compose exec redis-1 redis-cli mget producte:formatge-curat producte:tomaquet-rosa   # (error) CROSSSLOT

CLUSTER KEYSLOT mostra el CRC16 mod 16384 de cada clau i demostra que les hash tags entre claus situen claus relacionades al mateix slot. La resposta MOVED sense -c és el mapa de particions parlant amb el client. I CROSSSLOT recorda que les operacions multiclau, en una memòria cau particionada, exigeixen col·localització deliberada.

Failover: docker compose stop redis-1; als 5 s (cluster-node-timeout), cluster nodes mostra redis-1 com a master,fail i la seva rèplica promocionada a master amb els slots 0–5460; les escriptures a aquestes claus continuen funcionant. En arrencar redis-1, s'uneix com a rèplica del nou mestre. Des de Python:

from redis.cluster import RedisCluster
rc = RedisCluster(host="redis-1", port=6379, decode_responses=True)   # des d'un contenidor de la xarxa de Compose
rc.set("producte:formatge-curat", "…")                                     # el client encamina per slot
print(rc.cluster_keyslot("producte:formatge-curat"))

RedisCluster de redis-py descarrega el mapa de slots, encamina cada ordre i segueix MOVED/ASK automàticament: el mòdul cache.py funciona sense canvis substituint redis.Redis per RedisCluster, llevat de scan_iter (que recorre tots els mestres) i de les operacions multiclau, que necessiten hash tags. Una millora natural del mòdul seria anomenar les claus {formatge-curat}:producte i {formatge-curat}:estoc:Girona per poder invalidar tot el d'un producte amb un sol DEL multiclau.

Errors Comuns i Consells

  • Guardar sense TTL "perquè ja invalidem". La invalidació fallarà alguna vegada (un camí d'escriptura oblidat, Redis caigut en l'instant just). El TTL és la xarxa de seguretat; posa'l sempre.
  • Actualitzar la memòria cau en escriure en lloc d'invalidar. Dues escriptures concurrents deixen la memòria cau amb el valor equivocat fins al TTL. Esborra; la lectura següent carrega el valor bo.
  • Invalidar abans d'escriure a la base de dades. Finestra gran per guardar el valor vell. Escriu primer, invalida després; millor encara, invalida des dels esdeveniments de l'outbox.
  • Totes les claus amb el mateix TTL, carregades alhora després d'un desplegament. Caduquen juntes: estampida. Jitter i, per al calent, refresh-ahead.
  • Sense guardat d'absències. Un bot amb slugs inventats travessa la memòria cau i arriba a la base de dades a cada petició. Sentinella amb TTL curt o filtre de Bloom.
  • Un lock sense caducitat o alliberat sense comprovar el propietari. Un procés mort bloqueja la clau per sempre; un procés lent allibera el lock d'un altre. NX PX i alliberament amb script Lua.
  • Confondre el lock de la memòria cau amb un lock de correcció. El de Redis amb SET NX pot fallar en un failover (les escriptures asíncrones es perden); si d'ell depèn no vendre dues vegades l'última peça, fes servir etcd (03-03) o la base de dades.
  • Guardar la resposta de la reserva d'estoc. L'indicador "queden poques unitats" pot anar 10 s endarrerit; la reserva real mai no llegeix de la memòria cau. Decideix la tolerància a l'obsolescència per dada.
  • Operacions multiclau a Redis Cluster sense hash tags. CROSSSLOT. Dissenya les claus amb {tag} des del principi; canviar-les després obliga a buidar la memòria cau.
  • Tractar Redis Cluster com a base de dades. El failover perd les escriptures no replicades. És una memòria cau (o un magatzem reconstruïble), i comandes i inventari viuen on va decidir 04-04.

Exercicis

Exercici 1. Implementa a cache.py la variant stale-while-revalidate d'obtenir: desa a Redis un hash amb els camps valor i caduca_logic (marca de temps), amb TTL físic igual al doble del lògic. Si en llegir el valor lògic ha caducat, retorna el valor vell immediatament i llança la recàrrega (en un fil, protegida pel mateix lock). Explica què guanya la web durant la Setmana del Formatge Artesà enfront de la versió amb lock de l'apartat 9 i què es perd.

Exercici 2. L'esdeveniment estoc.actualitzat es publica a comandes.esdeveniments amb clau de partició = id de comanda. Dues reserves de formatge-curat a Girona (comandes P-2026-000125 i P-2026-000126) generen dos esdeveniments en particions diferents que el consumidor cataleg-cache pot processar en ordre invers. Explica (a) per què la invalidació per esborrat és correcta en qualsevol ordre; (b) què passaria si el consumidor en lloc d'esborrar fes SET estoc:formatge-curat:Girona <disponible de l'esdeveniment>; (c) com canviaria la situació si inventari publiqués els esdeveniments d'estoc amb clau = producte, i què es perd amb aquest canvi (pista: 02-04 i la relació amb comanda.creada).

Exercici 3. Durant la campanya, producte:formatge-curat rep 15 000 lectures/s i el seu slot viu a redis-3, que satura (la resta de mestres és al 10 %). Proposa dues solucions combinades, escriu el codi de la fragmentació de la clau en N còpies ({formatge-curat}:producte:0..N-1) amb lectura aleatòria i invalidació de totes, i raona per què la hash tag {formatge-curat} fa que la fragmentació no ajudi a Redis Cluster i com anomenaries les còpies perquè sí que ajudi.

Solucions

Solució 1:

import threading

def obtenir_swr(clau: str, carregar, ttl_logic: int):
    h = r.hgetall(clau)
    ara = time.time()
    if h:
        valor = None if h["valor"] == SENTINELLA else json.loads(h["valor"])
        if float(h["caduca_logic"]) > ara:
            return valor                                     # fresc
        # caducat lògicament: retornem el vell i recarreguem en segon pla
        threading.Thread(target=_recarregar_swr, args=(clau, carregar, ttl_logic), daemon=True).start()
        return valor
    return _recarregar_swr(clau, carregar, ttl_logic)        # sense valor: càrrega síncrona (amb lock)

def _recarregar_swr(clau, carregar, ttl_logic):
    token = _adquirir_lock(clau)
    if not token:                                            # un altre procés recarrega; llegir el que hi hagi
        h = r.hgetall(clau)
        return json.loads(h["valor"]) if h and h["valor"] != SENTINELLA else None
    try:
        dada = carregar()
        r.hset(clau, mapping={"valor": SENTINELLA if dada is None else json.dumps(dada),
                              "caduca_logic": time.time() + _ttl_amb_jitter(ttl_logic)})
        r.expire(clau, ttl_logic * 2)                         # TTL físic: xarxa de seguretat
        return dada
    finally:
        _alliberar_lock(clau, token)

El que guanya la web: durant la campanya, cap petició d'una clau calenta no espera la base de dades (ni tan sols la que dispara la recàrrega), així que el p99 es manté en el submil·lisegon de Redis en lloc de saltar a 8 ms cada 10 minuts; i l'estampida és impossible perquè només una recàrrega corre alhora i les altres serveixen el valor antic. El que es perd: obsolescència addicional (el valor vell se serveix fins que la recàrrega acaba, i si l'origen falla, fins al TTL físic), memòria (un hash per clau) i complexitat (fils en segon pla, que en un servidor asíncron es farien amb una tasca). Per al preu d'una fitxa és un bon tracte; per a l'indicador d'estoc amb TTL de 10 s, discutible.

Solució 2:

(a) DEL és idempotent i no porta valor: tant és quin dels dos esdeveniments es processi primer, el resultat final és "la clau no hi és", i la lectura següent la recarrega des d'inventari, que té el valor correcte (disponible després de totes dues reserves). La invalidació converteix un problema d'ordre en un problema d'existència, que no té ordre.

(b) Amb SET <disponible de l'esdeveniment>, si l'esdeveniment de P-2026-000126 (disponible = 1) es processa abans que el de P-2026-000125 (disponible = 2), la memòria cau acaba en 2 mentre l'estoc real és 1: incorrecta fins al TTL de 10 s. Caldria comparar data_ms o una versió i descartar esdeveniments antics (el patró last-writer-wins de 03-04, amb els seus riscos de rellotge de 01-05), que és més codi i més fallades que un DEL.

(c) Amb clau = producte, tots els estoc.actualitzat de formatge-curat anirien a la mateixa partició i arribarien en ordre (02-04), i llavors SET seria segur. Però es perd la col·localització amb els altres esdeveniments de la comanda: comanda.creada, estoc.reservat i pagament.confirmat de P-2026-000125 deixarien de ser a la mateixa partició que el seu estoc.actualitzat, i els consumidors que reconstrueixen la història d'una comanda en ordre (la saga per coreografia de 03-05, analitica) perdrien aquesta garantia. Alternativa neta: un tòpic separat inventari.estoc amb clau = producte per als esdeveniments d'estoc, que és el que faria un disseny madur; mentrestant, el DEL funciona amb qualsevol clau.

Solució 3:

Solucions combinades: (1) una memòria cau local a cada instància de cataleg (cachetools.TTLCache(maxsize=500, ttl=2)) per a les claus més llegides, que absorbeix la immensa majoria de les 15 000 lectures/s abans d'arribar a Redis, i invalidada també pel consumidor d'esdeveniments (cada instància consumeix amb el seu propi group_id, o es publica un PUBLISH de Redis a totes); (2) fragmentar la clau en N còpies per repartir-la entre mestres:

N_COPIES = 8
def clau_copia(slug, i): return f"producte:{slug}:c{i}"           # sense hash tag: cada còpia al seu propi slot
def producte_fragmentat(slug, carregar_de_bd):
    i = random.randrange(N_COPIES)
    return obtenir(clau_copia(slug, i), lambda: carregar_de_bd(slug), TTL_PRODUCTE)
def invalidar_producte_fragmentat(slug):
    for i in range(N_COPIES):
        r.delete(clau_copia(slug, i))                                  # a Cluster: N DEL, un per slot

Amb la hash tag {formatge-curat} totes les còpies tindrien el mateix slot (només es hasheja el que hi ha entre claus) i per tant el mateix mestre: es repartiria la càrrega entre claus, no entre nodes, que és just el que no serveix. Sense hash tag (producte:formatge-curat:c0c7), el CRC16 de cada nom complet cau en slots diferents i, amb alta probabilitat, en mestres diferents; es pot comprovar amb CLUSTER KEYSLOT i ajustar els sufixos fins que les 8 còpies cobreixin els 3 mestres. El cost: 8 misses en lloc d'1 després de cada invalidació (irrellevant amb 300 canvis al dia) i 8 DEL per invalidació, que ja no pot ser multiclau (van a slots diferents); les rèpliques de lectura de Redis Cluster (READONLY al client) són la tercera opció, que reparteix lectures del mateix slot entre mestre i rèplica sense canviar les claus, a canvi de llegir amb retard de replicació.

Conclusió

Una memòria cau distribuïda és una rèplica de conveniència mantinguda a mà per l'aplicació, i per això concentra al codi tots els problemes de replicació que les bases de dades resolen per protocol. Guardar en memòria cau compensa quan la ràtio lectures/escriptures és alta i la dada tolera una certa obsolescència, com el catàleg de Quilòmetre Zero amb 20 000 lectures per escriptura: la mesura ha fet baixar la fitxa de formatge-curat de 8 ms a 0,2 ms i ha tret a PostgreSQL el 98 % de les consultes de la Setmana del Formatge Artesà. Hem situat la memòria cau a les seves capes (client, CDN, local, distribuïda), recorregut els patrons (cache-aside com a elecció, refresh-ahead per al calent, write-through i write-behind per a altres casos), i tractat la invalidació amb la regla "TTL com a xarxa, invalidació explícita com a mecanisme, esborrar en lloc d'actualitzar, després d'escriure a la base de dades". Contra l'estampida: lock per clau amb token i script Lua, coalescing, jitter i recàrrega anticipada; contra les claus calentes: memòria cau local, rèpliques i fragmentació; contra la penetració: sentinelles i filtres de Bloom. La invalidació pels esdeveniments estoc.actualitzat de comandes.esdeveniments ha convertit l'outbox de 02-05 en el mecanisme de coherència entre inventari i la memòria cau de cataleg, durador i correcte en qualsevol ordre. Redis ha guanyat Memcached per les seves estructures i els seus locks, i Redis Cluster ha tancat el cercle amb el mòdul: 16 384 slots fixos, MOVED com a mapa de particions en mans del client, hash tags per col·localitzar, rèpliques i failover asíncron que el fan adequat com a memòria cau i no com a base de dades.

Amb aquesta lliçó es tanca el Mòdul 4, i cada dada de Quilòmetre Zero ja té el seu lloc:

Dada On viu Lliçó Per què
Comandes (historial, "les meves comandes", sagues) Cassandra km0_comandes, particionat per client_id (+ mes), RF=3, LOCAL_QUORUM 04-01, 04-04 Escriptura intensiva, consultes estables, disponibilitat
Estoc per producte i mercat PostgreSQL km0_inventari (primari + rèplica), particionat per producte_slug 03-04, 04-01, 04-04 Comptador CP amb restriccions i transaccions locals
Pagaments PostgreSQL 04-04 CP, baix volum, auditoria
Catàleg (fitxes, productors) PostgreSQL amb jsonb + Redis (cache-aside, TTL 10 min, invalidació per esdeveniments) 04-04, 04-05 20 000 lectures per escriptura; tolera segons de retard
Indicador d'estoc a la fitxa Redis, TTL 10 s, invalidat per estoc.actualitzat 04-05 Tolerància explícita a 10 s; la reserva mai no llegeix d'aquí
Posicions de furgoneta-3 Cassandra, partició repartidor + dia, ONE 04-01, 04-04 AP, 2,4 M escriptures/dia, accés per repartidor
Índexs per mercat i productor Taules materialitzades des de comandes.esdeveniments 04-01 Índex global asíncron, sense scatter/gather
Fotos de productes MinIO km0-fotos, versionat, URLs presignats, CDN al davant 04-03 Objectes immutables servits per HTTP; egress
Factures PDF i backups de PostgreSQL MinIO km0-factures, km0-backups, cicle de vida 04-03 Immutables, retenció, cost per GB
Esdeveniments de comandes.esdeveniments i logs de clics HDFS /km0/esdeveniments/<dia>/, /km0/clics/<dia>/, un fitxer per dia 04-02 Llac de dades: massiu, seqüencial, write-once
Mapa de particions i lideratge etcd 03-03, 04-01 Consens per a l'estat de coordinació

Les dades estan repartides entre molts nodes i sabem per què cadascuna és on és. La pregunta que segueix és com processar-les en massa ara que ja no caben en una màquina: com calcular les vendes de la Setmana de la Verema per productor i mercat sobre els centenars de milions d'esdeveniments del llac, com entrenar recomanacions amb els clics d'un any, com alimentar en temps real el panell de repartiment. És el terreny del Mòdul 5, la computació distribuïda, que comença pels models de computació distribuïda: què vol dir repartir un càlcul, i no només una dada, entre molts nodes.

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