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-idempotencia sota 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

  1. Garanties de lliurament: les tres, i per què una és gairebé mentida
  2. Idempotència: la propietat que ho arregla tot
  3. La taula mercadofresco-idempotencia
  4. Un consumidor idempotent complet
  5. Reintents: retrocés exponencial i fluctuació
  6. Quins errors mereixen reintent i quins no
  7. On reintentar: SDK, servei o flux
  8. La tempesta de reintents
  9. Interruptor de circuit per al proveïdor de pagaments
  10. Cues de missatges fallits: classificar i reprocessar
  11. El runbook de la DLQ de la Marta
  12. Ordre i agrupació: quan importa de debò
  13. El patró outbox i el problema de la doble escriptura
  14. Contrapressió, esmorteïment i estrangulament
  15. Taula decisòria: SQS, SNS, EventBridge, Step Functions o síncron
  16. L'arquitectura d'integració completa de MercadoFresco
  17. Errors habituals i consells
  18. Exercicis
  19. 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:

  1. Rep el missatge de cola-mercadofresco-pedidos.
  2. Crida la passarel·la. La passarel·la cobra.
  3. La xarxa es talla abans que arribi la resposta.
  4. El procés mor, o llança una excepció, i no esborra el missatge.
  5. El temps de visibilitat expira. Un altre consumidor rep el mateix missatge.
  6. 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 Escriure un valor absolut
DECR stock:FRUT-011 BY 2 No Cada execució resta un altre cop
PutItem amb la mateixa clau i dades Sobreescriu amb el mateix
UpdateItem ADD contador 1 No Increment relatiu
INSERT amb clau primària pedido_id (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 Sobreescriu el mateix objecte de S3
s3:PutObject amb la mateixa clau 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:

  • MessageId d'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-1

Un 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)
        raise

Els quatre punts que fan que això funcioni de debò:

  1. 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.
  2. L'EN_CURSO caduca. Sense la condició sobre caduca_bloqueo, un consumidor que morís entre el put_item i el completar deixaria la clau bloquejada 24 hores i aquella comanda no es cobraria mai.
  3. 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.
  4. 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 pics

La 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 Fallada del servidor, probablement transitòria
429 / ThrottlingException Sí, amb més espera Vas massa de pressa
ProvisionedThroughputExceededException 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 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:

  1. La passarel·la de pagament es degrada: la latència puja de 240 ms a 4 s.
  2. Els consumidors esperen més, així que el ritme de procés cau i la cua creix.
  3. Lambda veu créixer la cua i escala: de 20 a 60 sondejadors.
  4. La passarel·la rep el triple de peticions just quan pitjor està, i comença a retornar 503.
  5. Cada 503 dispara reintents. La càrrega es multiplica de nou.
  6. La passarel·la cau del tot. Tots els reintents fallen i consumeixen maxReceiveCount.
  7. Milers de comandes legítimes acaben a la DLQ.
  8. 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 (MaximumConcurrency al 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 resultat

L'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 resum

VisibilityTimeout=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-1

6. 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 Operacions relatives sobre el mateix comptador
Correus de dues comandes diferents No Independents
«Comanda creada» i «comanda cancel·lada» de la mateixa comanda 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))         # 2

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

  1. Taula d'idempotència amb clau cobro:<pedido_id> al consumidor. És l'única que fa inofensiu el duplicat, vingui d'on vingui.
  2. Fer servir Idempotency-Key a 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.
  3. VisibilityTimeout a 400 s, folgadament per damunt del read_timeout de 180 s.
  4. read_timeout a 30 s i max_attempts de 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.
  5. 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-1

El 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

Mòdul 2: Serveis principals d'AWS

Mòdul 3: Xarxes i lliurament de contingut

Mòdul 4: Seguretat i identitat

Mòdul 5: Monitoratge i gestió

Mòdul 6: Bases de dades

Mòdul 7: Integració d'aplicacions

Mòdul 8: Eines per a desenvolupadors

Mòdul 9: Infraestructura com a codi i govern de comptes

Mòdul 10: Contenidors a AWS

Mòdul 11: Millors pràctiques i gestió de costos

© Copyright 2026. Tots els drets reservats