Späť na blog

Dvojité Účtovanie z Idempotency Keys: Pasca Replica Lag

Idempotency keys sme brali ako istotu, kym z nich replica lag neurobila klamstvo. “Naša idempotency implementácia je nepriestreln, ale stále sme spracovali rovnakú platbu dvakrát.” Bug: idempotency key lookup ide na read repliku ktorá je 3 sekundy za primary počas load špičiek.

Prostredie: PostgreSQL primary-replica setup, microservices s connection poolingom, platobný systém

Problém

Incident Dvojitej Platby

Časová os duplicitnej platby:

T+0.000s  Klient posiela payment request (idempotency_key: "pay_abc123")
T+0.001s  Service kontroluje repliku: "Existuje pay_abc123?" → NIE
T+0.002s  Service začína spracovanie platby
T+0.003s  Service zapisuje do PRIMARY: INSERT pay_abc123

T+0.500s  Network timeout, klient opakuje (rovnaký key: "pay_abc123")
T+0.501s  Service kontroluje repliku: "Existuje pay_abc123?" → NIE
          (Replika je 2 sekundy pozadu počas traffic špičky!)
T+0.502s  Service začína DRUHÉ spracovanie platby
T+0.503s  Service zapisuje do PRIMARY: INSERT pay_abc123 → KONFLIKT!
          Ale platba už bola odoslaná platobnej bráne...

Výsledok: Zákazník účtovaný dvakrát, jeden DB záznam

”Správny” Kód Ktorý Zlyháva

class PaymentService:
    def __init__(self, db_pool):
        # Connection pool smeruje reads na repliky pre "výkon"
        self.db = db_pool

    def process_payment(self, idempotency_key: str, amount: Decimal):
        # Skontroluj či sme už videli tento request
        # BUG: Tento query ide na READ REPLIKU!
        existing = self.db.query(
            "SELECT * FROM payments WHERE idempotency_key = %s",
            idempotency_key
        )

        if existing:
            return existing.result  # Vráť cached výsledok

        # Spracuj platbu s externým providerom
        result = self.payment_provider.charge(amount)  # SIDE EFFECT!

        # Ulož výsledok pre budúce idempotentné requesty
        # Toto ide na PRIMARY
        self.db.execute(
            "INSERT INTO payments (idempotency_key, result) VALUES (%s, %s)",
            idempotency_key, result
        )

        return result

Príčina

Replica Lag Je Variabilný

Normálna prevádzka (replica lag ~10ms):
┌─────────────────────────────────────────────────────┐
│ PRIMARY: INSERT pay_abc123 v T+0                    │
│ REPLICA: Prijíma INSERT v T+10ms                    │
│ Retry v T+500ms: Replika má záznam ✓                │
└─────────────────────────────────────────────────────┘

Počas load špičky (replica lag ~3 sekundy):
┌─────────────────────────────────────────────────────┐
│ PRIMARY: INSERT pay_abc123 v T+0                    │
│ REPLICA: Stále spracováva transakcie z T-3s...      │
│ Retry v T+500ms: Replika ukazuje ŽIADNY záznam! ✗   │
│                                                     │
│ Okno zraniteľnosti: 0 až ~3 sekundy                 │
└─────────────────────────────────────────────────────┘

Kedy replica lag špičkuje?
- Vysoký write load na primary
- Veľké transakcie (batch operácie)
- Replika pod CPU tlakom
- Network kongescencia medzi primary/replica
- Vacuum operácie na replike

Prečo Connection Pools Toto Zhoršujú

# Mnoho connection poolov auto-routuje podľa typu query

# PgBouncer s read/write splitting:
# - SELECT → replika
# - INSERT/UPDATE → primary

# SQLAlchemy s routingom:
class RoutingSession(Session):
    def get_bind(self, mapper=None, clause=None):
        if self._flushing:
            return engines['primary']
        return engines['replica']  # Všetky reads idú na repliku!

# Aj "smart" routing minie sémantickú požiadavku:
# "Tento SELECT MUSÍ vidieť výsledok nedávneho INSERT"

Diagnostika

Skontroluj Replica Lag

-- Na PostgreSQL replike:
SELECT
    now() - pg_last_xact_replay_timestamp() AS replica_lag,
    pg_is_in_recovery() AS is_replica;

-- Na primary, skontroluj všetky repliky:
SELECT
    client_addr,
    state,
    sent_lsn,
    write_lsn,
    flush_lsn,
    replay_lsn,
    pg_wal_lsn_diff(sent_lsn, replay_lsn) AS bytes_behind
FROM pg_stat_replication;

Nájdi Dvojito Spracované Requesty

-- Hľadaj idempotency key konflikty
SELECT
    idempotency_key,
    COUNT(*) as attempts,
    MIN(created_at) as first_attempt,
    MAX(created_at) as last_attempt,
    MAX(created_at) - MIN(created_at) as window
FROM payment_attempts
GROUP BY idempotency_key
HAVING COUNT(*) > 1
ORDER BY last_attempt DESC;

-- Koreluj s replica lag metrikami
SELECT
    date_trunc('minute', timestamp) as minute,
    MAX(replica_lag_seconds) as max_lag,
    COUNT(CASE WHEN duplicate_detected THEN 1 END) as duplicates
FROM payment_metrics
GROUP BY 1
ORDER BY 1 DESC;

Riešenie

Možnosť 1: Vynúť Primary pre Idempotency Kontroly

class PaymentService:
    def __init__(self, primary_db, replica_db):
        self.primary = primary_db
        self.replica = replica_db

    def process_payment(self, idempotency_key: str, amount: Decimal):
        # KRITICKÉ: Kontroluj PRIMARY pre idempotency keys
        existing = self.primary.query(
            "SELECT * FROM payments WHERE idempotency_key = %s",
            idempotency_key
        )

        if existing:
            return existing.result

        # ... zvyšok spracovania

Možnosť 2: Použi INSERT … ON CONFLICT pre Atomickú Kontrolu

def process_payment(idempotency_key: str, amount: Decimal):
    # Najprv sa pokús atomicky claimnúť idempotency key
    # Toto MUSÍ ísť na primary
    result = self.primary.execute("""
        INSERT INTO idempotency_locks (key, status, created_at)
        VALUES (%s, 'processing', now())
        ON CONFLICT (key) DO UPDATE
        SET key = idempotency_locks.key  -- no-op update
        RETURNING status, result, (xmax = 0) AS inserted
    """, idempotency_key)

    if not result.inserted:
        # Key už existuje
        if result.status == 'completed':
            return result.result
        elif result.status == 'processing':
            raise ConcurrentProcessingError("Request prebieha")

    try:
        # Vyhrali sme lock, spracuj platbu
        payment_result = self.payment_provider.charge(amount)

        self.primary.execute("""
            UPDATE idempotency_locks
            SET status = 'completed', result = %s
            WHERE key = %s
        """, payment_result, idempotency_key)

        return payment_result
    except Exception as e:
        self.primary.execute("""
            UPDATE idempotency_locks
            SET status = 'failed', error = %s
            WHERE key = %s
        """, str(e), idempotency_key)
        raise

Možnosť 3: Synchronous Replica pre Kritické Reads

-- Nakonfiguruj synchronous replication pre kritické tabuľky
-- postgresql.conf na primary:
synchronous_commit = on
synchronous_standby_names = 'critical_replica'

-- Alebo použi synchronous_commit per-transaction:
BEGIN;
SET LOCAL synchronous_commit = on;
-- Tvoje kritické idempotency operácie tu
COMMIT;

Možnosť 4: Pridaj Client-Side Deduplikáciu

# Nespoliehaj sa len na server-side idempotency

class PaymentClient:
    def __init__(self):
        self.pending_requests = {}  # V Redis alebo podobne

    def charge(self, idempotency_key: str, amount: Decimal):
        # Skontroluj či už máme pending request
        if idempotency_key in self.pending_requests:
            return self.wait_for_result(idempotency_key)

        # Označ ako pending PRED odoslaním
        self.pending_requests[idempotency_key] = 'pending'

        try:
            result = self.api.process_payment(idempotency_key, amount)
            self.pending_requests[idempotency_key] = result
            return result
        except NetworkError:
            # Nemažeme pending - nech ďalší retry skontroluje server
            raise

Monitoring

groups:
  - name: idempotency-safety
    rules:
      - alert: HighReplicaLag
        expr: |
          pg_replication_lag_seconds > 1
        for: 1m
        labels:
          severity: warning
        annotations:
          summary: "Replica lag {{ $value }}s môže spôsobiť idempotency zlyhania"

      - alert: DuplicatePaymentsDetected
        expr: |
          rate(payment_duplicates_total[5m]) > 0
        for: 1m
        labels:
          severity: critical
        annotations:
          summary: "Detekované duplicitné platby - skontroluj idempotency"

Checklist

## Idempotency Replica Lag

### Symptómy
- [ ] Duplicitné transakcie napriek idempotency keys
- [ ] Duplikáty korelujú s vysokou traffic periódou
- [ ] Duplikáty sa dejú počas retry búrok
- [ ] Jeden DB záznam ale dvojité externé efekty

### Diagnostika
- [ ] Skontroluj ktorá DB handluje idempotency lookups
- [ ] Monitoruj replica lag počas incidentov
- [ ] Prehľadaj logy pre duplicitné idempotency keys
- [ ] Over connection pool routing logiku

### Riešenia
- [ ] Vynúť idempotency kontroly na primary
- [ ] Použi INSERT ON CONFLICT pre atomické claims
- [ ] Zváž synchronous replication
- [ ] Pridaj client-side deduplikačnú vrstvu
- [ ] Alertuj na replica lag > 1 sekunda

Záver

Lekcia: idempotency je distributed systems problém, nie len databázový problém. “Check-then-act” vzory zlyhávajú keď kontrola a akcia môžu vidieť rôzne databázové stavy.

Kľúčové princípy:

  1. Idempotency kontroly MUSIA ísť na primary - nikdy nedôveruj replikám
  2. Atomic claim-then-process - použi ON CONFLICT pre claim key ako prvé
  3. Monitoruj replica lag - alertuj predtým než spôsobí duplikáty
  4. Defense in depth - kombinuj server-side a client-side deduplikáciu

Súvisiace články

Súvisiace články

Citujte tento článok

Ak na článok odkazujete, pridajte pôvodnú URL a uveďte autora.

Michal Drozd. "Dvojité Účtovanie z Idempotency Keys: Pasca Replica Lag". https://www.michal-drozd.com/sk/blog/idempotency-keys-replica-lag/ (Publikované 29. januára 2025).