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:
- Idempotency kontroly MUSIA ísť na primary - nikdy nedôveruj replikám
- Atomic claim-then-process - použi ON CONFLICT pre claim key ako prvé
- Monitoruj replica lag - alertuj predtým než spôsobí duplikáty
- Defense in depth - kombinuj server-side a client-side deduplikáciu
Súvisiace články
- Clock Step Backwards Split-Brain - Ďalšia timing pasca
- PostgreSQL WAL Forensics - Debugging replikačných problémov
Súvisiace články
Vyčerpanie Connection Poolu: Tichý Spúšťač Výpadkov
Aplikácia visí, ale databáza vyzerá zdravo. Najčastejšie je vyčerpaný connection pool. Ukážem detekciu, rozumné dimenzovanie a prevenciu únikov spojení.
PostgreSQL Read Replica Konflikty: Prečo sa vaše dotazy rušia
Dotazy na read replikách zlyhávajú s 'canceling statement due to conflict with recovery'. Riešenie závisí od toho, ktorý z 5 typov konfliktov máte - tu je návod ako diagnostikovať a vyriešiť každý z nich.
Split-Brain z Posunu Hodín Dozadu: Wall Time v Lease-Based Systémoch
Dva nody súčasne veria že držia leader lease. Príčina: malá NTP korekcia hodín dozadu kombinovaná s kódom ktorý mieša wall-clock čas s duration-based timeoutmi.
Gossip Protocol Ghost Nodes: IP Reuse Strašiaci Váš Cluster
Nový node sa pripája ku clusteru ale je odmietaný. IP starého nodu je stále v blackliste failure detection gossip protokolu. Zombie membership záznam žije ďalej.
Citujte tento článok
Ak na článok odkazujete, pridajte pôvodnú URL a uveďte autora.