·
7 min čtení
·
Napsal Tomáš Mikeš
Zpracování GB/s síťového provozu v .NET: architektura bez droppování packetů
Pro Netigo zpracováváme IPFIX/NetFlow flows v objemech GB/s. Klíč je buffering, backpressure a horizontal scaling — žádný sync processing. Co musí sedět, aby systém nezačal ztrácet data pod zátěží.
Sensor pošle 50 000 NetFlow flows v peaku za sekundu. Typický beginner system: sync parsing, sync DB insert. Výsledek — server nestíhá, UDP packety padají, 30 % dat zmizí bez stopy. Nikdo to nezjistí dokud někdo nemá důvod to kontrolovat.
Pro Netigo jsme postavili pipeline, která zvládá peaky 2 GB/s síťového provozu bez dropu i při dlouhodobém nárůstu. Architektura má několik load-bearing principů, a dodržení všech je podmínka.
Princip 1: UDP ingestion mimo hlavní vlákno
NetFlow/IPFIX jde přes UDP. UDP nemá retry, pokud ho neberu dostatečně rychle, ztratím ho. První věc — UDP socket listener nesmí dělat nic jiného než vzít bytes a shodit je do in-memory queue.
V .NET:
while (true) {
var result = await udpClient.ReceiveAsync();
await channel.Writer.WriteAsync(new RawPacket {
Bytes = result.Buffer,
ReceivedAt = DateTime.UtcNow,
SourceEndPoint = result.RemoteEndPoint
});
}Žádné parsing. Žádná validace. Žádný DB call. Pouze přesun bytes do System.Threading.Channels bounded channel. Čas na jeden packet: ~15 μs. To znamená, že jedno vlákno zvládne ~60 000 packetů/s.
Pokud budeš chtít víc: druhý UDP listener na druhém portu + load balancer před tím. Horizontální scaling ingestion.
Princip 2: Bounded channel = implicit backpressure
Když processing pipeline zpomalí (DB je pomalá, network blip), in-memory queue roste. Neomezená queue = out of memory za minuty. Správné řešení: bounded channel s limitem.
var options = new BoundedChannelOptions(capacity: 100_000) {
FullMode = BoundedChannelFullMode.Wait
};
var channel = Channel.CreateBounded<RawPacket>(options);Když je queue plná, writer čeká. Co se stane s UDP packety? Drop — ale drop detekovaný (metric count increment). Nemá to sice vracet packety zpět, ale vidíš, kdy se to stane a můžeš scale-up.
Alternativa: kernel-level buffers (setsockopt SO_RCVBUF). Zvýšit socket buffer na 32 MB dává několik vteřin rezervy, než začne UDP drop na OS level.
Princip 3: Parser v dedicated vláknech, ne v thread pool
Parser čte z channelu, dekóduje IPFIX template, extrahuje fields. CPU-intensive práce. Pokud běží v default thread pool, konkuruje s HTTP API, logováním a vším ostatním.
Dedicated thread pool pro parser. Počet vláken = count CPU cores. V .NET:
var parserTasks = Enumerable.Range(0, Environment.ProcessorCount)
.Select(_ => Task.Factory.StartNew(
() => ParseLoop(channel.Reader),
TaskCreationOptions.LongRunning))
.ToArray();Každý task pull-uje z channel.Reader, parsuje packet, generuje 1-30 parsed flows (IPFIX packet obsahuje multiple flow records) a shodí je do druhého channelu pro DB batch writer.
Princip 4: DB write v dávkách, ne po jednom
Insert jedné řádky do TimescaleDB = ~1 ms (network + parse + write). 500 000 rows/s jako single inserts je nemožné — 500 000 ms = 500 sekund práce na sekundu dat.
Řešení: COPY FROM (Postgres bulk insert). Batchujeme 5 000-10 000 rows do jednoho COPY statement. Latence ~30 ms per batch. Throughput ~200 000 rows/s per writer, a writery jsou horizontálně scalovatelné.
V .NET přes 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();Rozdíl oproti individual INSERTs: 100× vyšší throughput.
Princip 5: Metriky z každé fáze, ne jen end-to-end
Když system zpomalí, musíš vědět KDE. Naše metriky v každé fázi:
- UDP receive: packets/s, bytes/s, drop count (ze socket stats)
- Raw channel: current depth, enqueue wait time (když je full)
- Parser: packets parsed/s, parse errors/s, parse time p99
- Parsed channel: current depth, enqueue wait time
- DB writer: batches/s, rows/s, COPY time p99, DB connection pool utilization
- End-to-end latency: od UDP receive po DB commit (p99)
Když p99 end-to-end latence rostoucí, ale UDP drop count stále 0 — řekneš mi: backpressure funguje, někde je pomalá fáze. Podívej se na per-stage metriky. Vidíš, že parser p99 se zhoršil? Tam je problém. DB writer batches/s klesl? DB je pomalá.
Princip 6: Horizontální scale namísto vertikálního
Když hit limit single-node (~1M packets/s s naším HW), neškáluj RAM/CPU. Scale out.
Každý sensor posílá svoje flows na jeden ze čtyř ingestion nodes (stateless, load-balanced UDP přes consistent hashing podle sensor IP). Každý node má kompletní pipeline (parser + writer) a píše do sdílené TimescaleDB.
4 nodes × 500k packets/s = 2M packets/s kapacita. Pokud DB začne být úzké hrdlo, přidáme multi-node TimescaleDB nebo shardujeme podle sensor ID.
Výsledek pro Netigo po 12 měsících provozu
- Peak throughput: 850 000 flows/s (1,7 GB/s síťových dat)
- Average: 350 000 flows/s
- UDP drop rate: < 0,001 % (méně než 1 v milionu)
- End-to-end latence P99: 2,3 s (UDP → DB committed)
- Single TimescaleDB single-node, 4 ingestion nodes
- Downtime za rok: 0
Co si z toho vzít
Zpracování velkého objemu network dat je hodně o nesnažit se dělat moc na jednom místě. Každá fáze pipeline má svoji velikost queue, svoje vlákno, svoje metriky. Když některá fáze zpomalí, backpressure ji izoluje od zbytku.
Sync end-to-end pipeline (UDP receive → parse → insert) pracuje do 20-50 MB/s. Nad tím se musíš rozdělit. Princip nezávisí na jazyce — v Javě, Go, Rustu bude stejný. V .NET máš dobré nástroje (Channels, NpgsqlBinaryImporter), ale ne výrazně lepší nebo horší než jiné mainstream prostředí.
Ř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ů →