Skip to content

Leaders write 30-200x more to disk than they receive over the network #2176

Description

@viktorerlingsson

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.

Metadata

Metadata

Labels

No labels
No labels

Type

Projects

No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions