Les quatre lliçons anteriors han donat a MercadoFresco les peces: cues per desacoblar, temes per repartir, un bus per encaminar per contingut i fluxos per orquestrar. Però han anat deixant preguntes ajornades amb un «ho veurem a 07-05», i totes apunten al mateix lloc. Què significa exactament «almenys una vegada» quan el que es duplica és un cobrament de 48,20 €? Com s'escriu un consumidor que pot rebre el mateix missatge tres vegades sense cobrar tres vegades? Quantes vegades cal reintentar, i quan cal parar per no tombar qui s'està recuperant? Què es fa exactament amb 340 missatges en una DLQ un dilluns al matí? I com es garanteix que una comanda escrita a Aurora es publica sempre, si no hi ha transacció que abasti la base de dades i el bus d'esdeveniments?
Aquesta lliçó no presenta serveis nous. Converteix els quatre que ja coneixes en criteri de disseny: quan fer servir cadascun, quines garanties donen de debò i què hi has de posar tu perquè el servei no ho posa. És la lliçó que separa una arquitectura que funciona a la demostració d'una que sobreviu a un divendres d'octubre amb el proveïdor de pagaments a mig gas.
Avís de cost. La taula
mercadofresco-idempotenciasota demanda amb TTL de 24 h costa uns 0,90 USD al mes amb el volum de MercadoFresco. El car no és això: és un cobrament duplicat, que a més dels 48 € costa una trucada a atenció al client i una devolució. Dades fictícies.
Contingut
- Garanties de lliurament: les tres, i per què una és gairebé mentida
- Idempotència: la propietat que ho arregla tot
- La taula
mercadofresco-idempotencia - Un consumidor idempotent complet
- Reintents: retrocés exponencial i fluctuació
- Quins errors mereixen reintent i quins no
- On reintentar: SDK, servei o flux
- La tempesta de reintents
- Interruptor de circuit per al proveïdor de pagaments
- Cues de missatges fallits: classificar i reprocessar
- El runbook de la DLQ de la Marta
- Ordre i agrupació: quan importa de debò
- El patró outbox i el problema de la doble escriptura
- Contrapressió, esmorteïment i estrangulament
- Taula decisòria: SQS, SNS, EventBridge, Step Functions o síncron
- L'arquitectura d'integració completa de MercadoFresco
- Errors habituals i consells
- Exercicis
- Conclusió
Garanties de lliurament: les tres, i per què una és gairebé mentida
| Garantia | Què promet | Què falla | On apareix |
|---|---|---|---|
| Com a molt una vegada | No duplica mai | Pot perdre | SNS a correu/SMS, UDP, «dispara i oblida» |
| Almenys una vegada | No perd mai | Pot duplicar | SQS estàndard, SNS a SQS, EventBridge, Lambda asíncron |
| Exactament una vegada | Ni perd ni duplica | Costa i gairebé mai és d'extrem a extrem | SQS FIFO (a la seva finestra), Step Functions estàndard |
Pràcticament tota la missatgeria seriosa tria almenys una vegada, i per una raó senzilla: entre perdre una comanda i processar-la dues vegades, la segona cosa té solució i la primera no.
L'«exactament una vegada» gairebé mai no existeix d'extrem a extrem, i convé entendre per què. Imagina el consumidor que cobra la targeta:
- Rep el missatge de
cola-mercadofresco-pedidos. - Crida la passarel·la. La passarel·la cobra.
- La xarxa es talla abans que arribi la resposta.
- El procés mor, o llança una excepció, i no esborra el missatge.
- El temps de visibilitat expira. Un altre consumidor rep el mateix missatge.
- Crida la passarel·la. La passarel·la cobra un altre cop.
Cap servei de missatgeria no ho pot evitar, perquè el problema no és al lliurament: és que cobrar i confirmar que has cobrat són dues operacions diferents i entre elles hi cap una fallada. SQS FIFO dedupla l'enviament del productor durant 5 minuts, no l'efecte del consumidor. Step Functions estàndard garanteix que la seva pròpia màquina no repeteix un pas, però si la Lambda del pas va cobrar i va fallar en respondre, Step Functions reintentarà el pas.
La conclusió operativa és curta i cal acceptar-la sense resistència: el sistema lliura almenys una vegada; l'efecte exactament una vegada el posa el teu consumidor. I això s'anomena idempotència.
Idempotència: la propietat que ho arregla tot
Una operació és idempotent si executar-la diverses vegades amb la mateixa entrada produeix el mateix resultat que executar-la una vegada. No vol dir «no fa res la segona vegada»: vol dir que l'estat final és el mateix.
| Operació | Idempotent? | Per què |
|---|---|---|
SET stock:FRUT-011 = 40 |
Sí | Escriure un valor absolut |
DECR stock:FRUT-011 BY 2 |
No | Cada execució resta un altre cop |
PutItem amb la mateixa clau i dades |
Sí | Sobreescriu amb el mateix |
UpdateItem ADD contador 1 |
No | Increment relatiu |
INSERT amb clau primària pedido_id |
Sí (falla la segona) | La restricció ho impedeix |
| Cobrar 48,20 € a la passarel·la | No | Dos cobraments, dos apunts |
| Enviar un correu | No | Dos correus a la safata |
| Generar la miniatura d'una foto | Sí | Sobreescriu el mateix objecte de S3 |
s3:PutObject amb la mateixa clau |
Sí | Sobreescriu |
| Publicar un esdeveniment al bus | No (els consumidors el veuen dues vegades) | — |
La regla que se'n dedueix: les operacions que fixen un valor són idempotents; les que el modifiquen en relatiu o produeixen efectes externs, no. I quan l'operació no és idempotent per naturalesa, cal fer-la idempotent afegint-hi una clau d'idempotència.
Una clau d'idempotència és un identificador estable de l'operació lògica, no del missatge. La diferència és crucial:
- ❌
MessageIdd'SQS. Canvia si el productor reenvia la mateixa feina. No serveix. - ❌ Un UUID generat al consumidor. Canvia a cada intent. No serveix de res.
- ✅
f"cobro:{pedido_id}"— deriva del domini, és estable entre reintents i entre productors. - ✅
f"correo-confirmacion:{pedido_id}"— un correu per comanda, sigui quin sigui el camí. - ✅
f"reserva:{pedido_id}:{sku}"— un moviment d'estoc per línia de comanda. - ✅ Un resum del contingut canònic, quan no hi ha identificador natural.
Fixa't que la clau inclou quina operació a més de sobre què. PED-084417 a seques no val: la
mateixa comanda genera un cobrament, un correu i diverses reserves, i són operacions diferents que han
de poder executar-se cadascuna una vegada.
Moltes API de tercers accepten la clau directament. La passarel·la de pagament de MercadoFresco admet una
capçalera Idempotency-Key: enviant cobro:PED-2026-084417, un segon intent retorna el mateix
cobrament en lloc de crear-ne un de nou. Quan el proveïdor ho admet, aquesta és sempre la millor
opció, perquè la garantia la dona qui té l'estat. Quan no ho admet, hem de portar el registre
nosaltres.
La taula mercadofresco-idempotencia
aws dynamodb create-table \
--table-name mercadofresco-idempotencia \
--attribute-definitions AttributeName=clave_idempotencia,AttributeType=S \
--key-schema AttributeName=clave_idempotencia,KeyType=HASH \
--billing-mode PAY_PER_REQUEST \
--sse-specification Enabled=true,SSEType=KMS,KMSMasterKeyId=alias/mercadofresco-datos \
--tags Key=Proyecto,Value=mercadofresco Key=Entorno,Value=produccion \
Key=Componente,Value=integracion Key=Propietario,Value=marta \
Key=CentroCoste,Value=tecnologia \
--profile mercadofresco-dev --region eu-west-1
aws dynamodb update-time-to-live --table-name mercadofresco-idempotencia \
--time-to-live-specification 'Enabled=true,AttributeName=expira_en' \
--profile mercadofresco-dev --region eu-west-1Un element té aquesta forma:
{
"clave_idempotencia": "cobro:PED-2026-084417",
"estado": "COMPLETADO",
"resultado": { "referencia_cobro": "PAY-77321", "importe_eur": 48.20 },
"iniciado_en": "2026-08-02T18:41:07Z",
"completado_en": "2026-08-02T18:41:08Z",
"expira_en": 1785350467,
"ejecucion": "arn:aws:states:eu-west-1:111122223333:execution:...:PED-2026-084417"
}Quatre decisions de disseny, totes amb motiu:
L'estado té tres valors, no dos. EN_CURSO, COMPLETADO i FALLIDO. EN_CURSO és el que resol
el cas difícil: dos consumidors que reben el mateix missatge alhora. El primer marca EN_CURSO amb
una escriptura condicional; el segon veu que existeix i es retira. Amb només dos estats, tots dos
veurien «no hi és» i cobrarien tots dos.
Es desa el resultado. Si la feina ja s'ha fet, el segon intent no ha d'ignorar simplement el
missatge: ha de retornar el mateix resultat que la primera vegada. En una màquina d'estats, això
significa que el pas continua amb la mateixa referencia_cobro i el procés tira endavant en lloc de
trencar-se.
TTL de 24 hores (expira_en en segons epoch, el mateix mecanisme de mercadofresco-carritos a
06-02). El termini ha de cobrir amb folgança la vida màxima d'un missatge al sistema: la retenció de la
cua (4 dies) és massa, i una hora és massa poc si alguna cosa es queda a la DLQ i es reprocessa a la
tarda. 24 hores és l'equilibri de MercadoFresco. Sense TTL, la taula creix indefinidament i pagues
emmagatzematge per registres que ningú no consultarà mai.
Xifrada amb alias/mercadofresco-datos, perquè el resultado pot contenir referències de cobrament.
Un consumidor idempotent complet
import json, os, time
from datetime import datetime, timezone
import boto3
from botocore.exceptions import ClientError
ddb = boto3.resource("dynamodb", region_name="eu-west-1")
taula = ddb.Table("mercadofresco-idempotencia")
TTL_SEGONS = 24 * 3600
TERMINI_EN_CURS = 900 # 15 min: mes enlla, s'assumeix que l'altre consumidor ha mort
class TreballEnCurs(Exception):
"""Un altre consumidor esta processant aquesta mateixa operacio ara mateix."""
def reservar_execucio(clau):
"""Marca l'operacio com a EN_CURSO. Retorna None si som els primers,
o l'element existent si ja hi era (completat, fallit o en curs)."""
ara = int(time.time())
try:
taula.put_item(
Item={
"clave_idempotencia": clau,
"estado": "EN_CURSO",
"iniciado_en": datetime.now(timezone.utc).isoformat(),
"expira_en": ara + TTL_SEGONS,
},
# L'escriptura condicional es el cor del patro: nomes escriu si no
# existeix, o si existeix pero es un EN_CURSO abandonat fa mes de 15 min.
ConditionExpression="attribute_not_exists(clave_idempotencia) "
"OR (estado = :en_curso AND caduca_bloqueo < :ahora)",
ExpressionAttributeValues={":en_curso": "EN_CURSO", ":ahora": ara},
)
return None # som els primers
except ClientError as e:
if e.response["Error"]["Code"] != "ConditionalCheckFailedException":
raise
return taula.get_item(Key={"clave_idempotencia": clau})["Item"]
def completar(clau, resultat):
taula.update_item(
Key={"clave_idempotencia": clau},
UpdateExpression="SET estado = :c, resultado = :r, completado_en = :t",
ExpressionAttributeValues={
":c": "COMPLETADO", ":r": resultat,
":t": datetime.now(timezone.utc).isoformat(),
},
)
def alliberar(clau):
"""Fallada transitoria: s'esborra el registre perque el reintent pugui entrar."""
taula.delete_item(Key={"clave_idempotencia": clau})
def processar_comanda_idempotent(missatge):
"""Consumidor de cola-mercadofresco-pedidos. Pot rebre el mateix missatge N vegades."""
comanda = json.loads(missatge["Body"])
clau = f"cobro:{comanda['pedido_id']}" # clau de domini, no MessageId
existent = reservar_execucio(clau)
if existent and existent["estado"] == "COMPLETADO":
# Ja s'ha fet. Retornem el MATEIX resultat i esborrem el missatge.
log.info("duplicat ignorat", extra={"clau": clau})
return existent["resultado"]
if existent and existent["estado"] == "EN_CURSO":
# Un altre consumidor el te ara mateix. NO esborrem el missatge: que torni.
raise TreballEnCurs(clau)
try:
resultat = passarella.cobrar(
comanda["cliente_id"], comanda["importe_eur"],
idempotency_key=clau, # doble xarxa: tambe al proveidor
)
completar(clau, {"referencia_cobro": resultat.referencia,
"importe_eur": comanda["importe_eur"]})
return {"referencia_cobro": resultat.referencia}
except TarjetaRechazada:
# Fallada permanent: es registra com a FALLIDO per no reintentar en bucle.
taula.update_item(
Key={"clave_idempotencia": clau},
UpdateExpression="SET estado = :f", ExpressionAttributeValues={":f": "FALLIDO"},
)
raise
except Exception:
# Fallada transitoria: s'allibera el bloqueig perque el reintent pugui entrar.
alliberar(clau)
raiseEls quatre punts que fan que això funcioni de debò:
- L'escriptura condicional és atòmica. DynamoDB garanteix que només un consumidor guanya la cursa. No cal cap bloqueig distribuït; és una única crida.
- L'
EN_CURSOcaduca. Sense la condició sobrecaduca_bloqueo, un consumidor que morís entre elput_itemi elcompletardeixaria la clau bloquejada 24 hores i aquella comanda no es cobraria mai. - Fallada transitòria allibera; fallada permanent no. Si la xarxa ha fallat, cal permetre el reintent. Si la targeta s'ha rebutjat, reintentar és llençar peticions a les escombraries.
- La clau viatja també a la passarel·la. Si el proveïdor admet
Idempotency-Key, es fan servir les dues xarxes: la nostra i la seva. Si la nostra falla just al buit entre cobrar i registrar, la seva ens cobreix.
Powertools for AWS Lambda implementa aquest patró complet amb un decorador, incloent-hi la taula, el
TTL, el bloqueig i la memòria cau del resultat. En Python n'hi ha prou amb @idempotent sobre el
manejador, configurant DynamoDBPersistenceLayer i l'event_key_jmespath que extreu la clau de
l'esdeveniment. En producció és el raonable; escriure'l a mà una vegada, com aquí, és el que permet
entendre què fa i depurar-lo quan falli.
Reintents: retrocés exponencial i fluctuació
Reintentar sense espera és contraproduent: si el servei falla per saturació, els reintents immediats el saturen més. El retrocés exponencial (exponential backoff) multiplica l'espera a cada intent; la fluctuació (jitter) l'aleatoritza.
Sense fluctuacio (base 1 s, factor 2) Amb fluctuacio completa
intent 1 → 1,00 s intent 1 → 0,42 s
intent 2 → 2,00 s intent 2 → 1,17 s
intent 3 → 4,00 s intent 3 → 0,88 s
intent 4 → 8,00 s intent 4 → 6,31 s
intent 5 → 16,00 s intent 5 → 3,05 s
450 clients reintentant 450 clients reintentant
| | | | .. . ... . .. .. . ...
↑ ↑ ↑ ↑ distribuits en l'interval
pics sincronitzats sense picsLa fluctuació no és un detall d'afinament: és el que evita que la recuperació provoqui la caiguda següent. Sense ella, si la passarel·la cau 30 segons, les 450 execucions en curs reintenten exactament en el mateix instant i la tomben altre cop just quan s'estava aixecant.
import random, time
def amb_reintents(fn, intents=5, base=1.0, sostre=30.0):
"""Retroces exponencial amb fluctuacio completa. Nomes reintenta el reintentable."""
for n in range(intents):
try:
return fn()
except ErrorPermanent:
raise # 4xx de negoci: no es reintenta
except ErrorTransitori as e:
if n == intents - 1:
raise # s'han esgotat: que pugi
espera = random.uniform(0, min(sostre, base * (2 ** n)))
log.warning("reintent", extra={"intent": n + 1, "espera_s": round(espera, 2),
"error": str(e)})
time.sleep(espera)random.uniform(0, ...) és la fluctuació completa, la variant que millor dispersa la càrrega segons
les anàlisis d'AWS. Hi ha alternatives —fluctuació «igualada» (meitat + aleatori(0, meitat)) o
«descorrelacionada»— però la completa és la més simple i la que millor funciona en la majoria dels
casos. El sostre evita que el quart reintent esperi vuit minuts.
Quins errors mereixen reintent i quins no
| Error | Reintentar? | Per què |
|---|---|---|
500, 502, 503, 504 |
Sí | Fallada del servidor, probablement transitòria |
429 / ThrottlingException |
Sí, amb més espera | Vas massa de pressa |
ProvisionedThroughputExceededException |
Sí | Igual, a DynamoDB |
| Temps d'espera de connexió o lectura | Sí, amb compte | Pot haver-se executat: exigeix idempotència |
400 / ValidationException |
No | El missatge està malament; reintentar no ho arregla |
401 / 403 / AccessDenied |
No | Falten permisos; cal corregir la política |
404 |
Depèn | Si és una escriptura recent, pot ser consistència eventual |
409 / conflicte |
Depèn | Amb escriptura condicional, sol voler dir «ja està fet» |
Error de negoci (TarjetaRechazada) |
No | Reintentar no canviarà la decisió del banc |
La distinció pràctica —5xx sí, 4xx no— té dos matisos importants. El 429 és un 4xx que sí que
es reintenta, perquè significa exactament «torna més tard», i cal fer-ho amb més espera del que és
normal (respecta la capçalera Retry-After si ve). I els temps d'espera esgotats són el cas
perillós: no saps si l'operació s'ha executat. Reintentar-los és correcte només si la teva operació és
idempotent; si no ho és, un reintent després d'un temps d'espera és exactament com es cobra dues
vegades a un client.
Un error molt comú és tractar ConditionalCheckFailedException de DynamoDB com a transitori. No ho és:
significa que la condició no s'ha complert —normalment, que ja existeix—, i reintentar donarà el mateix
resultat. Al patró d'idempotència, aquest error és la resposta correcta, no una fallada.
On reintentar: SDK, servei o flux
Hi ha tres capes de reintent i s'acumulen, cosa que sorprèn molta gent.
| Capa | Qui | Configuració | Abast |
|---|---|---|---|
| SDK | boto3 / botocore | retries={"max_attempts": 5, "mode": "standard"} |
Crides a API d'AWS |
| Servei | SQS, Lambda, EventBridge, SNS | maxReceiveCount, MaximumRetryAttempts |
Lliurament del missatge |
| Flux | Step Functions | Retry amb BackoffRate i JitterStrategy |
Un pas del procés |
El perill és el producte. Si l'SDK reintenta 5 vegades, el consumidor rep el missatge 4 vegades
(maxReceiveCount=4) i el Retry del flux ho intenta 4 cops més, una caiguda de 30 segons pot generar
80 crides per a una sola comanda. Amb 450 comandes en curs són 36.000 crides contra un servei que ja
estava malament.
La disciplina de MercadoFresco: una capa mana i les altres són mínimes. Al procés de comanda, la capa
que mana és el Retry de Step Functions, que és on hi ha la política de negoci; l'SDK es deixa a
max_attempts=2 per absorbir fallades de xarxa instantànies, i el maxReceiveCount de les cues actua
només com a xarxa de seguretat davant de caigudes del mateix consumidor. Escriu en algun lloc quants
intents totals pot rebre una dependència en el pitjor cas: si el nombre supera 15, hi ha alguna cosa mal.
La tempesta de reintents
La tempesta de reintents (retry storm) és la fallada en cascada més habitual en sistemes distribuïts, i mereix descriure's sencera perquè gairebé sempre es diagnostica al revés:
- La passarel·la de pagament es degrada: la latència puja de 240 ms a 4 s.
- Els consumidors esperen més, així que el ritme de procés cau i la cua creix.
- Lambda veu créixer la cua i escala: de 20 a 60 sondejadors.
- La passarel·la rep el triple de peticions just quan pitjor està, i comença a retornar 503.
- Cada 503 dispara reintents. La càrrega es multiplica de nou.
- La passarel·la cau del tot. Tots els reintents fallen i consumeixen
maxReceiveCount. - Milers de comandes legítimes acaben a la DLQ.
- La passarel·la es recupera. Tots els reintents pendents surten alhora i la tomben un altre cop.
Quatre defenses, i calen totes quatre:
- Fluctuació a tots els reintents. Trenca la sincronització dels passos 5 i 8.
- Sostre de concurrència (
MaximumConcurrencyal mapatge d'origen d'esdeveniments). Evita el pas 3: la cua creix, però la pressió sobre la passarel·la no. - Pressupost de reintents. Reintentar com a màxim un percentatge del trànsit (un 10 % és habitual). Si més del 10 % de les crides són reintents, es deixa de reintentar fins que la proporció baixi.
- Interruptor de circuit. Deixa de cridar del tot mentre el servei estigui caigut.
Interruptor de circuit per al proveïdor de pagaments
L'interruptor de circuit (circuit breaker) és un component que compta fallades i, quan passen d'un llindar, deixa de cridar el servei i falla immediatament. Sona dràstic i és exactament el que cal: si la passarel·la porta 20 fallades seguides, la petició 21 també fallarà, i l'única cosa que aconsegueix és gastar 30 segons de temps d'espera i mantenir la pressió sobre un servei que intenta recuperar-se.
stateDiagram-v2
[*] --> Tancat
Tancat --> Obert: 20 fallades en 60 s
Obert --> SemiObert: passen 30 s
SemiObert --> Tancat: 3 proves correctes
SemiObert --> Obert: 1 prova falla
note right of Tancat
Passa tot.
Es compten les fallades.
end note
note right of Obert
No es crida el servei.
Falla a l'instant (fail fast).
end note
note right of SemiObert
Deixa passar unes poques
peticions de prova.
end note
L'estat es desa a mercadofresco-catalogo (ElastiCache/Valkey, 06-05), perquè ha de ser compartit
entre tots els consumidors: un interruptor a la memòria del procés no serveix de res quan hi ha 20
Lambdes concurrents, cadascuna amb el seu propi comptador.
r = redis.Redis(host="mercadofresco-catalogo.xxxxx.cache.amazonaws.com",
port=6379, ssl=True, decode_responses=True)
LLINDAR_FALLADES, FINESTRA_S, ESPERA_OBERT_S, PROVES_SEMI = 20, 60, 30, 3
class CircuitObert(Exception):
"""El servei esta marcat com a caigut: no se'l crida."""
def cridar_amb_interruptor(nom, fn):
k_estat, k_fallades = f"cb:{nom}:estat", f"cb:{nom}:fallades"
k_exits = f"cb:{nom}:exits_semi"
estat = r.get(k_estat) or "tancat"
if estat == "obert":
# SET NX: nomes el primer consumidor que arriba despres de l'espera passa a semiobert.
if r.set(f"cb:{nom}:sonda", "1", nx=True, ex=ESPERA_OBERT_S):
r.set(k_estat, "semiobert", ex=ESPERA_OBERT_S * 4)
r.delete(k_exits)
else:
raise CircuitObert(nom) # falla a l'instant, sense cridar
try:
resultat = fn()
except Exception:
fallades = r.incr(k_fallades)
r.expire(k_fallades, FINESTRA_S) # finestra lliscant aproximada
if estat == "semiobert" or fallades >= LLINDAR_FALLADES:
r.set(k_estat, "obert", ex=ESPERA_OBERT_S * 10)
r.delete(k_fallades, k_exits)
raise
if estat == "semiobert":
if r.incr(k_exits) >= PROVES_SEMI:
r.set(k_estat, "tancat") # el servei ha tornat
r.delete(k_fallades, k_exits, f"cb:{nom}:sonda")
else:
r.delete(k_fallades) # ratxa neta
return resultatL'important no és el codi, és què es fa quan el circuit està obert. Fallar de pressa només té valor si hi ha un pla:
- El cobrament no es pot degradar: si la passarel·la està caiguda, la comanda no es confirma i es diu al client que ho torni a intentar en uns minuts. És millor que 30 segons d'espera i un error genèric.
- L'avís a l'ERP sí que pot esperar: el missatge es torna a la cua amb
ChangeMessageVisibility(300)i es reintenta al cap de cinc minuts, sense gastar recepcions. - El correu de confirmació pot degradar-se a un proveïdor secundari, o simplement endarrerir-se.
I el circuit ha de ser observable: cada transició a obert publica una mètrica a
MercadoFresco/Tienda i una alarma avisa la Marta per alertas-mercadofresco. Un interruptor obert que
ningú no veu és una funcionalitat apagada en silenci.
Cues de missatges fallits: classificar i reprocessar
Un missatge en una DLQ no és un error: és una pregunta sense resposta. El primer és classificar-lo, perquè els tres tipus exigeixen accions completament diferents.
| Tipus | Símptoma | Causa | Acció |
|---|---|---|---|
| Enverinat | Tots els intents fallen igual, error d'anàlisi | Format invàlid, camp que falta, versió desconeguda | Corregir el consumidor o descartar; mai redrive tal qual |
| Transitori | Ràfega de missatges a la mateixa franja horària | Dependència caiguda, límit superat | Redrive quan el servei torni |
| De dades | Un missatge solt, error de negoci | SKU descatalogat, client esborrat, preu nul | Corregir la dada o el missatge, després redrive |
La manera de distingir-los en 30 segons: mira la distribució temporal. Si els 340 missatges van entrar entre les 18:12 i les 18:41, és transitori i el redrive ho arregla. Si van entrar degotant al llarg de tres dies, és enverinat o de dades, i fer redrive només els tornarà a la DLQ.
def inspeccionar_dlq(url_dlq, mostra=10):
"""Llegeix sense esborrar: retorna el missatge als 5 s per no consumir recepcions."""
r = sqs.receive_message(
QueueUrl=url_dlq, MaxNumberOfMessages=mostra, WaitTimeSeconds=5,
VisibilityTimeout=5, MessageAttributeNames=["All"],
AttributeNames=["ApproximateReceiveCount", "SentTimestamp"],
)
resum = collections.Counter()
for m in r.get("Messages", []):
try:
cos = json.loads(m["Body"])
resum[str((cos.get("version", "?"), sorted(cos.keys())[:3]))] += 1
except json.JSONDecodeError:
resum["JSON_INVALID"] += 1
print(json.dumps({"message_id": m["MessageId"], "cos": m["Body"][:300],
"recepcions": m["Attributes"]["ApproximateReceiveCount"],
"enviat_en": m["Attributes"]["SentTimestamp"]}, ensure_ascii=False))
return resumVisibilityTimeout=5 és el detall que converteix això en una eina segura: els missatges tornen a la DLQ
de seguida i no es perden si l'script mor. No inspeccionis mai una DLQ esborrant missatges.
I l'alarma, que ja vam muntar a 07-01 però que ara s'entén del tot:
ApproximateNumberOfMessagesVisible > 0 sobre la DLQ és el senyal de salut més valuós de tot el
mòdul, perquè és l'únic que diu «hi ha feina pagada que no s'ha fet». Va a
alertas-mercadofresco amb llindar 0 i període de 5 minuts.
El runbook de la DLQ de la Marta
Un runbook és un procediment escrit que algú pot seguir a les 3 de la matinada sense pensar. Aquest és el de MercadoFresco, i viu al repositori al costat de la definició de les cues.
1. Contenir. Continua creixent? Mira ApproximateNumberOfMessagesVisible de la DLQ i
ApproximateAgeOfOldestMessage de la cua d'origen. Si creix, el problema és viu: el redrive pot
esperar; primer s'atura l'hemorràgia. Si és la dependència la que està caiguda, considera desactivar
temporalment el mapatge d'origen d'esdeveniments perquè els missatges s'acumulin a la cua —que els desa
4 dies— en lloc d'esgotar reintents i caure a la DLQ.
2. Classificar. Executa inspeccionar_dlq sobre 10 missatges. Apunta: mateixa franja horària o
degoteig? mateix error? ApproximateReceiveCount uniforme? Decideix enverinat, transitori o de dades.
3. Diagnosticar. Cerca a CloudWatch Logs pel MessageId de dos o tres missatges per veure l'excepció
real. Creua la franja horària amb les mètriques de les dependències i amb trail-mercadofresco per si hi
va haver un canvi de configuració.
4. Corregir. Segons el tipus: desplegar el consumidor arreglat, esperar que el servei torni, o corregir la dada d'origen. No hi ha redrive sense correcció prèvia; tornar missatges a una cua el consumidor de la qual continua trencat només duplica la feina i omple els registres.
5. Reprocessar. Amb límit de velocitat, sempre:
aws sqs start-message-move-task \
--source-arn arn:aws:sqs:eu-west-1:111122223333:mercadofresco-pedidos-fallidos \
--max-number-of-messages-per-second 20 \
--profile mercadofresco-dev --region eu-west-1
aws sqs list-message-move-tasks \
--source-arn arn:aws:sqs:eu-west-1:111122223333:mercadofresco-pedidos-fallidos \
--profile mercadofresco-dev --region eu-west-16. Verificar. La DLQ ha de quedar a zero i NumberOfMessagesDeleted de la cua d'origen ha de pujar en
la quantitat esperada. Si els missatges tornen a la DLQ, atura't i torna al pas 2: la correcció no era la
bona.
7. Registrar. Quants missatges, quina causa, què s'ha canviat i què hauria avisat abans. Aquest pas és el que evita que el mateix incident passi tres vegades.
Un advertiment sobre el reprocessament. Tots els missatges que tornen passaran un altre cop pel consumidor, i alguns es van poder processar parcialment abans de fallar. Si el consumidor no és idempotent, el redrive és una màquina de generar duplicats. Aquí és on la primera meitat d'aquesta lliçó deixa de ser teoria.
Ordre i agrupació: quan importa de debò
L'ordre es demana molt més del que es necessita, i costa car. Abans d'exigir-lo, fes-te tres preguntes: els missatges afecten la mateixa dada? l'operació és relativa (sumar, restar) o absoluta (fixar)? el resultat final canvia si s'apliquen a l'inrevés?
| Cas | Ordre? | Per què |
|---|---|---|
| Reservar i alliberar 2 unitats del mateix SKU | Sí | Operacions relatives sobre el mateix comptador |
| Correus de dues comandes diferents | No | Independents |
| «Comanda creada» i «comanda cancel·lada» de la mateixa comanda | Sí | Cancel·lar abans de crear no té sentit |
| Actualitzacions de preu del mateix SKU | Depèn | Si porten marca de temps, guanya l'última: no cal |
| Esdeveniments d'analítica | No | S'agreguen; l'ordre és irrellevant |
L'ordre global és caríssim i gairebé mai necessari. Exigir-lo en una cua FIFO amb un sol
MessageGroupId limita a un missatge en vol: tant se val que tinguis 50 consumidors, processes en sèrie.
Amb el sku com a grup, MercadoFresco té 3.400 grups, ordre garantit on importa i paral·lelisme de
3.400 on no.
Triar la clau de grup és la mateixa decisió que la clau de partició de DynamoDB (06-02): tan granular
com l'ordre ho permeti. Per als moviments d'estoc, el sku. Per al cicle de vida d'una comanda
—creada, pagada, preparada, enviada—, el pedido_id, perquè l'ordre importa dins d'una comanda i no
entre comandes.
Hi ha una alternativa que evita FIFO del tot i convé conèixer: fer que l'ordre no importi. Si cada
missatge porta una marca de temps o un número de versió i el consumidor descarta el que sigui més
antic que l'estat actual —una escriptura condicional del tipus if versio > versio_actual—, els
missatges desordenats es resolen sols. És més feina al consumidor i moltíssim millor en rendiment. És
la mateixa idea que la idempotència: el consumidor robust val més que la garantia cara del
transport.
El patró outbox i el problema de la doble escriptura
A 07-01 vam deixar pendent aquest forat. El codi era:
comanda_id = aurora.inserir_comanda(cistella, client, cobrament.referencia) # 1
sqs.send_message(QueueUrl=CUA_COMANDES, MessageBody=json.dumps(cos)) # 2No hi ha transacció que abasti Aurora i SQS. Si el pas 2 falla —xarxa, estrangulament, el procés mor— la comanda existeix a la base de dades i ningú no se n'assabenta: no es prepara, no s'envia correu, no es reparteix. Invertir l'ordre no ajuda: aleshores el risc és anunciar una comanda que no existeix, que és pitjor. És el problema de la doble escriptura (dual write), i no es resol amb reintents, perquè el procés pot morir entre les dues operacions.
Solució 1: taula outbox. S'escriu l'esdeveniment a la mateixa transacció que la comanda, en una taula de la mateixa base de dades. Un procés a part llegeix aquesta taula i publica.
BEGIN;
INSERT INTO pedidos (pedido_id, cliente_id, importe_eur, estado)
VALUES ('PED-2026-084417', 'CLI-30912', 48.20, 'confirmado');
INSERT INTO outbox (id, agregado, tipo_evento, carga, publicado)
VALUES (gen_random_uuid(), 'PED-2026-084417', 'PedidoConfirmado',
'{"pedido_id":"PED-2026-084417","importe_eur":48.20}'::jsonb, false);
COMMIT;O la comanda i el seu esdeveniment existeixen, o no n'existeix cap dels dos: això és el que dona la
transacció. Després, un publicador llegeix les files amb publicado = false, les envia i les marca.
Pot fallar i reintentar sense problema: publicarà l'esdeveniment dues vegades com a molt, que és
«almenys una vegada», que és justament el que els consumidors idempotents d'aquesta lliçó saben tolerar.
def publicar_outbox():
"""S'executa cada segon. Idempotent i represa."""
files = aurora.query(
"SELECT id, agregado, tipo_evento, carga FROM outbox "
"WHERE publicado = false ORDER BY creado_en LIMIT 100 FOR UPDATE SKIP LOCKED"
)
for f in files:
eb.put_events(Entries=[{
"EventBusName": "bus-mercadofresco",
"Source": "mercadofresco.tienda",
"DetailType": f["tipo_evento"],
"Detail": f["carga"],
}])
aurora.execute("UPDATE outbox SET publicado = true, publicado_en = now() "
"WHERE id = %s", f["id"])FOR UPDATE SKIP LOCKED permet diversos publicadors en paral·lel sense que es trepitgin, i
ORDER BY creado_en conserva l'ordre per si importa.
Solució 2: captura de canvis sobre Streams. Si el magatzem d'escriptura és DynamoDB, el patró encara
és més net: s'escriu només a la taula, i DynamoDB Streams genera l'esdeveniment automàticament.
No hi ha doble escriptura perquè només hi ha una escriptura. Un pipe-mf-... d'EventBridge Pipes (07-03)
llegeix el stream, filtra i publica al bus. Per a Aurora hi ha l'equivalent amb Debezium/DMS llegint el
registre de transaccions, encara que és bastant més pesat d'operar.
| Taula outbox | Streams + Pipes | |
|---|---|---|
| On viu l'estat | Base de dades relacional | DynamoDB |
| Peces per mantenir | Taula + publicador + neteja | Cap: és gestionat |
| Ordre | Controlable amb ORDER BY |
Per clau de partició |
| Latència | La del sondeig (1–5 s) | Menys d'1 s |
| Càrrega extra a la BD | Escriptura + sondeig | Cap |
| Quan fer-lo servir | Aurora és la font de la veritat | DynamoDB és la font de la veritat |
MercadoFresco fa servir totes dues: outbox a Aurora per als esdeveniments de comanda, i Pipes sobre els Streams
de mercadofresco-carritos per a CarritoAbandonado. I la taula outbox necessita la seva pròpia neteja
—esborrar el publicat fa més de 7 dies— o creixerà sense control, com les cistelles zombis de 06-02.
Contrapressió, esmorteïment i estrangulament
La cua com a esmorteïdor. El divendres entren 900 comandes/hora en ràfegues; els consumidors en processen 600. Sense cua, les 300 de diferència serien errors 503 a la cara del client. Amb cua, la profunditat puja a 1.200 missatges cap a les 20:00 i torna a zero a les 22:00. El pic es converteix en temps, que és un recurs molt més barat que la capacitat.
La regla per dimensionar és senzilla: el sistema no necessita aguantar el pic, necessita aguantar la mitjana més un marge, i tenir cua suficient per a l'àrea sota la corba del pic. Si el pic dura 4 hores amb 300 missatges/hora d'excés, la cua arribarà a uns 1.200 missatges: perfectament normal.
Límits de concurrència. Aurora aurora-mf-escritor aguanta unes 200 connexions. Si Lambda escala a
400 invocacions concurrents, cadascuna amb la seva connexió, la base de dades rebutja connexions i
tot falla, inclosa la botiga. Tres capes de defensa:
| Capa | Mecanisme | Valor a MercadoFresco |
|---|---|---|
| Cua → Lambda | --scaling-config MaximumConcurrency |
20 al correu, 12 a l'ERP |
| Lambda (compte) | Concurrència reservada per funció | 50 per a les que toquen Aurora |
| Aurora | RDS Proxy o pool a l'aplicació | Multiplexa 400 clients sobre 100 connexions |
Estrangulament controlat. Quan qui se satura és un tercer, cal limitar el ritme des del nostre costat:
invocation-rate-limit-per-second a les destinacions d'API d'EventBridge (07-03),
throttlePolicy.maxReceivesPerSecond a les polítiques de lliurament d'SNS (07-02), MaxConcurrency en un
Map de Step Functions (07-04). Tots són la mateixa idea: és millor anar a poc a poc a propòsit que
anar de pressa i provocar una caiguda.
I una regla d'higiene: contrapressió cap amunt, mai cap avall. Quan el sistema va saturat, la resposta correcta és deixar que la cua creixi i avisar, no augmentar el paral·lelisme contra una dependència que ja està patint.
Taula decisòria: SQS, SNS, EventBridge, Step Functions o síncron
| Servei | La pregunta que respon | Fes-lo servir quan | Evita'l quan |
|---|---|---|---|
| Crida síncrona | «Quin és el resultat, ara?» | L'usuari necessita la dada per continuar | El resultat no forma part de la resposta |
| SQS | «Qui fa aquesta feina, quan pugui?» | Un consumidor, feina duradora, esmorteïment | Hi ha diversos interessats |
| SNS | «A qui cal avisar d'això?» | Fan-out d'un fet, latència mínima, SMS/correu | Cal encaminar per contingut complex |
| EventBridge | «On va això segons el que diu?» | Diversos tipus d'esdeveniment, esdeveniments d'AWS/SaaS, arxiu | Volum enorme amb latència crítica |
| Step Functions | «En quin punt va el procés i què cal desfer?» | Passos dependents, esperes, compensacions | Fets independents sense estat |
Tres combinacions que resolen la majoria dels casos reals: SNS→SQS (fan-out amb durabilitat), EventBridge→SQS→Lambda (encaminament per contingut amb esmorteïment) i EventBridge→Step Functions (un fet arrenca un procés). I una que gairebé mai no és bona idea: SNS→Lambda directe per a feina que importa, per tot el que s'ha dit a 07-02.
L'arquitectura d'integració completa de MercadoFresco
flowchart TD
CLI([El client prem Confirmar comanda]) --> APP[Botiga a asg-mercadofresco-tienda]
APP -->|1. cobrar 240 ms| PAY[/Passarella de pagament/]
APP -->|2. INSERT comanda + outbox<br/>mateixa transaccio 38 ms| AUR[(aurora-mercadofresco-pedidos)]
APP -->|3. respon ~400 ms p95| CLI
AUR -.->|publicador d'outbox| BUS{{bus-mercadofresco}}
DDB[(mercadofresco-carritos<br/>Streams)] -->|pipe-mf-carritos-abandonados| BUS
BUS -->|regla: PedidoConfirmado| SFN[[mercadofresco-procesar-pedido]]
BUS -->|regla: fan-out| TEMA{{mercadofresco-pedido-confirmado}}
BUS -->|regla: StockBajo| ALERT{{alertas-mercadofresco}}
TEMA --> QA[(cola-mercadofresco-almacen)]
TEMA --> QB[(cola-mercadofresco-correo)]
TEMA --> QC[(cola-mercadofresco-analitica)]
SFN -->|waitForTaskToken| QA
QA --> ERP[/ERP del magatzem/]
QB --> LC[Lambda correu<br/>idempotent, max 20] --> SES[/SES/]
QC --> RS[(wg-mercadofresco-analitica)]
QA -.->|4 fallades| DLQ[(mercadofresco-pedidos-fallidos)]
QB -.->|4 fallades| DLQ
DLQ -.->|alarma| ALERT
LC -.->|clau d'idempotencia| IDEM[(mercadofresco-idempotencia<br/>TTL 24 h)]
SFN -.->|compensacio| ALERT
style APP fill:#cfe2ff
style BUS fill:#cfe2ff
style SFN fill:#d1e7dd
style DLQ fill:#f8d7da
style IDEM fill:#fff3cd
El que s'ha guanyat, mesurat:
| Abans del mòdul 7 | Després | |
|---|---|---|
| Confirmació de la comanda (p50 / p95) | 2.893 ms / 9.100 ms | 312 ms / 400 ms |
| Punts de fallada síncrons | 8 | 2 (passarel·la i Aurora) |
| Comandes trencades després de cobrar, per hora en pic | ~7 | 0 en tres mesos |
| Caiguda de 2 h de l'ERP | ~1.800 vendes perdudes | 0: es processen en tornar |
| Afegir un consumidor nou | Desplegament de la botiga | Una subscripció |
| Saber en quin punt va una comanda | Impossible | Historial de l'execució |
| Cost mensual d'integració | 0 | ~62 USD |
Seixanta-dos dòlars al mes —SQS gairebé de franc, SNS 2,60, EventBridge 0,95, Step Functions 54, DynamoDB 0,90— a canvi que confirmar una comanda torni a ser una sola cosa ràpida i fiable, i que la resta del món se n'assabenti al seu ritme.
Errors Habituals i Consells
Fer servir el MessageId com a clau d'idempotència. Canvia si el productor reenvia la mateixa feina,
així que no protegeix del cas més freqüent. La clau es deriva del domini: cobro:<pedido_id>.
Taula d'idempotència sense TTL. Creix indefinidament i pagues emmagatzematge per registres que ningú no consultarà. TTL de 24 h.
Idempotència amb només dos estats. Sense EN_CURSO, dos consumidors simultanis veuen «no hi és» i
executen tots dos. L'escriptura condicional amb EN_CURSO caducable és el que tanca la cursa.
Bloquejar amb EN_CURSO sense caducitat. Un consumidor que mor a mig fer deixa aquella operació
bloquejada fins que expiri el TTL, i aquella comanda no es processa mai.
Reintentar sense fluctuació. La recuperació provoca la caiguda següent.
Reintentar errors 4xx de negoci. Endarrereix la resposta i no arregla res. Excepció: el 429, que
sí que es reintenta i amb més espera.
Acumular reintents en tres capes. SDK × servei × flux pot multiplicar per 80 la càrrega sobre una dependència degradada. Una capa mana; les altres, mínimes.
Redrive sense corregir abans. Els missatges tornen a la DLQ, i si el consumidor no és idempotent, a més dupliquen efectes.
Inspeccionar una DLQ esborrant missatges. Fes servir VisibilityTimeout curt i no esborris mai
durant el diagnòstic.
Demanar ordre global «per si de cas». Un sol MessageGroupId converteix una cua distribuïda en un
procés en sèrie.
Escriure a la base de dades i publicar sense transacció. El problema de la doble escriptura. Outbox o Streams; no hi ha una tercera opció que funcioni.
Consell: fes-ho idempotent abans de fer-ho ràpid. Un consumidor idempotent permet reintentar sense por, reprocessar DLQ, reproduir arxius d'EventBridge i desplegar dues vegades per error. És la propietat que més problemes evita per línia de codi.
Consell: escriu el runbook abans de l'incident. A les 3 de la matinada no es dissenya un procediment.
Consell: mesura el pitjor cas d'intents per dependència. Si el nombre supera 15, revisa la configuració: alguna capa està reintentant de més.
Exercicis
Exercici 1: el cobrament duplicat del divendres
Un client reclama que li han cobrat dues vegades 48,20 €. Les dades: cola-mercadofresco-pedidos amb
VisibilityTimeout 120 s; el consumidor crida la passarel·la amb read_timeout de 180 s; l'SDK està a
max_attempts=5; maxReceiveCount=4; no hi ha taula d'idempotència; la passarel·la admet
Idempotency-Key però no es fa servir; els registres mostren un pic de latència de la passarel·la a les 19:14.
Respon: (a) els dos mecanismes independents que van poder duplicar el cobrament; (b) quin és més probable donats els números; (c) cinc correccions ordenades per eficàcia; (d) quina és l'única que elimina el problema d'arrel; (e) què hauria canviat si s'hagués fet servir una cua FIFO.
Exercici 2: 340 missatges a la DLQ un dilluns
mercadofresco-pedidos-fallidos té 340 missatges. L'ApproximateAgeOfOldestMessage de la cua d'origen
és de 12 s (normal). En inspeccionar 10 missatges: tots amb ApproximateReceiveCount = 5,
SentTimestamp repartit entre el diumenge a les 22:04 i les 23:51, i tots amb "version": 2 al cos
mentre el consumidor espera "version": 1. Aquell diumenge a la nit hi va haver un desplegament.
Aplica el runbook: (a) els passos 1 i 2 amb la teva conclusió; (b) quin tipus de fallada és i per què la distribució temporal despista; (c) les dues correccions possibles amb els seus pros i contres; (d) l'ordre de reprocessament i per què el límit de velocitat importa aquí; (e) quina salvaguarda de disseny ho hauria evitat.
Exercici 3: redissenyar la publicació de la comanda
La botiga fa INSERT a Aurora i després put_events a bus-mercadofresco. La Marta detecta que 1 de
cada 900 comandes existeix a Aurora però no va generar esdeveniment: no es va preparar, no es va avisar
el client i ningú no ho va saber fins que va reclamar.
Dissenya la solució: (a) per què reintentar el put_events no n'hi ha prou; (b) l'esquema de la taula
outbox i la transacció SQL; (c) el publicador, indicant cada quant corre i com evita duplicar i
trepitjar-se amb altres; (d) quina garantia de lliurament ofereix el conjunt i què exigeix als
consumidors; (e) quines dues coses més cal operar que abans no existien.
Solucions
Solució 1
(a) Els dos mecanismes. Un: el temps de visibilitat és menor que el temps de procés. La cua té
120 s i el consumidor pot esperar 180 s la passarel·la. Si a les 19:14 la passarel·la va trigar 150 s, el
missatge es va fer visible als 120 s, un altre consumidor el va agafar i va cobrar en paral·lel. Dos:
els reintents de l'SDK sobre una crida no idempotent. Amb max_attempts=5, si la passarel·la va cobrar
i va fallar en respondre —o va trigar més que el temps d'espera—, botocore reintenta i cobra un altre
cop, tot dins de la mateixa recepció del missatge.
(b) Quin és més probable. El primer. La coincidència entre el pic de latència i la relació 120 < 180 és massa exacta: qualsevol crida que superés els 120 s produïa un doble lliurament garantit. El segon mecanisme també va actuar probablement, però requereix que la crida falli d'una manera concreta, mentre que el primer només requereix que trigui.
(c) Cinc correccions per eficàcia.
- Taula d'idempotència amb clau
cobro:<pedido_id>al consumidor. És l'única que fa inofensiu el duplicat, vingui d'on vingui. - Fer servir
Idempotency-Keya la passarel·la. Cost gairebé nul i la garantia la dona qui té l'estat del cobrament. S'hauria d'haver fet des del primer dia. VisibilityTimeouta 400 s, folgadament per damunt delread_timeoutde 180 s.read_timeouta 30 s imax_attemptsde l'SDK a 2. Cent vuitanta segons és massa per a una passarel·la; si triga tant, val més fallar i reintentar de manera controlada.- Alarma sobre la latència p99 de la passarel·la i un interruptor de circuit, per deixar de cridar-la quan es degrada en lloc d'acumular crides de 150 s.
(d) L'única que elimina el problema d'arrel és la 1 (amb la 2 com a reforç). Les correccions 3, 4 i 5 redueixen la probabilitat, però SQS lliura almenys una vegada per disseny: pot duplicar encara que tot estigui perfectament configurat. Només un consumidor idempotent converteix el duplicat en un no-esdeveniment.
(e) Amb cua FIFO. Hauria ajudat poc. FIFO dedupla l'enviament del productor dins d'una finestra
de 5 minuts, i aquí el problema era al consumidor: el missatge es va lliurar una vegada i es va processar
dues per expiració de visibilitat. L'única aportació de FIFO seria que, amb
MessageGroupId = pedido_id, no hi hauria un segon missatge de la mateixa comanda en vol —però el mateix
missatge relliurat continua sent el mateix missatge, i l'efecte duplicat passa igualment—. És un bon
exemple de FIFO fet servir com a substitut de la idempotència, que és un error car.
Solució 2
(a) Passos 1 i 2. Contenir: la cua d'origen té 12 s d'antiguitat, és a dir, el problema no és
viu. El consumidor actual funciona amb normalitat i no hi ha hemorràgia; es pot diagnosticar sense
pressa. Classificar: els 340 missatges van entrar en una finestra d'1 h 47 min del diumenge a la nit,
coincidint amb un desplegament, i tots tenen ApproximateReceiveCount = 5 i "version": 2.
Conclusió: són missatges enverinats, generats per un productor desplegat abans que el seu consumidor.
(b) Tipus de fallada i per què despista. Són enverinats —fallada de format, no de disponibilitat—
però la seva distribució temporal és la d'una fallada transitòria: concentrats en una finestra. El
que despista és que la finestra coincideix amb l'interval en què el productor nou va conviure amb el
consumidor antic, no amb una caiguda. El que desambigua és el contingut: "version": 2 davant d'un
consumidor que espera 1. Regla: la distribució temporal és una pista, el contingut és la prova.
(c) Dues correccions. Desplegar el consumidor que entén la versió 2 i fer redrive. Pro: no es
perd ni una comanda i el sistema queda en el seu estat desitjat. Contra: cal desplegar d'urgència i
verificar que la versió 2 es processa bé. Escriure un consumidor de compatibilitat que tradueixi
la versió 2 a la versió 1. Pro: no toca el consumidor principal. Contra: afegeix una peça permanent
per a un problema temporal. La primera és clarament millor; la segona només té sentit si el consumidor
nou no està llest i les comandes no poden esperar. I el fons de l'assumpte: el productor no s'havia
d'haver desplegat abans que el consumidor; amb version al cos, el consumidor antic hauria pogut
ignorar els camps nous en lloc de fallar, que és justament per què l'evolució compatible només afegeix
camps (07-03).
(d) Reprocessament.
aws sqs start-message-move-task \
--source-arn arn:aws:sqs:eu-west-1:111122223333:mercadofresco-pedidos-fallidos \
--max-number-of-messages-per-second 10 --profile mercadofresco-dev --region eu-west-1El límit importa perquè aquestes 340 comandes porten més de 24 hores aturades: en processar-les colpegen alhora la passarel·la, Aurora i l'ERP, a més del trànsit normal del dilluns al matí. A 10 per segon triguen 34 segons i no es nota; de cop, podrien provocar exactament la tempesta de reintents que descriu aquesta lliçó.
(e) La salvaguarda. Dues, en realitat. La regla d'evolució compatible: no desplegar mai un productor amb un contracte nou abans que els seus consumidors, i fer que els canvis siguin només additius perquè un consumidor antic pugui ignorar el que no coneix. I l'alarma de la DLQ, que hauria avisat el diumenge a les 22:09 en lloc del dilluns a les 09:00; amb ella, algú hauria pogut revertir el desplegament en minuts i cap comanda no hauria esperat 11 hores.
Solució 3
(a) Per què no n'hi ha prou de reintentar. Els reintents cobreixen la fallada de la crida, no la
fallada del procés. Si la instància mor entre el COMMIT d'Aurora i el put_events —desplegament,
reducció de l'ASG, OutOfMemory, una fallada de maquinari— no queda ningú que reintenti i no hi ha rastre
que faltés publicar res. La probabilitat és baixa, però 1 entre 900 amb 240.000 comandes al mes són 266
comandes perdudes al mes.
(b) Taula i transacció.
CREATE TABLE outbox (
id uuid PRIMARY KEY,
agregado text NOT NULL,
tipo_evento text NOT NULL,
carga jsonb NOT NULL,
publicado boolean NOT NULL DEFAULT false,
creado_en timestamptz NOT NULL DEFAULT now(),
publicado_en timestamptz
);
CREATE INDEX idx_outbox_pendientes ON outbox (creado_en) WHERE publicado = false;L'índex parcial és important: la consulta del publicador només mira files no publicades, i un índex
complet creixeria amb tota la història. La transacció és la de l'apartat corresponent: INSERT a
pedidos i INSERT a outbox dins del mateix BEGIN/COMMIT.
(c) El publicador. Corre cada segon, com a procés de fons a les instàncies de treballadors o com
a Lambda disparada per EventBridge Scheduler. Llegeix 100 files pendents amb FOR UPDATE SKIP LOCKED,
que permet diversos publicadors en paral·lel sense que dos agafin la mateixa fila. Publica i marca
publicado = true. Si mor entre publicar i marcar, la fila es republicarà: és un duplicat acceptable.
Convé fer servir l'id de la fila outbox com a identificador de deduplicació aigües avall, de manera que
el duplicat sigui detectable.
(d) Garantia. Almenys una vegada, d'extrem a extrem. Mai no es perd un esdeveniment —perquè és a la transacció— i pot duplicar-se —perquè el publicador pot morir entre publicar i marcar—. Això exigeix que tots els consumidors siguin idempotents, que és precisament el que aquesta lliçó ha construït. No es pot tenir outbox i consumidors no idempotents: seria canviar un problema de pèrdua per un de duplicació.
(e) Dues coses noves per operar. Primer, el procés publicador: cal monitorar-lo (mètrica de files pendents, alarma si superen 500 o si la més antiga porta més de 60 s) perquè si s'atura, les comandes tornen a no publicar-se i ara la fallada és silenciosa i global en lloc d'esporàdica. Segon, la neteja de la taula: un esborrat diari de les files publicades fa més de 7 dies, o creixerà sense control com les cistelles zombis de 06-02. I com a cost indirecte, la latència de l'esdeveniment puja de mil·lisegons al segon del sondeig, cosa irrellevant en aquest cas perquè tot el que hi ha darrere és asíncron.
Conclusió
Aquest mòdul va començar amb una botiga que feia vuit coses seguides abans de respondre i acaba amb una
arquitectura on confirmar una comanda són dues operacions —cobrar i escriure— i 400 mil·lisegons.
Pel camí han aparegut les peces: cola-mercadofresco-pedidos i les seves germanes per desacoblar,
mercadofresco-pedido-confirmado perquè un fet arribi a tots els interessats,
bus-mercadofresco per encaminar per contingut i rebre el que publica el mateix AWS, i
mercadofresco-procesar-pedido per governar el procés llarg amb el seu camí de compensació.
Però el que de debò sosté tot això no és cap dels quatre serveis: és el d'aquesta lliçó. Que el sistema
lliura almenys una vegada i que l'efecte exactament una vegada el posa el teu consumidor, amb una
clau de domini i una escriptura condicional a mercadofresco-idempotencia. Que els reintents necessiten
fluctuació o la recuperació provoca la caiguda següent, i que acumular-los en tres capes multiplica
la càrrega sobre el que ja està patint. Que un interruptor de circuit compartit a ElastiCache val més
que trenta segons d'espera contra un servei caigut. Que una DLQ és una pregunta sense resposta i
necessita un runbook que algú pugui seguir a les 3 de la matinada, començant per contenir i classificar,
i sense redrive abans de corregir. Que l'ordre global es demana molt més del que es necessita i costa
el paral·lelisme sencer. Que escriure a la base de dades i publicar sense transacció perd un esdeveniment
de cada nou-cents, i que l'outbox o els Streams són les dues úniques respostes reals. I que la
contrapressió —deixar que la cua creixi en lloc d'augmentar la pressió— és el que converteix un pic
en temps en lloc d'en una caiguda.
L'arquitectura de MercadoFresco ja és sòlida. I tanmateix, hi ha un problema del curs que continua
exactament igual que el primer dia: tot això s'ha desplegat a mà. Les cues es van crear amb
aws sqs create-queue escrit en un terminal, les regles amb put-rule, la màquina d'estats pujant un
JSON. Ningú no sap del cert si l'entorn de proves té la mateixa configuració que producció. El Luis
continua pujant canvis per SSH un divendres a la tarda, amb el codi al portàtil i els dits creuats. No hi
ha proves automàtiques que diguin si un canvi al consumidor trenca la idempotència. No hi ha manera de
tornar enrere tret de copiar el fitxer anterior, si és que algú el va desar. I l'incident de l'exercici 2
—un productor desplegat abans que el seu consumidor— és exactament el tipus d'error que un procés de
desplegament seriós fa impossible.
Al mòdul 8, «Eines per a desenvolupadors», començant per 08-01, «AWS CodeCommit», ataquem el quart problema del curs: els desplegaments arriscats. Veurem on viu el codi i com es governen els canvis, com es construeix i es prova automàticament amb CodeBuild, com es desplega sense tallar el servei i es reverteix sol amb CodeDeploy, i com s'encadena tot en un flux continu amb CodePipeline, fins a muntar un pipeline d'extrem a extrem que porti un canvi del Luis des del seu portàtil fins a producció sense que ningú hagi d'escriure una ordre un divendres a les set de la tarda.
Curs d'AWS
Mòdul 1: Introducció a AWS
- Què és AWS?
- Configuració del teu compte d'AWS
- Infraestructura global d'AWS
- Consola d'administració d'AWS
- AWS CLI i SDK
Mòdul 2: Serveis principals d'AWS
Mòdul 3: Xarxes i lliurament de contingut
- Amazon VPC
- Grups de seguretat i llistes de control d'accés
- Elastic Load Balancing
- Amazon CloudFront
- Route 53
Mòdul 4: Seguretat i identitat
- AWS Identity and Access Management (IAM)
- AWS Key Management Service (KMS)
- Secrets Manager i Parameter Store
- AWS Shield
- AWS WAF
Mòdul 5: Monitoratge i gestió
- Amazon CloudWatch
- AWS X-Ray i traçabilitat distribuïda
- AWS CloudTrail
- AWS Config
- AWS Trusted Advisor
Mòdul 6: Bases de dades
- Com triar la base de dades adequada
- Amazon DynamoDB
- Amazon Aurora
- Amazon Redshift
- Amazon ElastiCache
Mòdul 7: Integració d'aplicacions
- Amazon SQS
- Amazon SNS
- Amazon EventBridge
- AWS Step Functions
- Patrons d'integració: idempotència, reintents i cues de missatges fallits
