·
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é".
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.
Naše služba:
Systémy, které škálují — bez bottlenecků →