·
7 Min Lesezeit
·
Geschrieben von Tomáš Mikeš
IPFIX/NetFlow-Parser in .NET: Binärprotokolle in Produktion
Binärprotokolle in .NET bei hohem Durchsatz. Für Netigo parsen wir täglich Millionen IPFIX-Pakete. Wie man Allokationen minimiert, was Span<T>/Memory<T> bringt, und wo die Grenze zwischen "elegant" und "schnell" liegt.
Ein IPFIX-Paket kommt als UDP-Datagramm mit binärem Inhalt. Header 16 Bytes, dann Template-Records, dann Data-Records, jeder geparst nach einem Template aus einem vorherigen Paket. Der naive Ansatz mit BinaryReader und byte[]-Allokationen schafft 30 000 Pakete/s. Wir brauchen 500 000/s.
Der Unterschied liegt im Umgang mit Speicher. Hier die Patterns, die wir nutzen, und warum sie zählen.
Problem 1: Per-Paket-Allokationen
Naiver Code:
var reader = new BinaryReader(new MemoryStream(packet));
var version = reader.ReadUInt16();
var length = reader.ReadUInt16();
// ... weitere Fields
var flows = new List<Flow>();
while (reader.BaseStream.Position < length) {
var flow = new Flow { ... };
flows.Add(flow);
}
return flows;Per-Paket-Allokationen: MemoryStream, BinaryReader, List, 1-30 Flow-Objekte. Im .NET-GC hat jede Allokation nicht-null Overhead. Bei 500k Paketen/s sind das Millionen Allokationen pro Sekunde — GC frisst die meiste CPU-Zeit.
Ziel: Zero-Alloc-Parsing im Hot-Path. Alles stack-allocated oder gepoolt.
Lösung: Span<T> statt byte[]
Span<byte> ist eine View auf Speicher — keine neue Allokation. Das Paket kommt als byte[] vom UDP-Socket, alles andere arbeitet über den Span:
public static void ParsePacket(
ReadOnlySpan<byte> packet,
ref IpfixHeader header,
Span<Flow> flowsBuffer,
out int flowCount
) {
header.Version = BinaryPrimitives.ReadUInt16BigEndian(packet[0..2]);
header.Length = BinaryPrimitives.ReadUInt16BigEndian(packet[2..4]);
// ... weitere Fields
int position = 16;
flowCount = 0;
while (position < header.Length && flowCount < flowsBuffer.Length) {
ParseFlow(packet.Slice(position), ref flowsBuffer[flowCount]);
position += flowSize;
flowCount++;
}
}Keine neue Allokation. BinaryPrimitives (Namespace System.Buffers.Binary) liest Primitive-Types aus Span<byte> mit expliziter Endianness. IPFIX ist Big-Endian, x86-Byte-Order ist Little-Endian — Konvertierung nötig.
Lösung: ArrayPool für DB-Writer-Buffer
Der Parser erzeugt Flows, die in Batches zum DB-Writer gehen. Er braucht ein Flow[] von ~5000 Items. Ohne Pooling:
var batch = new Flow[5000];
= Allokation 5000 × sizeof(Flow) = ~640 KB jede Batch, alle 3 Sekunden. Mit Pooling:
var batch = ArrayPool<Flow>.Shared.Rent(5000);
try {
// parse, fill batch
await WriteBatchAsync(batch, flowCount);
} finally {
ArrayPool<Flow>.Shared.Return(batch, clearArray: true);
}ArrayPool hält einen Pool wiederverwendeter Arrays. Rent liefert ein bestehendes Array, Return gibt es zurück. Allokationen → 0 nach den ersten paar Betriebssekunden.
Problem 2: IPFIX-Template-State
Das IPFIX-Protokoll schickt TEMPLATE-Records, die die Struktur folgender DATA-Records definieren. Ein Template kommt einmal, dann sagt eine Flut von Data-Records „ich bin Template ID 256“ und Sie müssen wissen, was das heißt.
Per-Sensor-Template-Cache. ConcurrentDictionary? Nein — heavy Synchronisation bei 500k Paketen/s. Per-Sensor-ImmutableDictionary-Swap:
private ImmutableDictionary<ushort, Template> _templates =
ImmutableDictionary<ushort, Template>.Empty;
public void AddTemplate(ushort id, Template tmpl) {
_templates = _templates.Add(id, tmpl); // atomic swap
}
public Template? GetTemplate(ushort id) {
return _templates.GetValueOrDefault(id); // lock-free read
}Template-Updates sind selten (1× pro 10 Min pro Sensor). Lookups sind häufig (jedes Flow-Paket). Lock-free-Read mit Occasional-Write-Pattern ist das Optimum.
Problem 3: String-Allokationen für IP-Adressen
IP-Adresse in IPFIX = 4 Bytes (IPv4) oder 16 Bytes (IPv6). Im Code will man oft einen String für Logging oder DB-Write. Naiv:
var ipString = new IPAddress(bytes).ToString(); // Allokation
Allokiert IPAddress-Objekt plus String. Lösung: den DB-Writer via Npgsql-Binary-Protokoll direkt mit 4 Bytes füttern (kein String-Roundtrip):
writer.Write(new IPAddress(bytes), NpgsqlDbType.Inet); // alternativ für super-heiße Pfade: writer.WriteRaw(bytes, NpgsqlDbType.Inet);
Postgres-INET-Typ ist 5 oder 17 Bytes auf Disk. Das Binary-Protokoll schiebt rohe Bytes ohne Formatierung.
Problem 4: Error-Handling im Hot-Path
Exceptions in .NET zu werfen ist TEUER — Stack-Walks, Objekt-Allokationen, Logging-Hooks. Im Hot-Path mit 500k Paketen/s sind 1000 Exceptions/s (schlechte Templates, malformed Data) = 10-20 % zusätzliche CPU.
Pattern: Error-Codes statt Exceptions:
public enum ParseResult { Success, UnknownTemplate, Truncated, BadLength }
public static ParseResult ParseFlow(
ReadOnlySpan<byte> data,
ref Flow flow
) {
if (data.Length < 16) return ParseResult.Truncated;
// ... parse
return ParseResult.Success;
}Exceptions nur für wirklich exceptional cases (unerwarteter Parser-State, der das Programm abreißen sollte). Business-Errors = Error-Codes.
Performance-Zahlen
Gemessen auf Netigo-Produktions-HW (32-Core-Xeon, 64 GB RAM):
- Naiver BinaryReader + List: 28 000 Pakete/s (Single-Thread)
- Span<T> + Stack-Buffer: 180 000 Pakete/s (Single-Thread) — 6× Speedup
- + ArrayPool + ImmutableDictionary-Templates: 260 000 Pakete/s (Single-Thread)
- Multi-Thread (16 Cores, dedicated Thread-Pool): 2 100 000 Pakete/s — ~8× Scaling (Rest ist GC und I/O)
GC-Allocation-Rate (ETW-Trace): ~50 MB/s in der naiven Version, ~3 MB/s im Endzustand. 15× weniger GC-Druck.
Zum Mitnehmen
Für High-Throughput-Binary-Parsing in .NET gibt es eine Reihe von Patterns, die jede Anwendung, die > 100k Pakete/s verarbeitet, nutzen muss. Die Kernpunkte:
- Span<T> + BinaryPrimitives statt BinaryReader/byte[]
- ArrayPool für wiederholt genutzte Buffer
- Immutable-Collections + Atomic-Swap für geteilten State mit Rare-Write-Pattern
- Error-Codes statt Exceptions im Hot-Path
- Binary-Protokoll am DB-Writer, keine String-Roundtrips
Das ist Terrain, in dem .NET wirklich seine Flügel ausspielt. Die Sprache — nach der Rust-Revolution — hat den Ruf als „Enterprise-langsam“. Die Realität ist: richtig geschrieben liegt sie innerhalb 10-20 % der Rust- oder C++-Performance, bei 10× besserer Developer-Experience.
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 →