Idempotencja u konsumentów: deduplikacja i klucz idempotencji
Skonfigurowałeś swój system sterowany zdarzeniami za pomocą SQS, Kafka lub EventBridge. Wiadomości płyną, konsumenci przetwarzają zdarzenia, w rozwoju wszystko działa idealnie. Następnie udajesz się do produkcji i okazuje się, że niektóre e-maile z potwierdzeniem zamówienia są wysyłane dwukrotnie razy lub, co gorsza, że niektóre płatności są pobierane dwukrotnie. Problemem nie jest błąd w kodzie: to podstawowa właściwość nowoczesnych systemów rozproszonych.
Wszyscy główni brokerzy wiadomości — SQS, Kafka, RabbitMQ, EventBridge — gwarantują dostawa przynajmniej raz: Wiadomość została dostarczona co najmniej raz czas, ale może być dostarczony wielokrotnie. Dzieje się tak w przypadku automatycznych ponownych prób, przywrócenie równowagi grup konsumentów, przekroczenie limitu czasu widoczności, awaria konsumenta w trakcie przetwarzanie. Rozwiązanie nie leży u brokera: jest u konsumenta. Konsument musi być idempotentny.
Czego się nauczysz
- Ponieważ systemy przynajmniej raz nieuchronnie generują zduplikowane wiadomości
- Klucz idempotencji: podstawowy wzorzec deduplikacji
- Idempotencja na poziomie bazy danych z INSERT ON CONFLICT
- Deduplikacja oparta na Redis z TTL zapewniająca wysoką wydajność
- Wzorzec skrzynki odbiorczej: ustrukturyzowane rozwiązanie zapewniające semantykę „dokładnie raz”.
- Naturalna idempotencja: jak projektować operacje naturalnie idempotentne
- Strategie testowania w celu sprawdzenia idempotencji konsumenta
Dlaczego zduplikowane wiadomości są nieuniknione
Aby zrozumieć, dlaczego idempotencja konsumenta jest konieczna, musimy zrozumieć, kiedy wiadomość jest dostarczana więcej niż raz. Główne przypadki w produkcji:
Przypadek 1: Awaria konsumencka po przetworzeniu, przed potwierdzeniem
Konsument pomyślnie przetwarza komunikat (zapisuje do bazy danych, wywołuje zewnętrzny interfejs API) ale ulega awarii przed wysłaniem potwierdzenia do brokera. Broker uważa wiadomość za nie dostarczony i odsyła go po upływie limitu czasu widoczności. Nowy konsument (lub ten sam po ponownym uruchomieniu) odbiera i ponownie przetwarza wiadomość.
Przypadek 2: Przekroczono limit czasu przetwarzania
SQS ma limit czasu widoczności (domyślnie 30 sekund). Jeżeli konsument zatrudnia więcej niż 30 sekund na przetworzenie wiadomości bez przedłużania limitu czasu, stwierdza SQS przekaz widoczny dla innych konsumentów. Wiadomość jest przetwarzana dwukrotnie równolegle przez dwóch różnych konsumentów.
Przypadek 3: Przywrócenie równowagi w Grupie Konsumenckiej Kafki
Podczas przywracania równowagi grupy konsumenckiej Kafka (w celu dodania/usunięcia konsumentów, wdrażanie kroczące), niektóre partycje zostaną ponownie przypisane. Jeśli konsument, który przyjdzie usunięte nie zatwierdziło jeszcze przesunięcia, przychodzą komunikaty w tej partii ponownie przetworzone przez nowego konsumenta przypisanego do partycji.
// Simulazione: perche i duplicati sono inevitabili
// Questo codice mostra IL PROBLEMA, non la soluzione
async function processPayment(message: SQSMessage): Promise<void> {
const { paymentId, amount, customerId } = JSON.parse(message.Body);
// Step 1: chiama l'API di pagamento esterna
await paymentGateway.charge(customerId, amount);
// ^^^ SUCCESSO: il pagamento e stato addebitato
// -- CRASH QUI --
// Il processo muore per OOM, segfault, deploy, ecc.
// Il pagamento e gia stato addebitato MA non abbiamo
// ancora eliminato il messaggio dalla coda SQS.
// SQS considera il messaggio non processato e lo
// rimanda dopo il visibility timeout.
await sqs.deleteMessage({
QueueUrl: QUEUE_URL,
ReceiptHandle: message.ReceiptHandle,
});
// ^^^ Mai eseguito se crashiamo sopra
}
// Risultato: il cliente viene addebitato due volte.
// Nessun bug nel codice. E' la natura del sistema at-least-once.
Klucz idempotencji wzorca
Najczęstszym rozwiązaniem jest użycie a klucz idempotencji: identyfikator unikalny dla każdej operacji, który pozwala konsumentowi wykryć, czy została ona już przetworzona tę wiadomość. Konsument sprawdza bazę danych przed przetworzeniem: jeśli klucz już istnieje, pomiń ciszę; jeśli nie istnieje, przetwórz i zapisz klucz.
// Pattern base: Idempotency Key con PostgreSQL
// Schema tabella per il tracking
CREATE TABLE processed_messages (
message_id VARCHAR(255) PRIMARY KEY,
processed_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
consumer_name VARCHAR(100) NOT NULL,
-- TTL gestito da un job di cleanup o da una policy di partizione
expires_at TIMESTAMPTZ NOT NULL DEFAULT NOW() + INTERVAL '7 days'
);
CREATE INDEX idx_processed_messages_expires
ON processed_messages(expires_at);
-- Job di cleanup: esegui ogni ora
DELETE FROM processed_messages WHERE expires_at < NOW();
// TypeScript: consumer idempotente con PostgreSQL
interface ProcessedMessageRecord {
messageId: string;
processedAt: Date;
consumerName: string;
}
class IdempotentConsumer {
constructor(
private readonly db: Pool,
private readonly consumerName: string
) {}
async processMessage<T>(
messageId: string,
payload: T,
handler: (payload: T) => Promise<void>
): Promise<{ processed: boolean; skipped: boolean }> {
const client = await this.db.connect();
try {
await client.query('BEGIN');
// Tenta di inserire il messageId (fail se gia esiste)
const result = await client.query<ProcessedMessageRecord>(`
INSERT INTO processed_messages (message_id, consumer_name, expires_at)
VALUES ($1, $2, NOW() + INTERVAL '7 days')
ON CONFLICT (message_id) DO NOTHING
RETURNING message_id
`, [messageId, this.consumerName]);
if (result.rowCount === 0) {
// Gia processato: skip idempotente
await client.query('ROLLBACK');
console.log(`[${this.consumerName}] Skipping duplicate: ${messageId}`);
return { processed: false, skipped: true };
}
// Prima volta: esegui l'handler nella stessa transazione
await handler(payload);
await client.query('COMMIT');
return { processed: true, skipped: false };
} catch (error) {
await client.query('ROLLBACK');
throw error;
} finally {
client.release();
}
}
}
// Utilizzo nel consumer SQS
const consumer = new IdempotentConsumer(db, 'payment-processor');
async function handlePaymentEvent(message: SQSMessage): Promise<void> {
const payload = JSON.parse(message.Body);
const messageId = message.MessageId; // ID univoco SQS
const { processed, skipped } = await consumer.processMessage(
messageId,
payload,
async (data) => {
await paymentGateway.charge(data.customerId, data.amount);
await db.query(
'UPDATE orders SET payment_status = $1 WHERE id = $2',
['paid', data.orderId]
);
}
);
if (skipped) {
// Log ma non errore: comportamento atteso
metrics.increment('consumer.duplicate_skipped');
}
}
Deduplikacja oparta na Redis: wysoka wydajność
Kontrola PostgreSQL wprowadza zapytanie do bazy danych dla każdej wiadomości. Dla Może to nastąpić w przypadku systemów o dużej przepustowości (tysiące wiadomości na sekundę). wąskie gardło. Redis z TTL i rozwiązaniem: operacja O(1), opóźnienie poniżej milisekundy, Natywny TTL dla automatycznego wygaśnięcia.
// Redis-based deduplication per alto throughput
import { Redis } from 'ioredis';
class RedisIdempotencyStore {
constructor(
private readonly redis: Redis,
private readonly ttlSeconds: number = 86400 // 24 ore default
) {}
// Ritorna true se e la PRIMA VOLTA che vediamo questa key
// Ritorna false se e un duplicato
async setIfAbsent(key: string): Promise<boolean> {
// SET key value NX EX ttl
// NX = solo se non esiste
// EX = TTL in secondi
const result = await this.redis.set(
`dedup:${key}`,
'1',
'EX',
this.ttlSeconds,
'NX'
);
return result === 'OK'; // 'OK' = primo inserimento, null = gia esisteva
}
async isProcessed(key: string): Promise<boolean> {
const exists = await this.redis.exists(`dedup:${key}`);
return exists === 1;
}
// Per operazioni atomiche: check + set in Lua script
async checkAndSet(key: string): Promise<boolean> {
const luaScript = `
local exists = redis.call('EXISTS', KEYS[1])
if exists == 0 then
redis.call('SET', KEYS[1], '1', 'EX', ARGV[1])
return 1
end
return 0
`;
const result = await this.redis.eval(
luaScript,
1,
`dedup:${key}`,
this.ttlSeconds.toString()
);
return result === 1;
}
}
// Consumer con Redis deduplication
class HighThroughputConsumer {
constructor(
private readonly dedup: RedisIdempotencyStore,
private readonly db: Pool
) {}
async handleKafkaMessage(
topic: string,
partition: number,
offset: string,
payload: OrderPayload
): Promise<void> {
// Componi una key univoca: topic + partition + offset
const messageKey = `${topic}-${partition}-${offset}`;
const isFirst = await this.dedup.setIfAbsent(messageKey);
if (!isFirst) {
// Duplicato: skip
return;
}
// Prima elaborazione: procedi
await this.processOrder(payload);
}
private async processOrder(payload: OrderPayload): Promise<void> {
await this.db.query(
'UPDATE inventory SET quantity = quantity - $1 WHERE product_id = $2',
[payload.quantity, payload.productId]
);
}
}
// ATTENZIONE: Redis ha durabilita limitata.
// Se Redis perde dati (AOF/RDB non aggiornati), i duplicati
// potrebbero passare dopo un crash Redis.
// Per operazioni critiche (pagamenti), usa sempre PostgreSQL.
Wzorzec skrzynki odbiorczej: semantyka dokładnie raz
Wzór Skrzynka odbiorcza i najsolidniejsza wersja idempotencji: wiadomość jest najpierw zapisywana do tabeli skrzynki odbiorczej (w ramach transakcji DB), potem próbowałem. Gwarantuje dokładnie raz, nawet w przypadku awarii podczas przetwarzania.
-- Schema Inbox Pattern
CREATE TABLE inbox_messages (
id UUID PRIMARY KEY,
source VARCHAR(100) NOT NULL, -- nome del producer/queue
event_type VARCHAR(100) NOT NULL,
payload JSONB NOT NULL,
received_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
processed_at TIMESTAMPTZ, -- NULL = non ancora processato
error TEXT, -- NULL = successo o non processato
retry_count INTEGER NOT NULL DEFAULT 0
);
-- Index per il worker che processa i messaggi pendenti
CREATE INDEX idx_inbox_pending
ON inbox_messages(received_at)
WHERE processed_at IS NULL AND retry_count < 3;
// TypeScript: Inbox Pattern completo
class InboxProcessor {
constructor(private readonly db: Pool) {}
// Fase 1: scrivi nella inbox (idempotente grazie al PK)
async receiveMessage(message: IncomingMessage): Promise<void> {
await this.db.query(`
INSERT INTO inbox_messages (id, source, event_type, payload)
VALUES ($1, $2, $3, $4)
ON CONFLICT (id) DO NOTHING
`, [
message.id,
message.source,
message.eventType,
JSON.stringify(message.payload)
]);
// Se il messaggio arriva due volte, ON CONFLICT DO NOTHING
// lo scarta silenziosamente
}
// Fase 2: worker che processa i messaggi dalla inbox
async processPendingMessages(): Promise<void> {
// Prendi un messaggio con SELECT FOR UPDATE SKIP LOCKED
// Evita che piu worker prendano lo stesso messaggio
const { rows } = await this.db.query(`
SELECT id, event_type, payload
FROM inbox_messages
WHERE processed_at IS NULL
AND retry_count < 3
ORDER BY received_at
LIMIT 1
FOR UPDATE SKIP LOCKED
`);
if (rows.length === 0) return;
const message = rows[0];
const client = await this.db.connect();
try {
await client.query('BEGIN');
// Esegui l'handler specifico per il tipo di evento
await this.dispatch(message.event_type, message.payload);
// Marca come processato nella stessa transazione
await client.query(`
UPDATE inbox_messages
SET processed_at = NOW(), error = NULL
WHERE id = $1
`, [message.id]);
await client.query('COMMIT');
} catch (error) {
await client.query('ROLLBACK');
// Incrementa retry count e salva l'errore
await this.db.query(`
UPDATE inbox_messages
SET retry_count = retry_count + 1,
error = $1
WHERE id = $2
`, [(error as Error).message, message.id]);
} finally {
client.release();
}
}
private async dispatch(
eventType: string,
payload: unknown
): Promise<void> {
switch (eventType) {
case 'OrderPlaced':
await this.handleOrderPlaced(payload as OrderPayload);
break;
case 'PaymentReceived':
await this.handlePaymentReceived(payload as PaymentPayload);
break;
default:
throw new Error(`Unknown event type: ${eventType}`);
}
}
private async handleOrderPlaced(payload: OrderPayload): Promise<void> {
await this.db.query(
'UPDATE inventory SET reserved = reserved + $1 WHERE product_id = $2',
[payload.quantity, payload.productId]
);
}
private async handlePaymentReceived(payload: PaymentPayload): Promise<void> {
await this.db.query(
'UPDATE orders SET status = $1 WHERE id = $2',
['confirmed', payload.orderId]
);
}
}
Naturalna idempotencja: projektowanie idempotentnych operacji dla natury
Najbardziej eleganckim rozwiązaniem problemu idempotencji jest zaprojektowanie operacji w taki sposób, aby: są naturalnie idempotentne: wielokrotne ich wykonanie daje ten sam wynik pojedynczej egzekucji. Eliminuje to potrzebę wyraźnego śledzenia.
// Operazioni naturalmente idempotenti vs non idempotenti
// NON IDEMPOTENTE: aggiornamento relativo
// Se eseguita due volte, l'inventory diventa -2 invece di -1
async function decrementInventory(productId: string, qty: number): Promise<void> {
await db.query(
'UPDATE inventory SET quantity = quantity - $1 WHERE product_id = $2',
[qty, productId]
);
}
// IDEMPOTENTE: aggiornamento assoluto con versioning
// Usa il numero dell'ordine come "target state"
async function setInventoryForOrder(
productId: string,
orderId: string,
newQuantity: number
): Promise<void> {
await db.query(`
INSERT INTO inventory_reservations (order_id, product_id, quantity)
VALUES ($1, $2, $3)
ON CONFLICT (order_id, product_id)
DO UPDATE SET quantity = EXCLUDED.quantity
`, [orderId, productId, newQuantity]);
}
// NON IDEMPOTENTE: INSERT senza conflict handling
async function createPaymentRecord(payment: Payment): Promise<void> {
await db.query(
'INSERT INTO payments (id, order_id, amount) VALUES ($1, $2, $3)',
[payment.id, payment.orderId, payment.amount]
);
// Fallisce con unique constraint la seconda volta
}
// IDEMPOTENTE: UPSERT con ON CONFLICT DO NOTHING
async function upsertPaymentRecord(payment: Payment): Promise<void> {
await db.query(`
INSERT INTO payments (id, order_id, amount, status)
VALUES ($1, $2, $3, 'pending')
ON CONFLICT (id) DO NOTHING
`, [payment.id, payment.orderId, payment.amount]);
}
// IDEMPOTENTE: update a stato finale (state machine idempotente)
// Transitare da 'confirmed' a 'confirmed' non cambia nulla
async function markOrderAsShipped(orderId: string): Promise<void> {
await db.query(`
UPDATE orders
SET status = 'shipped', shipped_at = COALESCE(shipped_at, NOW())
WHERE id = $1
AND status IN ('confirmed', 'processing')
`, [orderId]);
// Se lo stato e gia 'shipped', la WHERE non matcha: no-op
}
Deduplikacja na poziomie SQS
Kolejka SQS FIFO oferuje natywną deduplikację poprzez Identyfikator deduplikacji wiadomości. Wiadomości o tym samym identyfikatorze deduplikacji wysyłane w interwale deduplikacji (5 minut) dostarczane są tylko raz. Nie eliminuje to potrzeby idempotencji po stronie konsumenta, ale znacznie zmniejsza liczbę duplikatów.
// AWS SDK v3: invio su SQS FIFO con MessageDeduplicationId
import { SQSClient, SendMessageCommand } from '@aws-sdk/client-sqs';
const sqs = new SQSClient({ region: 'eu-west-1' });
async function publishOrderEvent(
orderId: string,
eventType: string,
payload: unknown
): Promise<void> {
// MessageDeduplicationId: hash del contenuto o ID evento univoco
// Stesso ID = stesso messaggio entro 5 minuti = consegnato una sola volta
const deduplicationId = `${eventType}-${orderId}-${Date.now()}`;
await sqs.send(new SendMessageCommand({
QueueUrl: 'https://sqs.eu-west-1.amazonaws.com/123456/orders.fifo',
MessageBody: JSON.stringify(payload),
MessageGroupId: orderId, // Ordine FIFO per stesso ordine
MessageDeduplicationId: deduplicationId,
MessageAttributes: {
EventType: {
DataType: 'String',
StringValue: eventType,
},
},
}));
}
// SQS Standard Queue: nessuna deduplication nativa
// Puoi usare l'Attribute MessageId come idempotency key nel consumer
async function handleSqsStandardMessage(msg: SQSMessage): Promise<void> {
// msg.MessageId e univoco per invio, ma se SQS rimanda il messaggio
// il MessageId rimane lo stesso. Usalo come idempotency key.
const idempotencyKey = msg.MessageId;
await consumer.processMessage(idempotencyKey, JSON.parse(msg.Body), handler);
}
Testowanie idempotencji
Konsument idempotentny musi zostać wyraźnie przetestowany: nie wystarczy przetestować normalny przypadek. Musisz przetestować, co się stanie, gdy nadejdzie ta sama wiadomość dwa, dziesięć razy, z kilkoma równoległymi odbiorcami.
// Test suite per consumer idempotente
describe('IdempotentPaymentConsumer', () => {
let consumer: IdempotentConsumer;
let db: Pool;
beforeEach(async () => {
db = await createTestDatabase();
consumer = new IdempotentConsumer(db, 'payment-test');
await db.query('DELETE FROM processed_messages');
});
it('should process message exactly once on first delivery', async () => {
const messageId = 'msg-001';
const payload = { orderId: 'ord-001', amount: 100 };
let callCount = 0;
const handler = async () => { callCount++; };
const result = await consumer.processMessage(messageId, payload, handler);
expect(result.processed).toBe(true);
expect(result.skipped).toBe(false);
expect(callCount).toBe(1);
});
it('should skip duplicate message silently', async () => {
const messageId = 'msg-001';
const payload = { orderId: 'ord-001', amount: 100 };
let callCount = 0;
const handler = async () => { callCount++; };
// Prima consegna
await consumer.processMessage(messageId, payload, handler);
// Seconda consegna (duplicato)
const result = await consumer.processMessage(messageId, payload, handler);
expect(result.processed).toBe(false);
expect(result.skipped).toBe(true);
expect(callCount).toBe(1); // Handler chiamato solo una volta
});
it('should handle concurrent duplicate messages correctly', async () => {
const messageId = 'msg-concurrent';
const payload = { orderId: 'ord-002', amount: 200 };
let callCount = 0;
const handler = async () => {
callCount++;
// Simula elaborazione lenta per forzare concorrenza
await new Promise((resolve) => setTimeout(resolve, 100));
};
// Simula 5 consumer che ricevono lo stesso messaggio in parallelo
const results = await Promise.allSettled([
consumer.processMessage(messageId, payload, handler),
consumer.processMessage(messageId, payload, handler),
consumer.processMessage(messageId, payload, handler),
consumer.processMessage(messageId, payload, handler),
consumer.processMessage(messageId, payload, handler),
]);
const processed = results.filter(
(r) => r.status === 'fulfilled' && r.value.processed
).length;
// Solo uno deve essere processato, gli altri skippati
expect(processed).toBe(1);
expect(callCount).toBe(1);
});
});
Anty-wzorzec: idempotencja tylko w pamięci
Nie używaj zestawu ani mapy w pamięci do śledzenia przetworzonych wiadomości. Jeśli proces zostanie wznowiony, pamięć i wszystkie komunikaty zostaną utracone przetworzone wcześniej zostaną przetworzone ponownie. Sklep idempotencji musi być trwały (PostgreSQL, Redis z AOF, DynamoDB).
Wnioski i dalsze kroki
Idempotencja konsumenta nie jest opcjonalną optymalizacją w systemie Poziom produkcji sterowany zdarzeniami: jest to wymóg podstawowy. Wybór pomiędzy Deduplikacja oparta na PostgreSQL, Redis i wzorzec skrzynki odbiorczej zależą od poziomu wymaganą trwałość i przepustowość systemu. Do krytycznych operacji (płatności, nieodwracalne aktualizacje statusu), Wzorzec skrzynki odbiorczej oferuje maksymalna gwarancja semantyki dokładnie jednorazowej.







