devto 2026-09-22 원문 보기 ↗
When we published our earlier benchmarks for rekuiper (our Rust reimplementation of LF Edge eKuiper for edge gateways and IoT hubs), the numbers answered our first question: on five real-world MQTT workloads, memory stayed bounded between 5 and 10 MB while Go-based engines climbed to hundreds of megabytes or failed.
In that test, rekuiper sustained 100,000 messages per second on a single pinned core. At offered 200,000 msg/s, however, it dropped packets or backlogged upstream.
Saying "100k passed and 200k failed" left a massive 100,000 msg/s blind spot. Is the true ceiling 105k? 140k? 195k? And why did it choke at higher rates when the CPU wasn't fully maxed out on every workload?
When we profiled the bottleneck under burst loads, the culprit wasn't stream parsing or window math. It was the disk.
Even when an edge stream engine's data plane runs in memory, typical implementations still touch disk for metadata: checking SQLite tables for stream and rule definitions, resolving auth keys, reading configuration YAMLs, or persisting rule state changes. On a developer NVMe drive, SQLite lookups take microseconds. On an industrial gateway running off slow eMMC flash or a microSD card, a batch of flash writes causes I/O wait spikes that stall the Tokio runtime thread.
For v0.500-beta, we rebuilt the engine's internal catalog and hot paths around an in-memory Redis-style architecture, tuned our storage engine to be zero-contention, and then ran a hierarchical search with 1,000 msg/s resolution under a strictly bounded Mosquitto broker to find the exact physical limit of every workload.
Here is what we built, what we measured, and where single-core stream processing actually hits physical walls.
In eKuiper and similar edge software, metadata (streams, rules, schemas, auth) is stored in a key-value store or an embedded SQLite database.
Under moderate load (5k–20k msg/s), querying SQLite to validate a rule or look up a schema is unnoticeable. But when 100,000+ messages per second hit an edge machine:
If we wanted to push edge ingestion beyond 100k msg/s on a single core, the hot path could not touch the filesystem at all.
MemoryCatalog)
We separated metadata persistence from metadata reads:
// crates/rekuiper-core/src/catalog.rs
pub struct MemoryCatalog {
streams: parking_lot::RwLock<HashMap<String, StreamDefinition>>,
tables: parking_lot::RwLock<HashMap<String, TableDefinition>>,
rules: parking_lot::RwLock<HashMap<String, RuleDefinition>>,
}
RwLock<HashMap>, completing lookups in nanoseconds with zero system calls and zero disk I/O.KUIPER_AUTH_PUBLIC_KEY_FILE) are parsed and cached in memory. Ingest requests no longer read public keys off disk per request./etc configuration overlays, source definitions, and JSON descriptors are cached in RAM on first access.INSERT INTO ... VALUES (...), (...) statements in parameterized chunks instead of emitting one query per record.We expanded internal actor queue depths from 1,024 to 32,768 records. On a single pinned core, this provides enough buffer headroom to absorb operating system scheduling jitter without propagating backpressure back into the MQTT network loop.
Rather than testing arbitrary rounded rates, we searched for the exact ceiling of each workload using a hierarchical binary ladder:
--memory-swap=1g, TOKIO_WORKER_THREADS=1.mqttgen (our standalone Rust publisher) pushing MQTT 3.1.1 QoS 0 across 8 connections from separate cores.Here are the verified limits for all five workloads in rekuiper v0.500-beta:
| Workload | Scenario | Certified Ceiling | First Failure | Limiting Factor | Single-Core CPU | Anon RAM |
|---|---|---|---|---|---|---|
| W1 | Telemetry Filter (1,000 devices) | 150,000 msg/s | 151,000 msg/s | CPU saturation (99.4%), broker drops 20.5% | 94.5% | 17.4 MB |
| W2 | Device 10s Windows (1,000 devices) | 200,000 msg/s | 210,000 msg/s | Generator schedule (engine lossless to 240k) | 94.4% | 6.7 MB |
| W3 | ESPHome Topics (10,000 topics, meta(topic)) |
150,000 msg/s | 151,000 msg/s | CPU saturation (99.3%), broker drops 5.0% | 97.4% | 16.6 MB |
| W4 | Vehicle Windows (10,000 VIN wildcard topics) | 200,000 msg/s | 210,000 msg/s | Generator schedule (engine lossless to 220k) | 97.5% | 18.1 MB |
| W5 | EV Charger Sessions (SESSIONWINDOW(10, 2)) |
126,000 msg/s | 127,000 msg/s | Session drain lag (16s exceeds 5s stability limit) | 86.7% | 6.0 MB |
SELECT id, device, temp, speed * 3.6 AS speed_kmh FROM telem WHERE temp > 21.0
SELECT device, count(*) AS n, avg(temp), max(speed) FROM telem GROUP BY device, TUMBLINGWINDOW(ss, 10)
count, avg, max) update in place without buffering raw rows.SELECT meta(topic) AS topic, self AS state FROM telem
SELECT device, count(*) AS n, avg(speed), max(temp) FROM telem GROUP BY device, TUMBLINGWINDOW(ss, 10)
bench/vehicles/+/telemetry.SESSIONWINDOW)
SELECT device, count(*) AS n, max(speed) FROM telem GROUP BY device, SESSIONWINDOW(ss, 10, 2)
Here is the updated head-to-head comparison on the common 5k–100k ladder:
| Workload | rekuiper 0.500 | eKuiper 2.4.1 | Telegraf 1.40.0 | Redpanda Connect 4.109.0 |
|---|---|---|---|---|
| W1: Telemetry filter | 150k certified | 20k | 50k (backlog) | 20k |
| W2: Device windows | 200k certified | 20k | Lost 6–27% | 5k |
| W3: ESPHome topics | 150k certified | 20k | 50k (backlog) | 20k |
| W4: Vehicle windows | 200k certified | 20k | Inconsistent | 5k |
| W5: Charger sessions | 126k certified | 20k | Unsupported | Unsupported |
| Workload | rekuiper 0.500 | eKuiper 2.4.1 | Telegraf 1.40.0 | Redpanda Connect 4.109.0 |
|---|---|---|---|---|
| W1: Telemetry filter | 4.4 MB | 15 MB | 92 MB | 72 MB |
| W2: Device windows | 6.4 MB | 536 MB | 52 MB (lossy) | 1,012 MB (crashed) |
| W3: ESPHome topics | 4.5 MB | 43 MB | 85 MB | 68 MB |
| W4: Vehicle windows | 10.2 MB | 886 MB | 94 MB (lossy) | 993 MB (crashed) |
| W5: Charger sessions | 7.3 MB | 832 MB | Unsupported | Unsupported |
At 20k msg/s on 10,000 vehicle windows, engines that buffer raw rows in RAM consume 886 MB to 1 GB, risking kernel OOM kills on edge hardware. rekuiper maintains 10.2 MB by computing aggregates incrementally in fixed-size accumulators.
One crucial lesson from this benchmark audit: eventual delivery is not throughput.
If an engine accepts 200,000 msg/s for 30 seconds by buffering everything into a huge internal queue, and then spends the next 45 seconds after the publisher stops draining that backlog, that is not a 200k engine. That is an engine surviving on buffer mercy.
In our benchmark harness:
When we claim 150,000 msg/s on W1 and W3, it means the engine processed all 4.5 million messages in real-time with an end-of-send backlog of 15 messages and a 1.0s drain time.
rekuiper is free and open source under MIT / Apache-2.0:
docker run -d --name rekuiper \
-p 9081:9081 -p 20499:20499 \
-e KUIPER__BASIC__CONSOLELOG=true \
ankurkrp/rekuiper:0.500-beta
All raw evidence files, harness scripts (mqttgen, iotrunner), and reproduction instructions are published in test/benchmark/iiot-mqtt/.