Přeskočit na obsah
Codedock
SlužbyJak pracujemeReferenceInsightsKariéraKontakt
Zpět na všechny články
Architektura & Konzultace

·

7 min čtení

·

Napsal Tomáš Mikeš

IPFIX/NetFlow parser v .NET: jak psát binární protokoly v produkci

Binární protokoly v .NET na high-throughput. Pro Netigo parsujeme miliony IPFIX packetů denně. Jak minimalizovat alokace, co dělá Span<T>/Memory<T>, a kde je hranice mezi "elegant" a "rychlé".

.NETBinary parsingPerformanceSpan

IPFIX packet přijde jako UDP datagram s binárním obsahem. Header 16 bytů, pak template records, pak data records, každý parsovaný podle template definovaného v předchozím packetu. Naivní přístup s BinaryReader a byte[] alokacemi zvládne 30 000 packetů/s. My potřebujeme 500 000/s.

Rozdíl je v tom, jak zacházíš s pamětí. Tady je rekapitulace patternů, které používáme, a proč na nich záleží.

Problém 1: Alokace per packet

Naivní kód:

var reader = new BinaryReader(new MemoryStream(packet));
var version = reader.ReadUInt16();
var length = reader.ReadUInt16();
// ... další fieldy
var flows = new List<Flow>();
while (reader.BaseStream.Position < length) {
  var flow = new Flow { ... };
  flows.Add(flow);
}
return flows;

Per packet alokace: MemoryStream, BinaryReader, List, 1-30 Flow objektů. V .NET GC každá alokace = nenulový overhead. Při 500k packetů/s znamená milióny alokací za sekundu — GC strávíš většinu času.

Cíl: zero-alloc parsing v hot path. Všechno stack-allocated nebo pooled.

Řešení: Span<T> namísto byte[]

Span<byte> je view nad pamětí — žádná nová alokace. Packet přijde jako byte[] z UDP socketu, všechno ostatní pracuje nad Span-em:

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]);
  // ... další fieldy

  int position = 16;
  flowCount = 0;
  while (position < header.Length && flowCount < flowsBuffer.Length) {
    ParseFlow(packet.Slice(position), ref flowsBuffer[flowCount]);
    position += flowSize;
    flowCount++;
  }
}

Žádná nová alokace. BinaryPrimitives (namespace System.Buffers.Binary) čte primitive typy z Span<byte> s explicitní endianness. IPFIX je big-endian, byte order fields na x86 jsou little-endian — konverze je nutná.

Řešení: ArrayPool pro DB writer buffery

Parser produkuje flows, které jdou do DB writeru v batches. Potřebuje pole Flow[] na ~5000 položek. Bez pooling:

var batch = new Flow[5000];

= alokace 5000 × sizeof(Flow) = ~640 KB každý batch, každé 3 sekundy. S 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 drží pool recyklovaných polí. Rent vrací existující pole z pool, Return vrací. Alokace → 0 po prvních pár sekundách provozu.

Problém 2: IPFIX template state

IPFIX protokol posílá TEMPLATE records, které definují strukturu následujících DATA records. Template přijde jednou, data záplava pak říká „jsem template ID 256“ a ty musíš vědět, co to znamená.

Per-sensor cache templates. ConcurrentDictionary? Ne — heavy synchronizace při 500k packets/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 jsou rare (1× za 10 min per sensor). Lookups jsou common (každý flow packet). Lock-free read s occasional-write pattern je optimum.

Problém 3: String allocations pro IP adresy

IP adresa v IPFIX = 4 bytes (IPv4) nebo 16 bytes (IPv6). V kódu chceš string pro logging nebo DB write. Naivně:

var ipString = new IPAddress(bytes).ToString();  // allocation

Alokace IPAddress objektu + string. Řešení: u DB writeru přes Npgsql binary protocol přímo 4 bytes ↓ (bez string round-tripu):

writer.Write(new IPAddress(bytes), NpgsqlDbType.Inet);
// alternativně pro super-hot path:
writer.WriteRaw(bytes, NpgsqlDbType.Inet);

Postgres INET typ je 5 nebo 17 bytes na disku. Binary protocol šoupe přímo bytes bez formátování.

Problém 4: Error handling v hot path

Exception throw v .NET je DRAHÝ — zahrnuje stack walk, object alloc, logging hooks. V hot path loop, kde se parsuje 500k packets/s, 1000 exceptions/s (špatné templates, malformed data) = 10-20 % CPU navíc.

Pattern: error codes místo 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 jen na skutečně exceptional cases (unexpected state v parseru, které by měly program ukončit). Business errors = error codes.

Výkonostní metriky

Naměřené na Netigo production HW (32 core Xeon, 64 GB RAM):

  • Naivní BinaryReader + List: 28 000 packets/s (single thread)
  • Span<T> + stack buffers: 180 000 packets/s (single thread) — 6× zrychlení
  • + ArrayPool + ImmutableDictionary templates: 260 000 packets/s (single thread)
  • Multi-threaded (16 cores, dedicated thread pool):2 100 000 packets/s — ~8× scaling (rezerva na GC, I/O)

GC allocation rate (ETW trace): ~50 MB/s v naivní verzi, ~3 MB/s ve finální. 15× méně GC pressure.

Co si z toho vzít

Pro high-throughput binary parsing v .NET existuje sada patternů, které každá aplikace, která zpracovává > 100k packets/s, musí použít. Klíčové:

  • Span<T> + BinaryPrimitives místo BinaryReader/byte[]
  • ArrayPool pro opakovaně používané buffery
  • Immutable collections + atomic swap pro shared state s rare-write pattern
  • Error codes místo exceptions v hot path
  • Binary protocol na DB writeru, bez string round-tripů

Tohle je prostor, kde .NET opravdu šíří křídla. Jazyk — po Rust revoluci — má pověst „enterprise pomalý“. Realita je, že při správném psaní je to v rámci 10-20 % Rust nebo C++ výkonu, při 10× lepším developer experience.

Řešíš něco podobného?

Domluvme si 30min technický call. Bez obchodních procesů — přímá architekturní zpětná vazba.

Vybrat termín

Architektura, cloud a integrace pro komplexní systémy. Senior architekt na každém projektu.

Navigace

SlužbyJak pracujemeReferenceInsightsKariéraKontaktSrovnání s agenturou

Služby

VývojCloudDevOpsAI & DataKonzultaceŘízení

Kontakt

CodeDock s.r.o.

Zlenická 863/9, 104 00 Praha 22

Česká republika

info@codedock.com

IČO: 14292769

DIČ: CZ14292769


© 2026 Codedock

KontaktOchrana osobních údajů
Domluvit call