Investigation by Claude (Claude Code), driven by @chadknutson . Numbers below are from our two production clusters plus a controlled 3-node AWS test cluster. This is independent of clustering.
TL;DR
Disk writes are dominated by a term that has nothing to do with message rate or message size:
disk_write ~ hot_files x folio_size x syncfs_rate + message_bytes
\____________ this dominates ____________/
We append messages by memcpy into MAP_SHARED mmaps, so the unit of a write is a page or folio, never a message. syncfs(2) is filesystem-wide and is paced by publisher confirms. And every queue has an acks.* file that takes 4 bytes per ack into that same page-sized unit. Multiply those together and you get 200x on a cluster doing 0.15 MiB/s.
It is not a leak, not replication, and not fan-out. Nothing is being written twice at the application level - we write each enqueued message exactly once.
What we measured
Cluster A (prod), from Grafana + node tracing:
network in 6.16 MiB/s
disk write 204 MiB/s (2h mean); 76.4 MiB/s in a live 30s sample
disk read 0 B/s <- rules out resync / re-read entirely
disk usage flat at 2.4 GiB <- steady-state churn, not accumulation
queues 78
syncfs 10.4 /s
confirms 170.6/s of 3177 publishes (~5% of publishers)
avg write req 72.9 KiB
Cluster B (prod): 167 KiB/s in -> 32.6 MiB/s written. Same fingerprint: zero reads, flat usage.
The three multipliers
1. The write unit is a page/folio, not a message
MessageStore#write_to_disk (src/lavinmq/message_store.cr:333-344) is a memcpy into an mmap. There is no write(2) and no msync anywhere on the publish path (MFile#flush, the only msync, has no callers). When anything flushes, the kernel writes the whole dirty unit. On ext4 that's 4 KiB. On XFS with a modern kernel (cluster A is on 6.17) the page cache uses large folios up to 64 KiB - we measured 57 KiB per dirty event and 72.9 KiB average write request. So a 2 KB message can dirty 64 KiB.
2. syncfs is filesystem-wide and is paced by publisher confirms
Persister#sync (src/lavinmq/persister.cr:47-54) is the leader's only durability barrier, reached only from the publish-confirm drain (Persister#drain_pending_acks) and tx_commit (src/lavinmq/amqp/channel.cr:832). Each call flushes every dirty folio of every queue file on the volume, not just the file being confirmed. publish_confirm_loop re-arms the instant syncfs returns, so it runs as fast as the disk allows.
The uncomfortable part: only ~5% of cluster A's publishes use confirms. That minority sets the flush cadence for the whole filesystem, and every other queue on the node pays for it.
3. The ack files are the pathological case
Each ack appends 4 bytes to a separate mmap'd acks.* file (src/lavinmq/message_store.cr:176-203). A 64 KiB folio holds 16384 acks, so at a few hundred acks/s it takes minutes to fill - meaning it gets rewritten nearly in full on every flush. When we traced dirtied folios by inode on cluster A, acks.* files were all over the top of the list. 78 queues x 64 KiB x 10.4/s ~ 52 MiB/s from ack files alone.
Arithmetic check for cluster A: 78 queues x 64 KiB x 10.4/s ~ 52 MiB/s, measured 76 MiB/s, against ~12 MiB/s of actual enqueued message data.
Controlled reproduction
On a clean 3-node test cluster, 100 queues, 500 B messages, no confirms, sweeping the publish rate 10 -> 1000 msg/s:
| msg/s |
net MiB/s |
disk MiB/s |
amplification |
| 10 |
0.0054 |
0.321 |
59x |
| 30 |
0.0163 |
0.418 |
25.6x |
| 100 |
0.0545 |
0.532 |
9.8x |
| 300 |
0.1633 |
0.532 |
3.3x |
| 1000 |
0.5443 |
0.960 |
1.8x |
fit: disk_MiB_s = 602 bytes/msg x rate + 397 KiB/s
Note the disk column barely moves across a 100x change in rate. The 602 B/msg term is just the message written once. The 397 KiB/s constant floor is the amplification - ~4 KiB/s per queue, paid whether the page holds 4 bytes or 4 KiB. "Amplification ratio" is that floor divided by a shrinking denominator, which is why our slower cluster looks worse than our faster one.
Then the same run with confirms turned on (-c 1), everything else identical:
|
no confirms |
with confirms |
|
| disk write |
0.416 MiB/s* |
6.269 MiB/s |
<- 15x |
| amplification |
3.3x |
235x |
|
| bytes written/msg |
1.8 KiB |
131 KiB |
|
* model prediction at the same 48.8 msg/s the confirm run achieved
Why RabbitMQ sits at ~2x
Quorum queues append to a WAL with buffered write(2) plus periodic fsync. The unit is the byte range, so a partially-filled block is never rewritten. We pay the page/folio every time because we go through mmap.
Measurement note
One gotcha for anyone digging in: our own /api/nodes io_write_count is ru_oublock, which Linux charges per page-dirty transition. It happens to track physical writes well here, but it is not a physical I/O counter and it cannot see journal or metadata traffic.
Investigation by Claude (Claude Code), driven by @chadknutson . Numbers below are from our two production clusters plus a controlled 3-node AWS test cluster. This is independent of clustering.
TL;DR
Disk writes are dominated by a term that has nothing to do with message rate or message size:
We append messages by memcpy into
MAP_SHAREDmmaps, so the unit of a write is a page or folio, never a message.syncfs(2)is filesystem-wide and is paced by publisher confirms. And every queue has anacks.*file that takes 4 bytes per ack into that same page-sized unit. Multiply those together and you get 200x on a cluster doing 0.15 MiB/s.It is not a leak, not replication, and not fan-out. Nothing is being written twice at the application level - we write each enqueued message exactly once.
What we measured
Cluster A (prod), from Grafana + node tracing:
Cluster B (prod): 167 KiB/s in -> 32.6 MiB/s written. Same fingerprint: zero reads, flat usage.
The three multipliers
1. The write unit is a page/folio, not a message
MessageStore#write_to_disk(src/lavinmq/message_store.cr:333-344) is a memcpy into an mmap. There is nowrite(2)and nomsyncanywhere on the publish path (MFile#flush, the onlymsync, has no callers). When anything flushes, the kernel writes the whole dirty unit. On ext4 that's 4 KiB. On XFS with a modern kernel (cluster A is on 6.17) the page cache uses large folios up to 64 KiB - we measured 57 KiB per dirty event and 72.9 KiB average write request. So a 2 KB message can dirty 64 KiB.2. syncfs is filesystem-wide and is paced by publisher confirms
Persister#sync(src/lavinmq/persister.cr:47-54) is the leader's only durability barrier, reached only from the publish-confirm drain (Persister#drain_pending_acks) andtx_commit(src/lavinmq/amqp/channel.cr:832). Each call flushes every dirty folio of every queue file on the volume, not just the file being confirmed.publish_confirm_loopre-arms the instantsyncfsreturns, so it runs as fast as the disk allows.The uncomfortable part: only ~5% of cluster A's publishes use confirms. That minority sets the flush cadence for the whole filesystem, and every other queue on the node pays for it.
3. The ack files are the pathological case
Each ack appends 4 bytes to a separate mmap'd
acks.*file (src/lavinmq/message_store.cr:176-203). A 64 KiB folio holds 16384 acks, so at a few hundred acks/s it takes minutes to fill - meaning it gets rewritten nearly in full on every flush. When we traced dirtied folios by inode on cluster A,acks.*files were all over the top of the list. 78 queues x 64 KiB x 10.4/s ~ 52 MiB/s from ack files alone.Arithmetic check for cluster A: 78 queues x 64 KiB x 10.4/s ~ 52 MiB/s, measured 76 MiB/s, against ~12 MiB/s of actual enqueued message data.
Controlled reproduction
On a clean 3-node test cluster, 100 queues, 500 B messages, no confirms, sweeping the publish rate 10 -> 1000 msg/s:
fit:
disk_MiB_s = 602 bytes/msg x rate + 397 KiB/sNote the disk column barely moves across a 100x change in rate. The 602 B/msg term is just the message written once. The 397 KiB/s constant floor is the amplification - ~4 KiB/s per queue, paid whether the page holds 4 bytes or 4 KiB. "Amplification ratio" is that floor divided by a shrinking denominator, which is why our slower cluster looks worse than our faster one.
Then the same run with confirms turned on (
-c 1), everything else identical:* model prediction at the same 48.8 msg/s the confirm run achieved
Why RabbitMQ sits at ~2x
Quorum queues append to a WAL with buffered
write(2)plus periodicfsync. The unit is the byte range, so a partially-filled block is never rewritten. We pay the page/folio every time because we go through mmap.Measurement note
One gotcha for anyone digging in: our own
/api/nodesio_write_countisru_oublock, which Linux charges per page-dirty transition. It happens to track physical writes well here, but it is not a physical I/O counter and it cannot see journal or metadata traffic.