Django-Sessions werden bei Login und Logout zurückgesetzt. Ein eigenes Besucher-Cookie überlebt beides, sodass Seitenaufrufe von vor dem Login dem Konto zugeordnet werden können, das sich schließlich anmeldet. Das Cookie ist HTTP-only und enthält eine zufällige Kennung, nichts Persönliches.
Leistungen
Backend Engineering, Infrastruktur
Branche
B2B-Marktplatz
Jahr
2021-2024
Besuchertracking, das seinen eigenen Traffic überlebt.
Bei 50 Requests pro Sekunde war die Tracking-Middleware eines B2B-Marktplatzes unsichtbar. Bei 500 begann sie zu werfen. Zwei Requests desselben Besuchers kollidierten dutzende Male pro Minute in der Datenbank, einer stürzte ab, und ein Seitenaufruf, den niemand bemerken sollte, riss den ganzen Request mit sich. Nebenbei liefen Cookies über und Bots füllten die Tabellen.
DIE HERAUSFORDERUNG
Wenn get_or_create sich selbst begegnet
Djangos get_or_create sind zwei Queries, nicht eine. Zwei gleichzeitige Requests suchen den Besucher, beide finden nichts, beide fügen ein. Die Datenbank behält eine Zeile und antwortet dem anderen Request mit einem IntegrityError. Die unbehandelte Exception wird zur Fehlerseite, für etwas so Banales wie das Zählen eines Seitenaufrufs. Die naheliegenden Auswege waren gesperrt: keine Transaktion um jeden Request, keine Locks, und nie ein Fehler, der beim Nutzer ankommt.
DIE LÖSUNG
Das Double-Try-Pattern
Die Kandidaten waren SELECT FOR UPDATE, Advisory Locks oder eine Retry-Schleife mit Backoff. Alle drei zahlen bei jedem Request, um eine Kollision zu verhindern, die nur einen kleinen Teil davon trifft: Ein Row Lock serialisiert jeden Besucher-Lookup in eine Warteschlange, eine Backoff-Schleife wartet, wo nichts zu warten ist. Die Entscheidung: die Kollision billig machen, statt sie zu verhindern. Die Middleware ruft get_or_create auf wie bisher. Antwortet die Datenbank mit einem IntegrityError, existiert die Zeile, also fängt der Handler den Fehler und macht ein einfaches get(). Kein Lock, keine Transaktion, keine Schleife: ein zusätzlicher Read von etwa 2 ms, nur bei den Requests, die tatsächlich kollidiert sind. Der Preis: ein Unique Constraint auf dem Session-Key, der existieren muss, und ein Codepfad, der jedem falsch vorkommt, der gelernt hat, IntegrityErrors nie zu schlucken.
Der Lookup, mit der Cookie-Prüfung davor:
Python
def resolve_visitor(self, request):
session_key = request.COOKIES.get('visitor_key')
# Validate cookie format
if session_key and len(session_key) > 64:
session_key = None # discard oversized cookie
if not session_key:
session_key = uuid4().hex
try:
visitor, created = Visitor.objects.get_or_create(
session_key=session_key,
defaults={
'ip_address': self.get_client_ip(request),
'user_agent': request.META.get('HTTP_USER_AGENT', '')[:512],
},
)
except IntegrityError:
# A concurrent request already created it, just fetch
visitor = Visitor.objects.get(session_key=session_key)
return visitor, session_keyKollisionen beobachten
Requests treffen etwa einmal pro Sekunde ein; rund ein Drittel kommt paarweise für denselben neuen Besucher. Kollidiert ein Paar, wird der erste Versuch des unterlegenen Requests rot mit einem IntegrityError, und sein zweiter Versuch liest die Zeile, die der Gewinner gerade angelegt hat. Beide antworten 200. Behalte den Zähler für abgestürzte Requests im Auge: Er bleibt bei null.
Middleware-Trace
| Besucher-Key | Versuch 1: get_or_create | Versuch 2: get() | Antwort |
|---|---|---|---|
| v-c5b3 | angelegt | nicht nötig | 200 OK |
| v-f1a9 | gefunden | nicht nötig | 200 OK |
| v-d3e7 | IntegrityError | gefunden | 200 OK |
| v-d3e7 | angelegt | nicht nötig | 200 OK |
| v-b8c1 | gefunden | nicht nötig | 200 OK |
| v-a4f2 | angelegt | nicht nötig | 200 OK |
- Requests
- 6
- Kollisionen abgefangen
- 1
- Requests abgestürzt
- 0
- Kollisionsrate
- 17%
Was sonst noch unter Last brach
- Übergroße Cookies
Manche Browser und Proxys kürzen Cookies über 4 KB. Die Middleware prüft Länge und Format des Session-Keys vor jedem Lookup. Ein fehlerhafter Wert wird verworfen und ein neuer Key ausgegeben, damit keine Query mit einem Key läuft, der nie passen kann.
- Bots und Monitoring
Kompilierte Muster erkennen Googlebot, Bingbot, UptimeRobot, Pingdom sowie die Pfade /healthcheck und /status. Diese Requests umgehen die Middleware komplett: kein Write, kein Cookie, keine Besucherzeile. Die Regex wird einmal beim Import kompiliert, nicht pro Request.
- Weitergeleitete IP-Adressen
Hinter dem Load Balancer kommt X-Forwarded-For mit mehreren Adressen, IPv6 oder Müll an. Die Middleware nimmt die linkeste nicht-private Adresse, validiert sie, fällt auf die direkte Verbindung zurück und speichert 'unknown', statt eine Exception zu werfen.
Die Ausschlussliste, einmal beim Import kompiliert:
Python
BOT_PATTERNS = re.compile(
r'bot|crawl|spider|slurp|Googlebot|Bingbot|'
r'UptimeRobot|Pingdom|StatusCake|NewRelic|Datadog',
re.IGNORECASE,
)
SKIP_PATHS = re.compile(
r'^/(healthcheck|status|robots\.txt|favicon\.ico)',
)
def should_track(self, request):
ua = request.META.get('HTTP_USER_AGENT', '')
if BOT_PATTERNS.search(ua):
return False
if SKIP_PATHS.match(request.path):
return False
return TrueDAS ERGEBNIS
Wieder unsichtbar
Die Middleware ist wieder etwas, das niemand bemerkt. Seit der Änderung hat kein IntegrityError einen Nutzer erreicht, und die Logs zeigen in 18 Monaten Produktion keinen einzigen Middleware-Fehler. Der Bot-Ausschluss senkte die Datenbank-Writes um 34 Prozent, die Cookie-Prüfung beseitigte jede Query mit fehlerhaftem Key. In dieser Zeit zählte die Middleware 47 Millionen Seitenaufrufe, und der Marktplatz behielt seine Analytics auf den eigenen Servern: kein Drittanbieter-Tool, kein Datenschutz-Banner, Zahlen, die das Team im Code nachlesen kann.
WICHTIGE KENNZAHLEN
500+Requests pro Sekunde, dauerhaft
2,8MEindeutige Besucher in 18 Monaten
0Middleware-Fehler in 18 Monaten
FÜR DEIN PROJEKT
- Wann es passt
Jedes get_or_create, Upsert oder Prüfen-dann-Einfügen auf einem heißen Pfad: Besucherzeilen, Session-Zeilen, Idempotency-Keys. Wenn zwei Requests gleichzeitig denselben Key tragen können, kollidieren sie irgendwann.
- Was du prüfen solltest
Such in deinem Error-Tracker nach IntegrityError auf einem Unique Constraint. Jeder Treffer ist ein Request, den ein Nutzer scheitern sah. Die Lösung ist ein try/except um den Aufruf, kein Lock um die Tabelle.
- Was es braucht
Ein Unique Constraint, den du vermutlich schon hast, ein paar Zeilen pro Aufrufstelle und eine Bot-Liste, die jemand pflegt. Kein Lock-Manager, keine Queue, keine Änderung an der Datenbank.
KUNDENSTIMME
Unsere Analytics laufen jetzt auf unseren eigenen Servern. Nichts verlässt das Haus, es gibt kein Datenschutz-Banner, und wenn eine Zahl komisch aussieht, öffnen wir die Middleware und lesen nach.
CTO
B2B-Marktplatz
FAQ
TECHNOLOGIE-STACK
Django
Python
PostgreSQL
Manuel Kasbarian - Geschäftsführer, SophistiXSeit mittlerweile 10 Jahren arbeiten wir immer wieder gerne mit Daniel zusammen. Wir schätzen dabei sehr seine schnellen Reaktionszeiten rund um die Uhr und sein Allroundwissen. Egal ob Serverkonfigurationen oder Programmierungen, er hat immer die passende Lösung.
Tritt in die Fußstapfen von Manuel und erwecke deine Vision zum Leben.
Frei für neue ProjekteSchreibe mir