El problema
Tu sistema procesa facturas. Cuando llega una, publicas un evento con los datos para que el resto reaccione. Un día alguien adjunta el PDF al evento y todo se rompe.
El motivo es que cada tramo de un sistema serverless tiene su propio techo de tamaño, y son distintos:
| Tramo | Techo |
|---|---|
| Publicación en SNS | 256 KiB |
| Mensaje en SQS | 1 MiB |
| Payload síncrono de Lambda | 6 MB |
| Payload asíncrono de Lambda | 1 MB |
| Payload de API Gateway | 10 MB |
| Entrada o salida de un estado de Step Functions | 256 KiB |
| Objeto en S3 | 48 TB |
El que manda es el más bajo de la cadena, y casi nunca es el que estás
mirando. Un evento de 400 KB cabe de sobra en tus colas y aun así el Publish
de SNS lo rechaza. Un documento de 2 MB pasa por API Gateway y muere en la
Lambda. Un JSON que crece estado a estado revienta a mitad de una saga, con la
mitad de los efectos ya aplicados.
La solución
Separar el contenido de la notificación. El productor guarda el payload en S3 y publica un mensaje minúsculo con la referencia: bucket, clave, y los metadatos que los consumidores necesitan para decidir si les interesa.
El nombre viene de la ficha del guardarropa: dejas el abrigo, te llevas un número. Por el sistema circula el resguardo, y el abrigo se queda quieto donde lo dejaste.
Tres ventajas, y la tercera es la que no se ve al principio:
- Los límites desaparecen. El mensaje pesa unos cientos de bytes, así que ningún tramo de la cadena lo rechaza.
- Es más barato. SNS y SQS facturan por porciones de 64 KB: un mensaje de 256 KB cuenta como cuatro peticiones. Con una referencia, cuenta como una.
- El consumidor decide si descarga. Un consumidor que solo necesita saber que hubo una factura no baja nada. Antes, todos pagaban el peso de todos.
Cuándo usarlo
- El payload puede superar el límite de algún tramo, aunque casi nunca lo haga. Basta con que ocurra el día del pico.
- El contenido es binario: PDF, imagen, audio, vídeo.
- Hay varios consumidores y solo alguno necesita el contenido completo.
- Vas a pasar datos entre estados de Step Functions, donde el techo son 256 KiB y el fallo llega tarde.
Cuándo NO usarlo
Cómo implementarlo
- Escribe primero en S3, publica después. Al revés garantiza mensajes que apuntan a nada.
- Genera la clave con un identificador del hecho, no con un nombre de archivo del usuario.
- Mete en el mensaje los metadatos que permiten filtrar —tipo, tamaño, propietario— para que un consumidor pueda descartar sin descargar. Es lo que hace que las filter policies sigan sirviendo.
- Versiona el formato del mensaje: un campo
vque te deje cambiar la forma de la referencia más adelante. - Pon una regla de ciclo de vida en el prefijo del claim-check. El payload sobrevive al mensaje si nadie lo borra.
- Da permiso de lectura a los consumidores sobre ese prefijo, no sobre el bucket entero.
El código
// ── Productor ──────────────────────────────────────────────────────
const clave = `claims/${new Date().toISOString().slice(0, 10)}/${ulid()}`;
// Primero el contenido. Si esto falla, no hay mensaje huerfano.
await s3.send(new PutObjectCommand({
Bucket: BUCKET,
Key: clave,
Body: pdf,
ContentType: 'application/pdf',
}));
await sns.send(new PublishCommand({
TopicArn: TOPIC,
// El mensaje lleva la referencia y lo justo para decidir sin descargar.
Message: JSON.stringify({
v: 1,
tipo: 'FacturaRecibida',
facturaId: factura.id,
proveedor: factura.proveedor,
importe: factura.importe,
contenido: { bucket: BUCKET, clave, bytes: pdf.length },
}),
MessageAttributes: {
tipo: { DataType: 'String', StringValue: 'FacturaRecibida' },
},
}));
// ── Consumidor que NO necesita el PDF ──────────────────────────────
export const registrarFactura = async (evento: SQSEvent) => {
for (const r of evento.Records) {
const m = JSON.parse(r.body);
// Le basta con los metadatos: no toca S3 en ningun momento.
await guardar({ id: m.facturaId, proveedor: m.proveedor, importe: m.importe });
}
};
// ── Consumidor que SI lo necesita ──────────────────────────────────
export const extraerTexto = async (evento: SQSEvent) => {
for (const r of evento.Records) {
const m = JSON.parse(r.body);
const obj = await s3.send(new GetObjectCommand({
Bucket: m.contenido.bucket,
Key: m.contenido.clave,
}));
await indexar(m.facturaId, await extraer(obj.Body));
}
};
Te va a morder
Coste
| Concepto | Con payload en el mensaje | Con claim-check |
|---|---|---|
| Petición de SNS (256 KB) | 4 peticiones | 1 |
| Petición de SQS (256 KB) | 4 peticiones | 1 |
| Escritura en S3 | — | 1 PUT |
| Lectura en S3 | — | 1 GET por consumidor que lo necesite |
| Almacenamiento | — | mientras dure el ciclo de vida |
La regla de los 64 KB es la que decide: SNS y SQS facturan cada porción de 64 KB como una petición, así que un mensaje de 256 KB cuesta cuatro. Con la referencia cuesta una, y a cambio pagas un PUT y los GET de quien de verdad descargue.
El patrón gana claramente cuando hay más consumidores que descargas: cinco consumidores de los que solo uno abre el PDF pagan un transporte barato cinco veces y una lectura cara una vez. Pierde si todos descargan siempre y los mensajes eran pequeños — pero entonces tampoco tenías un problema de tamaño.
Fuentes
Tamaño máximo de mensaje de SNS y regla de facturación por porciones de 64 KB: Amazon SNS endpoints and quotas. Tamaño máximo de mensaje de SQS y Extended Client Libraries hasta 2 GB: Amazon SQS message quotas. Payloads síncrono y asíncrono de Lambda: Lambda quotas. Límite de 256 KiB entre estados de Step Functions: Step Functions service quotas. Tamaño máximo de objeto en S3: Amazon S3 endpoints and quotas.