All posts

Fast, without losing messages

How Felix answers a fsynced publish in a third of a millisecond, writes at the disk's full rate under load, and slows publishers down instead of dropping their messages.

A message broker has two jobs that pull against each other. It should answer quickly and move a lot of data, and it should never lose a message it was given. The easy way to look fast is to cut the second job short.

Felix does both with a handful of ideas, none of them new, each put where it does the most good. This post walks through them, with the numbers from one broker on Azure and where each number comes from.

Two kinds of latency

Ask how long a publish takes and the honest answer is: it depends on how many other publishes are waiting.

With nothing else going on, a Felix broker receives a 256-byte publish, writes it, fsyncs it to disk and acknowledges it in 0.31 ms at the median and 0.39 ms at p99. That's service time, the work itself. An in-memory stream skips the disk and answers in 0.16 to 0.18 ms.

Now load the same broker with a throughput test: 64 publishers, each keeping 64 batches in flight, each batch 64 records of 4 KiB, on a durable stream. The median ack takes 767 ms. The broker didn't get slower. The requests are standing in line.

Two panels of the same coffee counter, with the Felix cat as barista. Top: one customer cat at the counter, served in 0.31 ms. Bottom: the same barista, smiling and no slower, with a line of cats out the door, 4,096 in flight. A sticky note works Little's law: W = L divided by lambda, 4,096 divided by 5,340 a second, about 0.77 seconds. The line sets the wait.ONE PUBLISH IN FLIGHTFelixserved in0.31 msservice time: the work itself4,096 IN FLIGHTFelix…and 4,089 more out the doorW = L ÷ λ4,096 ÷ 5,340/s ≈ 0.77 ssame barista, same speed.The line sets the wait.
Figure 1Same barista, same speed. The only thing that changed is the line.

Little's law says exactly how long the line takes. For anything that queues, L = λ × W: the number of requests in the system is the rate they complete times how long each one spends there. Turn it around and W = L ÷ λ.

Work it for that durable test. In flight: 64 publishers × 64 batches = 4,096 batches. The broker completes 1.40 GB/s, and a batch is 64 × 4 KiB = 256 KiB, so about 5,340 batches a second. 4,096 ÷ 5,340 is 0.77 seconds. The measured median is 767 ms. The same test on an in-memory stream completes 4.40 GB/s, so the law predicts 244 ms, and the median came in at 229 ms.

Nothing in that sum depends on how fast one request is served. Once the broker is busy, putting more in flight adds no throughput, only waiting. If you want lower latency at the same throughput, keep fewer requests in flight. And when a benchmark reports its saturated ack time as "request latency", it's really reporting how much its load generator chose to queue.

One fsync, everyone aboard

Durable writes are expensive because of fsync. It asks the drive to put the data on stable storage, and it's the slowest step on the write path. If every publish paid for its own, the broker would spend most of its time waiting on the drive.

But an fsync flushes the whole file, not one caller's bytes, so one flush can cover every write that's waiting. In Felix's storage layer every append that needs to be durable goes for the same flush lock. The one that gets it flushes. The others wake up, find their bytes already on disk, and return without flushing again. They're all acknowledged together.

Top panel, marked one fsync each: six cars queue for the disk, each carrying one write, six trips. Bottom panel, marked group commit: one bus with an fsync sign carries all six writes, the Felix cat driving, one trip to the disk and six acks.ONE FSYNC EACH123456disksix trips to the disk, one at a timeGROUP COMMITfsync123456diskone trip, six acks
Figure 2Group commit: the bus takes everyone at the stop, and the trip costs the same.

PostgreSQL, MySQL and RocksDB do the same thing, under the same name. The more publishes are waiting, the more each fsync carries, so durable throughput climbs with load until the disk is the limit. On the test VM's NVMe volume, fio writes 1,414 MB/s with an fsync after every write. Felix wrote durable 4 KiB batches at 1.40 GB/s, fsynced before every ack, using less than half the broker's cores.

Fsyncing before the ack is FELIX_DURABLE_FSYNC_MODE=on_commit. The default is periodic, an fsync every 250 ms in the background, which survives a broker crash but can lose the last interval to a power cut. Pick on_commit when an ack has to mean "on disk".

Pacing instead of dropping

Sooner or later a broker gets more data than it can take. It then has two honest choices: slow the publisher down, or throw something away. Felix's ingress queue and byte budget are bounded, so one of the two has to happen.

With FELIX_PUB_INGRESS_WAIT=1, a fire-and-forget publish that finds the broker full waits for room instead of being dropped. It waits on its own, so the stream keeps reading the publishes behind it, up to 1,024 waiting per stream. Past that, the broker stops reading the stream, and QUIC flow control pushes back on the publisher. The publisher slows to the rate the broker can take, and nothing is lost. It's a metered on-ramp: cars wait at the light, and nobody gets pushed off the road.

Two panels of a full highway with an on-ramp. Top, the default for fire-and-forget: a car merging onto the full road is pushed off the ramp, marked shed, plus one dropped, and the publisher cat at the bottom of the ramp smiles, not told. Bottom, with FELIX_PUB_INGRESS_WAIT=1: a ramp meter shows red, three cars wait their turn on the ramp, nothing is shed, and the publisher slows down.DEFAULT, FIRE-AND-FORGETshed+1 droppedpublisher not toldFELIX_PUB_INGRESS_WAIT=1waits for room0 shedpublisher slows
Figure 3Shed or paced. Both keep the broker healthy; only one keeps every message.

That isn't the default for fire-and-forget. Out of the box, a fire-and-forget publish that arrives while the broker is full is dropped and counted in the broker's metrics, and the publisher isn't told. That's at-most-once delivery, which is all fire-and-forget promises. In the benchmark, the defaults accepted 4.2 GB/s and dropped 1.2 to 2.2% of what was sent.

Acked publishes never disappear quietly. On a durable stream the ack waits for the commit by default, and a publish that finds the broker full waits for room for up to 2 seconds before it's refused with an error. On an in-memory stream a full broker refuses it straight away. Either way the publisher knows, and decides whether to send again.

The fastest way to lose nothing turned out to be an acked publisher with a window. Each of the 64 publishers kept 64 requests in flight, and the broker took 4.4 GB/s of 4 KiB batches with nothing shed. The window keeps the broker busy, and the acks pace the publisher for free: when the broker slows down, so do the acks, and so does the publisher. Fire-and-forget with ingress wait was lossless too, but slower: 3.6 GB/s.

Straight to the owner

A Felix cluster splits each stream into shards, and each shard has one leader that writes it. A publish can go to any broker. If that broker doesn't lead the shard, it forwards the publish to the one that does. That always works, and it costs a hop.

ClusterClient skips the hop. The first time it publishes to a stream, it asks the cluster who leads each shard and keeps the answer. For every record it works out the shard from the key, with the same function the broker uses, and sends the record over its connection to that shard's leader. Refreshing the answer never holds up a publish. If leadership has moved and a cached leader turns a publish away, the client sends it once through the broker it first connected to, which forwards it.

A Felix cat holding a letter addressed to shard 40. Three houses stand below: broker A with shards 0 to 15, broker B with 16 to 31, and broker C with 32 to 47, wearing a crown as the leader. A solid arrow carries the letter straight to broker C. A dashed detour goes through broker A, which forwards it on, at a cost of 0.38 ms: 0.83 ms against 0.45 ms for one in-memory 256 B publish.broker Ashards 0–15broker Bshards 16–31broker Cshards 32–47key → shard 40straight to the leaderforwarded: +0.38 ms
Figure 4The letter goes straight to the house, not through the sorting office.

On a three-broker cluster, forwarding added about 0.38 ms to an in-memory publish at the median: 0.83 ms through another broker, against 0.45 ms straight to the leader. Under load the hop costs much more. With two thirds of the publishes forwarded, an in-memory stream of 256 B batches ran at 864 MB/s; sent straight to the leaders, it ran at 2.0 GB/s. A replicated, durable stream fell further, from 475 MB/s to 107 MB/s, because each forwarded write holds its place on the link between brokers until a majority has it.

The numbers

One broker on an Azure Standard_L8as_v4 (8 vCPU, local NVMe in RAID0), over QUIC with TLS, with 64 publishers on four load generators. Throughput is the payload the broker accepted. These were measured on the final campaign build. The release build re-measured batched throughput within about 1% of them and latency within about 5%, except small fire-and-forget messages (#1469).

WhatResultConditions
Lossless4.4 GB/sIn-memory, 4 KiB batches of 64, acked, 64 in flight per publisher, nothing shed
Durable1.4 GB/s4 KiB batches of 64, fsynced before the ack
Fsynced publish0.31 ms p50
0.39 ms p99
256 B, one publish in flight, fsynced before the ack
Against NATS JetStream1.8× to 3.5×Durable single messages and 4 KiB batches on the same VMs, both fsyncing every write before the ack

The NATS comparison ran on the same machines, interleaved, with NATS tuned first. With both systems fsyncing every write before acknowledging it, Felix was 1.8 to 3.5 times faster for single messages and for 4 KiB batches. For batches of small messages the gap was 14.8 times, but part of that is the benchmark tools: Felix kept 64 batches in flight where nats bench sends one at a time. Core NATS is faster at fire-and-forget, by 1.34 to 1.39 times. With no subscriber it checks for interest and drops the message, while Felix routes every record to its shard.

The full performance write-up has every condition, the trials behind each number, the cluster measurements and how to reproduce the run.

Try Felix

Write the compose file from the Docker Compose guide, then start a broker and its control plane. With Podman, use podman compose.

docker compose up -d
Felix on GitHub RSS feed More posts