Skip to content
About
Main content

Group commit

Why does one flush per commit cap throughput, and how does sharing a flush lift the ceiling with no tuning?

This is the fixed-cost rule and Little's Law in their purest joint form. Let us do it with numbers.

The log is a single stream. Durable commits through it are serialized by the flush: each must wait for the flush to finish. :

RelationshipLittle's Law →
commits_in_flight
— average number of items inside the system (the concurrency)
commit_rate
— arrival rate (items per second)
commit_latency
— average time an item stays in the system (residence time, including waiting)

With one flush per commit and a 1 ms flush, one log stream can do at most 1 / 0.001 s = 1,000 commits per second, however many clients you add. Extra clients just wait in a queue. This is Nadia's problem. And note what the formula says: the fix is not a faster device. The fix is more work per flush.

Group commit lets many transactions share one flush. How big should the group be? The natural batch size is where the marginal cost of adding data meets the fixed cost of the flush:

T_flush
— the setup cost paid for each operation, whatever its size
bandwidth
— bytes per second once data streams
B (records per flush)
  • Hard disk: 8 ms × 100 MB/s ÷ 200 B = 0.008 × 100,000,000 ÷ 200 = 4,000 records per flush.
  • Enterprise NVMe with power-loss protection: 20 µs × 3 GB/s ÷ 200 B = 0.00002 × 3,000,000,000 ÷ 200 = 300 records per flush.

The natural batch shrinks about 13× (4,000 / 300) while the device becomes 400× faster (8 ms / 20 µs). More important: At 20 µs, the biggest per-commit cost is no longer the device. It is the system call, the context switch (the OS pausing one thread to run another), and the latch (a short lock) on the shared log buffer. This is why modern designs (Aether's decoupled log-buffer insert, Silo's epoch-based commit, per-core logs) stop optimizing the flush and start optimizing the : the component that puts records into the log in order.

The elegant implementation: no timer, no tuning

You do not need a timer or a configuration knob. The rule is:

  1. Flush whatever has accumulated.
  2. While that flush is running, let new arrivals accumulate.
  3. When the flush ends, flush all of those together.
The batch window is the previous flushillustrative · 1 ms flush

The batch window is exactly the flush's service time, so the batch tunes itself: small and low-latency when the system is idle, large and high-throughput when it is busy. This is the same control idea as Nagle's algorithm (TCP's rule of sending a small packet only when the previous one is acknowledged) and as interrupt coalescing (a network card raising one interrupt for many packets). It is a recurring pattern: using the service time of the previous operation as the batching window makes the batch size self-tuning.

The cost, stated precisely

Added per transaction is at most one flush service time, and on average about half of one. That is why is one of the very few trades that is close to free.

Who pays: the single transaction that arrives in an idle system and waits for a batch it does not need. The self-tuning window removes most of that cost.

When the wait is a timer: the square-root rule

Some logs do not batch on the previous flush but wait a fixed time W before each flush. The price is latency for the first arrival: it waits for the batch. The best wait time is derived the same way as the compaction interval. With λ commits per second arriving and a wait W, the cost per commit and the wait that minimizes it are below (the first term is the share of the each commit pays; the second is the average waiting time):

W (wait before the flush)
— how long you wait (or how much you gather) before paying the fixed cost
fsync ÷ λ (fsync share)
— cost paid once per interval (a rewrite, an fsync)
1/2 (average wait)
— cost per unit of interval that accumulates while you wait (reads slowed by deltas, time spent waiting)

A fixed cost against an accrual rate gives a square-root batching rule. Treat √(fixed ÷ rate) as a reflex and not as a new derivation. Acknowledge each commit only after its batch's fsync completes, so durability is not violated. If the arrival rate is too low, there is no batch to form, and a faster log device or a replicated (not locally fsynced) commit is the alternative.