·
7 Min Lesezeit
·
Geschrieben von Tomáš Mikeš
GB/s Netzwerkverkehr in .NET verarbeiten: Architektur ohne Paket-Drops
Für Netigo verarbeiten wir IPFIX/NetFlow-Flows in GB/s-Volumen. Der Schlüssel ist Buffering, Backpressure und horizontales Scaling — kein Sync-Processing. Was sitzen muss, damit das System unter Last nichts verliert.
Ein Sensor sendet 50 000 NetFlow-Flows im Peak pro Sekunde. Typisches Beginner-System: Sync-Parsing, Sync-DB-Insert. Ergebnis — der Server kommt nicht mit, UDP-Pakete fallen, 30 % der Daten verschwinden spurlos. Niemand merkt es, bis jemand einen Grund zu prüfen hat.
Für Netigo haben wir eine Pipeline gebaut, die Peaks von 2 GB/s Netzwerkverkehr ohne Drops verkraftet, auch bei langfristigem Wachstum. Die Architektur hat mehrere tragende Prinzipien; alle einzuhalten ist die Bedingung.
Prinzip 1: UDP-Ingestion abseits des Hot-Path
NetFlow/IPFIX läuft über UDP. UDP kennt kein Retry; ist es nicht schnell genug abgeholt, ist es weg. Erstens — der UDP-Socket-Listener darf nichts tun als Bytes greifen und in eine In-Memory-Queue schieben.
In .NET:
while (true) {
var result = await udpClient.ReceiveAsync();
await channel.Writer.WriteAsync(new RawPacket {
Bytes = result.Buffer,
ReceivedAt = DateTime.UtcNow,
SourceEndPoint = result.RemoteEndPoint
});
}Kein Parsing. Keine Validierung. Kein DB-Call. Nur Bytes in einen System.Threading.Channels-Bounded-Channel. Pro-Packet-Zeit: ~15 μs. Heißt: ein Thread schafft ~60 000 Pakete/s.
Wenn mehr nötig: zweiter UDP-Listener auf anderem Port + Load-Balancer davor. Horizontales Ingestion-Scaling.
Prinzip 2: Bounded-Channel = implizite Backpressure
Wenn die Processing-Pipeline langsamer wird (DB langsam, Network-Blip), wächst die In-Memory-Queue. Unbegrenzte Queue = Out-of-Memory in Minuten. Richtig: Bounded-Channel mit Limit.
var options = new BoundedChannelOptions(capacity: 100_000) {
FullMode = BoundedChannelFullMode.Wait
};
var channel = Channel.CreateBounded<RawPacket>(options);Ist die Queue voll, wartet der Writer. Was passiert mit UDP-Paketen? Drops — aber erkannte Drops (Metric-Inkrement). Pakete kommen nicht zurück, aber Sie sehen, wann es passiert und können scale-up.
Alternative: Kernel-Level-Buffer (setsockopt SO_RCVBUF). Socket-Buffer auf 32 MB zu heben bringt einige Sekunden Reserve, bevor UDP auf OS-Level droppt.
Prinzip 3: Parser in dedizierten Threads, nicht im Thread-Pool
Der Parser liest aus dem Channel, dekodiert das IPFIX-Template, extrahiert Fields. CPU-intensiv. Läuft er im Default-Thread-Pool, konkurriert er mit HTTP-API, Logging und allem anderen.
Dedizierter Thread-Pool für den Parser. Anzahl Threads = CPU-Core-Count. In .NET:
var parserTasks = Enumerable.Range(0, Environment.ProcessorCount)
.Select(_ => Task.Factory.StartNew(
() => ParseLoop(channel.Reader),
TaskCreationOptions.LongRunning))
.ToArray();Jeder Task zieht aus channel.Reader, parst das Paket, erzeugt 1-30 geparste Flows (IPFIX-Paket enthält mehrere Flow-Records) und schiebt sie in einen zweiten Channel für den DB-Batch-Writer.
Prinzip 4: DB-Writes in Batches, nicht einzeln
Eine Zeile in TimescaleDB einfügen = ~1 ms (Network + Parse + Write). 500 000 Zeilen/s als Einzel-Inserts ist unmöglich — 500 000 ms = 500 Sekunden Arbeit pro Sekunde Daten.
Lösung: COPY FROM (Postgres-Bulk-Insert). Batchen Sie 5 000-10 000 Zeilen in ein COPY-Statement. Latenz ~30 ms pro Batch. Durchsatz ~200 000 Zeilen/s pro Writer, und Writer skalieren horizontal.
In .NET via NpgsqlBinaryImporter:
using var writer = conn.BeginBinaryImport(
"COPY flows (ts, src_ip, dst_ip, bytes, ...) FROM STDIN BINARY");
foreach (var flow in batch) {
writer.StartRow();
writer.Write(flow.Timestamp, NpgsqlDbType.TimestampTz);
writer.Write(flow.SrcIp, NpgsqlDbType.Inet);
// ...
}
await writer.CompleteAsync();Gegenüber einzelnen INSERTs: 100× höherer Durchsatz.
Prinzip 5: Per-Stage-Metriken, nicht nur End-to-End
Wenn das System langsamer wird, müssen Sie wissen WO. Unsere Metriken pro Stufe:
- UDP-Empfang: Pakete/s, Bytes/s, Drop-Count (Socket-Stats)
- Raw-Channel: aktuelle Tiefe, Enqueue-Wait-Zeit (wenn voll)
- Parser: Pakete geparst/s, Parse-Errors/s, Parse-Zeit p99
- Parsed-Channel: aktuelle Tiefe, Enqueue-Wait-Zeit
- DB-Writer: Batches/s, Zeilen/s, COPY-Zeit p99, DB-Connection-Pool-Auslastung
- End-to-End-Latenz: UDP-Empfang bis DB-Commit (p99)
Wenn End-to-End-p99-Latenz steigt, UDP-Drop-Count aber 0 bleibt — Backpressure funktioniert; eine Stufe ist langsam. Per-Stage anschauen. Parser-p99 verschlechtert? Dort ist das Problem. DB-Writer-Batches/s gefallen? Die DB ist langsam.
Prinzip 6: Horizontales Scaling, nicht vertikales
Wenn Sie Single-Node-Limits erreichen (~1M Pakete/s auf unserer HW), skalieren Sie nicht RAM/CPU. Scale-out.
Jeder Sensor schickt Flows an einen von vier Ingestion-Nodes (stateless, load-balanced UDP via Consistent-Hashing nach Sensor-IP). Jeder Node hat die komplette Pipeline (Parser + Writer) und schreibt in geteilte TimescaleDB.
4 Nodes × 500k Pakete/s = 2M Pakete/s Kapazität. Wird die DB zum Bottleneck, fügen wir Multi-Node-TimescaleDB hinzu oder sharden nach Sensor-ID.
Netigo-Ergebnis nach 12 Monaten Betrieb
- Peak-Durchsatz: 850 000 Flows/s (1,7 GB/s Netzwerkdaten)
- Durchschnitt: 350 000 Flows/s
- UDP-Drop-Rate: < 0,001 % (weniger als 1 in einer Million)
- End-to-End-p99-Latenz: 2,3 s (UDP → DB committet)
- Single-Node-TimescaleDB, 4 Ingestion-Nodes
- Downtime im Jahr: 0
Zum Mitnehmen
Große Volumen von Netzwerkdaten zu verarbeiten heißt viel nicht zu versuchen, zu viel an einem Ort zu tun. Jede Pipeline-Stufe hat eigene Queue-Größe, eigene Threads, eigene Metriken. Wird eine Stufe langsam, isoliert Backpressure sie vom Rest.
Eine synchrone End-to-End-Pipeline (UDP-Empfang → Parse → Insert) schafft 20-50 MB/s. Darüber muss man teilen. Das Prinzip ist sprachunabhängig — Java, Go, Rust sehen alle gleich aus. .NET hat gute Tools (Channels, NpgsqlBinaryImporter), ist aber nicht dramatisch besser oder schlechter als andere Mainstream-Umgebungen.
Arbeiten Sie an etwas Ähnlichem?
Vereinbaren Sie ein 30-minütiges technisches Gespräch. Kein Vertriebsprozess — direktes architektonisches Feedback.
Unser Service:
Systeme bauen, die skalieren — ohne Engpässe →