Zum Inhalt springen
Codedock
LeistungenWie wir arbeitenInsightsFallstudienKarriereKontakt
Zurück zu allen Artikeln
Architektur & Consulting

·

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.

.NETBinary parsingPerformanceSpan

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.

Termin auswählen

Architektur, Cloud und Integration für komplexe Systeme. Ein Senior-Architekt in jedem Projekt.

Navigation

LeistungenWie wir arbeitenInsightsFallstudienKarriereKontaktAgentur vs. Freelancer vs. wir

Leistungen

EntwicklungCloudDevOpsAI & DatenBeratungDelivery

Kontakt

CodeDock s.r.o.

Zlenická 863/9, 104 00 Praha 22

Tschechische Republik

info@codedock.com

IČO: 14292769

DIČ: CZ14292769


© 2026 Codedock

KontaktDatenschutzerklärung
Termin buchen