Tips & Tricks Statnive Live · Parhum Khoshbakht

Eine Million Seitenaufrufe pro Minute auf einem einzigen Server: Wie Statnive Live für Skalierbarkeit entwickelt wurde

Wie ein Go-Binary, ClickHouse-Rollups und ein 687-Byte-Tracker eine Million Seitenaufrufe pro Minute auf einem einzigen 8-Kern-Server verarbeiten – ohne Ihre Website zu verlangsamen.

Webanalyse-Performance ist ein Website-Geschwindigkeitsproblem

Die meisten Artikel über Webanalyse-Performance konzentrieren sich auf das Backend – wie viele Events pro Sekunde der Server verarbeiten kann. Das ist die falsche Kennzahl. Die Zahl, die Site-Betreiber tatsächlich bezahlen, ist was Ihr Webanalyse-Skript mit den Ladezeiten Ihrer Besucher macht – und damit mit Core Web Vitals, Konversionsrate und SEO.

Googles Core Web Vitals (INP löste FID am 12. März 2024 ab) – LCP, INP, CLS – sind ein Ranking-Signal. JavaScript-Parsing ist auf Mobilgeräten etwa 2–5× langsamer als auf dem Desktop, was bedeutet: Ein 50-KB-Webanalyse-Skript auf dem Desktop kann auf einem Smartphone zu einem äquivalenten Parse-Aufwand von 200 KB führen. Render-blockierende Webanalyse-Skripte sind der größte Performance-Killer in dieser Kategorie.

Statnive Live wurde mit dieser Asymmetrie im Blick entwickelt. Die Kernzahlen – 200 Mio. Events/Tag pro Knoten, ein 687-Byte-Tracker, unter 500 ms p99-Abfragelatenz – dienen alle einem Ziel: Die Webanalyse-Schicht wird nie zum Grund, warum Ihr Checkout langsamer wird. Dieser Beitrag erklärt das Wie – mit Dateipfaden, damit Sie jede Aussage nachprüfen können.

Dies ist der abschließende Teil unserer vierteiligen statnive.live-Vorstart-Serie. Wo wir eine messbare Aussage treffen, finden Sie die Datei oder den Befehl, der sie belegt.

Der 687-Byte-Tracker

Der Statnive-Live-Tracker maß am 28.04.2026 1.394 Bytes minifiziert / 687 Bytes gzippt. Das sind keine angestrebten Werte – es sind die Bytes, die Gos go:embed-Direktive in das Binary geschrieben hat, und Sie können sie in jedem Klon des Repos nachvollziehen:

$ wc -c internal/tracker/dist/tracker.js
    1394 internal/tracker/dist/tracker.js

$ gzip -9 -c internal/tracker/dist/tracker.js | wc -c
     687

Die Zahlen driften nicht, weil die Datei per go:embed ins Binary eingebettet ist – Sie können keinen anderen Tracker als den im Repo vorhandenen ausliefern, ohne das Binary neu zu bauen. Und sie können nicht unbemerkt wachsen: Ein Go-Test in internal/tracker/tracker_test.go setzt das Budget auf 1.500 Bytes minifiziert / 700 Bytes gzippt durch und lässt den Build scheitern, wenn einer der Schwellenwerte überschritten wird:

const (
    maxMinifiedBytes = 1500
    maxGzippedBytes  = 700
)

Derselbe Test verbietet die gesamte Struktur eines nicht-trivialen Trackers – XMLHttpRequest, localStorage, sessionStorage, indexedDB, document.cookie, Klartext-URLs, CDN-Importe – per String-Suche im eingebetteten Bundle. Wenn ein Refactoring versehentlich eine größere Transport-Bibliothek einbringt oder ein neues Feature auf localStorage zugreift, lehnt die CI den PR ab.

Zum Vergleich: Das GA4-Skript gtag.js ist ungefähr 110 KB komprimiert, und Plausibles veröffentlichte Zahl liegt bei 135 KB gzippt für dasselbe Skript. Unabhängig davon, welche Zahl Sie als Referenz nehmen: Statnive Lives Tracker ist zwei Größenordnungen kleiner – deutlich über 50× leichter als GA4.

Der Transport läuft über sendBeacon plus fetch keepalive – beides Fire-and-Forget, keines blockiert den Haupt-Thread. Die Struktur ist ein IIFE in Vanilla JS; es gibt kein Framework, weil in 1.394 Bytes keines Platz hat. Der Tracker wird per go:embed First-Party ausgeliefert: kein externes CDN, kein DNS-Lookup außerhalb der Domain des Betreibers, kein Drittanbieter-Tag-Manager. Die Air-Gap-Validator-Regel in der CI lehnt jede Tracker-Änderung ab, die eine externe Referenz wieder einführen würde.

Der Ingest-Pfad – Fire-and-Forget, WAL-first

Der Vertrag des Site-Betreibers mit dem Tracker lautet: „Das darf meine Seite nie blockieren.” Der Vertrag des Servers mit dem Tracker lautet: „Das darf dein Event nie verlieren.” Statnive Lives Ingest-Pipeline ist darauf ausgelegt, beide Verträge günstig einzuhalten.

Jede Ingest-Anfrage durchläuft einen Write-Ahead Log, bevor der Handler mit 202 antwortet. Der Handler wartet auf fsync – aber mit einem 100-ms-Gruppen-Commit-Ticker, nicht pro Event, weil ein Event-by-Event-fsync den Durchsatz auf ~100 Events/s auf Standard-Disks begrenzen würde und wir ~7 K EPS dauerhaft auf dem SaaS-Einstiegsniveau benötigen. Das WAL ist tidwall/wal (MIT-lizenziert, vendored), geöffnet mit NoSync: true; der 100-ms-Ticker übernimmt die Dauerhaftigkeit. Der Handler wartet über AppendAndWait, bevor er sein 202-Ack sendet. Falls die Synchronisierung fehlschlägt, beendet sich der Prozess – Webanalyse ist nicht der richtige Ort, um Geschichte still zu korrumpieren.

Der Handler begrenzt Request-Bodies auf 8 KB via Gos http.MaxBytesReader:

const (
    maxBodyBytes  = 8 * 1024  // 8 KB MaxBytesReader
    maxArrayItems = 10        // batch at most 10 events per request
    uaMinLen      = 16
    uaMaxLen      = 500
)

Vor dem WAL filtert ein Fast-Reject-Gate offensichtlichen Junk mit HTTP 204 heraus – User-Agent-Länge außerhalb von 16–500, Nicht-ASCII-UA, IP-als-UA, UUID-als-UA, Prefetch-Header (X-Purpose, X-Moz). Diese Anfragen erreichen nie die Anreicherung, das WAL oder die Rollups. ClickHouse Async-Insert ist vorhanden, aber nur auf einem separaten /ingest-fallback-Endpunkt – nie auf dem heißen /api/event-Pfad.

Das Rate-Limiting ist CGNAT-bewusst: Anfragen von Mobilfunk-ASNs erhalten einen zusammengesetzten (ip, site_id)-Schlüssel mit 1 K Req/s dauerhaft / 2 K Burst, während alle anderen auf 100 Req/s pro IP fallen. Ein globales Cap von 25 K Req/s pro site_id verhindert, dass ein einzelner Kunde den Host sättigt. Die CGNAT-Berücksichtigung ist wichtig, weil ein Mobilfunk-Gateway hinter einer einzigen IP sitzen kann – ein naives per-IP-Limit würde Tausende legitimer Besucher desselben Netzbetreibers blockieren.

Rohe IP-Adressen werden niemals gespeichert. Sie fließen in die Pipeline nur für den GeoIP-Lookup und werden anschließend verworfen, bevor der Batch-Writer die Zeile sieht. Das Audit-Log ist ebenfalls IP-frei – der Rate-Limiter schlüsselt intern noch auf der IP auf, aber die Audit-Log-Serialisierung verliert sie. Die gdpr-code-review-Regel setzt das in der CI durch.

Der Abfrage-Pfad – drei Rollups + HyperLogLog

Das Dashboard fragt niemals Roh-Events ab. Alle Dashboard-Lesezugriffe kommen aus Rollup-Tabellen – das ist Architecture Rule 1, durchgesetzt durch einen CI-Engpass. Die rohe events_raw-Tabelle ist ausschließlich schreibend, außer für Funnel-Fenster, die windowFunnel() mit stündlich gecachtem Ergebnis aufrufen.

Die drei v1-Rollups sind AggregatingMergeTree-Views, alle mit site_id als erstem Schlüssel:

  • hourly_visitorsENGINE = AggregatingMergeTree() PARTITION BY toYYYYMM(hour) ORDER BY (site_id, hour)
  • daily_pagesORDER BY (site_id, day, pathname)
  • daily_sourcesORDER BY (site_id, day, channel, referrer_name, utm_source, utm_medium)

Die Besucher-Kardinalität wird über HyperLogLog via AggregateFunction(uniqCombined64, FixedString(16)) berechnet – rund 0,5 % Fehler, sublinearer Speicherbedarf. Der FixedString(16) ist ein auf 16 Bytes gekürzter BLAKE3-128-Hash; die Identität lautet BLAKE3(daily_salt || identity_input), wobei der Tages-Salt als HMAC(master_secret, site_id || YYYY-MM-DD) abgeleitet, täglich rotiert und nie gespeichert wird. Gleicher Besucher, anderer Hash an jedem Tag – und die Rollups tragen nur den Hash-Zustand, nie die Eingabe.

Jede Dashboard-Abfrage läuft durch einen einzigen Helfer:

// whereTimeAndTenant emits the WHERE clause every read query MUST start
// with: site_id = ? AND <timeColumn> >= ? AND <timeColumn> < ?.
// site_id is the first WHERE term so the (site_id, …) ORDER BY prefix
// can prune partitions cleanly.
func whereTimeAndTenant(f *Filter, timeColumn string) (string, []any) {
    clause := fmt.Sprintf("WHERE site_id = ? AND %s >= ? AND %s < ?",
        timeColumn, timeColumn)
    return clause, []any{f.SiteID, f.From, f.To}
}

Eine CI-Regel lehnt jede neue Abfrage ab, die whereTimeAndTenant umgeht oder nicht mit WHERE site_id = ? beginnt. Das klingt pingelig; in der Praxis ist es der Unterschied zwischen sauber beschnittenen Partitionen und einem mandantenfähigen ClickHouse, das bei jedem Dashboard-Rendering die Daten aller Kunden scannt.

Nullable(...) ist für Webanalyse-Spalten verboten – der gemessene Aufwand beträgt 10–200 % bei Aggregationen (Projektdok. 20 maß 2× bei Nullable(Int8)). Die Rollups verwenden stattdessen DEFAULT '' und DEFAULT 0, was sowohl den Schreib- als auch den Merge-Pfad schnell hält.

Die Zahlen

Das veröffentlichte Proof-Strip auf der /live-Seite nennt vier Werte:

  • 600 B gzippt Tracker (die gerundete Marketing-Version von 687 B)
  • 200 Mio. Events/Tag pro Knoten
  • <500 ms p99
  • EU-/EWR-only-Daten

Ehrliche Anmerkungen zu jedem:

  • Tracker: gemessen am 28.04.2026: 1.394 B min / 687 B gz; Budget 1.500 B / 700 B gz, in CI sichergestellt.
  • 200 Mio. Events/Tag: Designobergrenze, kein gemessener Produktionswert. Quelle: Projektdok. 19 Hetzner-Klasse-Umschlag; das SaaS-Einstiegsniveau ist ein Hetzner AX42 (8 Kerne / 64 GB) mit Headroom. 200 Mio./Tag = ~2.300 EPS dauerhaft, weit innerhalb von ClickHouses veröffentlichtem Durchsatzbereich (Cloudflare betreibt eine Ingestrate von 11 Mio. Zeilen/s über 36 Knoten; Plausible migrierte von PostgreSQL, weil ClickHouse ab ~1 Mio. Events/Tag notwendig ist).
  • <500 ms p99: Designobergrenze, kein gemessener Produktionswert. Der Phase-11a-Produktions-p99 wird nach dem öffentlichen Signup-Launch veröffentlicht; die ProofStrip-Aussage ist ein Graduierungstor-Schwellenwert, keine Messung.
  • EU-/EWR-only-Daten: verarbeitet in Nürnberg, Deutschland, auf einem Netcup VPS 2000 G12 NUE – gemessen in dem Sinne, dass ein Integrationstest das Binary unter iptables -P OUTPUT DROP ausführt und beweist, dass kein notwendiger Egress existiert.

Das Dashboard liegt innerhalb eines 16-KB-gzippt-Initial-JS-Budgets, sichergestellt durch size-limit gegen den gebauten index-*.js-Chunk. Der Lazy-Chart-Chunk ist auf 25 KB begrenzt, Lazy-Panel-Chunks auf 10 KB, CSS auf 5 KB / 3 KB. Das Gate lässt sich lokal erneut ausführen:

$ npm --prefix web run bundle-gate

Die Webanalyse-Invarianten-SLOs, die das Test-Gate durchsetzt:

  • Eventverlust ≤ 0,05 % server / ≤ 0,5 % client
  • Duplikate ≤ 0,1 %
  • Attributionskorrektheit ≥ 99,5 %
  • Einwilligungs- / Personenbezug-Leaks = 0
  • TTFB-Overhead ≤ +10 % / +25 ms

Jeder Schwellenwert blockiert das Release, wird bei jedem PR durch die CI sichergestellt und zusätzlich während des 72-stündigen Soaks + 6-Szenario-Chaos-Matrix pro Phase vor jedem Produktions-Cutover geprüft. Egal wie der nächste große Traffic-Spike aussieht – er muss diese Gates passieren, bevor er ausgeliefert wird.

Der ehrliche Kompromiss – 1 Stunde Verzögerung

Die 1-Stunde-Verzögerung ist der Teil von Statnive Live, der manchen Lesern nicht gefällt – benennen wir ihn daher offen. Architecture Rule 3 lautet:

1-Stunde-Verzögerung, NICHT Echtzeit – spart 98 % der Abfragekosten. Niemals eine 5-Minuten-Echtzeit-Pipeline bauen.

Die „98 %” sind ein Vergleichswert gegenüber einer hypothetischen 5-Minuten-Pipeline auf demselben Stack – Rollup-Schreibvorgänge günstig halten, den Rollup-Fußabdruck pro Site unter 100 KB/Tag/Site halten (3 v1-Rollups; bis zu 6 in v1.1), Dashboard-Abfragen aus kompakten Aggregaten bedienen statt heiße Tabellen zu scannen. Wer Webanalyse einmal pro Stunde oder einmal täglich prüft, merkt die 1-Stunde-Verzögerung nicht. Wer Sub-Minuten-Feedback für einen Live-Event-Spike-Monitor benötigt, ist mit Live falsch bedient – wählen Sie ein Echtzeit-Webanalyse-Produkt, akzeptieren Sie die ~50× höheren Abfragekosten und weiter.

Das Echtzeit-Panel existiert weiterhin und zeigt aktiv in der letzten Stunde aus demselben hourly_visitors-Rollup, aus dem alles andere liest. Es gibt keine separate 5-Minuten-Pipeline dahinter – absichtlich. Der Kompromiss ist das Herzstück der Architektur, keine versteckten Kosten.

Was das für Ihre Website bedeutet

Die obige Architektur macht die Site-Betreiber-Geschichte unspektakulär:

Der Tracker kann Ihren Checkout nicht blockieren. sendBeacon plus fetch keepalive ist Fire-and-Forget – selbst wenn der Webanalyse-Endpunkt offline ist, navigiert die Seite weiter und der Kunde zahlt trotzdem. Nachprüfbar: Schalten Sie den Webanalyse-Endpunkt ab und beobachten Sie, wie die Seite weiter funktioniert.

Der Core-Web-Vitals-Einfluss ist auf 687 Bytes plus ein Inline-IIFE begrenzt. Das liegt weit unter jedem dokumentierten „render-blocking”-Schwellenwert für diese Kategorie. Wir haben den LCP-Einfluss des Trackers des WordPress-Plugins in einem separaten Beitrag untersucht; ein gemessenes LCP-Delta für den Live-Tracker liegt noch nicht vor – wir behaupten keines, das wir nicht haben.

Der serverseitige Overhead liegt auf einem separaten Origin. Der Tracker postet an einen Statnive-Live-Endpunkt, nicht an Ihre Web-App. Der 100-ms-WAL-fsync-Ticker sichert ~7 K EPS dauerhaft auf dem SaaS-Einstiegsniveau – nichts davon konkurriert mit dem PHP-, Node- oder Rails-Request-Budget Ihrer Anwendung.

Häufige Fragen

Skaliert es auf 10 Mio. Seitenaufrufe pro Tag?

Ja. 10 Mio. Seitenaufrufe/Tag entsprechen etwa 115 Events/Sekunde dauerhaft – weit unter der Designobergrenze von 200 Mio./Tag (~2.300 EPS dauerhaft) auf einer einzigen 8-Kern-/32-GB-Box. Wächst Ihr Traffic über einen Knoten hinaus, verwenden die Migrationen bereits {{if .Cluster}}-Go-Templates, sodass der Übergang von einem Knoten zu Distributed ein Konfigurations-Flip ist, kein Re-Plattforming.

Kann ich es auf Shared-Hosting betreiben?

Nein. ClickHouse benötigt einen echten Server (mindestens 8 Kerne / 32 GB). Für Shared-Hosting ist das WordPress-Plugin die richtige Wahl – es speichert in Ihrer vorhandenen MySQL/MariaDB und hat keinen neuen Ops-Aufwand.

Wie verhält es sich im Vergleich zum 110-KB-Skript von GA4?

gtag.js von GA4 liegt je nach Payload-Version zwischen 110 KB komprimiert (Stape) und 135 KB gzippt (Plausible). Statnive Lives Tracker ist 687 B gzippt. Deutlich über 50× kleiner, unabhängig davon, welche GA4-Zahl Sie verwenden. Der Parse-Zeit-Unterschied auf Mobilgeräten dominiert; auf einem mittleren Android-Smartphone verschwindet der Tracker im Rauschen.

Auf welcher Hardware läuft das SaaS-Angebot?

Das veröffentlichte SaaS-Einstiegsniveau ist ein Hetzner AX42 (8 Kerne / 64 GB). Das aktive SaaS-Produktions-VPS ist ein Netcup VPS 2000 G12 NUE in Nürnberg, Deutschland – ausschließlich EU-/EWR-Verarbeitung, kein Kapitel-V-Transfer. Artikel 3 behandelt die vertragliche Seite; Artikel 2 behandelt die regulatorische Seite.

Wie wird das Größenbudget durchgesetzt?

Zwei CI-Gates laufen bei jedem PR. (a) go test ./internal/tracker/... setzt das Tracker-Budget von 1.500 B / 700 B gz sowie die Verboten-Token-Ablehnung durch. (b) npm --prefix web run bundle-gate führt size-limit gegen alle fünf Dashboard-Einträge in web/.size-limit.json aus. Beide sind Teil von make ci-local, das der GitHub-Actions-Workflow end-to-end gegen ein echtes ClickHouse in 8–12 Minuten ausführt.

Die Belege

Jede Aussage oben ist aus einem Klon von statnive-live reproduzierbar:

# Tracker size budget — 1,500 B min / 700 B gz, asserted by Go test
$ wc -c internal/tracker/dist/tracker.js
    1394 internal/tracker/dist/tracker.js
$ gzip -9 -c internal/tracker/dist/tracker.js | wc -c
     687
$ go test ./internal/tracker/...
ok      github.com/statnive/statnive.live/internal/tracker      0.32s

# Dashboard bundle budget — five size-limit entries
$ npm --prefix web run bundle-gate

# Whole gate — ClickHouse + integration + smoke + e2e (~8–12 min)
$ make ci-local

Dieselben Befehle laufen in GitHub Actions bei jedem PR. Es gibt keinen separaten „Release-Benchmark” – wenn ein PR ein Budget bricht, wird er nicht gemergt. Wenn ein Release einen SLO während des 72-stündigen Soaks verletzt, wird es nicht ausgeliefert. Engineering für eine Million Seitenaufrufe pro Minute sieht in der Praxis unspektakulär aus; größtenteils sind es CI-Gates, Fast-Reject-Filter und Rollup-Tabellen – und sehr wenig davon ist heroisch.

Das Fazit

Der Webanalyse-Stack, den Sie 2026 einsetzen, wird hauptsächlich danach beurteilt, was er mit Ihrer Website macht, nicht was er für sie tut. Die Design-Entscheidungen von Statnive Live machen den Kompromiss explizit: ein 687-Byte-First-Party-Tracker, eine 1-Stunde-Verzögerungs-Rollup-Pipeline, die 98 % der Abfragekosten spart, die Echtzeit gefordert hätte, und ein CI-gesicherter Satz von SLOs, der ein Release blockiert, bevor es Sie je erreicht. Wir behaupten keine produktionsgemessenen p99-Werte, die wir noch nicht ausgeliefert haben, und keine LCP-Deltas, die wir noch nicht gemessen haben – aber jede obige Zahl lässt sich auf einen Dateipfad zurückführen, den Sie nachprüfen können.

Statnive Live kommt bald auf statnive.de/live. Diese vierteilige Serie ist die langsame Einführung: WordPress-Plugin vs. Statnive Live für den Entscheidungsbaum, DSGVO-konforme Webanalyse 2026 für die regulatorische Seite, Webanalyse-Daten selbst besitzen für die Deployment-Form, und dieser Beitrag für die technische Seite. Die Funktionsseite ist die Übersichtsseite. Wenn eine Zahl in diesem Beitrag falsch sein sollte, schreiben Sie mir – hinter jeder Aussage steckt eine Datei oder ein Befehl, und wir korrigieren lieber einmal als eine polierte Halbwahrheit auszuliefern.

Statnive kostenlos herunterladen