El problema
Tienes una Lambda que consume una cola y cobra pedidos. Funciona. Lleva meses funcionando. Y un martes cualquiera, un cliente te escribe porque le has cobrado dos veces los mismos 50 €.
Revisas los logs y no hay nada: ninguna excepción, ningún reintento, ningún error. Solo dos invocaciones perfectamente normales, con el mismo contenido, separadas por unos segundos.
No es un bug tuyo. Es el contrato del servicio que estás usando, escrito en su documentación y aceptado el día que elegiste una cola estándar:
“more than one copy of a message might be delivered, and messages may occasionally arrive out of order”
SQS entrega al menos una vez. SNS también. Y “al menos una vez” no es una forma educada de decir “una vez”; significa literalmente que la entrega duplicada es un comportamiento correcto del sistema, no una avería.
Las causas son de lo más rutinario:
- El visibility timeout expiró mientras tu función seguía trabajando, así que el mensaje reapareció y otro consumidor lo cogió.
- Tu función hizo el trabajo y falló después, al borrar el mensaje o al responder. El efecto ya estaba hecho; el mensaje volvió igualmente.
- El productor reintentó porque no vio la respuesta de
Publish, aunque el mensaje sí había entrado. - La arquitectura es un fan-out, y ahí se encadenan dos entregas “al menos una vez”: la de SNS a la cola y la de la cola a tu función.
La solución
Que el consumidor recuerde lo que ya hizo y decida en la misma operación atómica en la que lo anota.
Antes de hacer nada con efectos, el consumidor intenta escribir la clave del
mensaje en una tabla de DynamoDB con una escritura condicional:
attribute_not_exists(pk). Solo hay dos desenlaces posibles:
- La escritura gana. Nadie había procesado este mensaje. Adelante.
- La escritura choca con
ConditionalCheckFailedException. Ya está hecho. No hagas nada y borra el mensaje.
Lo que hace que esto funcione —y lo que se pierde en casi todas las
implementaciones caseras— es que comprobar y anotar son la misma operación.
Un GetItem seguido de un PutItem deja un hueco de milisegundos entre “no
existe” y “ahora sí”; dos entregas simultáneas del mismo mensaje pueden colarse
las dos por ese hueco. La escritura condicional no tiene hueco.
La clave importa más que el mecanismo
De qué derivas la clave decide si el patrón funciona:
- Bien: un identificador del hecho de negocio — el id del pedido, el id de
la transacción, un
Idempotency-Keyque envió el cliente. - Regla útil: si dos mensajes distintos deben producir dos efectos, sus claves tienen que diferir. Si el mismo hecho llega dos veces, sus claves tienen que coincidir.
- Mal: el
messageIdde SQS. Parece la elección obvia y es una trampa — cuando SNS reenvía el mismo evento a la cola, genera unmessageIdnuevo. Dos mensajes distintos, mismo hecho, y tu tabla no los relaciona. - Mal: un hash del cuerpo, si el cuerpo lleva un timestamp o un campo que cambia entre reintentos.
Cuándo usarlo
- Cualquier consumidor de SQS, SNS, EventBridge o Kinesis que tenga efectos observables: cobrar, enviar, crear, descontar, notificar.
- Cualquier endpoint que reciba webhooks de terceros — Stripe, GitHub y compañía reintentan, y lo dicen en su documentación.
- Cualquier paso de una saga o de una máquina de estados con reintentos.
Cuándo NO usarlo
Cómo implementarlo
- Elige la clave a partir del hecho de negocio, nunca del transporte.
- Crea la tabla con la clave como partition key y un atributo numérico para el TTL. No necesita nada más.
- Escribe primero, actúa después.
PutItemcondicional conattribute_not_exists, y solo si gana, ejecuta el efecto. - Captura
ConditionalCheckFailedExceptiony trátala como éxito: el trabajo está hecho, borra el mensaje. - Decide qué pasa si el efecto falla después de haber ganado la escritura (ver trampas).
- Pon un TTL generoso: más largo que cualquier ventana de reintento plausible, y consciente de que borra tarde.
El código
import { DynamoDBClient, ConditionalCheckFailedException } from '@aws-sdk/client-dynamodb';
import { DynamoDBDocumentClient, PutCommand, UpdateCommand } from '@aws-sdk/lib-dynamodb';
const ddb = DynamoDBDocumentClient.from(new DynamoDBClient({}));
const TABLA = process.env.TABLA_IDEMPOTENCIA!;
const VENTANA_H = 24 * 7; // muy por encima de cualquier reintento plausible
type Resultado = 'procesar' | 'ya-hecho';
async function reservar(clave: string): Promise<Resultado> {
try {
await ddb.send(new PutCommand({
TableName: TABLA,
Item: {
pk: clave,
estado: 'en-curso',
creado: Date.now(),
expira: Math.floor(Date.now() / 1000) + VENTANA_H * 3600,
},
// Comprobar y anotar en la MISMA operación atómica. Un GetItem
// seguido de un PutItem deja un hueco por el que caben dos
// entregas simultaneas del mismo mensaje.
ConditionExpression: 'attribute_not_exists(pk)',
}));
return 'procesar';
} catch (e) {
if (e instanceof ConditionalCheckFailedException) return 'ya-hecho';
throw e; // Un fallo real de DynamoDB no es un duplicado: que reintente.
}
}
export const cobrarPedido = async (evento: SQSEvent): Promise<SQSBatchResponse> => {
const fallidos: { itemIdentifier: string }[] = [];
for (const registro of evento.Records) {
const pedido = JSON.parse(registro.body);
// La clave sale del hecho de negocio, NO del messageId de SQS:
// cuando SNS reenvia el mismo evento, el messageId es otro.
const clave = `cobro#${pedido.id}`;
try {
if (await reservar(clave) === 'ya-hecho') continue;
const recibo = await cobrar(pedido);
await ddb.send(new UpdateCommand({
TableName: TABLA,
Key: { pk: clave },
UpdateExpression: 'SET estado = :ok, recibo = :r',
ExpressionAttributeValues: { ':ok': 'hecho', ':r': recibo.id },
}));
} catch (err) {
// Si el cobro falla despues de ganar la reserva, hay que soltarla:
// si no, el reintento vera "ya hecho" y el cobro no ocurrira jamas.
await soltar(clave).catch(() => {});
fallidos.push({ itemIdentifier: registro.messageId });
}
}
return { batchItemFailures: fallidos };
};
Te va a morder
Coste
Barato, pero no gratis, y el precio se paga en el camino feliz:
| Concepto | Efecto |
|---|---|
| Escritura condicional | 1 WCU por mensaje procesado |
| Actualización del estado final | 1 WCU más, si la haces |
| Borrado por TTL | sin coste de escritura |
| Almacenamiento | proporcional a la ventana de TTL |
Es decir: añades una o dos escrituras a DynamoDB por cada mensaje, incluso cuando no hay ningún duplicado — que es el 99,9 % de las veces. A cambio evitas el 0,1 % que te cobra dos veces a un cliente. Casi siempre es un cambio excelente, pero conviene saber que se está haciendo.
El borrado por TTL no consume capacidad de escritura, salvo en tablas globales: ahí la réplica del borrado sí se factura en cada región réplica.
Fuentes
Garantías de entrega y desorden en colas estándar: Amazon SQS standard queues. Retraso real del borrado por TTL, visibilidad de los elementos caducados y consumo de capacidad: Using time to live (TTL) in DynamoDB. Reintentos de entrega de SNS: Amazon SNS message delivery retries.