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
- Per què fer servir memòria cau: latència i càrrega a la base de dades
- On posar la memòria cau: client, CDN, aplicació i memòria cau distribuïda
- Patrons de memòria cau
- Invalidació i TTL: els dos problemes difícils
- Problemes clàssics: stampede, hot keys, penetració
- Consistència entre memòria cau i base de dades
- Memcached enfront de Redis
- Redis Cluster: slots, rèpliques i failover
- Pràctica:
serveis/cataleg/cache.pyi Redis Cluster - Errors Comuns i Consells
- Exercicis
- Conclusió
- 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.
- 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.
- 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.
- 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-curatvol 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.
- 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).
- 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
- 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.
- 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:6379i el client actualitza i reintenta; durant una migració de slots respon-ASK.redis-pyho gestiona ambRedisCluster. - Hash tags: només la part entre claus es hasheja:
{formatge-curat}:fitxai{formatge-curat}:estoc:Gironacauen al mateix slot, cosa que permet operacions multiclau (MGET, transaccions, scripts Lua) sobre elles. Sense hash tag, unMGETde claus en slots diferents falla ambCROSSSLOT. - Failover: si un mestre deixa de respondre durant
cluster-node-timeout(15 s per defecte), la majoria dels mestres el declaraFAILi 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 rebalanceoreshardmouen slots sencers entre mestres, clau a clau ambMIGRATE, 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.
- Pràctica:
serveis/cataleg/cache.py i Redis Cluster
serveis/cataleg/cache.py i Redis ClusterRedis 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 reintentaL'important de cada part:
obtenirés cache-aside amb lock per clau:SET lock:<clau> <token> NX PX 3000nomé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_jitterdesincronitza les caducitats;SENTINELLAguarda les absències contra la penetració.- El consumidor reutilitza el tòpic
comandes.esdevenimentsi l'embolcallid_esdeveniment/tipus/versio/data_ms/origen/dadesde 02-05.enable_auto_commit=Falsei elcommit()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 elsestoc.actualitzatd'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) CROSSSLOTCLUSTER 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 PXi alliberament amb script Lua. - Confondre el lock de la memòria cau amb un lock de correcció. El de Redis amb
SET NXpot 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
comandesiinventariviuen 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 slotAmb 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:c0…c7), 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
- 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
